From 54ae233ebc35a02aed59ff4ce02a7a0874c34c9b Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 27 Jul 2026 01:25:13 +0000 Subject: [PATCH] refactor(catalog): centralize provider cache IDs --- .../src/provider-models/cache-provider-id.ts | 47 +++++++++++++++++++ packages/catalog/src/provider-models/index.ts | 1 + .../src/provider-models/openai-compat.ts | 30 +++++------- .../catalog/src/provider-models/special.ts | 5 +- .../catalog/test/provider-cache-id.test.ts | 25 ++++++++++ .../coding-agent/src/config/model-registry.ts | 27 +++-------- 6 files changed, 92 insertions(+), 43 deletions(-) create mode 100644 packages/catalog/src/provider-models/cache-provider-id.ts create mode 100644 packages/catalog/test/provider-cache-id.test.ts diff --git a/packages/catalog/src/provider-models/cache-provider-id.ts b/packages/catalog/src/provider-models/cache-provider-id.ts new file mode 100644 index 000000000..aaa16401d --- /dev/null +++ b/packages/catalog/src/provider-models/cache-provider-id.ts @@ -0,0 +1,47 @@ +export interface ModelCacheProviderIdOptions { + apiKey?: string; + baseUrl?: string; +} + +export function getDefaultModelDiscoveryBaseUrl(providerId: string): string | undefined { + switch (providerId) { + case "litellm": + return Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1"; + case "opencode-go": + return "https://opencode.ai/zen/go/v1"; + case "opencode-zen": + return "https://opencode.ai/zen/v1"; + case "vllm": + return "http://127.0.0.1:8000/v1"; + default: + return undefined; + } +} + +/** Resolve the cache namespace used by a provider's model-manager options without constructing those options. */ +export function resolveModelCacheProviderId(providerId: string, options: ModelCacheProviderIdOptions = {}): string { + switch (providerId) { + case "cursor": + return "cursor:max-mode-v2"; + case "litellm": { + const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; + return `litellm:rich-v5:${Bun.hash(baseUrl).toString(36)}`; + } + case "opencode-go": + case "opencode-zen": { + const configuredBaseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; + const trimmedBaseUrl = configuredBaseUrl.endsWith("/") ? configuredBaseUrl.slice(0, -1) : configuredBaseUrl; + const discoveryBaseUrl = trimmedBaseUrl.endsWith("/v1") ? trimmedBaseUrl : `${trimmedBaseUrl}/v1`; + const scope = `${options.apiKey ?? ""}\u0000${discoveryBaseUrl}`; + return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`; + } + case "openrouter": + return "openrouter:pseudo-api"; + case "vllm": { + const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; + return `vllm:${Bun.hash(baseUrl).toString(36)}`; + } + default: + return providerId; + } +} diff --git a/packages/catalog/src/provider-models/index.ts b/packages/catalog/src/provider-models/index.ts index 9ff9da9e2..7a8ed4ce1 100644 --- a/packages/catalog/src/provider-models/index.ts +++ b/packages/catalog/src/provider-models/index.ts @@ -1,3 +1,4 @@ +export * from "./cache-provider-id"; export * from "./descriptor-types"; export * from "./descriptors"; export * from "./google"; diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 10d63160e..fac430654 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -27,6 +27,7 @@ import { parseGitHubCopilotApiKey, } from "../wire/github-copilot"; import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references"; +import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id"; const MODELS_DEV_URL = "https://models.dev/api.json"; @@ -2065,28 +2066,19 @@ function openCodeBaseUrlForApi(api: Api, basePath: string): string { return api === "anthropic-messages" ? basePath : `${basePath}/v1`; } -function openCodeModelCacheProviderId( - providerId: "opencode-go" | "opencode-zen", - apiKey: string | undefined, - discoveryBaseUrl: string, -): string { - // OpenCode catalogs are entitlement-scoped; isolate authoritative rows by credential and endpoint. - const scope = `${apiKey ?? ""}\u0000${discoveryBaseUrl}`; - return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`; -} - function openCodeModelManagerOptions( providerId: "opencode-go" | "opencode-zen", - defaultBasePath: string, config?: OpenCodeModelManagerConfig, ): ModelManagerOptions { const apiKey = config?.apiKey; + const defaultBaseUrl = getDefaultModelDiscoveryBaseUrl(providerId)!; + const defaultBasePath = defaultBaseUrl.endsWith("/v1") ? defaultBaseUrl.slice(0, -3) : defaultBaseUrl; const basePath = normalizeOpenCodeBasePath(config?.baseUrl, defaultBasePath); const discoveryBaseUrl = openCodeBaseUrlForApi("openai-completions", basePath); const references = createBundledReferenceMap(providerId); return { providerId, - cacheProviderId: openCodeModelCacheProviderId(providerId, apiKey, discoveryBaseUrl), + cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl: discoveryBaseUrl }), dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => @@ -2120,11 +2112,11 @@ function openCodeModelManagerOptions( } export function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions { - return openCodeModelManagerOptions("opencode-zen", "https://opencode.ai/zen", config); + return openCodeModelManagerOptions("opencode-zen", config); } export function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions { - return openCodeModelManagerOptions("opencode-go", "https://opencode.ai/zen/go", config); + return openCodeModelManagerOptions("opencode-go", config); } // --------------------------------------------------------------------------- @@ -2211,7 +2203,7 @@ export function openrouterModelManagerOptions( // Older builds cached OpenRouter discovery rows as `api: "openai-completions"`. // Namespace the refreshed pseudo-API cache separately so those rows cannot // override bundled `api: "openrouter"` models during online-if-uncached startup. - cacheProviderId: "openrouter:pseudo-api", + cacheProviderId: resolveModelCacheProviderId("openrouter"), fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openrouter", @@ -3970,7 +3962,7 @@ export function litellmModelManagerOptions( config?: LiteLLMModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; - const baseUrl = config?.baseUrl ?? Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1"; + const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!; return { providerId: "litellm", // rich-v5 invalidates rows cached before rich metadata pricing was mapped. @@ -3979,7 +3971,7 @@ export function litellmModelManagerOptions( // and filtered placeholder-only `all-team-models` rows. Bump the version // whenever the mappers below change, or warm authoritative caches keep // serving pre-change rows for the full TTL. - cacheProviderId: `litellm:rich-v5:${Bun.hash(baseUrl).toString(36)}`, + cacheProviderId: resolveModelCacheProviderId("litellm", { baseUrl }), // litellm is a local-only proxy and is never bundled in models.json (that // would leak the machine's localhost catalog). Prefer the proxy's richer // management metadata, then enrich ids against models.dev with the bundled @@ -4026,11 +4018,11 @@ export interface VllmModelManagerConfig { export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; - const baseUrl = config?.baseUrl ?? "http://127.0.0.1:8000/v1"; + const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("vllm")!; const references = createBundledReferenceMap<"openai-completions">("vllm" as Parameters[0]); return { providerId: "vllm", - cacheProviderId: `vllm:${Bun.hash(baseUrl).toString(36)}`, + cacheProviderId: resolveModelCacheProviderId("vllm", { baseUrl }), fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", diff --git a/packages/catalog/src/provider-models/special.ts b/packages/catalog/src/provider-models/special.ts index d9fd2a70a..a913c7fae 100644 --- a/packages/catalog/src/provider-models/special.ts +++ b/packages/catalog/src/provider-models/special.ts @@ -4,6 +4,7 @@ import type { DevinModelDiscoveryOptions } from "../discovery/devin"; import { buildGitLabDuoWorkflowFallbackModel, fetchGitLabDuoWorkflowModels } from "../discovery/gitlab-duo-workflow"; import type { ModelManagerOptions } from "../model-manager"; import type { FetchImpl, ModelSpec } from "../types"; +import { resolveModelCacheProviderId } from "./cache-provider-id"; // --------------------------------------------------------------------------- // OpenAI Codex @@ -94,13 +95,11 @@ export interface CursorModelManagerConfig { clientVersion?: string; } -const CURSOR_CACHE_PROVIDER_ID = "cursor:max-mode-v2"; - export function cursorModelManagerOptions(config: CursorModelManagerConfig = {}): ModelManagerOptions<"cursor-agent"> { const { apiKey, baseUrl, clientVersion } = config; return { providerId: "cursor", - cacheProviderId: CURSOR_CACHE_PROVIDER_ID, + cacheProviderId: resolveModelCacheProviderId("cursor"), ...(apiKey ? { fetchDynamicModels: async () => { diff --git a/packages/catalog/test/provider-cache-id.test.ts b/packages/catalog/test/provider-cache-id.test.ts new file mode 100644 index 000000000..9e673d997 --- /dev/null +++ b/packages/catalog/test/provider-cache-id.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { PROVIDER_DESCRIPTORS, resolveModelCacheProviderId } from "@oh-my-pi/pi-catalog/provider-models"; + +test("lightweight cache resolver matches every descriptor default", () => { + for (const descriptor of PROVIDER_DESCRIPTORS) { + const options = descriptor.createModelManagerOptions({}); + expect(resolveModelCacheProviderId(descriptor.providerId)).toBe(options.cacheProviderId ?? descriptor.providerId); + } +}); + +test("lightweight cache resolver matches scoped descriptor inputs", () => { + const cases = [ + { providerId: "litellm", baseUrl: "http://litellm.example:4100/v1" }, + { providerId: "opencode-go", baseUrl: "https://opencode.example/go" }, + { providerId: "opencode-zen", baseUrl: "https://opencode.example/zen/v1/" }, + { providerId: "vllm", baseUrl: "http://vllm.example:8000/v1" }, + ] as const; + for (const { providerId, baseUrl } of cases) { + const descriptor = PROVIDER_DESCRIPTORS.find(candidate => candidate.providerId === providerId); + if (!descriptor) throw new Error(`Missing descriptor for ${providerId}`); + const config = { apiKey: "cache-test-key", baseUrl }; + const options = descriptor.createModelManagerOptions(config); + expect(resolveModelCacheProviderId(providerId, config)).toBe(options.cacheProviderId ?? providerId); + } +}); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 18e4a7eee..0c37cbeee 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -26,6 +26,7 @@ import { type OpenAICodexAccount, openaiCodexModelManagerOptions, PROVIDER_DESCRIPTORS, + resolveModelCacheProviderId, } from "@oh-my-pi/pi-catalog/provider-models"; import { collapseBuiltModelVariants, @@ -44,18 +45,6 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS, ]; -// Cache namespaces whose descriptor defaults intentionally differ from the -// provider id. Keep startup cache reads lightweight: constructing all provider -// manager options also constructs their bundled reference maps. -const DEFAULT_STARTUP_MODEL_CACHE_PROVIDER_IDS: Readonly> = { - cursor: "cursor:max-mode-v2", - litellm: `litellm:rich-v5:${Bun.hash(Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1").toString(36)}`, - "opencode-go": `opencode-go:models-v1:${Bun.hash("\u0000https://opencode.ai/zen/go/v1").toString(36)}`, - "opencode-zen": `opencode-zen:models-v1:${Bun.hash("\u0000https://opencode.ai/zen/v1").toString(36)}`, - openrouter: "openrouter:pseudo-api", - vllm: `vllm:${Bun.hash("http://127.0.0.1:8000/v1").toString(36)}`, -}; - // Sentinels for local-only OAuth tokens — declared inline to avoid loading // provider modules at startup. Must match packages/ai/src/registry/llama-cpp.ts, // packages/ai/src/registry/lm-studio.ts, and packages/ai/src/registry/vllm.ts. @@ -1231,15 +1220,11 @@ export class ModelRegistry { } #resolveStartupModelCacheProviderId(providerId: string): string { - const explicitBaseUrl = - this.#runtimeProviderOverrides.get(providerId)?.baseUrl ?? this.#providerOverrides.get(providerId)?.baseUrl; - if (explicitBaseUrl === undefined && !this.#hasFullSnapshot) { - return DEFAULT_STARTUP_MODEL_CACHE_PROVIDER_IDS[providerId] ?? providerId; - } - const descriptor = PROVIDER_DESCRIPTORS.find(candidate => candidate.providerId === providerId); - if (!descriptor) return providerId; - const baseUrl = explicitBaseUrl ?? this.getProviderBaseUrl(providerId); - return descriptor.createModelManagerOptions({ baseUrl, fetch: this.#fetch }).cacheProviderId ?? providerId; + const baseUrl = + this.#runtimeProviderOverrides.get(providerId)?.baseUrl ?? + this.#providerOverrides.get(providerId)?.baseUrl ?? + (this.#hasFullSnapshot ? this.getProviderBaseUrl(providerId) : undefined); + return resolveModelCacheProviderId(providerId, { baseUrl }); } #loadCachedStandardProviderModels(): { models: Model[]; authoritativeFreshProviders: Set } {