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
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:
parent
d70d8d3b10
commit
c8854ca896
4 changed files with 550 additions and 29 deletions
19
src/cli.ts
19
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue