From c8854ca896a47a9e68499d715e30e8da5b16d782 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 14:51:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(discovery):=20cache=20stores=20pricing=20?= =?UTF-8?q?=E2=80=94=20unblocks=20zero-cost-but-not-:free=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's discovery cache stored only model IDs (string[]). Downstream isZeroCost(model?.cost) check evaluated against undefined for any dynamically-discovered model, so OpenRouter's zero-cost-but-not-:free entries (owl-alpha, lyria-3-pro-preview, lyria-3-clip-preview, openrouter/free) got silently blocked by the built-in provider policy. Cache entry shape now: {id, cost?, contextWindow?} per model. parseDiscoveredModels extracts pricing from OpenRouter's /api/v1/models response (pricing.prompt/completion/input_cache_read/ input_cache_write → numeric cost.{input,output,cacheRead,cacheWrite}). Other providers stay {id}-only — their /v1/models endpoints don't ship pricing. Migration: on first read of a legacy string[] cache, entries are converted in-place to {id} objects and the file is rewritten. No cost backfill (data wasn't there before), but the new readers handle them. Cost wired into policy: isModelAllowedByBuiltInProviderPolicy calls lookupDiscoveredModelCost("openrouter", modelId) as a fallback when the static model registry has no cost data. Plus: cli.ts --discover now eagerly refreshes SF-managed providers (opencode, opencode-go, kimi-coding, xiaomi) that the SDK's adapter doesn't cover — so they populate cache on first --discover instead of waiting for a session-start lazy refresh. Tests: 13 new across 5 groups (pricing extraction, round-trip, legacy migration, policy gate happy/sad paths, Google provider compat). Full suite: 184 files / 1971 tests, zero regressions. Real-world result: openrouter/owl-alpha, google/lyria-3-pro-preview, google/lyria-3-clip-preview, openrouter/free, plus any future zero-cost models now pass the policy filter on the next discovery refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli.ts | 19 ++ .../extensions/sf/model-catalog-cache.js | 232 +++++++++++-- .../extensions/sf/preferences-models.js | 7 +- .../sf/tests/discovery-cache-pricing.test.mjs | 321 ++++++++++++++++++ 4 files changed, 550 insertions(+), 29 deletions(-) create mode 100644 src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs diff --git a/src/cli.ts b/src/cli.ts index f0447faaf..e4aa77ace 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -741,6 +741,25 @@ if (cliFlags.listModels !== undefined) { sfPrefs.blocked_providers ?? [], ) : undefined; + // For --discover, eagerly refresh SF-managed providers (opencode, opencode-go, + // kimi-coding, xiaomi) that the SDK's discovery adapters don't cover. This + // writes their model lists into ~/.sf/agent/discovery-cache.json so the SDK's + // discoverModels() call inside listModels() reads them from cache, making them + // visible in `--list-models --discover` output even on a cold start. + if (cliFlags.discover) { + try { + const { refreshSfManagedProviders } = await import( + "./resources/extensions/sf/model-catalog-cache.js" + ); + const { getKeyManagerAuthStorage } = await import( + "./resources/extensions/sf/key-manager.js" + ); + await refreshSfManagedProviders(process.cwd(), getKeyManagerAuthStorage()); + } catch { + // Non-fatal — never block model listing. + } + } + await listModels(modelRegistry, { searchPattern, discover: cliFlags.discover, diff --git a/src/resources/extensions/sf/model-catalog-cache.js b/src/resources/extensions/sf/model-catalog-cache.js index db24baf3e..f583895e1 100644 --- a/src/resources/extensions/sf/model-catalog-cache.js +++ b/src/resources/extensions/sf/model-catalog-cache.js @@ -24,6 +24,10 @@ import { getProviderModelExcludePatterns, } from "./provider-catalog-config.js"; +/** + * @typedef {{ id: string, cost?: { input: number, output: number, cacheRead: number, cacheWrite: number }, contextWindow?: number }} DiscoveredModelEntry + */ + const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; /** @@ -53,28 +57,84 @@ function cacheFilePath(basePath, providerId) { } /** - * Read cached model IDs for a provider. Returns null if cache is missing or stale. + * Read cached model entries for a provider. Returns null if cache is missing or stale. + * Performs one-time migration from legacy string[] format to DiscoveredModelEntry[]. + * + * @returns {DiscoveredModelEntry[] | null} */ -export function readCachedModelIds(basePath, providerId) { +function readCachedModelEntries(basePath, providerId) { try { const path = cacheFilePath(basePath, providerId); if (!existsSync(path)) return null; - const entry = JSON.parse(readFileSync(path, "utf-8")); + let entry = JSON.parse(readFileSync(path, "utf-8")); if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return null; if (Date.now() - new Date(entry.fetchedAt).getTime() > CATALOG_TTL_MS) return null; + // Migration: legacy cache stored string[] — convert to object[] in-place. + if (entry.modelIds.length > 0 && typeof entry.modelIds[0] === "string") { + entry = { + ...entry, + modelIds: entry.modelIds.map((id) => ({ id })), + }; + try { + writeFileSync( + cacheFilePath(basePath, providerId), + JSON.stringify(entry), + "utf-8", + ); + } catch { + // Migration write failure is non-fatal. + } + } return entry.modelIds; } catch { return null; } } -function writeCacheEntry(basePath, providerId, modelIds) { +/** + * Read cached model IDs (strings only) for a provider. Returns null if cache is missing or stale. + * Legacy helper for call sites that only need model ID strings. + * + * @returns {string[] | null} + */ +export function readCachedModelIds(basePath, providerId) { + const entries = readCachedModelEntries(basePath, providerId); + if (!entries) return null; + return entries.map((e) => (typeof e === "string" ? e : e.id)); +} + +/** + * Return cached model entries (with pricing/contextWindow if available) for a provider. + * Returns empty array if cache is missing, stale, or the provider hasn't been discovered yet. + * + * @returns {DiscoveredModelEntry[]} + */ +export function getCachedModelEntries(basePath, providerId) { + return readCachedModelEntries(basePath, providerId) ?? []; +} + +/** + * Return cached model IDs as strings for a provider. + * Returns empty array if cache is missing or stale. + * + * @returns {string[]} + */ +export function getCachedModelIds(basePath, providerId) { + return readCachedModelIds(basePath, providerId) ?? []; +} + +/** + * @param {string} basePath + * @param {string} providerId + * @param {DiscoveredModelEntry[]} modelEntries + */ +function writeCacheEntry(basePath, providerId, modelEntries) { try { mkdirSync(cacheDirPath(basePath), { recursive: true }); writeFileSync( cacheFilePath(basePath, providerId), - JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds }), + JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds: modelEntries }), "utf-8", ); } catch { @@ -83,15 +143,18 @@ function writeCacheEntry(basePath, providerId, modelIds) { } /** - * Write fetched model IDs for an SF-managed provider into the SDK discovery + * Write fetched model entries for an SF-managed provider into the SDK discovery * cache (~/.sf/agent/discovery-cache.json) so that `--list-models --discover` * includes them. Only called for providers not covered by the SDK's native * discovery adapters (i.e. not in SDK_NATIVE_DISCOVERY_PROVIDERS). * - * Model objects are minimal: {id, name, provider, api, baseUrl} — enough for - * the model registry to register and display them. + * Model objects include cost data (when available) so that + * isModelAllowedByBuiltInProviderPolicy can evaluate zero-cost models correctly. + * + * @param {string} providerId + * @param {DiscoveredModelEntry[]} modelEntries */ -function writeSdkDiscoveryCacheEntry(providerId, modelIds) { +function writeSdkDiscoveryCacheEntry(providerId, modelEntries) { try { const cfg = getProviderCatalogConfig(providerId); if (!cfg) return; @@ -109,14 +172,21 @@ function writeSdkDiscoveryCacheEntry(providerId, modelIds) { } const api = cfg.type === "anthropic" ? "anthropic-messages" : "openai-completions"; - const models = modelIds.map((id) => ({ - id, - name: id, - provider: providerId, - api, - baseUrl: cfg.baseUrl, - input: ["text"], - })); + const models = modelEntries.map((entry) => { + const id = typeof entry === "string" ? entry : entry.id; + const cost = typeof entry === "object" ? entry.cost : undefined; + const contextWindow = typeof entry === "object" ? entry.contextWindow : undefined; + return { + id, + name: id, + provider: providerId, + api, + baseUrl: cfg.baseUrl, + input: ["text"], + ...(cost !== undefined ? { cost } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + }); cache.entries[providerId] = { models, fetchedAt: Date.now(), @@ -154,14 +224,28 @@ function buildUrl(cfg, apiKey) { return `${base}${path}`; } -function parseModelIds(cfg, json) { +/** + * Parse raw /v1/models (or equivalent) API response into DiscoveredModelEntry[]. + * For OpenRouter, extracts pricing into the cost field. + * For all other providers, returns {id}-only entries. + * + * Exported for testing. + * + * @param {{ type?: string, providerId?: string }} cfg + * @param {unknown} json + * @returns {DiscoveredModelEntry[] | null} + */ +export function parseDiscoveredModels(cfg, json) { let items; if (cfg.type === "google") { // Google: { models: [{name: "models/gemini-..."}] } items = Array.isArray(json?.models) ? json.models : null; if (!items) return null; return items - .map((m) => (m.name ?? "").replace(/^models\//, "")) + .map((m) => { + const id = (m.name ?? "").replace(/^models\//, ""); + return id ? { id } : null; + }) .filter(Boolean); } // OpenAI-compatible: { data: [{id}] } or { models: [{id}] } @@ -171,13 +255,41 @@ function parseModelIds(cfg, json) { ? json.models : null; if (!items) return null; - return items.map((m) => m.id ?? m.name).filter(Boolean); + // For OpenRouter, extract per-model pricing from the response. + const isOpenRouter = cfg.providerId === "openrouter"; + return items + .map((m) => { + const id = m.id ?? m.name; + if (!id) return null; + if (isOpenRouter && m.pricing) { + return { + id, + cost: { + input: parseFloat(m.pricing.prompt ?? "0") || 0, + output: parseFloat(m.pricing.completion ?? "0") || 0, + cacheRead: parseFloat(m.pricing.input_cache_read ?? "0") || 0, + cacheWrite: parseFloat(m.pricing.input_cache_write ?? "0") || 0, + }, + ...(m.context_length != null ? { contextWindow: m.context_length } : {}), + }; + } + return { id }; + }) + .filter(Boolean); } -function applyModelFilter(providerId, modelIds) { +/** + * @param {string} providerId + * @param {DiscoveredModelEntry[]} modelEntries + * @returns {DiscoveredModelEntry[]} + */ +function applyModelFilter(providerId, modelEntries) { const patterns = getProviderModelExcludePatterns(providerId); - if (patterns.length === 0) return modelIds; - return modelIds.filter((id) => !patterns.some((re) => re.test(id))); + if (patterns.length === 0) return modelEntries; + return modelEntries.filter((entry) => { + const id = typeof entry === "string" ? entry : entry.id; + return !patterns.some((re) => re.test(id)); + }); } /** @@ -199,18 +311,20 @@ export async function refreshProviderCatalog(basePath, providerId, apiKey) { }); if (!res.ok) return null; const json = await res.json(); - const raw = parseModelIds(cfg, json); + // Pass providerId into parseDiscoveredModels so it can apply provider-specific + // parsing (e.g. OpenRouter pricing extraction). + const raw = parseDiscoveredModels({ ...cfg, providerId }, json); if (!raw) return null; - const modelIds = applyModelFilter(providerId, raw); - writeCacheEntry(basePath, providerId, modelIds); + const modelEntries = applyModelFilter(providerId, raw); + writeCacheEntry(basePath, providerId, modelEntries); // Also populate the SDK discovery cache for providers that the SDK's // built-in discovery adapters do not cover (opencode, opencode-go, // kimi-coding, xiaomi, etc.). Without this, --list-models --discover // silently omits them because their SDK adapter has supportsDiscovery=false. if (!SDK_NATIVE_DISCOVERY_PROVIDERS.has(providerId)) { - writeSdkDiscoveryCacheEntry(providerId, modelIds); + writeSdkDiscoveryCacheEntry(providerId, modelEntries); } - return modelIds; + return modelEntries.map((e) => e.id); } catch { return null; } @@ -246,6 +360,39 @@ export function scheduleModelCatalogRefresh(basePath, auth) { }); } +/** + * Awaitable refresh of SF-managed providers — providers that DISCOVERABLE_PROVIDER_IDS + * covers but the SDK's native discovery adapters (supportsDiscovery=true) do not. + * + * Called before `--list-models --discover` so that the SDK's discoverModels() + * reads these providers from the (now-populated) discovery cache rather than + * skipping them due to StaticDiscoveryAdapter. + * + * Only fetches providers whose runtime cache is stale or absent to avoid + * unnecessary network calls on repeated --discover invocations. + */ +export async function refreshSfManagedProviders(basePath, auth) { + for (const providerId of DISCOVERABLE_PROVIDER_IDS) { + if (SDK_NATIVE_DISCOVERY_PROVIDERS.has(providerId)) continue; + try { + const creds = auth.getCredentialsForProvider(providerId); + const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key; + if (!apiKey) continue; + if (readCachedModelIds(basePath, providerId) !== null) continue; + const result = await refreshProviderCatalog(basePath, providerId, apiKey); + if (result === null) { + process.stderr.write( + `[model-catalog-cache] refresh failed for provider: ${providerId}\n`, + ); + } + } catch (err) { + process.stderr.write( + `[model-catalog-cache] unexpected error for provider ${providerId}: ${err?.message ?? err}\n`, + ); + } + } +} + /** * Return the union of SDK-static model IDs and live-discovered model IDs for a * provider. Hot path — reads only from disk cache, no network call. @@ -256,3 +403,32 @@ export function getKnownModelIds(basePath, providerId, sdkModelIds = []) { const cached = readCachedModelIds(basePath, providerId) ?? []; return [...new Set([...sdkModelIds, ...cached])]; } + +/** + * Look up the discovered cost for a given provider/modelId pair. + * Uses the current working directory as basePath (appropriate for runtime calls + * where basePath is not available on the call stack). + * + * Returns the cost object if the model was discovered with pricing data, + * undefined if the model is unknown or has no pricing info in the cache. + * + * Consumer: isModelAllowedByBuiltInProviderPolicy — zero-cost openrouter models + * that lack a `:free` suffix need their discovered cost to pass the policy check. + * + * @param {string} providerId + * @param {string} modelId + * @returns {{ input: number, output: number, cacheRead: number, cacheWrite: number } | undefined} + */ +export function lookupDiscoveredModelCost(providerId, modelId) { + try { + const entries = readCachedModelEntries(process.cwd(), providerId); + if (!entries) return undefined; + const entry = entries.find((e) => { + const id = typeof e === "string" ? e : e.id; + return id === modelId; + }); + return typeof entry === "object" ? entry.cost : undefined; + } catch { + return undefined; + } +} diff --git a/src/resources/extensions/sf/preferences-models.js b/src/resources/extensions/sf/preferences-models.js index 0a40fae55..3009a463a 100644 --- a/src/resources/extensions/sf/preferences-models.js +++ b/src/resources/extensions/sf/preferences-models.js @@ -40,6 +40,7 @@ function getGlobalSFPreferencesPath() { } import { getKeyManagerAuthStorage } from "./key-manager.js"; +import { lookupDiscoveredModelCost } from "./model-catalog-cache.js"; const OPENCODE_FREE_MODEL_IDS = new Set([ "big-pickle", @@ -120,9 +121,13 @@ function isModelAllowedByBuiltInProviderPolicy(provider, modelId, model) { return isMistralSelectionModel(modelId); } if (providerKey === "openrouter") { + // model?.cost is populated for statically-known models from the upstream SDK catalog. + // For dynamically-discovered models (absent from the catalog), fall back to the + // per-provider discovery cache which now carries pricing from /api/v1/models. + const effectiveCost = model?.cost ?? lookupDiscoveredModelCost("openrouter", modelId); return ( providerModelAllowEntryMatches(":free", modelKey) || - isZeroCost(model?.cost) + isZeroCost(effectiveCost) ); } if (providerKey === "opencode") { diff --git a/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs new file mode 100644 index 000000000..b29eaa56c --- /dev/null +++ b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs @@ -0,0 +1,321 @@ +/** + * discovery-cache-pricing.test.mjs + * + * Tests that the model-discovery cache now carries pricing data and that + * isModelAllowedByBuiltInProviderPolicy correctly passes zero-cost OpenRouter + * models that don't carry a `:free` suffix. + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { + getCachedModelEntries, + getCachedModelIds, + parseDiscoveredModels, +} from "../model-catalog-cache.js"; + +// Import preferences.js so that _initPrefsLoader is called and the +// circular dep lazy-loader is wired up (required by preferences-models.js). +import "../preferences.js"; +import { isProviderModelAllowed } from "../preferences-models.js"; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +const tmpDirs = []; + +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop(), { recursive: true, force: true }); + } +}); + +function tempBasePath() { + const dir = mkdtempSync(join(tmpdir(), "sf-discovery-cache-test-")); + tmpDirs.push(dir); + return dir; +} + +/** Write a model-catalog cache entry directly for testing. */ +function writeModelCatalogCache(basePath, providerId, modelEntries) { + // sfRuntimeRoot(basePath) resolves to sfRoot(basePath) which is basePath/.sf + // cacheDirPath = join(sfRuntimeRoot(basePath), "model-catalog") = basePath/.sf/model-catalog + const dir = join(basePath, ".sf", "model-catalog"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${providerId}.json`), + JSON.stringify({ + fetchedAt: new Date().toISOString(), + modelIds: modelEntries, + }), + "utf-8", + ); +} + +// ─── parseDiscoveredModels tests ────────────────────────────────────────────── + +describe("parseDiscoveredModels", () => { + test("openrouter: extracts cost from pricing field", () => { + const cfg = { providerId: "openrouter" }; + const json = { + data: [ + { + id: "openrouter/owl-alpha", + pricing: { + prompt: "0", + completion: "0", + input_cache_read: "0", + input_cache_write: "0", + }, + context_length: 128000, + }, + ], + }; + const result = parseDiscoveredModels(cfg, json); + assert.ok(result, "should return a result"); + assert.equal(result.length, 1); + const entry = result[0]; + assert.equal(entry.id, "openrouter/owl-alpha"); + assert.deepEqual(entry.cost, { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }); + assert.equal(entry.contextWindow, 128000); + }); + + test("openrouter: extracts non-zero cost correctly", () => { + const cfg = { providerId: "openrouter" }; + const json = { + data: [ + { + id: "openrouter/anthropic/claude-opus-4-6", + pricing: { + prompt: "0.000015", + completion: "0.000075", + input_cache_read: "0.0000015", + input_cache_write: "0.00001875", + }, + }, + ], + }; + const result = parseDiscoveredModels(cfg, json); + assert.ok(result); + const entry = result[0]; + assert.equal(entry.cost.input, 0.000015); + assert.equal(entry.cost.output, 0.000075); + assert.ok(entry.cost.cacheRead > 0); + assert.ok(entry.cost.cacheWrite > 0); + }); + + test("non-openrouter provider: returns id-only entries (no cost)", () => { + const cfg = { providerId: "opencode" }; + const json = { + data: [ + { id: "big-pickle" }, + { id: "gpt-5-nano" }, + ], + }; + const result = parseDiscoveredModels(cfg, json); + assert.ok(result); + assert.equal(result.length, 2); + for (const entry of result) { + assert.equal(typeof entry.id, "string"); + assert.equal(entry.cost, undefined, "non-openrouter should have no cost"); + } + }); + + test("google provider: wraps names as id-only entries", () => { + const cfg = { type: "google", providerId: "google" }; + const json = { + models: [ + { name: "models/gemini-2.5-pro" }, + { name: "models/gemini-2.5-flash" }, + ], + }; + const result = parseDiscoveredModels(cfg, json); + assert.ok(result); + assert.equal(result.length, 2); + assert.equal(result[0].id, "gemini-2.5-pro"); + assert.equal(result[0].cost, undefined); + }); + + test("returns null for unrecognized response shape", () => { + const result = parseDiscoveredModels({}, { unexpected: true }); + assert.equal(result, null); + }); +}); + +// ─── Cache read/write with new entry shape ──────────────────────────────────── + +describe("getCachedModelEntries / getCachedModelIds", () => { + test("round-trips DiscoveredModelEntry with cost", () => { + const basePath = tempBasePath(); + const entries = [ + { + id: "openrouter/owl-alpha", + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + }, + ]; + writeModelCatalogCache(basePath, "openrouter", entries); + + const read = getCachedModelEntries(basePath, "openrouter"); + assert.equal(read.length, 1); + assert.equal(read[0].id, "openrouter/owl-alpha"); + assert.deepEqual(read[0].cost, { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }); + assert.equal(read[0].contextWindow, 128000); + }); + + test("getCachedModelIds returns string array from rich entries", () => { + const basePath = tempBasePath(); + writeModelCatalogCache(basePath, "openrouter", [ + { id: "openrouter/owl-alpha", cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + { id: "openrouter/free" }, + ]); + + const ids = getCachedModelIds(basePath, "openrouter"); + assert.deepEqual(ids, ["openrouter/owl-alpha", "openrouter/free"]); + }); +}); + +// ─── Legacy cache migration ─────────────────────────────────────────────────── + +describe("legacy cache migration", () => { + test("string[] cache migrates to {id}[] on read", () => { + const basePath = tempBasePath(); + // Write legacy format: modelIds is a string[] + const dir = join(basePath, ".sf", "model-catalog"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "openrouter.json"), + JSON.stringify({ + fetchedAt: new Date().toISOString(), + modelIds: ["openrouter/owl-alpha", "openrouter/free"], + }), + "utf-8", + ); + + const entries = getCachedModelEntries(basePath, "openrouter"); + assert.equal(entries.length, 2); + assert.deepEqual(entries[0], { id: "openrouter/owl-alpha" }); + assert.deepEqual(entries[1], { id: "openrouter/free" }); + + // IDs are still accessible + const ids = getCachedModelIds(basePath, "openrouter"); + assert.deepEqual(ids, ["openrouter/owl-alpha", "openrouter/free"]); + }); +}); + +// ─── isProviderModelAllowed — zero-cost OpenRouter policy ───────────────────── + +describe("isProviderModelAllowed — openrouter zero-cost policy", () => { + let originalCwd; + + beforeEach(() => { + originalCwd = process.cwd(); + }); + + afterEach(() => { + process.chdir(originalCwd); + }); + + function makeProjectWithDiscoveryCache(modelEntries) { + const basePath = tempBasePath(); + // Create a valid .sf directory (needed for sfRoot resolution) + mkdirSync(join(basePath, ".sf"), { recursive: true }); + writeModelCatalogCache(basePath, "openrouter", modelEntries); + process.chdir(basePath); + return basePath; + } + + test("zero-cost model without :free suffix is allowed when cost in discovery cache", () => { + makeProjectWithDiscoveryCache([ + { + id: "openrouter/owl-alpha", + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]); + + // model object has no cost (as discovered dynamically) + const allowed = isProviderModelAllowed( + "openrouter", + "openrouter/owl-alpha", + undefined, // providerModelAllow + undefined, // providerModelBlock + {}, // model object without cost + ); + assert.equal(allowed, true, "zero-cost model should be allowed"); + }); + + test("paid model is blocked even when present in discovery cache", () => { + makeProjectWithDiscoveryCache([ + { + id: "openrouter/anthropic/claude-opus-4-6", + cost: { input: 0.000015, output: 0.000075, cacheRead: 0.0000015, cacheWrite: 0.00001875 }, + }, + ]); + + const allowed = isProviderModelAllowed( + "openrouter", + "openrouter/anthropic/claude-opus-4-6", + undefined, + undefined, + {}, // model object without cost (simulates dynamic discovery) + ); + assert.equal(allowed, false, "paid model should not pass zero-cost filter"); + }); + + test("model with :free suffix is allowed regardless of discovery cache", () => { + makeProjectWithDiscoveryCache([]); // empty cache + + const allowed = isProviderModelAllowed( + "openrouter", + "openrouter/qwen/qwen3-235b-a22b:free", + undefined, + undefined, + {}, // no cost + ); + assert.equal(allowed, true, ":free suffix model must always be allowed"); + }); + + test("non-openrouter provider with no cost entry is allowed", () => { + makeProjectWithDiscoveryCache([]); + + const allowed = isProviderModelAllowed( + "anthropic", + "claude-sonnet-4-6", + undefined, + undefined, + {}, + ); + assert.equal(allowed, true, "non-openrouter provider should pass through"); + }); + + test("discovery cache absent: openrouter model with no cost and no :free is blocked", () => { + // No cache written — chdir to tmpdir with no .sf + const emptyDir = tempBasePath(); + process.chdir(emptyDir); + + const allowed = isProviderModelAllowed( + "openrouter", + "openrouter/some/unknown-model", + undefined, + undefined, + {}, // no cost + ); + assert.equal( + allowed, + false, + "without cache data and no :free suffix, model must be blocked", + ); + }); +});