diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index 544a1073b..da1b321d1 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -512,9 +512,9 @@ export function registerHooks(pi, ecosystemHandlers = []) { } // Refresh the gemini-cli model catalog separately because google-gemini-cli // uses OAuth via @google/gemini-cli-core, not API-key REST, so it is not - // reachable through the generic refresh above. The cache lands in - // .sf/runtime/model-catalog/google-gemini-cli.json so getKnownModelIds and - // the model picker pick it up the same way as other providers. + // reachable through the generic refresh above. The cache lands in the + // global ~/.sf/model-catalog/google-gemini-cli.json so getKnownModelIds + // and the model picker pick it up the same way as other providers. try { const { scheduleGeminiCatalogRefresh } = await import( "../gemini-catalog.js" @@ -524,7 +524,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { /* non-fatal — gemini catalog refresh must never block session start */ } // Refresh the openai-codex model catalog by mirroring the codex CLI's - // own ~/.codex/models_cache.json into .sf/runtime/model-catalog/ + // own ~/.codex/models_cache.json into ~/.sf/model-catalog/ // openai-codex.json. Without this, the static catalog in // models.generated.ts carries phantom slugs (e.g. gpt-5-codex) that // the ChatGPT-account API rejects with 400 ("model is not supported diff --git a/src/resources/extensions/sf/gemini-catalog.js b/src/resources/extensions/sf/gemini-catalog.js index be443758f..24d765780 100644 --- a/src/resources/extensions/sf/gemini-catalog.js +++ b/src/resources/extensions/sf/gemini-catalog.js @@ -15,22 +15,22 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { sfRuntimeRoot } from "./paths.js"; +import { sfHome } from "./sf-home.js"; const GEMINI_CLI_PROVIDER_ID = "google-gemini-cli"; const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; -function cacheFilePath(basePath) { - return join( - sfRuntimeRoot(basePath), - "model-catalog", - `${GEMINI_CLI_PROVIDER_ID}.json`, - ); +function cacheDir() { + return join(sfHome(), "model-catalog"); } -function isCacheFresh(basePath) { +function cacheFilePath() { + return join(cacheDir(), `${GEMINI_CLI_PROVIDER_ID}.json`); +} + +function isCacheFresh() { try { - const path = cacheFilePath(basePath); + const path = cacheFilePath(); if (!existsSync(path)) return false; const entry = JSON.parse(readFileSync(path, "utf-8")); if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return false; @@ -40,13 +40,11 @@ function isCacheFresh(basePath) { } } -function writeCacheEntry(basePath, modelIds) { +function writeCacheEntry(modelIds) { try { - mkdirSync(join(sfRuntimeRoot(basePath), "model-catalog"), { - recursive: true, - }); + mkdirSync(cacheDir(), { recursive: true }); writeFileSync( - cacheFilePath(basePath), + cacheFilePath(), JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds, @@ -71,7 +69,7 @@ export async function refreshGeminiCatalog(basePath) { ); const modelIds = await discoverGeminiCliModels(basePath); if (!modelIds || modelIds.length === 0) return null; - writeCacheEntry(basePath, modelIds); + writeCacheEntry(modelIds); return modelIds; } catch { return null; @@ -85,7 +83,7 @@ export async function refreshGeminiCatalog(basePath) { * Consumer: bootstrap/register-hooks.js session_start hook. */ export function scheduleGeminiCatalogRefresh(basePath) { - if (isCacheFresh(basePath)) return; + if (isCacheFresh()) return; setImmediate(async () => { try { await refreshGeminiCatalog(basePath); diff --git a/src/resources/extensions/sf/model-catalog-cache.js b/src/resources/extensions/sf/model-catalog-cache.js index eded11146..67a14ac35 100644 --- a/src/resources/extensions/sf/model-catalog-cache.js +++ b/src/resources/extensions/sf/model-catalog-cache.js @@ -2,9 +2,11 @@ * model-catalog-cache.js — live model-list discovery with disk cache. * * Fetches model lists from providers that publish a /v1/models (or equivalent) - * endpoint. Results are cached to .sf/runtime/model-catalog/.json - * with a 6-hour TTL so the model picker and preference validation see fresh - * model IDs without blocking the event loop. + * endpoint. Results are cached globally to ~/.sf/model-catalog/.json + * (SF_HOME-aware) with a 6-hour TTL so the model picker and preference validation + * see fresh model IDs without blocking the event loop. Catalog contents are + * provider-level — identical across projects — so a single global cache avoids + * the per-repo re-discovery cost. * * For providers that the SDK's built-in discovery adapter registry does NOT * cover with supportsDiscovery=true (opencode, opencode-go, kimi-coding, xiaomi), @@ -17,7 +19,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { sfHome } from "./sf-home.js"; -import { sfRuntimeRoot } from "./paths.js"; import { DISCOVERABLE_PROVIDER_IDS, getProviderCatalogConfig, @@ -48,12 +49,14 @@ const SDK_NATIVE_DISCOVERY_PROVIDERS = new Set([ const SDK_DISCOVERY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — matches DISCOVERY_TTLS defaults -function cacheDirPath(basePath) { - return join(sfRuntimeRoot(basePath), "model-catalog"); +// Global catalog dir — identical across projects. `basePath` is accepted on +// public API for backward compat but ignored for path resolution. +function cacheDirPath(_basePath) { + return join(sfHome(), "model-catalog"); } -function cacheFilePath(basePath, providerId) { - return join(cacheDirPath(basePath), `${providerId}.json`); +function cacheFilePath(_basePath, providerId) { + return join(cacheDirPath(), `${providerId}.json`); } /** diff --git a/src/resources/extensions/sf/openai-codex-catalog.js b/src/resources/extensions/sf/openai-codex-catalog.js index cd86cb6c0..5da4273a0 100644 --- a/src/resources/extensions/sf/openai-codex-catalog.js +++ b/src/resources/extensions/sf/openai-codex-catalog.js @@ -12,22 +12,22 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { sfRuntimeRoot } from "./paths.js"; +import { sfHome } from "./sf-home.js"; const PROVIDER_ID = "openai-codex"; const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; -function sfCacheFilePath(basePath) { - return join( - sfRuntimeRoot(basePath), - "model-catalog", - `${PROVIDER_ID}.json`, - ); +function sfCacheDir() { + return join(sfHome(), "model-catalog"); } -function isSfCacheFresh(basePath) { +function sfCacheFilePath() { + return join(sfCacheDir(), `${PROVIDER_ID}.json`); +} + +function isSfCacheFresh() { try { - const path = sfCacheFilePath(basePath); + const path = sfCacheFilePath(); if (!existsSync(path)) return false; const entry = JSON.parse(readFileSync(path, "utf-8")); if (!entry?.fetchedAt || !Array.isArray(entry.modelIds)) return false; @@ -37,13 +37,11 @@ function isSfCacheFresh(basePath) { } } -function writeSfCache(basePath, modelIds) { +function writeSfCache(modelIds) { try { - mkdirSync(join(sfRuntimeRoot(basePath), "model-catalog"), { - recursive: true, - }); + mkdirSync(sfCacheDir(), { recursive: true }); writeFileSync( - sfCacheFilePath(basePath), + sfCacheFilePath(), JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds, @@ -80,10 +78,10 @@ export async function readCodexAvailableModels() { * * Consumer: scheduleOpenaiCodexCatalogRefresh during session_start. */ -export async function refreshOpenaiCodexCatalog(basePath) { +export async function refreshOpenaiCodexCatalog(_basePath) { const modelIds = await readCodexAvailableModels(); if (!modelIds || modelIds.length === 0) return null; - writeSfCache(basePath, modelIds); + writeSfCache(modelIds); return modelIds; } @@ -93,7 +91,7 @@ export async function refreshOpenaiCodexCatalog(basePath) { * Consumer: bootstrap/register-hooks.js session_start hook. */ export function scheduleOpenaiCodexCatalogRefresh(basePath) { - if (isSfCacheFresh(basePath)) return; + if (isSfCacheFresh()) return; setImmediate(async () => { try { await refreshOpenaiCodexCatalog(basePath); diff --git a/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs index b29eaa56c..b322d14c5 100644 --- a/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs +++ b/src/resources/extensions/sf/tests/discovery-cache-pricing.test.mjs @@ -25,24 +25,36 @@ import { isProviderModelAllowed } from "../preferences-models.js"; // ─── Test helpers ───────────────────────────────────────────────────────────── const tmpDirs = []; +let originalSfHomeTop; + +beforeEach(() => { + originalSfHomeTop = process.env.SF_HOME; +}); afterEach(() => { while (tmpDirs.length > 0) { rmSync(tmpDirs.pop(), { recursive: true, force: true }); } + if (originalSfHomeTop === undefined) delete process.env.SF_HOME; + else process.env.SF_HOME = originalSfHomeTop; }); function tempBasePath() { const dir = mkdtempSync(join(tmpdir(), "sf-discovery-cache-test-")); tmpDirs.push(dir); + // Catalog is global (~/.sf/model-catalog) — pin SF_HOME at the temp dir so + // each test's reads/writes land in isolation. + process.env.SF_HOME = dir; return dir; } -/** Write a model-catalog cache entry directly for testing. */ +/** Write a model-catalog cache entry directly for testing. + * Catalog is global (~/.sf/model-catalog/) — tests must point SF_HOME at the + * temp basePath for isolation. The caller is expected to have set + * `process.env.SF_HOME = basePath` before invoking this helper. + */ 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"); + const dir = join(basePath, "model-catalog"); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, `${providerId}.json`), @@ -193,7 +205,8 @@ 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"); + // Global catalog dir (tempBasePath() pins SF_HOME=basePath). + const dir = join(basePath, "model-catalog"); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "openrouter.json"), @@ -229,8 +242,9 @@ describe("isProviderModelAllowed — openrouter zero-cost policy", () => { }); function makeProjectWithDiscoveryCache(modelEntries) { + // tempBasePath() pins SF_HOME at the temp dir, so the global catalog + // writes/reads land in isolation per test. 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); diff --git a/src/resources/extensions/sf/tests/openai-codex-catalog.test.mjs b/src/resources/extensions/sf/tests/openai-codex-catalog.test.mjs index 33b1c1f74..24c4af581 100644 --- a/src/resources/extensions/sf/tests/openai-codex-catalog.test.mjs +++ b/src/resources/extensions/sf/tests/openai-codex-catalog.test.mjs @@ -22,9 +22,11 @@ import { const tmpDirs = []; const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME; +let originalSfHome; beforeEach(() => { delete process.env.CODEX_HOME; + originalSfHome = process.env.SF_HOME; }); afterEach(() => { @@ -33,6 +35,8 @@ afterEach(() => { } else { process.env.CODEX_HOME = ORIGINAL_CODEX_HOME; } + if (originalSfHome === undefined) delete process.env.SF_HOME; + else process.env.SF_HOME = originalSfHome; while (tmpDirs.length > 0) { const dir = tmpDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); @@ -43,6 +47,9 @@ function makeProject() { const dir = mkdtempSync(join(tmpdir(), "sf-codex-catalog-")); tmpDirs.push(dir); mkdirSync(join(dir, ".sf"), { recursive: true }); + // Catalog is global (~/.sf/model-catalog/) — pin SF_HOME at the temp dir + // so each test's catalog reads/writes land in isolation. + process.env.SF_HOME = dir; return dir; } @@ -137,12 +144,7 @@ describe("refreshOpenaiCodexCatalog", () => { "gpt-5.2", ]); - const cachePath = join( - project, - ".sf", - "model-catalog", - "openai-codex.json", - ); + const cachePath = join(project, "model-catalog", "openai-codex.json"); const cache = JSON.parse(readFileSync(cachePath, "utf-8")); expect(cache.modelIds).toEqual(result); expect(typeof cache.fetchedAt).toBe("string"); @@ -180,12 +182,7 @@ describe("scheduleOpenaiCodexCatalogRefresh", () => { scheduleOpenaiCodexCatalogRefresh(project); - const cachePath = join( - project, - ".sf", - "model-catalog", - "openai-codex.json", - ); + const cachePath = join(project, "model-catalog", "openai-codex.json"); const cache = await waitForCacheWrite(cachePath); expect(cache.modelIds).toContain("gpt-5.5"); }); @@ -195,9 +192,9 @@ describe("scheduleOpenaiCodexCatalogRefresh", () => { makeCodexHome(REAL_SHAPE_CACHE); // Pre-seed a fresh cache with a deliberately different model list // to confirm the next call does NOT overwrite it. - mkdirSync(join(project, ".sf", "model-catalog"), { recursive: true }); + mkdirSync(join(project, "model-catalog"), { recursive: true }); writeFileSync( - join(project, ".sf", "model-catalog", "openai-codex.json"), + join(project, "model-catalog", "openai-codex.json"), JSON.stringify({ fetchedAt: new Date().toISOString(), modelIds: ["sentinel-only"], @@ -211,7 +208,7 @@ describe("scheduleOpenaiCodexCatalogRefresh", () => { const cache = JSON.parse( readFileSync( - join(project, ".sf", "model-catalog", "openai-codex.json"), + join(project, "model-catalog", "openai-codex.json"), "utf-8", ), ); diff --git a/src/resources/extensions/sf/tests/provider-catalog-discovery.test.mjs b/src/resources/extensions/sf/tests/provider-catalog-discovery.test.mjs index ce59fd5b7..9aff77191 100644 --- a/src/resources/extensions/sf/tests/provider-catalog-discovery.test.mjs +++ b/src/resources/extensions/sf/tests/provider-catalog-discovery.test.mjs @@ -476,12 +476,10 @@ describe("refreshSfManagedProviders — auth header and iteration", () => { const { refreshSfManagedProviders } = await import("../model-catalog-cache.js"); const { writeFileSync, mkdirSync } = await import("node:fs"); - // Pre-populate the runtime cache for opencode to simulate a cache hit. - // sfRoot(tmp) → join(tmp, ".sf") when no .sf dir exists (probeSfRoot fallback). - // cacheDirPath(tmp) → join(sfRuntimeRoot(tmp), "model-catalog") - // → join(sfRoot(tmp), "model-catalog") (isRunningOnSelf=false for tmp) - // → join(tmp, ".sf", "model-catalog") - const cacheDir = join(tmp, ".sf", "model-catalog"); + // Pre-populate the global catalog for opencode to simulate a cache hit. + // SF_HOME is pinned to `tmp` in beforeEach, so the global catalog dir + // resolves to join(tmp, "model-catalog"). + const cacheDir = join(tmp, "model-catalog"); mkdirSync(cacheDir, { recursive: true }); writeFileSync( join(cacheDir, "opencode.json"),