sf snapshot: uncommitted changes after 56m inactivity

This commit is contained in:
Mikael Hugo 2026-05-16 16:26:40 +02:00
parent 4442400d11
commit b73e386090
7 changed files with 80 additions and 72 deletions

View file

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

View file

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

View file

@ -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/<providerId>.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/<providerId>.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`);
}
/**

View file

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

View file

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

View file

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

View file

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