diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 5d6970041..bfd60e34d 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -109,6 +109,9 @@ - Fixed LiteLLM discovery stopping at `/model_group/info` when that endpoint omitted `supports_vision`; it now continues to `/model/info` and preserves `model_info.supports_vision=true` for vision-capable proxy models. ([#4747](https://github.com/can1357/oh-my-pi/issues/4747)) - Fixed LiteLLM discovery to fall back to bundled catalog metadata when `models.dev` lacks a model reference, preserving reasoning and thinking support for models such as `glm-5.2`. ([#4695](https://github.com/can1357/oh-my-pi/issues/4695)) - Detected Azure AI Inference / Foundry Anthropic routes as strict-tool-incompatible so resolved Anthropic compat disables strict tools before request construction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)). +### Fixed + +- Fixed OpenCode Zen and Go discovery to replace stale bundled models with each provider's live model catalog. ([#4769](https://github.com/can1357/oh-my-pi/issues/4769)) ## [16.3.11] - 2026-07-06 diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 57c169220..58fac8b15 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -311,12 +311,14 @@ export const CATALOG_PROVIDERS = [ defaultModel: "kimi-k2.7-code", envVars: ["OPENCODE_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => opencodeGoModelManagerOptions(config), + dynamicModelsAuthoritative: true, }, { id: "opencode-zen", defaultModel: "claude-opus-4-8", envVars: ["OPENCODE_API_KEY"], createModelManagerOptions: (config: ModelManagerConfig) => opencodeZenModelManagerOptions(config), + dynamicModelsAuthoritative: true, }, { id: "openrouter", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index fc0212541..f358bf4e5 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -2036,6 +2036,16 @@ 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, @@ -2047,6 +2057,8 @@ function openCodeModelManagerOptions( const references = createBundledReferenceMap(providerId); return { providerId, + cacheProviderId: openCodeModelCacheProviderId(providerId, apiKey, discoveryBaseUrl), + dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ diff --git a/packages/catalog/test/opencode-provider.test.ts b/packages/catalog/test/opencode-provider.test.ts new file mode 100644 index 000000000..ec340be61 --- /dev/null +++ b/packages/catalog/test/opencode-provider.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; +import { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { + opencodeGoModelManagerOptions, + opencodeZenModelManagerOptions, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; + +const LIVE_FREE_MODEL_IDS = [ + "deepseek-v4-flash-free", + "hy3-free", + "mimo-v2.5-free", + "nemotron-3-ultra-free", + "north-mini-code-free", +] as const; + +const LIVE_PAID_MODEL_IDS = ["claude-opus-4-8", "gpt-5.5"] as const; + +function modelListResponse(ids: readonly string[]): Response { + return Response.json({ + object: "list", + data: ids.map(id => ({ id, object: "model", owned_by: "opencode" })), + }); +} + +describe("OpenCode provider discovery", () => { + test("treats the OpenCode model endpoints as authoritative catalogs", () => { + for (const providerId of ["opencode-go", "opencode-zen"]) { + const descriptor = PROVIDER_DESCRIPTORS.find(item => item.providerId === providerId); + expect(descriptor?.dynamicModelsAuthoritative).toBe(true); + } + expect(opencodeGoModelManagerOptions().dynamicModelsAuthoritative).toBe(true); + expect(opencodeZenModelManagerOptions().dynamicModelsAuthoritative).toBe(true); + }); + + test("replaces stale bundled Zen models with each credential's live endpoint list", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-opencode-zen-")); + try { + let freeFetches = 0; + const freeOptions = opencodeZenModelManagerOptions({ + apiKey: "free-account-key", + fetch: async () => { + freeFetches++; + return modelListResponse(LIVE_FREE_MODEL_IDS); + }, + }); + const freeResult = await resolveProviderModels( + { ...freeOptions, cacheDbPath: path.join(tempDir, "models.db") }, + "online-if-uncached", + ); + + let paidFetches = 0; + const paidOptions = opencodeZenModelManagerOptions({ + apiKey: "paid-account-key", + fetch: async () => { + paidFetches++; + return modelListResponse(LIVE_PAID_MODEL_IDS); + }, + }); + const paidResult = await resolveProviderModels( + { ...paidOptions, cacheDbPath: path.join(tempDir, "models.db") }, + "online-if-uncached", + ); + + expect(freeOptions.cacheProviderId).not.toBe(paidOptions.cacheProviderId); + expect(freeResult.stale).toBe(false); + expect(freeResult.models.map(model => model.id).sort()).toEqual([...LIVE_FREE_MODEL_IDS].sort()); + expect(paidResult.stale).toBe(false); + expect(paidResult.models.map(model => model.id).sort()).toEqual([...LIVE_PAID_MODEL_IDS].sort()); + expect([freeFetches, paidFetches]).toEqual([1, 1]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +});