From 26a4a4f89e6421d6addbcb64b35c9a7a22f09029 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 15:58:41 +0000 Subject: [PATCH 1/2] fix(catalog): made opencode discovery authoritative OpenCode Zen and Go now replace bundled fallback models after successful live discovery instead of retaining removed entries. Fixes #4769 --- packages/catalog/CHANGELOG.md | 4 ++ .../src/provider-models/descriptors.ts | 2 + .../src/provider-models/openai-compat.ts | 1 + .../catalog/test/opencode-provider.test.ts | 52 +++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 packages/catalog/test/opencode-provider.test.ts diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 42e39218a..7c34ebd54 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Added diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 29a80ecf4..a73849d0a 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -302,12 +302,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 d43a20143..6a6563623 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1887,6 +1887,7 @@ function openCodeModelManagerOptions( const references = createBundledReferenceMap(providerId); return { providerId, + 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..7e3a399a7 --- /dev/null +++ b/packages/catalog/test/opencode-provider.test.ts @@ -0,0 +1,52 @@ +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; + +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 the live endpoint list", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-opencode-zen-")); + try { + const options = opencodeZenModelManagerOptions({ + apiKey: "zen-test-key", + fetch: async () => + Response.json({ + object: "list", + data: LIVE_FREE_MODEL_IDS.map(id => ({ id, object: "model", owned_by: "opencode" })), + }), + }); + const result = await resolveProviderModels( + { ...options, cacheDbPath: path.join(tempDir, "models.db") }, + "online", + ); + + expect(result.stale).toBe(false); + expect(result.models.map(model => model.id).sort()).toEqual([...LIVE_FREE_MODEL_IDS].sort()); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); From b61dddc783b77e04c8680b1090e12a5c51e6ab02 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 20:08:32 +0000 Subject: [PATCH 2/2] fix(catalog): partitioned opencode model caches Authoritative OpenCode model catalogs now use credential-and-endpoint-specific cache namespaces so one account cannot reuse another account's entitlements. Fixes #4769 --- .../src/provider-models/openai-compat.ts | 11 ++++ .../catalog/test/opencode-provider.test.ts | 52 ++++++++++++++----- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 6a6563623..4d246c5db 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1876,6 +1876,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, @@ -1887,6 +1897,7 @@ function openCodeModelManagerOptions( const references = createBundledReferenceMap(providerId); return { providerId, + cacheProviderId: openCodeModelCacheProviderId(providerId, apiKey, discoveryBaseUrl), dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => diff --git a/packages/catalog/test/opencode-provider.test.ts b/packages/catalog/test/opencode-provider.test.ts index 7e3a399a7..ec340be61 100644 --- a/packages/catalog/test/opencode-provider.test.ts +++ b/packages/catalog/test/opencode-provider.test.ts @@ -17,6 +17,15 @@ const LIVE_FREE_MODEL_IDS = [ "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"]) { @@ -27,24 +36,41 @@ describe("OpenCode provider discovery", () => { expect(opencodeZenModelManagerOptions().dynamicModelsAuthoritative).toBe(true); }); - test("replaces stale bundled Zen models with the live endpoint list", async () => { + 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 { - const options = opencodeZenModelManagerOptions({ - apiKey: "zen-test-key", - fetch: async () => - Response.json({ - object: "list", - data: LIVE_FREE_MODEL_IDS.map(id => ({ id, object: "model", owned_by: "opencode" })), - }), + let freeFetches = 0; + const freeOptions = opencodeZenModelManagerOptions({ + apiKey: "free-account-key", + fetch: async () => { + freeFetches++; + return modelListResponse(LIVE_FREE_MODEL_IDS); + }, }); - const result = await resolveProviderModels( - { ...options, cacheDbPath: path.join(tempDir, "models.db") }, - "online", + const freeResult = await resolveProviderModels( + { ...freeOptions, cacheDbPath: path.join(tempDir, "models.db") }, + "online-if-uncached", ); - expect(result.stale).toBe(false); - expect(result.models.map(model => model.id).sort()).toEqual([...LIVE_FREE_MODEL_IDS].sort()); + 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 }); }