feat(discovery): cache stores pricing — unblocks zero-cost-but-not-:free models
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-15 14:51:00 +02:00
parent d70d8d3b10
commit c8854ca896
4 changed files with 550 additions and 29 deletions

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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") {

View file

@ -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",
);
});
});