From 63d9c69c117c33e67fa2aca271fb5849c7f280e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 23:38:11 -0700 Subject: [PATCH] fix(opencode): discover Zen and Go models on demand (#129831) * fix(opencode): discover provider models on demand * fix(opencode): honor provider-scoped catalog discovery * fix(opencode): scope Go models to authenticated catalogs * test(opencode): verify dynamic Go models efficiently --- config/assertion-safety-baseline.txt | 1 - docs/providers/opencode-go.md | 21 +- docs/providers/opencode.md | 36 +- extensions/opencode-go/index.test.ts | 554 ++++---- extensions/opencode-go/index.ts | 3 + extensions/opencode-go/openclaw.plugin.json | 29 + .../opencode-go/opencode-go.live.test.ts | 65 +- extensions/opencode-go/provider-catalog.ts | 280 ++-- extensions/opencode/index.test.ts | 1139 ++++++----------- extensions/opencode/index.ts | 25 + extensions/opencode/openclaw.plugin.json | 526 +------- extensions/opencode/opencode.live.test.ts | 60 +- extensions/opencode/provider-catalog.ts | 547 ++------ ...rovider-catalog-live-normalize.internal.ts | 218 ++++ .../provider-catalog-live-runtime.ts | 96 +- .../provider-catalog-live-upstream.test.ts | 213 +++ 16 files changed, 1573 insertions(+), 2240 deletions(-) create mode 100644 src/plugin-sdk/provider-catalog-live-upstream.test.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 7a689bf40b28..0a7ba7d4fe30 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -948,7 +948,6 @@ extensions/openai/realtime-voice-session-policy.ts 4 extensions/openai/speech-provider.ts 2 extensions/openai/tts.ts 2 extensions/openai/video-generation-provider.ts 3 -extensions/opencode-go/provider-catalog.ts 1 extensions/opencode-go/provider-policy-api.ts 1 extensions/opencode-go/reasoning-sanitizer.ts 2 extensions/opencode-go/stream-termination.ts 9 diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 33ef1167276b..37be480124b7 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -74,6 +74,13 @@ interactive onboarding or pass the shared OpenCode API key directly. ## Catalog Run `openclaw models list --provider opencode-go` for the current model list. +OpenClaw combines the models available to your Go account with authoritative +metadata from `https://models.opencode.ai/api.json`, so new upstream models +appear without an OpenClaw update. The upstream catalog is downloaded and +cached only when OpenCode Zen or Go is configured or explicitly selected with +OpenCode credentials; it is never fetched at startup or while using unrelated +providers. + Current active rows: | Model ref | Context | Max output | Inputs | Transport | @@ -92,19 +99,23 @@ Current active rows: | `opencode-go/mimo-v2.5-pro` | 1,048,576 | 128,000 | Text | Chat | | `opencode-go/minimax-m2.7` | 204,800 | 131,072 | Text | Messages | | `opencode-go/minimax-m3` | 1M | 131,072 | Text, image | Messages | +| `opencode-go/ox-alpha-free` | 1M | 131,072 | Text, image | Chat | | `opencode-go/qwen3.6-plus` | 1M | 65,536 | Text, image | Messages | | `opencode-go/qwen3.7-max` | 1M | 65,536 | Text | Messages | | `opencode-go/qwen3.7-plus` | 1M | 65,536 | Text, image | Messages | | `opencode-go/qwen3.8-max` | 1M | 131,072 | Text, image | Messages | -Deprecated and preview refs remain resolvable only for existing explicit -configurations. They are not part of static or live recommendations. +Current upstream preview models appear while available. Deprecated refs remain +resolvable only for existing explicit configurations and are not recommended. + +Ox Alpha Free is free for a limited time, but accessing the Go catalog still +requires a paid OpenCode Go subscription. ## Privacy -OpenCode's current policy says model training is not used for any active Go -route. Grok 4.5 and GPT-5.6 Luna retain data for up to 30 days; the other active -Go routes list zero-day retention. Review the current +OpenCode lists zero data retention and no model training for Ox Alpha Free. +Privacy policies vary by model: some routes retain data for up to 30 days, and +the Muse Spark 1.2 Contributor route permits model training. Review the current [OpenCode Go privacy table](https://opencode.ai/docs/go/#privacy) before using a model, because provider policy can change independently of OpenClaw. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 2275130aee63..4a584e930dfc 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -99,30 +99,38 @@ provider ids split so upstream per-model routing stays correct. ### Zen -| Property | Value | -| ---------------- | --------------------------------------------------------------------------------------------------------------------- | -| Runtime provider | `opencode` | -| Example models | `opencode/gpt-5.6-sol`, `opencode/kimi-k3`, `opencode/gemini-3.6-flash`, `opencode/minimax-m3`, `opencode/big-pickle` | +| Property | Value | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime provider | `opencode` | +| Example models | `opencode/gpt-5.6-sol`, `opencode/kimi-k3`, `opencode/gemini-3.6-flash`, `opencode/minimax-m3`, `opencode/big-pickle`, `opencode/x-preview-f-free` | Run `openclaw models list --provider opencode` for the current active list, which also includes the promoted free-tier rows `opencode/big-pickle`, `opencode/deepseek-v4-flash-free`, `opencode/laguna-s-2.1-free`, `opencode/ling-3.0-tiny-free`, `opencode/longcat-2.0-free`, `opencode/mimo-v2.5-free`, -`opencode/nemotron-3-ultra-free`, and `opencode/north-mini-code-free`. +`opencode/nemotron-3-ultra-free`, `opencode/north-mini-code-free`, and +`opencode/x-preview-f-free` (Ox Alpha Free). -Live discovery safely intersects OpenCode's returned IDs with trusted OpenClaw -metadata. A key-scoped response can omit models that are unavailable to that -workspace; that absence does not retire the offline definition. Deprecated -explicit refs remain resolvable for existing configurations but are not shown -as current recommendations. +Live discovery combines the models available to your OpenCode account with +authoritative model metadata from `https://models.opencode.ai/api.json`. +OpenClaw fetches and caches that catalog only when OpenCode Zen or Go is +configured or explicitly selected with OpenCode credentials; startup and +unrelated providers never download it. New upstream models become available +without an OpenClaw update. A key-scoped response can omit models unavailable +to that workspace. Deprecated explicit refs remain resolvable for existing +configurations but are not shown as current recommendations. + +Ox Alpha Free is available for a limited time. OpenCode says this model has +zero data retention and is not used for model training; see the current +[OpenCode Zen pricing and policy](https://opencode.ai/docs/zen/). ### Go -| Property | Value | -| ---------------- | ---------------------------------------------------------------------------- | -| Runtime provider | `opencode-go` | -| Example models | `opencode-go/kimi-k3`, `opencode-go/gpt-5.6-luna`, `opencode-go/qwen3.8-max` | +| Property | Value | +| ---------------- | --------------------------------------------------------------------------------------------------------- | +| Runtime provider | `opencode-go` | +| Example models | `opencode-go/kimi-k3`, `opencode-go/gpt-5.6-luna`, `opencode-go/qwen3.8-max`, `opencode-go/ox-alpha-free` | See [OpenCode Go](/providers/opencode-go) for the full Go model table. diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index e0200af56285..1d99724d1393 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -1,4 +1,3 @@ -import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { registerProviderPlugin, registerSingleProviderPlugin, @@ -21,14 +20,6 @@ import opencodeGoProviderDiscovery from "./provider-discovery.js"; const requireRecord = createRequireRecord("record", "expected-label-record"); -function requireMapEntry(map: Map, id: string): T { - const entry = map.get(id); - if (!entry) { - throw new Error(`expected model ${id}`); - } - return entry; -} - function requireCatalogEntry(entries: readonly unknown[] | null | undefined, id: string) { if (!entries) { throw new Error("expected supplemental catalog entries"); @@ -48,34 +39,46 @@ function runtimeCompatFields(value: unknown): Record | undefine return compat; } -const ACTIVE_MODEL_IDS = [ - "qwen3.7-plus", - "glm-5.1", - "deepseek-v4-flash", - "minimax-m2.7", - "glm-5.2", - "qwen3.7-max", - "kimi-k2.6", - "minimax-m3", - "hy3", - "deepseek-v4-pro", - "qwen3.8-max", - "mimo-v2.5", - "gpt-5.6-luna", - "grok-4.5", - "kimi-k2.7-code", - "kimi-k3", - "mimo-v2.5-pro", - "qwen3.6-plus", -] as const; -const DEPRECATED_MODEL_IDS = [ - "glm-5", - "qwen3.5-plus", - "mimo-v2-omni", - "kimi-k2.5", - "mimo-v2-pro", - "minimax-m2.5", -] as const; +const ACTIVE_MODEL_IDS = manifest.modelCatalog.providers["opencode-go"].models + .filter((model) => !("status" in model)) + .map((model) => model.id); + +function upstreamModel(id: string, overrides: Record = {}) { + return { + id, + name: id, + reasoning: true, + tool_call: true, + modalities: { input: ["text"] }, + limit: { context: 262_144, output: 32_768 }, + cost: { input: 0, output: 0 }, + ...overrides, + }; +} + +function createCatalogFetchGuard(params: { + upstreamModels: Record; + liveModelIds: string[]; +}) { + return vi.fn(async ({ url }: { url: string }) => ({ + response: new Response( + JSON.stringify( + url === "https://models.opencode.ai/api.json" + ? { + "opencode-go": { + id: "opencode-go", + api: "https://opencode.ai/zen/go/v1", + npm: "@ai-sdk/openai-compatible", + models: params.upstreamModels, + }, + } + : { data: params.liveModelIds.map((id) => ({ id, object: "model" })) }, + ), + ), + finalUrl: url, + release: vi.fn(async () => undefined), + })); +} describe("opencode-go provider plugin", () => { beforeEach(() => { @@ -130,32 +133,25 @@ describe("opencode-go provider plugin", () => { }); }); - it("keeps OpenCode Go catalog coverage aligned with upstream", async () => { + it("keeps offline starter models and provider-owned thinking policies usable", async () => { const provider = await registerSingleProviderPlugin(plugin); - expect(provider.catalog).toBeDefined(); + const supplemental = await provider.augmentModelCatalog?.({ entries: [] } as never); - const expectedModelIds = [...ACTIVE_MODEL_IDS, ...DEPRECATED_MODEL_IDS, "hy3-preview"]; - expect(new Set(expectedModelIds).size).toBe(expectedModelIds.length); - const models = new Map(); - for (const modelId of expectedModelIds) { - const model = provider.resolveDynamicModel?.({ modelId } as never); - if (!model) { - throw new Error(`expected OpenCode Go model ${modelId}`); - } - models.set(model.id, model); - } - expect([...models.keys()].toSorted()).toEqual(expectedModelIds.toSorted()); - expect( - provider.resolveThinkingProfile?.({ + for (const modelId of ACTIVE_MODEL_IDS) { + expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({ + id: modelId, provider: "opencode-go", - modelId: "deepseek-v4-pro", - api: "openai-completions", - reasoning: true, - compat: { supportedReasoningEfforts: ["high", "max"] }, - }), - ).toEqual({ - levels: [{ id: "off" }, { id: "high" }, { id: "max" }], - defaultLevel: "high", + }); + } + expect(requireCatalogEntry(supplemental, "hy3-preview").status).toBe("preview"); + expect(provider.resolveDynamicModel?.({ modelId: "kimi-k2.6" } as never)).toMatchObject({ + id: "kimi-k2.6", + input: ["text", "image"], + }); + expect(provider.resolveDynamicModel?.({ modelId: "qwen3.8-max" } as never)).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen/go", + compat: { thinkingFormat: "qwen" }, }); expect( provider.resolveThinkingProfile?.({ @@ -169,35 +165,6 @@ describe("opencode-go provider plugin", () => { levels: [{ id: "off" }, { id: "low" }, { id: "high" }, { id: "max" }], defaultLevel: "high", }); - expect( - provider.resolveThinkingProfile?.({ - provider: "opencode-go", - modelId: "kimi-k3", - api: "openai-completions", - reasoning: true, - compat: { supportedReasoningEfforts: ["max"] }, - }), - ).toEqual({ levels: [{ id: "off" }, { id: "max" }], defaultLevel: "off" }); - expect( - provider.resolveThinkingProfile?.({ - provider: "opencode-go", - modelId: "glm-5", - api: "openai-completions", - reasoning: true, - }), - ).toEqual({ levels: [{ id: "off", label: "always on" }], defaultLevel: "off" }); - expect( - provider.resolveThinkingProfile?.({ - provider: "opencode-go", - modelId: "grok-4.5", - api: "openai-completions", - reasoning: true, - compat: { supportedReasoningEfforts: ["low", "medium", "high"] }, - }), - ).toEqual({ - levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }], - defaultLevel: "medium", - }); expect( provider.resolveThinkingProfile?.({ provider: "opencode-go", @@ -206,161 +173,122 @@ describe("opencode-go provider plugin", () => { reasoning: true, }), ).toEqual({ levels: [{ id: "high", label: "always on" }], defaultLevel: "high" }); - expect( - provider.resolveThinkingProfile?.({ - provider: "opencode-go", - modelId: "minimax-m3", - api: "anthropic-messages", - reasoning: true, - }), - ).toEqual({ - levels: [{ id: "off" }, { id: "high", label: "on" }], - defaultLevel: "high", - }); - const supplemental = await provider.augmentModelCatalog?.({ - entries: [...models.values()].map((model) => ({ - provider: model.provider, - id: model.id, - name: model.name, - })), - } as never); - const supplementalIds = (supplemental ?? []).map((entry) => entry.id); - expect(new Set(supplementalIds).size).toBe(supplementalIds.length); - expect(supplementalIds.toSorted()).toEqual(expectedModelIds.toSorted()); - const deepSeekPro = requireCatalogEntry(supplemental, "deepseek-v4-pro"); - expect(deepSeekPro.provider).toBe("opencode-go"); - expect(deepSeekPro.name).toBe("DeepSeek V4 Pro"); - const deepSeekFlash = requireCatalogEntry(supplemental, "deepseek-v4-flash"); - expect(deepSeekFlash.provider).toBe("opencode-go"); - expect(deepSeekFlash.name).toBe("DeepSeek V4 Flash"); - for (const modelId of DEPRECATED_MODEL_IDS) { - expect(requireCatalogEntry(supplemental, modelId).status).toBe("deprecated"); - expect(requireCatalogEntry(supplemental, modelId).replacedBy).toBeUndefined(); - } - for (const modelId of ACTIVE_MODEL_IDS) { - expect(requireCatalogEntry(supplemental, modelId).status).toBeUndefined(); - } - expect(requireCatalogEntry(supplemental, "hy3-preview").status).toBe("preview"); + }); - const glm52 = requireMapEntry(models, "glm-5.2"); - expect(glm52.api).toBe("openai-completions"); - expect(glm52.baseUrl).toBe("https://opencode.ai/zen/go/v1"); - expect(glm52.input).toEqual(["text"]); - expect(glm52.reasoning).toBe(true); - expect(glm52.contextWindow).toBe(1_000_000); - expect(glm52.maxTokens).toBe(131_072); - expect(glm52.cost).toEqual({ - input: 1.4, - output: 4.4, - cacheRead: 0.26, - cacheWrite: 0, + it("joins paid-model availability with upstream capabilities and preserves lifecycle", async () => { + const fetchGuard = createCatalogFetchGuard({ + upstreamModels: { + "ox-alpha-free": upstreamModel("ox-alpha-free", { + name: "Ox Alpha Free (Unlimited)", + modalities: { input: ["text", "image", "video"] }, + limit: { context: 1_000_000, output: 131_072 }, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }), + "minimax-future": upstreamModel("minimax-future", { + provider: { npm: "@ai-sdk/anthropic" }, + cost: { input: 0.3, output: 1.2, cache_read: 0.06 }, + }), + "legacy-model": upstreamModel("legacy-model", { status: "deprecated" }), + }, + liveModelIds: ["ox-alpha-free", "minimax-future", "legacy-model", "unknown-live-model"], }); - expect(requireMapEntry(models, "kimi-k3")).toMatchObject({ + const result = await buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "resolved-opencode-key", + fetchGuard, + }); + + expect(result.models.map((model) => model.id)).toEqual(["ox-alpha-free", "minimax-future"]); + expect(result.models[0]).toMatchObject({ + id: "ox-alpha-free", + name: "Ox Alpha Free (Unlimited)", api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", input: ["text", "image"], - contextWindow: 1_048_576, + contextWindow: 1_000_000, maxTokens: 131_072, - cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - compat: { supportsReasoningEffort: true, supportedReasoningEfforts: ["max"] }, + compat: { supportedReasoningEfforts: ["low", "high", "max"], supportsTools: true }, + }); + expect(result.models[1]).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen/go", + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, }); - const kimi = requireMapEntry(models, "kimi-k2.6"); - expect(kimi.api).toBe("openai-completions"); - expect(kimi.baseUrl).toBe("https://opencode.ai/zen/go/v1"); - expect(kimi.input).toEqual(["text", "image"]); - expect(kimi.reasoning).toBe(true); - expect(kimi.contextWindow).toBe(262_144); - expect(kimi.maxTokens).toBe(65_536); + const provider = await registerSingleProviderPlugin(plugin); + expect(provider.resolveDynamicModel?.({ modelId: "legacy-model" } as never)).toBeUndefined(); + const supplemental = await provider.augmentModelCatalog?.({ entries: [] } as never); + expect(requireCatalogEntry(supplemental, "legacy-model").status).toBe("deprecated"); + expect(requireCatalogEntry(supplemental, "hy3-preview").status).toBe("preview"); + }); - const kimiCode = requireMapEntry(models, "kimi-k2.7-code"); - expect(kimiCode.api).toBe("openai-completions"); - expect(kimiCode.baseUrl).toBe("https://opencode.ai/zen/go/v1"); - expect(kimiCode.input).toEqual(["text", "image"]); - expect(kimiCode.contextWindow).toBe(262_144); - expect(kimiCode.maxTokens).toBe(262_144); - expect(kimiCode.cost).toEqual({ - input: 0.95, - output: 4, - cacheRead: 0.19, - cacheWrite: 0, + it("never resolves another account's upstream-only Go model outside its authenticated catalog", async () => { + const upstreamModels = { + "account-a-only": upstreamModel("account-a-only"), + "account-b-only": upstreamModel("account-b-only"), + }; + const accountAFetchGuard = createCatalogFetchGuard({ + upstreamModels, + liveModelIds: ["account-a-only"], }); - - const minimax = requireMapEntry(models, "minimax-m2.7"); - expect(minimax.api).toBe("anthropic-messages"); - expect(minimax.baseUrl).toBe("https://opencode.ai/zen/go"); - expect(minimax.reasoning).toBe(true); - expect(minimax.contextWindow).toBe(204_800); - expect(minimax.maxTokens).toBe(131_072); - - const minimaxM3 = requireMapEntry(models, "minimax-m3"); - expect(minimaxM3.api).toBe("anthropic-messages"); - expect(minimaxM3.baseUrl).toBe("https://opencode.ai/zen/go"); - expect(minimaxM3.reasoning).toBe(true); - expect(minimaxM3.input).toEqual(["text", "image"]); - expect(minimaxM3.contextWindow).toBe(1_000_000); - expect(minimaxM3.maxTokens).toBe(131_072); - - const mimoPro = requireMapEntry(models, "mimo-v2.5-pro"); - expect(mimoPro.api).toBe("openai-completions"); - expect(mimoPro.baseUrl).toBe("https://opencode.ai/zen/go/v1"); - expect(mimoPro.input).toEqual(["text"]); - expect(mimoPro.reasoning).toBe(true); - expect(mimoPro.contextWindow).toBe(1_048_576); - expect(mimoPro.maxTokens).toBe(128_000); - - const mimo = requireMapEntry(models, "mimo-v2.5"); - expect(mimo.input).toEqual(["text", "image"]); - expect(mimo.reasoning).toBe(true); - expect(mimo.contextWindow).toBe(1_000_000); - expect(mimo.maxTokens).toBe(128_000); - - const qwenMax = requireMapEntry(models, "qwen3.7-max"); - expect(qwenMax.api).toBe("anthropic-messages"); - expect(qwenMax.baseUrl).toBe("https://opencode.ai/zen/go"); - expect(qwenMax.input).toEqual(["text"]); - expect(qwenMax.reasoning).toBe(true); - expect(qwenMax.contextWindow).toBe(1_000_000); - expect(qwenMax.maxTokens).toBe(65_536); - expect(requireRecord(qwenMax.compat, "Qwen3.7 compat")).toMatchObject({ - thinkingFormat: "qwen", + const accountA = await buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "account-a-key", + fetchGuard: accountAFetchGuard, }); + expect(accountA.models.map((model) => model.id)).toEqual(["account-a-only"]); - const qwenPlus = requireMapEntry(models, "qwen3.6-plus"); - expect(qwenPlus.api).toBe("anthropic-messages"); - expect(qwenPlus.baseUrl).toBe("https://opencode.ai/zen/go"); - - const qwen37Plus = requireMapEntry(models, "qwen3.7-plus"); - expect(qwen37Plus.api).toBe("anthropic-messages"); - expect(qwen37Plus.baseUrl).toBe("https://opencode.ai/zen/go"); - expect(qwen37Plus.input).toEqual(["text", "image"]); - expect(qwen37Plus.reasoning).toBe(true); - expect(qwen37Plus.contextWindow).toBe(1_000_000); - expect(qwen37Plus.maxTokens).toBe(65_536); - expect(qwen37Plus.cost).toMatchObject({ - input: 0.4, - output: 1.6, - cacheRead: 0.04, - cacheWrite: 0.5, + const accountBFetchGuard = createCatalogFetchGuard({ + upstreamModels, + liveModelIds: ["account-b-only"], }); + const accountB = await buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "account-b-key", + fetchGuard: accountBFetchGuard, + }); + expect(accountB.models.map((model) => model.id)).toEqual(["account-b-only"]); - const dynamicModel = requireRecord( - provider.resolveDynamicModel?.({ - modelId: "deepseek-v4-pro", - } as never), - "dynamic model", - ); - expect(dynamicModel.id).toBe("deepseek-v4-pro"); - expect(dynamicModel.api).toBe("openai-completions"); - expect(dynamicModel.provider).toBe("opencode-go"); - expect(dynamicModel.baseUrl).toBe("https://opencode.ai/zen/go/v1"); - expect(dynamicModel.reasoning).toBe(true); - expect(dynamicModel.contextWindow).toBe(1_000_000); - expect(dynamicModel.maxTokens).toBe(384_000); - const compat = requireRecord(dynamicModel.compat, "dynamic model compat"); - expect(compat.supportsUsageInStreaming).toBe(true); - expect(compat.supportsReasoningEffort).toBe(true); - expect(compat.maxTokensField).toBe("max_tokens"); + const provider = await registerSingleProviderPlugin(plugin); + expect(provider.resolveDynamicModel?.({ modelId: "account-a-only" } as never)).toBeUndefined(); + expect(provider.resolveDynamicModel?.({ modelId: "account-b-only" } as never)).toBeUndefined(); + expect(provider.prepareDynamicModel).toBeUndefined(); + }); + + it("evicts withdrawn or unsafe upstream models while retaining trusted offline seeds", async () => { + const originalFetchGuard = createCatalogFetchGuard({ + upstreamModels: { + "withdrawn-model": upstreamModel("withdrawn-model"), + }, + liveModelIds: ["withdrawn-model"], + }); + await expect( + buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "resolved-opencode-key", + fetchGuard: originalFetchGuard, + }), + ).resolves.toMatchObject({ models: [expect.objectContaining({ id: "withdrawn-model" })] }); + + clearLiveCatalogCacheForTests(); + const refreshedFetchGuard = createCatalogFetchGuard({ + upstreamModels: { + "replacement-model": upstreamModel("replacement-model"), + "unsafe-model": upstreamModel("unsafe-model", { + provider: { api: "https://attacker.invalid/v1" }, + }), + }, + liveModelIds: ["replacement-model", "unsafe-model"], + }); + await expect( + buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "resolved-opencode-key", + fetchGuard: refreshedFetchGuard, + }), + ).resolves.toMatchObject({ models: [expect.objectContaining({ id: "replacement-model" })] }); + + const provider = await registerSingleProviderPlugin(plugin); + const entries = await provider.augmentModelCatalog?.({ entries: [] } as never); + expect(entries?.some((entry) => entry.id === "withdrawn-model")).toBe(false); + expect(entries?.some((entry) => entry.id === "unsafe-model")).toBe(false); + expect(requireCatalogEntry(entries, "hy3-preview").status).toBe("preview"); }); it("loads model discovery and keeps every promoted row identical to runtime", async () => { @@ -411,15 +339,13 @@ describe("opencode-go provider plugin", () => { } }); - it("exposes the complete offline catalog through provider discovery", async () => { + it("exposes only trusted offline starter models through provider discovery", async () => { const result = await opencodeGoProviderDiscovery.staticCatalog?.run({} as never); if (!result || !("provider" in result)) { throw new Error("expected OpenCode Go static provider"); } const deepSeekPro = result.provider.models.find((model) => model.id === "deepseek-v4-pro"); const deepSeekFlash = result.provider.models.find((model) => model.id === "deepseek-v4-flash"); - const glm52 = result.provider.models.find((model) => model.id === "glm-5.2"); - const modelIds = result.provider.models.map((model) => model.id); expect(new Set(modelIds).size).toBe(modelIds.length); expect(modelIds.toSorted()).toEqual(ACTIVE_MODEL_IDS.toSorted()); @@ -435,57 +361,87 @@ describe("opencode-go provider plugin", () => { maxTokens: 384_000, compat: { supportedReasoningEfforts: ["low", "high", "max"] }, }); - expect(glm52).toMatchObject({ - provider: "opencode-go", - contextWindow: 1_000_000, - maxTokens: 131_072, - }); + expect(modelIds).not.toContain("hy3-preview"); }); - it("skips live OpenCode Go catalog discovery when no shared key is configured", async () => { + it("skips unrelated scoped discovery even when OpenCode credentials are configured", async () => { const provider = await registerSingleProviderPlugin(plugin); - - await expect( - provider.catalog?.run({ - config: {}, - env: {}, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), - } as never), - ).resolves.toBeNull(); - }); - - it("keeps compatibility rows explicit-resolvable but out of static and live catalogs", async () => { - const provider = await registerSingleProviderPlugin(plugin); - const compatibilityModelIds = [...DEPRECATED_MODEL_IDS, "hy3-preview"]; - const activeModelIds = ["mimo-v2.5", "mimo-v2.5-pro"]; - const staticModelIds = buildStaticOpencodeGoProviderConfig().models.map((model) => model.id); - - expect(new Set(staticModelIds).size).toBe(staticModelIds.length); - expect(staticModelIds.toSorted()).toEqual(ACTIVE_MODEL_IDS.toSorted()); - expect(staticModelIds).toEqual(expect.not.arrayContaining(compatibilityModelIds)); - for (const modelId of compatibilityModelIds) { - expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({ id: modelId }); - } - - const fetchGuard = vi.fn(async () => ({ - response: new Response( - JSON.stringify({ - data: [...compatibilityModelIds, ...activeModelIds].map((id) => ({ - id, - object: "model", - })), - }), - ), - finalUrl: "https://opencode.ai/zen/go/v1/models", - release: vi.fn(async () => undefined), + const fetchMock = vi.spyOn(globalThis, "fetch"); + const resolveProviderApiKey = vi.fn(() => ({ + apiKey: "configured-opencode-key", + discoveryApiKey: "configured-opencode-key", })); + + try { + await expect( + provider.catalog?.run({ + config: {}, + env: {}, + providerIds: ["anthropic"], + resolveProviderApiKey, + } as never), + ).resolves.toBeNull(); + expect(resolveProviderApiKey).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + + await expect( + provider.catalog?.run({ + config: {}, + env: {}, + providerIds: ["opencode-go"], + resolveProviderApiKey: () => ({ apiKey: "configured-opencode-key" }), + } as never), + ).resolves.toMatchObject({ + provider: { apiKey: "configured-opencode-key" }, + }); + } finally { + fetchMock.mockRestore(); + } + }); + + it("never fetches either catalog when no shared OpenCode key is configured", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + try { + await expect( + provider.catalog?.run({ + config: {}, + env: {}, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), + } as never), + ).resolves.toBeNull(); + await expect(buildOpencodeGoLiveProviderConfig()).resolves.toMatchObject({ + models: expect.arrayContaining([expect.objectContaining({ id: "deepseek-v4-pro" })]), + }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + fetchMock.mockRestore(); + } + }); + + it("keeps the unavailable-upstream preview resolvable but out of advertised catalogs", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const staticModelIds = buildStaticOpencodeGoProviderConfig().models.map((model) => model.id); + const fetchGuard = createCatalogFetchGuard({ + upstreamModels: { + "deepseek-v4-pro": upstreamModel("deepseek-v4-pro"), + }, + liveModelIds: ["hy3-preview", "deepseek-v4-pro"], + }); + + expect(staticModelIds.toSorted()).toEqual(ACTIVE_MODEL_IDS.toSorted()); + expect(staticModelIds).not.toContain("hy3-preview"); + expect(provider.resolveDynamicModel?.({ modelId: "hy3-preview" } as never)).toMatchObject({ + id: "hy3-preview", + }); const live = await buildOpencodeGoLiveProviderConfig({ discoveryApiKey: "resolved-opencode-key", fetchGuard, }); - expect(live.models.map((model) => model.id)).toEqual(activeModelIds); + expect(live.models.map((model) => model.id)).toEqual(["deepseek-v4-pro"]); }); it.each([ @@ -541,20 +497,12 @@ describe("opencode-go provider plugin", () => { } }); - it("uses cached live OpenCode Go discovery and falls back to static rows on failure", async () => { - const fetchGuard = vi.fn(async () => ({ - response: new Response( - JSON.stringify({ - data: [ - { id: "minimax-m3", object: "model" }, - { id: "qwen3.7-max", object: "model" }, - { id: "qwen3.7-plus", object: "model" }, - ], - }), - ), - finalUrl: "https://opencode.ai/zen/go/v1/models", - release: vi.fn(async () => undefined), - })); + it("caches both catalog requests and falls back to trusted seeds on failure", async () => { + const liveIds = ["minimax-m3", "qwen3.7-max", "qwen3.7-plus"]; + const fetchGuard = createCatalogFetchGuard({ + upstreamModels: Object.fromEntries(liveIds.map((id) => [id, upstreamModel(id)])), + liveModelIds: liveIds, + }); const first = await buildOpencodeGoLiveProviderConfig({ apiKey: "OPENCODE_API_KEY", @@ -567,14 +515,13 @@ describe("opencode-go provider plugin", () => { fetchGuard, }); - expect(fetchGuard).toHaveBeenCalledTimes(1); + expect(fetchGuard).toHaveBeenCalledTimes(2); expect(first.apiKey).toBe("OPENCODE_API_KEY"); - const liveIds = ["minimax-m3", "qwen3.7-max", "qwen3.7-plus"]; expect(first.models.map((model) => model.id).toSorted()).toEqual(liveIds); expect(second.models.map((model) => model.id).toSorted()).toEqual(liveIds); clearLiveCatalogCacheForTests(); - fetchGuard.mockRejectedValueOnce(new Error("network unavailable")); + fetchGuard.mockRejectedValue(new Error("network unavailable")); const fallback = await buildOpencodeGoLiveProviderConfig({ apiKey: "OPENCODE_API_KEY", discoveryApiKey: "resolved-opencode-key", @@ -630,14 +577,25 @@ describe("opencode-go provider plugin", () => { ); it.each([ - ["glm-5.2", "max", undefined], - ["grok-4.5", "high", undefined], - ["hy3", "low", "none"], + ["glm-5.2", "max", undefined, ["high", "max"]], + ["grok-4.5", "high", undefined, ["low", "medium", "high"]], + ["hy3", "low", "none", ["none", "low", "high"]], ] as const)( "maps %s only to supported wire efforts", - async (modelId, enabledEffort, offEffort) => { - const provider = await registerSingleProviderPlugin(plugin); - const model = provider.resolveDynamicModel?.({ modelId } as never); + async (modelId, enabledEffort, offEffort, efforts) => { + const fetchGuard = createCatalogFetchGuard({ + upstreamModels: { + [modelId]: upstreamModel(modelId, { + reasoning_options: [{ type: "effort", values: efforts }], + }), + }, + liveModelIds: [modelId], + }); + const live = await buildOpencodeGoLiveProviderConfig({ + discoveryApiKey: "resolved-opencode-key", + fetchGuard, + }); + const model = live.models.find((candidate) => candidate.id === modelId); if (!model) { throw new Error(`expected ${modelId}`); } diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 2d1585e842d9..dde8248e94a4 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -95,6 +95,9 @@ export default defineSingleProviderPluginEntry({ catalog: { order: "simple", run: async (ctx) => { + if (ctx.providerIds !== undefined && !ctx.providerIds.includes(PROVIDER_ID)) { + return null; + } const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey); if (!auth) { return null; diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 72ba9395d038..938701f9c629 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -90,6 +90,20 @@ "codeMode": "capable" } }, + { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536, + "cost": { "input": 0.95, "output": 4, "cacheRead": 0.16, "cacheWrite": 0 }, + "compat": { + "supportsUsageInStreaming": true, + "supportsDeveloperRole": false, + "supportsStrictMode": false + } + }, { "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", @@ -131,6 +145,21 @@ "thinkingFormat": "qwen", "codeMode": "capable" } + }, + { + "id": "hy3-preview", + "name": "HY3 Preview", + "status": "preview", + "reasoning": true, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 32768, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "supportsUsageInStreaming": true, + "supportsDeveloperRole": false, + "supportsStrictMode": false + } } ] } diff --git a/extensions/opencode-go/opencode-go.live.test.ts b/extensions/opencode-go/opencode-go.live.test.ts index fc9607e1a8a5..f65a9072b4d3 100644 --- a/extensions/opencode-go/opencode-go.live.test.ts +++ b/extensions/opencode-go/opencode-go.live.test.ts @@ -1,6 +1,7 @@ import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; import { describe, expect, it } from "vitest"; import { + buildOpencodeGoLiveProviderConfig, buildStaticOpencodeGoProviderConfig, listOpencodeGoModelCatalogEntries, } from "./provider-catalog.js"; @@ -13,8 +14,8 @@ const describeLive = LIVE ? describe : describe.skip; type ModelsResponse = { data?: Array<{ id?: unknown; object?: unknown }> }; -describeLive("OpenCode Go live catalog drift", () => { - it("classifies every live id as active, deprecated, or preview", async () => { +describeLive("OpenCode Go live dynamic catalog", () => { + it("loads authorized current models from upstream metadata without expanding its offline seed", async () => { const response = await fetch(OPENCODE_GO_MODELS_URL, { headers: { accept: "application/json", @@ -30,45 +31,31 @@ describeLive("OpenCode Go live catalog drift", () => { .filter((id): id is string => typeof id === "string" && id.trim().length > 0) .map((id) => id.trim().toLowerCase()) .toSorted(); + const offlineIds = new Set( + buildStaticOpencodeGoProviderConfig().models.map((model) => model.id), + ); + const live = await buildOpencodeGoLiveProviderConfig({ + apiKey: OPENCODE_API_KEY, + discoveryApiKey: OPENCODE_API_KEY, + }); + const discoveredIds = live.models.map((model) => model.id); + const advertisedIds = new Set(liveIds); const trustedRows = listOpencodeGoModelCatalogEntries(); - const trustedIds = new Set(trustedRows.map((row) => row.id)); - const activeIds = buildStaticOpencodeGoProviderConfig().models.map((model) => model.id); - expect(liveIds.filter((id) => !trustedIds.has(id))).toEqual([]); - expect(new Set(activeIds).size).toBe(activeIds.length); - expect(activeIds.toSorted()).toEqual([ - "deepseek-v4-flash", - "deepseek-v4-pro", - "glm-5.1", - "glm-5.2", - "gpt-5.6-luna", - "grok-4.5", - "hy3", - "kimi-k2.6", - "kimi-k2.7-code", - "kimi-k3", - "mimo-v2.5", - "mimo-v2.5-pro", - "minimax-m2.7", - "minimax-m3", - "qwen3.6-plus", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.8-max", - ]); - expect( - trustedRows - .filter((row) => row.status === "deprecated") - .map((row) => row.id) - .toSorted(), - ).toEqual([ - "glm-5", - "kimi-k2.5", - "mimo-v2-omni", - "mimo-v2-pro", - "minimax-m2.5", - "qwen3.5-plus", - ]); + expect(discoveredIds.length).toBeGreaterThan(0); + expect(discoveredIds.every((id) => advertisedIds.has(id))).toBe(true); + expect(new Set(discoveredIds).size).toBe(discoveredIds.length); + expect(discoveredIds.some((id) => !offlineIds.has(id))).toBe(true); + expect(discoveredIds).not.toContain("hy3-preview"); expect(trustedRows.find((row) => row.id === "hy3-preview")?.status).toBe("preview"); + if (advertisedIds.has("ox-alpha-free")) { + expect(live.models.find((model) => model.id === "ox-alpha-free")).toMatchObject({ + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", + contextWindow: 1_000_000, + maxTokens: 131_072, + input: ["text", "image"], + }); + } }, 30_000); }); diff --git a/extensions/opencode-go/provider-catalog.ts b/extensions/opencode-go/provider-catalog.ts index 050788c1c7bd..a2090662d661 100644 --- a/extensions/opencode-go/provider-catalog.ts +++ b/extensions/opencode-go/provider-catalog.ts @@ -4,13 +4,17 @@ import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { buildLiveModelProviderConfig, fetchLiveProviderModelIds, + getCachedUpstreamProviderCatalog, + projectUpstreamProviderCatalogModel, type LiveModelCatalogFetchGuard, + type UpstreamProviderCatalog, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; const PROVIDER_ID = "opencode-go"; @@ -22,6 +26,7 @@ const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set([ "kimi-k2.7-code", ]); const OPENCODE_GO_MODELS_ENDPOINT = "https://opencode.ai/zen/go/v1/models"; +const OPENCODE_UPSTREAM_CATALOG_ENDPOINT = "https://models.opencode.ai/api.json"; const OPENCODE_GO_MODELS_TIMEOUT_MS = 5_000; const OPENCODE_GO_MODELS_CACHE_TTL_MS = 60_000; type OpencodeGoModelDefinition = ModelDefinitionConfig & { @@ -31,170 +36,76 @@ type OpencodeGoModelDefinition = ModelDefinitionConfig & { input: Array<"text" | "image">; }; -const T = ["text"] as const; -const TI = ["text", "image"] as const; -const E_HM = ["high", "max"] as const; -const E_LHM = ["low", "high", "max"] as const; -const E_LMH = ["low", "medium", "high"] as const; -const E_NONE_LH = ["none", "low", "high"] as const; -const E_NONE_LMHXM = ["none", "low", "medium", "high", "xhigh", "max"] as const; -const E_MAX = ["max"] as const; - -type OpencodeGoCostRow = - | readonly [number, number, number, number] - | readonly [number, number, number, number, number, number, number, number, number]; -type OpencodeGoModelRow = readonly [ - id: string, - contextWindow: number, - maxTokens: number, - input: ReadonlyArray<"text" | "image">, - cost: OpencodeGoCostRow, - reasoningEfforts?: readonly string[], - contextTokens?: number, -]; - -const OPENCODE_GO_MODEL_ROWS = [ - ["deepseek-v4-pro", 1_000_000, 384_000, T, [0.435, 0.87, 0.003625, 0], E_HM], - ["deepseek-v4-flash", 1_000_000, 384_000, T, [0.14, 0.28, 0.0028, 0], E_LHM], - ["glm-5", 202_752, 32_768, T, [1, 3.2, 0.2, 0]], - ["glm-5.1", 202_752, 32_768, T, [1.4, 4.4, 0.26, 0]], - ["glm-5.2", 1_000_000, 131_072, T, [1.4, 4.4, 0.26, 0], E_HM], - [ - "gpt-5.6-luna", - 1_050_000, - 128_000, - TI, - [0.2, 1.2, 0.02, 0.25, 272_000, 0.4, 1.8, 0.04, 0.5], - E_NONE_LMHXM, - 922_000, - ], - ["grok-4.5", 500_000, 500_000, TI, [2, 6, 0.3, 0], E_LMH], - ["hy3", 256_000, 64_000, T, [0.14, 0.58, 0.035, 0], E_NONE_LH], - ["hy3-preview", 262_144, 32_768, T, [0, 0, 0, 0]], - ["kimi-k2.5", 262_144, 65_536, TI, [0.6, 3, 0.1, 0]], - ["kimi-k2.6", 262_144, 65_536, TI, [0.95, 4, 0.16, 0]], - ["kimi-k2.7-code", 262_144, 262_144, TI, [0.95, 4, 0.19, 0]], - ["kimi-k3", 1_048_576, 131_072, TI, [3, 15, 0.3, 0], E_MAX], - ["mimo-v2-omni", 262_144, 128_000, TI, [0.4, 2, 0.08, 0]], - ["mimo-v2-pro", 1_048_576, 128_000, T, [1, 3, 0.2, 0, 256_000, 2, 6, 0.4, 0]], - ["mimo-v2.5", 1_000_000, 128_000, TI, [0.14, 0.28, 0.0028, 0]], - ["mimo-v2.5-pro", 1_048_576, 128_000, T, [0.435, 0.87, 0.003625, 0]], - ["minimax-m2.5", 204_800, 65_536, T, [0.3, 1.2, 0.06, 0.375]], - ["minimax-m2.7", 204_800, 131_072, T, [0.3, 1.2, 0.06, 0.375]], - ["minimax-m3", 1_000_000, 131_072, TI, [0.3, 1.2, 0.06, 0, 512_000, 0.6, 2.4, 0.12, 0]], - ["qwen3.5-plus", 262_144, 65_536, TI, [0.2, 1.2, 0.02, 0.25]], - ["qwen3.7-max", 1_000_000, 65_536, T, [2.5, 7.5, 0.5, 3.125]], - ["qwen3.7-plus", 1_000_000, 65_536, TI, [0.4, 1.6, 0.04, 0.5, 256_000, 1.2, 4.8, 0.12, 1.5]], - ["qwen3.8-max", 1_000_000, 131_072, TI, [2, 6, 0.25, 2.5]], - ["qwen3.6-plus", 1_000_000, 65_536, TI, [0.5, 3, 0.05, 0.625, 256_000, 2, 6, 0.2, 2.5]], -] as const satisfies readonly OpencodeGoModelRow[]; - -const OPENCODE_GO_MODEL_STATUS = new Map([ - ["glm-5", "deprecated"], - ["qwen3.5-plus", "deprecated"], - ["mimo-v2-omni", "deprecated"], - ["kimi-k2.5", "deprecated"], - ["mimo-v2-pro", "deprecated"], - ["minimax-m2.5", "deprecated"], - ["hy3-preview", "preview"], -]); - -function titleCaseModelPart(value: string): string { - return value ? `${value[0]?.toUpperCase()}${value.slice(1)}` : value; -} - -function formatOpencodeGoModelName(id: string): string { - if (id === "hy3" || id === "hy3-preview") { - return id === "hy3" ? "Hy3" : "HY3 Preview"; - } - if (id.startsWith("qwen")) { - const [version, ...parts] = id.slice(4).split("-"); - return `Qwen${version}${parts.length ? ` ${parts.map(titleCaseModelPart).join(" ")}` : ""}`; - } - const [family = "", ...parts] = id.split("-"); - const prefix: Record = { - deepseek: "DeepSeek", - glm: "GLM", - gpt: "GPT", - grok: "Grok", - kimi: "Kimi", - mimo: "MiMo", - minimax: "MiniMax", - }; - const separator = family === "glm" || family === "gpt" ? "-" : " "; - return `${prefix[family] ?? titleCaseModelPart(family)}${separator}${parts.map(titleCaseModelPart).join(" ")}`; -} - -function buildOpencodeGoCost(row: OpencodeGoCostRow): ModelDefinitionConfig["cost"] { - const [input, output, cacheRead, cacheWrite] = row; - const cost = { input, output, cacheRead, cacheWrite }; - if (row.length === 4) { - return cost; - } - const threshold = row[4]; - const tierInput = row[5]; - const tierOutput = row[6]; - const tierCacheRead = row[7]; - const tierCacheWrite = row[8]; - return { - ...cost, - tieredPricing: [ - { ...cost, range: [0, threshold] }, - { - input: tierInput, - output: tierOutput, - cacheRead: tierCacheRead, - cacheWrite: tierCacheWrite, - range: [threshold], - }, - ], - }; -} - -function buildOpencodeGoModel(row: OpencodeGoModelRow): OpencodeGoModelDefinition { - const [id, contextWindow, maxTokens, input, cost, reasoningEfforts, contextTokens] = row; - const anthropic = id.startsWith("minimax-") || id.startsWith("qwen"); - const api = id.startsWith("gpt-") - ? "openai-responses" - : anthropic - ? "anthropic-messages" - : "openai-completions"; - const model: OpencodeGoModelDefinition = { - id, - name: formatOpencodeGoModelName(id), - api, +const OPENCODE_GO_MANIFEST_PROVIDER = manifest.modelCatalog.providers[PROVIDER_ID]; +const OPENCODE_GO_SEED_MODELS = OPENCODE_GO_MANIFEST_PROVIDER.models.map((model) => { + const inheritedTransport = { + ...model, provider: PROVIDER_ID, - baseUrl: anthropic ? OPENCODE_GO_ANTHROPIC_BASE_URL : OPENCODE_GO_OPENAI_BASE_URL, - reasoning: true, - input: [...input], - cost: buildOpencodeGoCost(cost), - contextWindow, - ...(contextTokens ? { contextTokens } : {}), - maxTokens, - ...(reasoningEfforts - ? { - compat: { - supportsUsageInStreaming: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: [...reasoningEfforts], - maxTokensField: "max_tokens", - }, - } - : id.startsWith("qwen") - ? { compat: { thinkingFormat: "qwen" as const } } - : {}), + api: "api" in model ? model.api : OPENCODE_GO_MANIFEST_PROVIDER.api, + baseUrl: "baseUrl" in model ? model.baseUrl : OPENCODE_GO_MANIFEST_PROVIDER.baseUrl, }; - return normalizeModelCompat(model) as OpencodeGoModelDefinition; + // SAFETY: Bundled manifest rows supply model metadata, and inherited provider transport is filled above. + const hydrated = inheritedTransport as OpencodeGoModelDefinition; + // SAFETY: Compatibility normalization preserves the hydrated model's provider, transport, and input shape. + return normalizeModelCompat(hydrated) as OpencodeGoModelDefinition; +}); +const OPENCODE_GO_SEED_MODEL_BY_ID = new Map( + OPENCODE_GO_SEED_MODELS.map((model) => [model.id.toLowerCase(), model]), +); +const OPENCODE_GO_MODEL_BY_ID = new Map(OPENCODE_GO_SEED_MODEL_BY_ID); +const OPENCODE_GO_SEED_MODEL_STATUS = new Map( + manifest.modelCatalog.providers[PROVIDER_ID].models.flatMap((model) => + "status" in model && (model.status === "deprecated" || model.status === "preview") + ? [[model.id, model.status] as const] + : [], + ), +); +const OPENCODE_GO_MODEL_STATUS = new Map(OPENCODE_GO_SEED_MODEL_STATUS); + +function listStaticOpencodeGoModels(): OpencodeGoModelDefinition[] { + return OPENCODE_GO_SEED_MODELS.filter((model) => !OPENCODE_GO_MODEL_STATUS.has(model.id)); } -const OPENCODE_GO_RESOLVABLE_MODELS = OPENCODE_GO_MODEL_ROWS.map(buildOpencodeGoModel); - -const OPENCODE_GO_MODEL_BY_ID = new Map( - OPENCODE_GO_RESOLVABLE_MODELS.map((model) => [model.id, model]), -); -const OPENCODE_GO_MODELS = OPENCODE_GO_RESOLVABLE_MODELS.filter( - (model) => !OPENCODE_GO_MODEL_STATUS.has(model.id), -); +function cacheUpstreamOpencodeGoModels(catalog: UpstreamProviderCatalog): void { + const currentModels = new Map( + OPENCODE_GO_SEED_MODELS.map((model) => [model.id.toLowerCase(), model]), + ); + const currentStatuses = new Map(OPENCODE_GO_SEED_MODEL_STATUS); + for (const upstreamModel of Object.values(catalog.models)) { + const projected = projectUpstreamProviderCatalogModel({ + providerId: PROVIDER_ID, + provider: catalog, + model: upstreamModel, + anthropicBaseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + defaultBaseUrl: OPENCODE_GO_OPENAI_BASE_URL, + }); + if (!projected) { + continue; + } + const normalized = normalizeModelCompat({ + ...projected, + ...(projected.api === "anthropic-messages" && projected.id.startsWith("qwen") + ? { compat: { ...projected.compat, thinkingFormat: "qwen" as const } } + : {}), + }); + // SAFETY: The shared projector validates transport and limits; normalization preserves those model fields. + const model = normalized as OpencodeGoModelDefinition; + currentModels.set(model.id.toLowerCase(), model); + if (upstreamModel.status === "deprecated") { + currentStatuses.set(model.id, "deprecated"); + } else { + currentStatuses.delete(model.id); + } + } + OPENCODE_GO_MODEL_BY_ID.clear(); + for (const [id, model] of currentModels) { + OPENCODE_GO_MODEL_BY_ID.set(id, model); + } + OPENCODE_GO_MODEL_STATUS.clear(); + for (const [id, status] of currentStatuses) { + OPENCODE_GO_MODEL_STATUS.set(id, status); + } +} type FetchOpencodeGoLiveModelIdsParams = { apiKey?: string; @@ -208,7 +119,7 @@ export function buildStaticOpencodeGoProviderConfig(apiKey?: string): ModelProvi api: "openai-completions", baseUrl: OPENCODE_GO_OPENAI_BASE_URL, ...(apiKey ? { apiKey } : {}), - models: OPENCODE_GO_MODELS, + models: listStaticOpencodeGoModels(), }; } @@ -234,6 +145,23 @@ export async function resolveOpencodeGoStarterModel(params: { export async function buildOpencodeGoLiveProviderConfig( params: FetchOpencodeGoLiveModelIdsParams = {}, ): Promise { + const fallbackModels = listStaticOpencodeGoModels(); + if (!params.apiKey && !params.discoveryApiKey) { + return buildStaticOpencodeGoProviderConfig(); + } + try { + const upstream = await getCachedUpstreamProviderCatalog({ + endpoint: OPENCODE_UPSTREAM_CATALOG_ENDPOINT, + providerId: PROVIDER_ID, + fetchGuard: params.fetchGuard, + signal: params.signal, + }); + if (upstream) { + cacheUpstreamOpencodeGoModels(upstream); + } + } catch { + // Keep the trusted offline seed usable when upstream metadata is unavailable. + } return await buildLiveModelProviderConfig({ providerId: PROVIDER_ID, endpoint: OPENCODE_GO_MODELS_ENDPOINT, @@ -241,7 +169,7 @@ export async function buildOpencodeGoLiveProviderConfig( api: "openai-completions", baseUrl: OPENCODE_GO_OPENAI_BASE_URL, }, - models: OPENCODE_GO_MODELS, + models: fallbackModels, apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, @@ -249,11 +177,35 @@ export async function buildOpencodeGoLiveProviderConfig( timeoutMs: OPENCODE_GO_MODELS_TIMEOUT_MS, ttlMs: OPENCODE_GO_MODELS_CACHE_TTL_MS, auditContext: "opencode-go-model-discovery", + projectRows: (rows) => { + const seen = new Set(); + const models: OpencodeGoModelDefinition[] = []; + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + continue; + } + const object = "object" in row ? row.object : undefined; + if (object !== undefined && object !== "model") { + continue; + } + const id = "id" in row ? row.id : undefined; + const modelId = typeof id === "string" ? id.trim().toLowerCase() : ""; + if (!modelId || seen.has(modelId) || OPENCODE_GO_MODEL_STATUS.has(modelId)) { + continue; + } + seen.add(modelId); + const model = OPENCODE_GO_MODEL_BY_ID.get(modelId); + if (model) { + models.push(model); + } + } + return models; + }, }); } export function listOpencodeGoModelCatalogEntries(): ModelCatalogEntry[] { - return OPENCODE_GO_RESOLVABLE_MODELS.map((model) => { + return [...OPENCODE_GO_MODEL_BY_ID.values()].map((model) => { const entry: ModelCatalogEntry = { provider: model.provider, id: model.id, @@ -276,7 +228,7 @@ export function listOpencodeGoModelCatalogEntries(): ModelCatalogEntry[] { export function resolveOpencodeGoModel(modelId: string): ProviderRuntimeModel | undefined { const normalizedModelId = modelId.trim().toLowerCase(); - return OPENCODE_GO_MODEL_BY_ID.get(normalizedModelId); + return OPENCODE_GO_SEED_MODEL_BY_ID.get(normalizedModelId); } export function isOpencodeGoKimiNoReasoningModelId(modelId: unknown): boolean { diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index ec916f71c80c..8dca1a909284 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -1,5 +1,4 @@ import { readFileSync } from "node:fs"; -import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { registerProviderPlugin, registerSingleProviderPlugin, @@ -8,130 +7,114 @@ import { import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime"; import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { expectPassthroughReplayPolicy } from "openclaw/plugin-sdk/provider-test-contracts"; -// Opencode tests cover index plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildOpencodeZenLiveProviderConfig, + prepareOpencodeZenModel, resolveOpencodeZenStarterModel, } from "./provider-catalog.js"; const requireRecord = createRequireRecord("record", "expected-label-record"); - -function requireMapEntry(map: Map, id: string): T { - const entry = map.get(id); - if (!entry) { - throw new Error(`expected model ${id}`); - } - return entry; -} - -function requireCatalogEntry(entries: readonly unknown[] | null | undefined, id: string) { - if (!entries) { - throw new Error("expected supplemental catalog entries"); - } - const entry = entries.find((candidate) => requireRecord(candidate, "catalog entry").id === id); - if (!entry) { - throw new Error(`expected supplemental catalog entry ${id}`); - } - return requireRecord(entry, `supplemental catalog entry ${id}`); -} - -function runtimeCompatFields(value: unknown): Record | undefined { - if (!value) { - return undefined; - } - const { codeMode: _codeMode, ...compat } = requireRecord(value, "model compat"); - return compat; -} - -const ACTIVE_MODEL_IDS = [ - "claude-fable-5", +const METADATA_URL = "https://models.opencode.ai/api.json"; +const MODELS_URL = "https://opencode.ai/zen/v1/models"; +const OFFLINE_MODEL_IDS = [ "claude-opus-5", - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-haiku-4-5", - "gemini-3.6-flash", - "gemini-3.5-flash-lite", - "gemini-3.5-flash", - "gemini-3.1-pro", - "gemini-3-flash", "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.3-codex-spark", - "gpt-5.3-codex", - "gpt-5.2", - "gpt-5.1", - "gpt-5", "gpt-5-nano", - "grok-build-0.1", - "grok-4.5", - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.2", - "glm-5.1", + "gemini-3.6-flash", "minimax-m3", "kimi-k3", - "kimi-k2.7-code", - "kimi-k2.6", - "qwen3.6-plus", - "qwen3.5-plus", "big-pickle", - "deepseek-v4-flash-free", - "mimo-v2.5-free", - "ling-3.0-tiny-free", - "nemotron-3-ultra-free", - "north-mini-code-free", - "laguna-s-2.1-free", - "longcat-2.0-free", ] as const; -const DEPRECATED_MODEL_IDS = [ - "claude-opus-4-1", - "claude-opus-4-8", - "claude-sonnet-4", - "glm-5", - "gpt-5-codex", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2-codex", - "gpt-5.5", - "kimi-k2.5", - "ling-3.0-flash-free", - "minimax-m2.5", - "minimax-m2.7", -] as const; +type UpstreamFixtureModel = { + id: string; + name?: string; + npm?: string; + status?: string; + reasoningEfforts?: string[]; +}; -const REPLACED_BY = new Map([ - ["claude-opus-4-8", "claude-opus-5"], - ["gpt-5.5", "gpt-5.6-sol"], - ["minimax-m2.7", "minimax-m3"], -]); +function upstreamCatalog(models: readonly UpstreamFixtureModel[]) { + return { + opencode: { + id: "opencode", + api: "https://opencode.ai/zen/v1", + npm: "@ai-sdk/openai-compatible", + models: Object.fromEntries( + models.map((model) => [ + model.id, + { + id: model.id, + name: model.name ?? model.id, + reasoning: true, + tool_call: true, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1_000_000, input: 900_000, output: 131_072 }, + cost: { input: 0, output: 0, cache_read: 0 }, + ...(model.npm ? { provider: { npm: model.npm } } : {}), + ...(model.status ? { status: model.status } : {}), + ...(model.reasoningEfforts + ? { reasoning_options: [{ type: "effort", values: model.reasoningEfforts }] } + : {}), + }, + ]), + ), + }, + }; +} + +function guardedResponse(url: string, body: unknown) { + return { + response: new Response(JSON.stringify(body)), + finalUrl: url, + release: vi.fn(async () => undefined), + }; +} + +function createCatalogFetchGuard(params: { + metadata: readonly UpstreamFixtureModel[]; + advertised: readonly string[] | ((authorization: string | null) => readonly string[]); +}) { + return vi.fn(async ({ url, init }: { url: string; init?: RequestInit }) => { + if (url === METADATA_URL) { + expect(new Headers(init?.headers).has("authorization")).toBe(false); + return guardedResponse(url, upstreamCatalog(params.metadata)); + } + if (url === MODELS_URL) { + const authorization = new Headers(init?.headers).get("authorization"); + const ids = + typeof params.advertised === "function" + ? params.advertised(authorization) + : params.advertised; + return guardedResponse(url, { data: ids.map((id) => ({ id, object: "model" })) }); + } + throw new Error(`unexpected discovery URL: ${url}`); + }); +} + +function expectSeedModels(models: ReadonlyArray<{ id: string }>) { + expect(models.map((model) => model.id)).toEqual(OFFLINE_MODEL_IDS); +} describe("opencode provider plugin", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + it("registers only the Zen auth choice from its own provider manifest", async () => { const provider = await registerSingleProviderPlugin(plugin); expect(provider.id).toBe("opencode"); expect(provider.envVars).toEqual(["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]); - expect(provider.auth.map((method) => method.id)).toEqual(["api-key"]); expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["opencode-zen"]); expect(provider.auth[0]?.wizard).toMatchObject({ choiceLabel: "OpenCode Zen catalog", @@ -146,27 +129,20 @@ describe("opencode provider plugin", () => { id: "opencode", name: "OpenCode Zen Provider", }); - const mediaProvider = mediaProviders.find((provider) => provider.id === "opencode"); - if (!mediaProvider) { - throw new Error("Expected opencode media provider"); - } - expect(mediaProvider.capabilities).toEqual(["image"]); - expect(mediaProvider.defaultModels).toEqual({ image: "gpt-5-nano" }); - expect(typeof mediaProvider.describeImage).toBe("function"); - expect(typeof mediaProvider.describeImages).toBe("function"); + + expect(mediaProvider?.capabilities).toEqual(["image"]); + expect(mediaProvider?.defaultModels).toEqual({ image: "gpt-5-nano" }); + expect(typeof mediaProvider?.describeImage).toBe("function"); }); - it("owns passthrough-gemini replay policy for Gemini-backed models", async () => { + it("owns Gemini-only passthrough replay policy", async () => { await expectPassthroughReplayPolicy({ plugin, providerId: "opencode", modelId: "gemini-2.5-pro", sanitizeThoughtSignatures: true, }); - }); - - it("keeps non-Gemini replay policy minimal on passthrough routes", async () => { await expectPassthroughReplayPolicy({ plugin, providerId: "opencode", @@ -174,467 +150,76 @@ describe("opencode provider plugin", () => { }); }); - it("keeps OpenCode Zen catalog coverage aligned with the curated seed", async () => { + it("keeps the offline manifest seed resolvable without downloading metadata", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("unexpected fetch")); const provider = await registerSingleProviderPlugin(plugin); - expect(provider.catalog).toBeDefined(); + const result = await provider.staticCatalog?.run({} as never); - const expectedModelIds = [...ACTIVE_MODEL_IDS, ...DEPRECATED_MODEL_IDS]; - expect(new Set(expectedModelIds).size).toBe(expectedModelIds.length); - const models = new Map(); - for (const modelId of expectedModelIds) { - const model = provider.resolveDynamicModel?.({ modelId } as never); - if (!model) { - throw new Error(`expected OpenCode Zen model ${modelId}`); - } - models.set(model.id, model); + if (!result || !("provider" in result)) { + throw new Error("expected registered OpenCode Zen static provider"); } - expect([...models.keys()].toSorted()).toEqual(expectedModelIds.toSorted()); - - const supplemental = await provider.augmentModelCatalog?.({ - entries: [...models.values()].map((model) => ({ - provider: model.provider, - id: model.id, - name: model.name, - })), - } as never); - const supplementalIds = (supplemental ?? []).map((entry) => entry.id); - expect(new Set(supplementalIds).size).toBe(supplementalIds.length); - expect(supplementalIds.toSorted()).toEqual(expectedModelIds.toSorted()); - const opus48 = requireCatalogEntry(supplemental, "claude-opus-4-8"); - expect(opus48.provider).toBe("opencode"); - expect(opus48.name).toBe("Claude Opus 4.8"); - for (const modelId of ACTIVE_MODEL_IDS) { - expect(requireCatalogEntry(supplemental, modelId).status).toBeUndefined(); - expect(requireCatalogEntry(supplemental, modelId).replacedBy).toBeUndefined(); + expectSeedModels(result.provider.models); + expectSeedModels(manifest.modelCatalog.providers.opencode.models); + for (const modelId of OFFLINE_MODEL_IDS) { + expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({ id: modelId }); } - for (const modelId of DEPRECATED_MODEL_IDS) { - expect(requireCatalogEntry(supplemental, modelId).status).toBe("deprecated"); - expect(requireCatalogEntry(supplemental, modelId).replacedBy).toBe(REPLACED_BY.get(modelId)); - } - - const opus46 = requireMapEntry(models, "claude-opus-4-6"); - expect(opus46.api).toBe("anthropic-messages"); - expect(opus46.baseUrl).toBe("https://opencode.ai/zen"); - expect(opus46.input).toEqual(["text", "image"]); - expect(opus46.reasoning).toBe(true); - expect(opus46.contextWindow).toBe(1_000_000); - expect(opus46.maxTokens).toBe(128_000); - - expect(requireMapEntry(models, "gpt-5.5")).toMatchObject({ - api: "openai-responses", - baseUrl: "https://opencode.ai/zen/v1", - }); - expect(requireMapEntry(models, "gpt-5.6-luna")).toMatchObject({ - name: "GPT-5.6 Luna", - api: "openai-responses", - baseUrl: "https://opencode.ai/zen/v1", - input: ["text", "image"], - contextWindow: 1_050_000, - maxTokens: 128_000, - cost: { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - tieredPricing: [ - { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, range: [0, 272_000] }, - { input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5, range: [272_000] }, - ], - }, - compat: { - supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], - }, - }); - expect(requireMapEntry(models, "gpt-5.6-terra")).toMatchObject({ - name: "GPT-5.6 Terra", - contextWindow: 1_050_000, - maxTokens: 128_000, - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, - }); - expect(requireMapEntry(models, "gpt-5.6-sol")).toMatchObject({ - name: "GPT-5.6 Sol", - contextWindow: 1_050_000, - maxTokens: 128_000, - cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, - }); - expect(requireMapEntry(models, "gemini-3.5-flash")).toMatchObject({ - api: "google-generative-ai", - baseUrl: "https://opencode.ai/zen/v1", - }); - expect(requireMapEntry(models, "gemini-3.6-flash")).toMatchObject({ - name: "Gemini 3.6 Flash", - contextWindow: 1_048_576, - maxTokens: 65_536, - cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - }); - expect(requireMapEntry(models, "gemini-3.5-flash-lite")).toMatchObject({ - name: "Gemini 3.5 Flash Lite", - contextWindow: 1_048_576, - maxTokens: 65_536, - cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - }); - expect(requireMapEntry(models, "minimax-m2.7")).toMatchObject({ - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - }); - expect(requireMapEntry(models, "qwen3.6-plus")).toMatchObject({ - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen", - }); - expect(requireMapEntry(models, "glm-5.2")).toMatchObject({ - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - input: ["text"], - contextWindow: 1_000_000, - maxTokens: 131_072, - cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - }); - expect(requireMapEntry(models, "claude-sonnet-5")).toMatchObject({ - name: "Claude Sonnet 5", - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen", - input: ["text", "image"], - contextWindow: 1_000_000, - maxTokens: 128_000, - cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - }); - expect(requireMapEntry(models, "grok-4.5")).toMatchObject({ - name: "Grok 4.5", - api: "openai-responses", - baseUrl: "https://opencode.ai/zen/v1", - input: ["text", "image"], - contextWindow: 500_000, - maxTokens: 500_000, - cost: { - input: 2, - output: 6, - cacheRead: 0.3, - cacheWrite: 0, - tieredPricing: [ - { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0, range: [0, 200_000] }, - { input: 4, output: 12, cacheRead: 0.6, cacheWrite: 0, range: [200_000] }, - ], - }, - }); - expect(requireMapEntry(models, "kimi-k2.7-code")).toMatchObject({ - name: "Kimi K2.7 Code", - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - input: ["text", "image"], - contextWindow: 262_144, - maxTokens: 262_144, - cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - }); - expect(requireMapEntry(models, "kimi-k3")).toMatchObject({ - name: "Kimi K3", - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: 1_048_576, - maxTokens: 131_072, - cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - }); - expect(requireMapEntry(models, "minimax-m3")).toMatchObject({ - name: "MiniMax M3", - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - input: ["text", "image"], - contextWindow: 512_000, - maxTokens: 128_000, - cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - }); - - const dynamicModel = requireRecord( - provider.resolveDynamicModel?.({ - modelId: "claude-opus-4-8", - } as never), - "dynamic model", - ); - expect(dynamicModel.id).toBe("claude-opus-4-8"); - expect(dynamicModel.api).toBe("anthropic-messages"); - expect(dynamicModel.provider).toBe("opencode"); - expect(dynamicModel.baseUrl).toBe("https://opencode.ai/zen"); - const compat = requireRecord(dynamicModel.compat, "dynamic model compat"); - expect(compat.supportsUsageInStreaming).toBe(true); - expect(compat.supportsReasoningEffort).toBe(true); - expect(compat.maxTokensField).toBe("max_tokens"); - - const manifestProvider = requireRecord( - manifest.modelCatalog.providers.opencode, - "manifest provider", - ); - const manifestModels = manifestProvider.models; - if (!Array.isArray(manifestModels)) { - throw new Error("expected manifest opencode models"); - } - const manifestIds = manifestModels.map((model) => requireRecord(model, "manifest model").id); - expect(new Set(manifestIds).size).toBe(manifestIds.length); - expect(manifestIds).toEqual([ - "claude-opus-5", - "claude-opus-4-8", - "claude-sonnet-4", - "gpt-5.6-sol", - "gpt-5.5", - "gemini-3.6-flash", - "gemini-3.1-pro", - "minimax-m3", - "minimax-m2.7", - "kimi-k3", - "big-pickle", - "deepseek-v4-flash-free", - "mimo-v2.5-free", - "laguna-s-2.1-free", - "ling-3.0-flash-free", - "nemotron-3-ultra-free", - "north-mini-code-free", - "ling-3.0-tiny-free", - "longcat-2.0-free", - ]); - const manifestClaude48 = requireRecord( - manifestModels.find( - (model) => requireRecord(model, "manifest model").id === "claude-opus-4-8", - ), - "manifest claude-opus-4-8", - ); - expect(manifestClaude48).toMatchObject({ - contextWindow: 1_000_000, - maxTokens: 128_000, - status: "deprecated", - replacedBy: "claude-opus-5", - }); - const manifestGpt55 = requireRecord( - manifestModels.find((model) => requireRecord(model, "manifest model").id === "gpt-5.5"), - "manifest gpt-5.5", - ); - expect(manifestGpt55).toMatchObject({ - contextWindow: 1_050_000, - status: "deprecated", - replacedBy: "gpt-5.6-sol", - }); - const manifestMiniMax = requireRecord( - manifestModels.find((model) => requireRecord(model, "manifest model").id === "minimax-m2.7"), - "manifest minimax-m2.7", - ); - expect(manifestMiniMax.api).toBe("openai-completions"); - expect(manifestMiniMax.baseUrl).toBe("https://opencode.ai/zen/v1"); - expect(manifestMiniMax).toMatchObject({ - cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - status: "deprecated", - replacedBy: "minimax-m3", - }); - const manifestKimiK3 = requireRecord( - manifestModels.find((model) => requireRecord(model, "manifest model").id === "kimi-k3"), - "manifest kimi-k3", - ); - expect(manifestKimiK3).toMatchObject({ - name: "Kimi K3", - api: "openai-completions", - provider: "opencode", - baseUrl: "https://opencode.ai/zen/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - contextWindow: 1_048_576, - maxTokens: 131_072, - }); + expect( + provider.resolveDynamicModel?.({ modelId: "unknown-offline-fixture" } as never), + ).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("keeps documented OpenCode Zen example models resolvable", async () => { + it("retains the documented offline provider families", async () => { const provider = await registerSingleProviderPlugin(plugin); const docs = readFileSync("docs/providers/opencode.md", "utf8"); const exampleRow = docs.match(/^\| Example models\s+\| (?.+) \|$/m); if (!exampleRow?.groups?.examples) { throw new Error("expected OpenCode Zen example model row"); } - - const exampleModelRefs = [...exampleRow.groups.examples.matchAll(/`opencode\/(.*?)`/g)].map( - (match) => match[1], + const examples = new Set( + [...exampleRow.groups.examples.matchAll(/`opencode\/(.*?)`/g)].map((match) => match[1]), ); - expect(exampleModelRefs.length).toBeGreaterThan(0); - for (const modelId of exampleModelRefs) { + for (const modelId of OFFLINE_MODEL_IDS.filter((id) => examples.has(id))) { expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({ id: modelId }); } - }); - - it("keeps every OpenCode Zen row within the required cost contract", async () => { - const provider = await registerSingleProviderPlugin(plugin); - const manifestProvider = requireRecord( - manifest.modelCatalog.providers.opencode, - "manifest provider", - ); - const manifestModels = manifestProvider.models; - if (!Array.isArray(manifestModels)) { - throw new Error("expected manifest opencode models"); - } - const supplemental = await provider.augmentModelCatalog?.({ entries: [] } as never); - - for (const manifestModel of manifestModels) { - const manifestModelRecord = requireRecord(manifestModel, "manifest model"); - const modelId = manifestModelRecord.id; - if (typeof modelId !== "string") { - throw new Error("expected manifest model id"); - } - requireRecord(manifestModelRecord.cost, `manifest cost ${modelId}`); - const runtimeModel = requireRecord( - provider.resolveDynamicModel?.({ modelId } as never), - `runtime model ${modelId}`, - ); - requireRecord(runtimeModel.cost, `runtime cost ${modelId}`); - } - - const verifiedCostExamples = new Map([ - ["claude-fable-5", { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }], - ["claude-opus-5", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], - ["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], - ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], - ["claude-opus-4-1", { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }], - ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }], - [ - "gpt-5.6-luna", - { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - tieredPricing: [ - { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, range: [0, 272_000] }, - { input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5, range: [272_000] }, - ], - }, - ], - [ - "gpt-5.6-terra", - { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 2.5, - tieredPricing: [ - { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 2.5, - range: [0, 272_000], - }, - { - input: 4, - output: 18, - cacheRead: 0.4, - cacheWrite: 5, - range: [272_000], - }, - ], - }, - ], - [ - "gpt-5.6-sol", - { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, - tieredPricing: [ - { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25, range: [0, 272_000] }, - { input: 10, output: 45, cacheRead: 1, cacheWrite: 12.5, range: [272_000] }, - ], - }, - ], - ["gpt-5.4-mini", { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }], - ["glm-5.2", { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }], - ["kimi-k2.7-code", { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }], - ["kimi-k3", { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }], - ["laguna-s-2.1-free", { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }], - ["ling-3.0-flash-free", { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }], - ["minimax-m2.5", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }], - ["minimax-m2.7", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }], - ["minimax-m3", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }], - ] as const); - - for (const [modelId, expectedCost] of verifiedCostExamples) { - const verifiedCostModel = requireRecord( - provider.resolveDynamicModel?.({ modelId } as never), - `verified cost model ${modelId}`, - ); - expect(verifiedCostModel.cost).toEqual(expectedCost); - } - - for (const manifestModel of manifestModels) { - const manifestModelRecord = requireRecord(manifestModel, "manifest model"); - const modelId = manifestModelRecord.id; - if (typeof modelId !== "string") { - throw new Error("expected manifest model id"); - } - const runtimeModel = requireRecord( - provider.resolveDynamicModel?.({ modelId } as never), - `runtime manifest anchor ${modelId}`, - ); - const lifecycleEntry = requireCatalogEntry(supplemental, modelId); - expect({ - api: manifestModelRecord.api ?? manifestProvider.api, - baseUrl: manifestModelRecord.baseUrl ?? manifestProvider.baseUrl, - reasoning: manifestModelRecord.reasoning, - input: manifestModelRecord.input, - cost: manifestModelRecord.cost, - contextWindow: manifestModelRecord.contextWindow, - contextTokens: manifestModelRecord.contextTokens, - maxTokens: manifestModelRecord.maxTokens, - thinkingLevelMap: manifestModelRecord.thinkingLevelMap, - compat: runtimeCompatFields(manifestModelRecord.compat), - status: manifestModelRecord.status, - replacedBy: manifestModelRecord.replacedBy, - }).toEqual({ - api: runtimeModel.api, - baseUrl: runtimeModel.baseUrl, - reasoning: runtimeModel.reasoning, - input: runtimeModel.input, - cost: runtimeModel.cost, - contextWindow: runtimeModel.contextWindow, - contextTokens: runtimeModel.contextTokens, - maxTokens: runtimeModel.maxTokens, - thinkingLevelMap: runtimeModel.thinkingLevelMap, - compat: runtimeCompatFields(runtimeModel.compat), - status: lifecycleEntry.status, - replacedBy: lifecycleEntry.replacedBy, - }); - } - }); - - it("loads OpenCode Zen model discovery through the provider runtime", () => { - expect(manifest.providerCatalogEntry).toBe("./provider-discovery.ts"); - expect(manifest.modelCatalog.discovery.opencode).toBe("runtime"); - }); - - it("exposes the complete offline OpenCode Zen catalog through provider discovery", async () => { - const { default: opencodeProviderDiscovery } = await import("./provider-discovery.js"); - const result = await opencodeProviderDiscovery.staticCatalog?.run({} as never); - if (!result || !("provider" in result)) { - throw new Error("expected OpenCode Zen static provider"); - } - - const modelIds = result.provider.models.map((model) => model.id); - expect(new Set(modelIds).size).toBe(modelIds.length); - expect(modelIds).toEqual(ACTIVE_MODEL_IDS); - }); - - it("exposes the offline catalog fallback through the full provider registration", async () => { - const provider = await registerSingleProviderPlugin(plugin); - const result = await provider.staticCatalog?.run({} as never); - if (!result || !("provider" in result)) { - throw new Error("expected registered OpenCode Zen static provider"); - } - - expect(result.provider.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); - expect(result.provider.models.find((model) => model.id === "grok-4.5")).toMatchObject({ + expect(provider.resolveDynamicModel?.({ modelId: "claude-opus-5" } as never)).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen", + }); + expect(provider.resolveDynamicModel?.({ modelId: "gpt-5.6-sol" } as never)).toMatchObject({ api: "openai-responses", baseUrl: "https://opencode.ai/zen/v1", - provider: "opencode", + }); + expect(provider.resolveDynamicModel?.({ modelId: "gemini-3.6-flash" } as never)).toMatchObject({ + api: "google-generative-ai", + baseUrl: "https://opencode.ai/zen/v1", }); }); - it("skips live OpenCode Zen catalog discovery when no shared key is configured", async () => { + it("exposes the offline seed through provider discovery without network access", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("unexpected fetch")); + const { default: discovery } = await import("./provider-discovery.js"); + const result = await discovery.staticCatalog?.run({} as never); + + if (!result || !("provider" in result)) { + throw new Error("expected OpenCode Zen static provider"); + } + expectSeedModels(result.provider.models); + expect(manifest.modelCatalog.discovery.opencode).toBe("runtime"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never downloads metadata when OpenCode has no configured credentials", async () => { + vi.stubEnv("OPENCODE_API_KEY", undefined); + vi.stubEnv("OPENCODE_ZEN_API_KEY", undefined); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("unexpected fetch")); const provider = await registerSingleProviderPlugin(plugin); await expect( @@ -645,134 +230,263 @@ describe("opencode provider plugin", () => { resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), } as never), ).resolves.toBeNull(); + await expect( + provider.prepareDynamicModel?.({ + config: {}, + provider: "opencode", + modelId: "unconfigured-preview-fixture", + modelRegistry: {}, + } as never), + ).resolves.toBeUndefined(); + await expect( + provider.prepareDynamicModel?.({ + config: {}, + provider: "opencode", + modelId: "unconfigured-preview-fixture", + modelRegistry: {}, + authProfileMode: "api_key", + authProfileId: "anthropic:default", + } as never), + ).resolves.toBeUndefined(); + expectSeedModels((await buildOpencodeZenLiveProviderConfig()).models); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not resolve OpenCode credentials or download metadata for another provider scope", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("unexpected fetch")); + const resolveProviderApiKey = vi.fn( + (): { apiKey: string; discoveryApiKey: string | undefined } => ({ + apiKey: "configured-opencode-key", + discoveryApiKey: "configured-opencode-key", + }), + ); + const provider = await registerSingleProviderPlugin(plugin); + + await expect( + provider.catalog?.run({ + config: {}, + env: {}, + providerIds: ["anthropic"], + resolveProviderApiKey, + } as never), + ).resolves.toBeNull(); + expect(resolveProviderApiKey).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + + resolveProviderApiKey.mockReturnValueOnce({ + apiKey: NON_ENV_SECRETREF_MARKER, + discoveryApiKey: undefined, + }); + const ownScope = await provider.catalog?.run({ + config: {}, + env: {}, + providerIds: ["opencode"], + resolveProviderApiKey, + } as never); + expect(ownScope).toMatchObject({ provider: { apiKey: NON_ENV_SECRETREF_MARKER } }); + expect(resolveProviderApiKey).toHaveBeenCalledOnce(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("downloads upstream metadata only after an explicitly selected provider is configured", async () => { + vi.stubEnv("OPENCODE_API_KEY", undefined); + vi.stubEnv("OPENCODE_ZEN_API_KEY", undefined); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response( + JSON.stringify( + upstreamCatalog([{ id: "configured-selection-fixture", npm: "@ai-sdk/anthropic" }]), + ), + ), + ); + const provider = await registerSingleProviderPlugin(plugin); + + await expect( + provider.prepareDynamicModel?.({ + config: { models: { providers: { opencode: { baseUrl: "https://opencode.ai/zen/v1" } } } }, + provider: "opencode", + modelId: "configured-selection-fixture", + modelRegistry: {}, + } as never), + ).resolves.toMatchObject({ + id: "configured-selection-fixture", + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const request = fetchMock.mock.calls[0]; + expect(request?.[0]).toBe(METADATA_URL); + expect(new Headers(request?.[1]?.headers).has("authorization")).toBe(false); }); it("does not mix provider-specific runtime auth with shared discovery auth", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("unexpected fetch")); const provider = await registerSingleProviderPlugin(plugin); - const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); + const result = await provider.catalog?.run({ + config: {}, + env: {}, + resolveProviderApiKey: (providerId: string) => + providerId === "opencode" + ? { apiKey: NON_ENV_SECRETREF_MARKER, discoveryApiKey: undefined } + : { apiKey: "shared-opencode-key", discoveryApiKey: "shared-opencode-key" }, + resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), + } as never); - try { - const result = await provider.catalog?.run({ - config: {}, - env: {}, - resolveProviderApiKey: (providerId: string) => - providerId === "opencode" - ? { - apiKey: NON_ENV_SECRETREF_MARKER, - discoveryApiKey: undefined, - } - : { - apiKey: "shared-opencode-key", - discoveryApiKey: "shared-opencode-key", - }, - resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }), - } as never); - - if (!result || !("provider" in result)) { - throw new Error("expected OpenCode Zen provider result"); - } - expect(result.provider.apiKey).toBe(NON_ENV_SECRETREF_MARKER); - expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-7"); - expect(fetchMock).not.toHaveBeenCalled(); - } finally { - fetchMock.mockRestore(); + if (!result || !("provider" in result)) { + throw new Error("expected OpenCode Zen provider result"); } + expect(result.provider.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expectSeedModels(result.provider.models); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("uses cached live OpenCode Zen discovery and filters live-only rows", async () => { - const fetchGuard = vi.fn(async () => ({ - response: new Response( - JSON.stringify({ - data: [ - { id: "kimi-k3", object: "model" }, - { id: "claude-opus-4-7", object: "model" }, - { id: "claude-opus-4-8", object: "model" }, - { id: "claude-sonnet-4", object: "model" }, - { id: "gpt-5.5", object: "model" }, - { id: "minimax-m2.7", object: "model" }, - { id: "ling-3.0-flash-free", object: "model" }, - { id: "gpt-6-experimental", object: "model" }, - ], - }), - ), - finalUrl: "https://opencode.ai/zen/v1/models", - release: vi.fn(async () => undefined), - })); + it("discovers previously unknown trusted models and their upstream-owned transport", async () => { + const fetchGuard = createCatalogFetchGuard({ + metadata: [ + { id: "x-preview-f-free", name: "Ox Alpha Free", reasoningEfforts: ["low", "high", "max"] }, + { id: "fresh-claude-fixture", npm: "@ai-sdk/anthropic" }, + { id: "fresh-gemini-fixture", npm: "@ai-sdk/google" }, + { id: "fresh-gpt-fixture", npm: "@ai-sdk/openai" }, + { id: "retired-zen-fixture", status: "deprecated" }, + ], + advertised: [ + "x-preview-f-free", + "fresh-claude-fixture", + "fresh-gemini-fixture", + "fresh-gpt-fixture", + "retired-zen-fixture", + "untrusted-live-only-fixture", + ], + }); + const provider = await registerSingleProviderPlugin(plugin); + expect( + provider.resolveDynamicModel?.({ modelId: "x-preview-f-free" } as never), + ).toBeUndefined(); const first = await buildOpencodeZenLiveProviderConfig({ - apiKey: "OPENCODE_API_KEY", - discoveryApiKey: "resolved-opencode-key", + apiKey: "runtime-key", + discoveryApiKey: "discovery-key", fetchGuard, }); const second = await buildOpencodeZenLiveProviderConfig({ - apiKey: "OPENCODE_API_KEY", - discoveryApiKey: "resolved-opencode-key", + apiKey: "other-runtime-key", + discoveryApiKey: "discovery-key", fetchGuard, }); - expect(fetchGuard).toHaveBeenCalledTimes(1); - expect(first.apiKey).toBe("OPENCODE_API_KEY"); - expect(first.models.map((model) => model.id)).toEqual(["kimi-k3", "claude-opus-4-7"]); - expect(second.models.map((model) => model.id)).toEqual(["kimi-k3", "claude-opus-4-7"]); - expect(first.models.find((model) => model.id === "kimi-k3")).toMatchObject({ + expect(fetchGuard.mock.calls.map(([request]) => request.url)).toEqual([ + METADATA_URL, + MODELS_URL, + ]); + expect(first.models.map((model) => model.id)).toEqual([ + "x-preview-f-free", + "fresh-claude-fixture", + "fresh-gemini-fixture", + "fresh-gpt-fixture", + ]); + expect(second.apiKey).toBe("other-runtime-key"); + expect(second.models.map((model) => model.id)).toEqual(first.models.map((model) => model.id)); + expect(first.models.find((model) => model.id === "x-preview-f-free")).toMatchObject({ + name: "Ox Alpha Free", api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - contextWindow: 1_048_576, + input: ["text", "image"], + contextWindow: 1_000_000, + contextTokens: 900_000, maxTokens: 131_072, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { supportedReasoningEfforts: ["low", "high", "max"] }, }); - const claudeModel = first.models.find((model) => model.id === "claude-opus-4-7"); - expect(claudeModel).toMatchObject({ + expect(first.models.find((model) => model.id === "fresh-claude-fixture")).toMatchObject({ api: "anthropic-messages", baseUrl: "https://opencode.ai/zen", - provider: "opencode", }); - const liveOnlyModel = first.models.find((model) => model.id === "gpt-6-experimental"); - expect(liveOnlyModel).toBeUndefined(); - - clearLiveCatalogCacheForTests(); - fetchGuard.mockResolvedValueOnce({ - response: new Response( - JSON.stringify({ - data: [{ id: "gpt-6-experimental", object: "model" }], - }), - ), - finalUrl: "https://opencode.ai/zen/v1/models", - release: vi.fn(async () => undefined), + expect(first.models.find((model) => model.id === "fresh-gemini-fixture")).toMatchObject({ + api: "google-generative-ai", + baseUrl: "https://opencode.ai/zen/v1", }); - const unknownOnly = await buildOpencodeZenLiveProviderConfig({ - apiKey: "OPENCODE_API_KEY", - discoveryApiKey: "resolved-opencode-key", - fetchGuard, + expect(first.models.find((model) => model.id === "fresh-gpt-fixture")).toMatchObject({ + api: "openai-responses", + baseUrl: "https://opencode.ai/zen/v1", }); - expect(unknownOnly.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); - - clearLiveCatalogCacheForTests(); - fetchGuard.mockRejectedValueOnce(new Error("network unavailable")); - const fallback = await buildOpencodeZenLiveProviderConfig({ - apiKey: "OPENCODE_API_KEY", - discoveryApiKey: "resolved-opencode-key", - fetchGuard, + expect( + provider.resolveDynamicModel?.({ modelId: "retired-zen-fixture" } as never), + ).toMatchObject({ + id: "retired-zen-fixture", }); - expect(fallback.apiKey).toBe("OPENCODE_API_KEY"); - expect(fallback.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); + expect( + provider.resolveDynamicModel?.({ modelId: "untrusted-live-only-fixture" } as never), + ).toBeUndefined(); }); - it("keeps live OpenCode Zen discovery caches scoped to discovery credentials", async () => { - const fetchGuard = vi - .fn() - .mockResolvedValueOnce({ - response: new Response( - JSON.stringify({ data: [{ id: "claude-opus-4-7", object: "model" }] }), - ), - finalUrl: "https://opencode.ai/zen/v1/models", - release: vi.fn(async () => undefined), - }) - .mockResolvedValueOnce({ - response: new Response(JSON.stringify({ data: [{ id: "gpt-5.6-luna", object: "model" }] })), - finalUrl: "https://opencode.ai/zen/v1/models", - release: vi.fn(async () => undefined), - }); + it("prepares explicitly selected upstream models without guessing names or protocol", async () => { + const fetchGuard = createCatalogFetchGuard({ + metadata: [{ id: "selected-provider-fixture", npm: "@ai-sdk/anthropic" }], + advertised: [], + }); + await expect( + prepareOpencodeZenModel({ modelId: "selected-provider-fixture", fetchGuard }), + ).resolves.toMatchObject({ + id: "selected-provider-fixture", + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen", + }); + expect(fetchGuard.mock.calls.map(([request]) => request.url)).toEqual([METADATA_URL]); + }); + + it("evicts dynamic models removed or rejected by a refreshed authoritative catalog", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const fetchGuard = createCatalogFetchGuard({ + metadata: [{ id: "refreshed-model-fixture" }], + advertised: [], + }); + await prepareOpencodeZenModel({ modelId: "refreshed-model-fixture", fetchGuard }); + expect( + provider.resolveDynamicModel?.({ modelId: "refreshed-model-fixture" } as never), + ).toMatchObject({ + id: "refreshed-model-fixture", + }); + + clearLiveCatalogCacheForTests(); + const removedFetchGuard = createCatalogFetchGuard({ metadata: [], advertised: [] }); + await prepareOpencodeZenModel({ + modelId: "refreshed-model-fixture", + fetchGuard: removedFetchGuard, + }); + expect( + provider.resolveDynamicModel?.({ modelId: "refreshed-model-fixture" } as never), + ).toBeUndefined(); + + clearLiveCatalogCacheForTests(); + await prepareOpencodeZenModel({ modelId: "refreshed-model-fixture", fetchGuard }); + clearLiveCatalogCacheForTests(); + const unsupportedFetchGuard = createCatalogFetchGuard({ + metadata: [{ id: "refreshed-model-fixture", npm: "@untrusted/unknown" }], + advertised: [], + }); + await prepareOpencodeZenModel({ + modelId: "refreshed-model-fixture", + fetchGuard: unsupportedFetchGuard, + }); + expect( + provider.resolveDynamicModel?.({ modelId: "refreshed-model-fixture" } as never), + ).toBeUndefined(); + expect(provider.resolveDynamicModel?.({ modelId: "gpt-5-nano" } as never)).toMatchObject({ + id: "gpt-5-nano", + }); + }); + + it("shares upstream metadata while keeping advertised models scoped to discovery keys", async () => { + const fetchGuard = createCatalogFetchGuard({ + metadata: [{ id: "account-a-fixture" }, { id: "account-b-fixture" }], + advertised: (authorization) => + authorization === "Bearer discovery-a" ? ["account-a-fixture"] : ["account-b-fixture"], + }); const first = await buildOpencodeZenLiveProviderConfig({ apiKey: "runtime-a", discoveryApiKey: "discovery-a", @@ -783,32 +497,39 @@ describe("opencode provider plugin", () => { discoveryApiKey: "discovery-b", fetchGuard, }); - const secondCached = await buildOpencodeZenLiveProviderConfig({ - apiKey: "runtime-c", - discoveryApiKey: "discovery-b", + + expect(first.models.map((model) => model.id)).toEqual(["account-a-fixture"]); + expect(second.models.map((model) => model.id)).toEqual(["account-b-fixture"]); + expect(fetchGuard.mock.calls.map(([request]) => request.url)).toEqual([ + METADATA_URL, + MODELS_URL, + MODELS_URL, + ]); + }); + + it("retains the compact offline seed when discovery fails", async () => { + const fetchGuard = vi.fn(async () => { + throw new Error("network unavailable"); + }); + const fallback = await buildOpencodeZenLiveProviderConfig({ + apiKey: "runtime-key", + discoveryApiKey: "discovery-key", fetchGuard, }); - expect(fetchGuard).toHaveBeenCalledTimes(2); - expect(first.apiKey).toBe("runtime-a"); - expect(first.models.map((model) => model.id)).toEqual(["claude-opus-4-7"]); - expect(second.apiKey).toBe("runtime-b"); - expect(second.models.map((model) => model.id)).toEqual(["gpt-5.6-luna"]); - expect(secondCached.apiKey).toBe("runtime-c"); - expect(secondCached.models.map((model) => model.id)).toEqual(["gpt-5.6-luna"]); + expect(fallback.apiKey).toBe("runtime-key"); + expectSeedModels(fallback.models); }); it.each([ [["claude-opus-5"], "opencode/claude-opus-5"], [["gpt-5.6-sol"], undefined], ])("selects only the advertised preferred onboarding model %#", async (modelIds, expected) => { - const fetchGuard = vi.fn(async () => ({ - response: new Response( - JSON.stringify({ data: modelIds.map((id) => ({ id, object: "model" })) }), - ), - finalUrl: "https://opencode.ai/zen/v1/models", - release: vi.fn(async () => undefined), - })); + const fetchGuard = vi.fn(async () => + guardedResponse(MODELS_URL, { + data: modelIds.map((id) => ({ id, object: "model" })), + }), + ); await expect( resolveOpencodeZenStarterModel({ @@ -826,10 +547,7 @@ describe("opencode provider plugin", () => { const provider = await registerSingleProviderPlugin(plugin); const capturedPayloads: Record[] = []; const baseStreamFn = (_model: unknown, _context: unknown, options: unknown) => { - const payload: Record = { - model: "kimi-k3", - reasoning_effort: "max", - }; + const payload: Record = { model: "kimi-k3", reasoning_effort: "max" }; (options as { onPayload?: (payload: Record) => void })?.onPayload?.(payload); capturedPayloads.push(payload); return {} as never; @@ -855,7 +573,6 @@ describe("opencode provider plugin", () => { it("canonicalizes stale OpenCode Zen base URLs", async () => { const provider = await registerSingleProviderPlugin(plugin); - const normalizedConfig = requireRecord( provider.normalizeConfig?.({ provider: "opencode", @@ -874,41 +591,21 @@ describe("opencode provider plugin", () => { provider: "opencode", model: { provider: "opencode", - id: "claude-opus-4-8", - name: "Claude Opus 4.8", + id: "claude-opus-5", api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 65_536, + baseUrl: MODELS_URL.replace("/models", ""), }, } as never), "normalized model", ); expect(normalizedModel.baseUrl).toBe("https://opencode.ai/zen"); - expect( provider.normalizeTransport?.({ provider: "opencode", api: "openai-completions", baseUrl: "https://opencode.ai/zen", } as never), - ).toEqual({ - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - }); - expect( - provider.normalizeTransport?.({ - provider: "opencode", - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen/v1", - } as never), - ).toEqual({ - api: "anthropic-messages", - baseUrl: "https://opencode.ai/zen", - }); + ).toEqual({ api: "openai-completions", baseUrl: "https://opencode.ai/zen/v1" }); }); it("exposes provider-owned thinking levels for proxied models", async () => { @@ -920,52 +617,14 @@ describe("opencode provider plugin", () => { const provider = requireRegisteredProvider(providers, "opencode"); const resolveThinkingProfile = provider.resolveThinkingProfile; if (!resolveThinkingProfile) { - throw new Error("Expected OpenCode provider resolveThinkingProfile"); + throw new Error("expected OpenCode provider thinking profile"); } - const opus47Profile = resolveThinkingProfile({ + const opusProfile = resolveThinkingProfile({ provider: "opencode", modelId: "claude-opus-4-7", }); - const opus47LevelIds = opus47Profile?.levels.map((level) => level.id) ?? []; - expect(opus47Profile?.defaultLevel).toBe("off"); - expect(opus47LevelIds).toContain("xhigh"); - expect(opus47LevelIds).toContain("adaptive"); - expect(opus47LevelIds).toContain("max"); - const opus46Profile = resolveThinkingProfile({ - provider: "opencode", - modelId: "claude-opus-4.6", - }); - const opus46LevelIds = opus46Profile?.levels.map((level) => level.id) ?? []; - expect(opus46Profile?.defaultLevel).toBe("adaptive"); - expect(opus46LevelIds).toContain("adaptive"); - expect(opus46LevelIds).not.toContain("xhigh"); - expect(opus46LevelIds).not.toContain("max"); - const sonnet46Profile = resolveThinkingProfile({ - provider: "opencode", - modelId: "claude-sonnet-4-6", - }); - const sonnet46LevelIds = sonnet46Profile?.levels.map((level) => level.id) ?? []; - expect(sonnet46Profile?.defaultLevel).toBe("adaptive"); - expect(sonnet46LevelIds).toContain("adaptive"); - expect(sonnet46LevelIds).not.toContain("xhigh"); - expect(sonnet46LevelIds).not.toContain("max"); - - const gpt56Profile = resolveThinkingProfile({ - provider: "opencode", - modelId: "gpt-5.6-luna", - api: "openai-responses", - reasoning: true, - compat: { - supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], - }, - }); - const gpt56LevelIds = gpt56Profile?.levels.map((level) => level.id) ?? []; - expect(gpt56Profile?.defaultLevel).toBe("medium"); - expect(gpt56LevelIds).not.toContain("minimal"); - expect(gpt56LevelIds).toContain("xhigh"); - expect(gpt56LevelIds).toContain("max"); - + expect(opusProfile?.levels.map((level) => level.id)).toContain("adaptive"); expect( resolveThinkingProfile({ provider: "opencode", @@ -975,18 +634,6 @@ describe("opencode provider plugin", () => { compat: { supportedReasoningEfforts: ["max"] }, }), ).toEqual({ levels: [{ id: "off" }, { id: "max" }], defaultLevel: "off" }); - expect( - resolveThinkingProfile({ - provider: "opencode", - modelId: "grok-4.5", - api: "openai-responses", - reasoning: true, - compat: { supportedReasoningEfforts: ["low", "medium", "high"] }, - }), - ).toEqual({ - levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }], - defaultLevel: "medium", - }); expect( resolveThinkingProfile({ provider: "opencode", diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index f32d42911a31..7a1aff3a8891 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -14,6 +14,7 @@ import { buildStaticOpencodeZenProviderConfig, listOpencodeZenModelCatalogEntries, normalizeOpencodeZenBaseUrl, + prepareOpencodeZenModel, resolveOpencodeZenModel, resolveOpencodeZenStarterModel, } from "./provider-catalog.js"; @@ -102,9 +103,33 @@ export default defineSingleProviderPluginEntry({ : undefined; }, resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId), + prepareDynamicModel: async (ctx) => { + const profileProvider = ctx.authProfileId + ? ctx.config?.auth?.profiles?.[ctx.authProfileId]?.provider + : undefined; + const ownsProfile = Boolean( + ctx.authProfileId && + (ctx.authProfileId.startsWith("opencode:") || + ctx.authProfileId.startsWith("opencode-go:") || + profileProvider === "opencode" || + profileProvider === "opencode-go"), + ); + const configured = Boolean( + ownsProfile || + ctx.providerConfig || + ctx.config?.models?.providers?.opencode || + ctx.config?.models?.providers?.["opencode-go"] || + process.env.OPENCODE_API_KEY?.trim() || + process.env.OPENCODE_ZEN_API_KEY?.trim(), + ); + return configured ? await prepareOpencodeZenModel({ modelId: ctx.modelId }) : undefined; + }, catalog: { order: "simple", run: async (ctx) => { + if (ctx.providerIds !== undefined && !ctx.providerIds.includes(PROVIDER_ID)) { + return null; + } const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey); if (!auth) { return null; diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 3d7d4c384450..5c8a89d0c7d7 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -1,31 +1,16 @@ { "id": "opencode", "icon": "https://cdn.simpleicons.org/opencode", - "doctorContract": { - "configRepair": true - }, - "activation": { - "onStartup": true - }, + "doctorContract": { "configRepair": true }, + "activation": { "onStartup": true }, "providerCatalogEntry": "./provider-discovery.ts", "enabledByDefault": true, - "providers": [ - "opencode" - ], + "providers": ["opencode"], "providerEndpoints": [ - { - "endpointClass": "opencode-native", - "hostSuffixes": [ - "opencode.ai" - ] - } + { "endpointClass": "opencode-native", "hostSuffixes": ["opencode.ai"] } ], "providerRequest": { - "providers": { - "opencode": { - "family": "opencode" - } - } + "providers": { "opencode": { "family": "opencode" } } }, "modelCatalog": { "providers": { @@ -40,16 +25,8 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen", "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 5, - "output": 25, - "cacheRead": 0.5, - "cacheWrite": 6.25 - }, + "input": ["text", "image"], + "cost": { "input": 5, "output": 25, "cacheRead": 0.5, "cacheWrite": 6.25 }, "contextWindow": 1000000, "maxTokens": 128000, "compat": { @@ -60,73 +37,6 @@ "codeMode": "capable" } }, - { - "id": "claude-opus-4-8", - "name": "Claude Opus 4.8", - "api": "anthropic-messages", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 5, - "output": 25, - "cacheRead": 0.5, - "cacheWrite": 6.25 - }, - "contextWindow": 1000000, - "maxTokens": 128000, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"], - "maxTokensField": "max_tokens", - "codeMode": "capable" - }, - "status": "deprecated", - "replacedBy": "claude-opus-5" - }, - { - "id": "claude-sonnet-4", - "name": "Claude Sonnet 4", - "api": "anthropic-messages", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen", - "reasoning": true, - "input": ["text", "image"], - "cost": { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 3.75, - "tieredPricing": [ - { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 3.75, - "range": [0, 200000] - }, - { - "input": 6, - "output": 22.5, - "cacheRead": 0.6, - "cacheWrite": 7.5, - "range": [200000] - } - ] - }, - "contextWindow": 1000000, - "maxTokens": 64000, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens" - }, - "status": "deprecated" - }, { "id": "gpt-5.6-sol", "name": "GPT-5.6 Sol", @@ -134,10 +44,7 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text", - "image" - ], + "input": ["text", "image"], "cost": { "input": 5, "output": 30, @@ -149,19 +56,14 @@ "output": 30, "cacheRead": 0.5, "cacheWrite": 6.25, - "range": [ - 0, - 272000 - ] + "range": [0, 272000] }, { "input": 10, "output": 45, "cacheRead": 1, "cacheWrite": 12.5, - "range": [ - 272000 - ] + "range": [272000] } ] }, @@ -171,68 +73,30 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, - "supportedReasoningEfforts": [ - "none", - "low", - "medium", - "high", - "xhigh", - "max" - ], + "supportedReasoningEfforts": ["none", "low", "medium", "high", "xhigh", "max"], "maxTokensField": "max_tokens", "codeMode": "capable" } }, { - "id": "gpt-5.5", - "name": "GPT-5.5", + "id": "gpt-5-nano", + "name": "GPT-5 Nano", "api": "openai-responses", "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 5, - "output": 30, - "cacheRead": 0.5, - "cacheWrite": 0, - "tieredPricing": [ - { - "input": 5, - "output": 30, - "cacheRead": 0.5, - "cacheWrite": 0, - "range": [ - 0, - 272000 - ] - }, - { - "input": 10, - "output": 45, - "cacheRead": 1, - "cacheWrite": 0, - "range": [ - 272000 - ] - } - ] - }, - "contextWindow": 1050000, - "contextTokens": 922000, + "input": ["text", "image"], + "cost": { "input": 0.05, "output": 0.4, "cacheRead": 0.005, "cacheWrite": 0 }, + "contextWindow": 400000, + "contextTokens": 272000, "maxTokens": 128000, + "thinkingLevelMap": { "off": null }, "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["none", "low", "medium", "high", "xhigh"], - "maxTokensField": "max_tokens", - "codeMode": "capable" - }, - "status": "deprecated", - "replacedBy": "gpt-5.6-sol" + "supportedReasoningEfforts": ["minimal", "low", "medium", "high"], + "maxTokensField": "max_tokens" + } }, { "id": "gemini-3.6-flash", @@ -241,16 +105,8 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 1.5, - "output": 7.5, - "cacheRead": 0.15, - "cacheWrite": 0 - }, + "input": ["text", "image"], + "cost": { "input": 1.5, "output": 7.5, "cacheRead": 0.15, "cacheWrite": 0 }, "contextWindow": 1048576, "maxTokens": 65536, "compat": { @@ -260,53 +116,6 @@ "maxTokensField": "max_tokens" } }, - { - "id": "gemini-3.1-pro", - "name": "Gemini 3.1 Pro Preview", - "api": "google-generative-ai", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 2, - "output": 12, - "cacheRead": 0.2, - "cacheWrite": 0, - "tieredPricing": [ - { - "input": 2, - "output": 12, - "cacheRead": 0.2, - "cacheWrite": 0, - "range": [ - 0, - 200000 - ] - }, - { - "input": 4, - "output": 18, - "cacheRead": 0.4, - "cacheWrite": 0, - "range": [ - 200000 - ] - } - ] - }, - "contextWindow": 1048576, - "maxTokens": 65536, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["low", "medium", "high"], - "maxTokensField": "max_tokens" - } - }, { "id": "minimax-m3", "name": "MiniMax M3", @@ -314,16 +123,8 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.06, - "cacheWrite": 0 - }, + "input": ["text", "image"], + "cost": { "input": 0.3, "output": 1.2, "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 512000, "maxTokens": 128000, "compat": { @@ -333,33 +134,6 @@ "supportsStrictMode": false } }, - { - "id": "minimax-m2.7", - "name": "MiniMax M2.7", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0.3, - "output": 1.2, - "cacheRead": 0.06, - "cacheWrite": 0 - }, - "contextWindow": 204800, - "maxTokens": 131072, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - }, - "status": "deprecated", - "replacedBy": "minimax-m3" - }, { "id": "kimi-k3", "name": "Kimi K3", @@ -367,16 +141,8 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 0 - }, + "input": ["text", "image"], + "cost": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 0 }, "contextWindow": 1048576, "maxTokens": 131072, "compat": { @@ -396,15 +162,8 @@ "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 200000, "contextTokens": 160000, "maxTokens": 32000, @@ -414,219 +173,15 @@ "supportsDeveloperRole": false, "supportsStrictMode": false } - }, - { - "id": "deepseek-v4-flash-free", - "name": "DeepSeek V4 Flash Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 200000, - "maxTokens": 128000, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["low", "high", "max"], - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "mimo-v2.5-free", - "name": "MiMo V2.5 Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 200000, - "maxTokens": 32000, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "laguna-s-2.1-free", - "name": "Laguna S 2.1 Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 256000, - "maxTokens": 32000, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["low", "medium", "high"], - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "ling-3.0-flash-free", - "name": "Ling-3.0-flash Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 32768, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["low", "medium", "high"], - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - }, - "status": "deprecated" - }, - { - "id": "nemotron-3-ultra-free", - "name": "Nemotron 3 Ultra Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 1000000, - "maxTokens": 128000, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "north-mini-code-free", - "name": "North Mini Code Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 256000, - "maxTokens": 64000, - "compat": { - "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, - "supportedReasoningEfforts": ["none", "high"], - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "ling-3.0-tiny-free", - "name": "Ling-3.0-tiny Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": ["text"], - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 32768, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } - }, - { - "id": "longcat-2.0-free", - "name": "LongCat-2.0 Free", - "api": "openai-completions", - "provider": "opencode", - "baseUrl": "https://opencode.ai/zen/v1", - "reasoning": true, - "input": ["text"], - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, - "maxTokens": 131072, - "compat": { - "supportsUsageInStreaming": true, - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsStrictMode": false - } } ] } }, - "discovery": { - "opencode": "runtime" - } + "discovery": { "opencode": "runtime" } }, "setup": { "providers": [ - { - "id": "opencode", - "envVars": [ - "OPENCODE_API_KEY", - "OPENCODE_ZEN_API_KEY" - ] - } + { "id": "opencode", "envVars": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] } ] }, "providerAuthChoices": [ @@ -645,19 +200,11 @@ "cliDescription": "OpenCode API key (Zen catalog)" } ], - "contracts": { - "mediaUnderstandingProviders": [ - "opencode" - ] - }, + "contracts": { "mediaUnderstandingProviders": ["opencode"] }, "mediaUnderstandingProviderMetadata": { "opencode": { - "capabilities": [ - "image" - ], - "defaultModels": { - "image": "gpt-5-nano" - } + "capabilities": ["image"], + "defaultModels": { "image": "gpt-5-nano" } } }, "configSchema": { @@ -668,10 +215,7 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": true - } + "enabled": { "type": "boolean", "default": true } } } } diff --git a/extensions/opencode/opencode.live.test.ts b/extensions/opencode/opencode.live.test.ts index 3cfede769f0e..ff20e2906a7e 100644 --- a/extensions/opencode/opencode.live.test.ts +++ b/extensions/opencode/opencode.live.test.ts @@ -9,7 +9,7 @@ import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { - buildStaticOpencodeZenProviderConfig, + buildOpencodeZenLiveProviderConfig, listOpencodeZenModelCatalogEntries, } from "./provider-catalog.js"; @@ -20,7 +20,7 @@ const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENCODE_DEEPSEEK_MODEL?.trim() || "deepseek-v4-flash-free"; const LIVE = isLiveTestEnabled(["OPENCODE_LIVE_TEST"]) && OPENCODE_API_KEY.length > 0; const describeLive = LIVE ? describe : describe.skip; -const describeCatalogLive = isLiveTestEnabled(["OPENCODE_LIVE_TEST"]) ? describe : describe.skip; +const describeCatalogLive = LIVE ? describe : describe.skip; type OpencodeModelsResponse = { data?: Array<{ id?: unknown; object?: unknown }>; @@ -85,52 +85,30 @@ async function fetchOpencodeZenModelIds(): Promise { return modelIds; } -function listStaticOpencodeZenModelIds(): string[] { - return buildStaticOpencodeZenProviderConfig() - .models.map((model) => model.id) - .toSorted(); -} - describeCatalogLive("opencode Zen live catalog drift", () => { - it("covers every global live id with trusted metadata and filters deprecated rows", async () => { + it("discovers active live ids from authoritative metadata without hardcoding the catalog", async () => { const liveIds = await fetchOpencodeZenModelIds(); - const staticIds = listStaticOpencodeZenModelIds(); - expect(new Set(staticIds).size).toBe(staticIds.length); + const discovered = await buildOpencodeZenLiveProviderConfig({ + apiKey: OPENCODE_API_KEY, + discoveryApiKey: OPENCODE_API_KEY, + }); + const discoveredIds = discovered.models.map((model) => model.id).toSorted(); + expect(new Set(discoveredIds).size).toBe(discoveredIds.length); const trustedRows = listOpencodeZenModelCatalogEntries(); const trustedIdSet = new Set(trustedRows.map((row) => row.id)); const missingTrustedMetadata = liveIds.filter((id) => !trustedIdSet.has(id)); - const deprecatedLiveIds = trustedRows - .filter((row) => row.status === "deprecated" && liveIds.includes(row.id)) - .map((row) => row.id) - .toSorted(); - const expectedActiveIds = liveIds.filter((id) => !deprecatedLiveIds.includes(id)); + const deprecatedIds = new Set( + trustedRows.filter((row) => row.status === "deprecated").map((row) => row.id), + ); - expect( - { missingTrustedMetadata, deprecatedLiveIds, staticIds }, - [ - "OpenCode Zen global catalog has ids without trusted provider metadata,", - "or active discovery no longer matches global availability after lifecycle filtering.", - "Key-scoped absence is not retirement evidence.", - ].join(" "), - ).toEqual({ - missingTrustedMetadata: [], - deprecatedLiveIds: [ - "claude-opus-4-8", - "claude-sonnet-4", - "glm-5", - "gpt-5-codex", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2-codex", - "gpt-5.5", - "kimi-k2.5", - "ling-3.0-flash-free", - "minimax-m2.5", - "minimax-m2.7", - ], - staticIds: expectedActiveIds, + expect(missingTrustedMetadata).toEqual([]); + expect(discoveredIds.every((id) => liveIds.includes(id) && !deprecatedIds.has(id))).toBe(true); + expect(discovered.models.find((model) => model.id === "x-preview-f-free")).toMatchObject({ + api: "openai-completions", + contextWindow: 1_000_000, + maxTokens: 131_072, + compat: { supportedReasoningEfforts: ["low", "high", "max"] }, }); }, 30_000); }); diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index 22a5c8632159..92dd91742c15 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -1,383 +1,28 @@ -// Opencode Zen provider module implements model/runtime integration. import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { buildLiveModelProviderConfig, fetchLiveProviderModelIds, + getCachedUpstreamProviderCatalog, + projectUpstreamProviderCatalogModel, type LiveModelCatalogFetchGuard, + type UpstreamProviderCatalog, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; import type { - ModelApi, ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; const PROVIDER_ID = "opencode"; - const OPENCODE_ZEN_OPENAI_BASE_URL = "https://opencode.ai/zen/v1"; const OPENCODE_ZEN_ANTHROPIC_BASE_URL = "https://opencode.ai/zen"; const OPENCODE_ZEN_MODELS_ENDPOINT = "https://opencode.ai/zen/v1/models"; +const OPENCODE_UPSTREAM_CATALOG_ENDPOINT = "https://models.opencode.ai/api.json"; const OPENCODE_ZEN_MODELS_TIMEOUT_MS = 5_000; const OPENCODE_ZEN_MODELS_CACHE_TTL_MS = 60_000; -const FREE_COST: ModelDefinitionConfig["cost"] = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -type ZenModelCapabilities = { - contextWindow: number; - contextTokens?: number; - maxTokens: number; - input: ReadonlyArray<"text" | "image">; - reasoningEfforts?: readonly string[]; - status?: "deprecated"; - replacedBy?: string; -}; - -const T = ["text"] as const; -const TI = ["text", "image"] as const; - -// The official machine catalog owns limits, representable modalities, and the -// reasoning boolean. Pinned provider metadata/source owns exact effort enums. -const E_LMHXM = ["low", "medium", "high", "xhigh", "max"] as const; -const E_LMHM = ["low", "medium", "high", "max"] as const; -const E_LMH = ["low", "medium", "high"] as const; -const E_MIN_LMH = ["minimal", "low", "medium", "high"] as const; -const E_NONE_LMHX = ["none", "low", "medium", "high", "xhigh"] as const; -const E_MHX = ["medium", "high", "xhigh"] as const; -const E_NONE_LMHXM = ["none", "low", "medium", "high", "xhigh", "max"] as const; -const E_LMHX = ["low", "medium", "high", "xhigh"] as const; -const E_NONE_LMH = ["none", "low", "medium", "high"] as const; -const E_LOW_HIGH_MAX = ["low", "high", "max"] as const; -const E_HIGH_MAX = ["high", "max"] as const; -const E_MAX = ["max"] as const; -const E_NONE_HIGH = ["none", "high"] as const; - -type ZenModelMetadata = Pick; - -const INPUT_128 = { contextTokens: 128_000 } as const; -const INPUT_160 = { contextTokens: 160_000 } as const; -const INPUT_272 = { contextTokens: 272_000 } as const; -const INPUT_922 = { contextTokens: 922_000 } as const; -const DEPRECATED = { status: "deprecated" } as const; -const INPUT_272_DEPRECATED = { contextTokens: 272_000, status: "deprecated" } as const; -const DEPRECATED_BY_OPUS_5 = { status: "deprecated", replacedBy: "claude-opus-5" } as const; -const DEPRECATED_BY_GPT_56_SOL = { - status: "deprecated", - replacedBy: "gpt-5.6-sol", -} as const; -const INPUT_922_DEPRECATED_BY_GPT_56_SOL = { - contextTokens: 922_000, - ...DEPRECATED_BY_GPT_56_SOL, -} as const; -const DEPRECATED_BY_MINIMAX_M3 = { - status: "deprecated", - replacedBy: "minimax-m3", -} as const; - -type ZenModelCapabilityRow = readonly [ - id: string, - contextWindow: number, - maxTokens: number, - input: ReadonlyArray<"text" | "image">, - reasoningEfforts?: readonly string[], - metadata?: ZenModelMetadata, -]; - -const MODEL_CAPABILITY_ROWS = [ - ["claude-fable-5", 1000000, 128000, TI, E_LMHXM], - ["claude-opus-5", 1000000, 128000, TI, E_LMHXM], - ["claude-opus-4-8", 1000000, 128000, TI, E_LMHXM, DEPRECATED_BY_OPUS_5], - ["claude-opus-4-7", 1000000, 128000, TI, E_LMHXM], - ["claude-opus-4-6", 1000000, 128000, TI, E_LMHM], - ["claude-opus-4-5", 200000, 64000, TI, E_LMH], - ["claude-sonnet-5", 1000000, 128000, TI, E_LMHXM], - ["claude-sonnet-4-6", 1000000, 64000, TI, E_LMHM], - ["claude-sonnet-4-5", 1000000, 64000, TI], - ["claude-sonnet-4", 1000000, 64000, TI, undefined, DEPRECATED], - ["claude-haiku-4-5", 200000, 64000, TI], - ["gemini-3.6-flash", 1048576, 65536, TI, E_MIN_LMH], - ["gemini-3.5-flash-lite", 1048576, 65536, TI, E_MIN_LMH], - ["gemini-3.5-flash", 1048576, 65536, TI, E_MIN_LMH], - ["gemini-3.1-pro", 1048576, 65536, TI, E_LMH], - ["gemini-3-flash", 1048576, 65536, TI, E_MIN_LMH], - ["gpt-5.6-sol", 1050000, 128000, TI, E_NONE_LMHXM, INPUT_922], - ["gpt-5.6-terra", 1050000, 128000, TI, E_NONE_LMHXM, INPUT_922], - ["gpt-5.6-luna", 1050000, 128000, TI, E_NONE_LMHXM, INPUT_922], - ["gpt-5.5", 1050000, 128000, TI, E_NONE_LMHX, INPUT_922_DEPRECATED_BY_GPT_56_SOL], - ["gpt-5.5-pro", 1050000, 128000, TI, E_MHX, INPUT_922], - ["gpt-5.4", 1050000, 128000, TI, E_NONE_LMHX, INPUT_922], - ["gpt-5.4-pro", 1050000, 128000, TI, E_MHX, INPUT_922], - ["gpt-5.4-mini", 400000, 128000, TI, E_NONE_LMHX, INPUT_272], - ["gpt-5.4-nano", 400000, 128000, TI, E_NONE_LMHX, INPUT_272], - ["gpt-5.3-codex-spark", 128000, 128000, T, E_LMHX, INPUT_128], - ["gpt-5.3-codex", 400000, 128000, TI, E_NONE_LMHX, INPUT_272], - ["gpt-5.2", 400000, 128000, TI, E_NONE_LMHX, INPUT_272], - ["gpt-5.2-codex", 400000, 128000, TI, E_LMHX, INPUT_272_DEPRECATED], - ["gpt-5.1", 400000, 128000, TI, E_NONE_LMH, INPUT_272], - ["gpt-5.1-codex-max", 400000, 128000, TI, E_LMHX, INPUT_272_DEPRECATED], - ["gpt-5.1-codex", 400000, 128000, TI, E_LMH, INPUT_272_DEPRECATED], - ["gpt-5.1-codex-mini", 400000, 128000, TI, E_LMH, INPUT_272_DEPRECATED], - ["gpt-5", 400000, 128000, TI, E_MIN_LMH, INPUT_272], - ["gpt-5-codex", 400000, 128000, TI, E_LMH, INPUT_272_DEPRECATED], - ["gpt-5-nano", 400000, 128000, TI, E_MIN_LMH, INPUT_272], - ["grok-build-0.1", 256000, 256000, TI], - ["grok-4.5", 500000, 500000, TI, E_LMH], - ["deepseek-v4-pro", 1000000, 384000, T, E_HIGH_MAX], - ["deepseek-v4-flash", 1000000, 384000, T, E_LOW_HIGH_MAX], - ["glm-5.2", 1000000, 131072, T, E_HIGH_MAX], - ["glm-5.1", 204800, 131072, T], - ["glm-5", 204800, 131072, T, undefined, DEPRECATED], - ["minimax-m3", 512000, 128000, TI], - ["minimax-m2.7", 204800, 131072, T, undefined, DEPRECATED_BY_MINIMAX_M3], - ["minimax-m2.5", 204800, 131072, T, undefined, DEPRECATED], - ["kimi-k3", 1048576, 131072, TI, E_MAX], - ["kimi-k2.7-code", 262144, 262144, TI], - ["kimi-k2.6", 262144, 65536, TI], - ["kimi-k2.5", 262144, 65536, TI, undefined, DEPRECATED], - ["qwen3.6-plus", 262144, 65536, TI], - ["qwen3.5-plus", 262144, 65536, TI], - ["big-pickle", 200000, 32000, T, undefined, INPUT_160], - ["deepseek-v4-flash-free", 200000, 128000, T, E_LOW_HIGH_MAX], - ["mimo-v2.5-free", 200000, 32000, TI], - ["ling-3.0-flash-free", 262144, 32768, T, E_LMH, DEPRECATED], - ["ling-3.0-tiny-free", 262144, 32768, T], - ["nemotron-3-ultra-free", 1000000, 128000, T], - ["north-mini-code-free", 256000, 64000, T, E_NONE_HIGH], - ["laguna-s-2.1-free", 256000, 32000, T, E_LMH], - ["longcat-2.0-free", 1000000, 131072, T], - ["claude-opus-4-1", 200000, 32000, TI, undefined, DEPRECATED], -] as const satisfies readonly ZenModelCapabilityRow[]; -type ZenModelId = (typeof MODEL_CAPABILITY_ROWS)[number][0]; - -const MODEL_CAPABILITIES = Object.fromEntries( - MODEL_CAPABILITY_ROWS.map(([id, contextWindow, maxTokens, input, reasoningEfforts, metadata]) => [ - id, - { - contextWindow, - maxTokens, - input, - ...(reasoningEfforts ? { reasoningEfforts } : {}), - ...metadata, - }, - ]), -) as Record; - -const MODEL_COSTS: Record = { - "big-pickle": FREE_COST, - "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-sonnet-4": { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - tieredPricing: [ - { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, range: [0, 200_000] }, - { input: 6, output: 22.5, cacheRead: 0.6, cacheWrite: 7.5, range: [200_000] }, - ], - }, - "claude-sonnet-4-5": { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - tieredPricing: [ - { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, range: [0, 200_000] }, - { input: 6, output: 22.5, cacheRead: 0.6, cacheWrite: 7.5, range: [200_000] }, - ], - }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, - "deepseek-v4-flash-free": FREE_COST, - "deepseek-v4-pro": { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 }, - "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "gemini-3.1-pro": { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 0, - tieredPricing: [ - { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0, range: [0, 200_000] }, - { input: 4, output: 18, cacheRead: 0.4, cacheWrite: 0, range: [200_000] }, - ], - }, - "gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, - "gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - "gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, - "gpt-5.6-luna": { - input: 0.2, - output: 1.2, - cacheRead: 0.02, - cacheWrite: 0.25, - tieredPricing: [ - { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, range: [0, 272_000] }, - { input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5, range: [272_000] }, - ], - }, - "gpt-5.6-sol": { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, - tieredPricing: [ - { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25, range: [0, 272_000] }, - { input: 10, output: 45, cacheRead: 1, cacheWrite: 12.5, range: [272_000] }, - ], - }, - "gpt-5.6-terra": { - input: 2, - output: 12, - cacheRead: 0.2, - cacheWrite: 2.5, - tieredPricing: [ - { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5, range: [0, 272_000] }, - { input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5, range: [272_000] }, - ], - }, - "glm-5": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 }, - "glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, - "gpt-5": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "gpt-5-codex": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 }, - "gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "gpt-5.1-codex": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - "gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "gpt-5.2-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "gpt-5.3-codex-spark": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - "gpt-5.4": { - input: 2.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 0, - tieredPricing: [ - { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0, range: [0, 272_000] }, - { input: 5, output: 22.5, cacheRead: 0.5, cacheWrite: 0, range: [272_000] }, - ], - }, - "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, - "gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, - "gpt-5.4-pro": { input: 30, output: 180, cacheRead: 30, cacheWrite: 0 }, - "gpt-5.5": { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 0, - tieredPricing: [ - { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0, range: [0, 272_000] }, - { input: 10, output: 45, cacheRead: 1, cacheWrite: 0, range: [272_000] }, - ], - }, - "gpt-5.5-pro": { input: 30, output: 180, cacheRead: 30, cacheWrite: 0 }, - "grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 }, - "grok-4.5": { - input: 2, - output: 6, - cacheRead: 0.3, - cacheWrite: 0, - tieredPricing: [ - { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0, range: [0, 200_000] }, - { input: 4, output: 12, cacheRead: 0.6, cacheWrite: 0, range: [200_000] }, - ], - }, - "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 }, - "kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 }, - "kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, - "kimi-k3": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, - "laguna-s-2.1-free": FREE_COST, - "ling-3.0-flash-free": FREE_COST, - "ling-3.0-tiny-free": FREE_COST, - "longcat-2.0-free": FREE_COST, - "mimo-v2.5-free": FREE_COST, - "minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, - "nemotron-3-ultra-free": FREE_COST, - "north-mini-code-free": FREE_COST, - "qwen3.5-plus": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, - "qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 }, -}; - -const MODEL_NAMES: Record = { - "big-pickle": "Big Pickle", - "claude-fable-5": "Claude Fable 5", - "claude-haiku-4-5": "Claude Haiku 4.5", - "claude-opus-4-1": "Claude Opus 4.1", - "claude-opus-4-5": "Claude Opus 4.5", - "claude-opus-4-6": "Claude Opus 4.6", - "claude-opus-4-7": "Claude Opus 4.7", - "claude-opus-4-8": "Claude Opus 4.8", - "claude-opus-5": "Claude Opus 5", - "claude-sonnet-4": "Claude Sonnet 4", - "claude-sonnet-4-5": "Claude Sonnet 4.5", - "claude-sonnet-4-6": "Claude Sonnet 4.6", - "claude-sonnet-5": "Claude Sonnet 5", - "deepseek-v4-flash": "DeepSeek V4 Flash", - "deepseek-v4-flash-free": "DeepSeek V4 Flash Free", - "deepseek-v4-pro": "DeepSeek V4 Pro", - "gemini-3-flash": "Gemini 3 Flash", - "gemini-3.1-pro": "Gemini 3.1 Pro Preview", - "gemini-3.5-flash": "Gemini 3.5 Flash", - "gemini-3.5-flash-lite": "Gemini 3.5 Flash Lite", - "gemini-3.6-flash": "Gemini 3.6 Flash", - "gpt-5.6-luna": "GPT-5.6 Luna", - "gpt-5.6-sol": "GPT-5.6 Sol", - "gpt-5.6-terra": "GPT-5.6 Terra", - "glm-5": "GLM-5", - "glm-5.1": "GLM-5.1", - "glm-5.2": "GLM-5.2", - "gpt-5": "GPT-5", - "gpt-5-codex": "GPT-5 Codex", - "gpt-5-nano": "GPT-5 Nano", - "gpt-5.1": "GPT-5.1", - "gpt-5.1-codex": "GPT-5.1 Codex", - "gpt-5.1-codex-max": "GPT-5.1 Codex Max", - "gpt-5.1-codex-mini": "GPT-5.1 Codex Mini", - "gpt-5.2": "GPT-5.2", - "gpt-5.2-codex": "GPT-5.2 Codex", - "gpt-5.3-codex": "GPT-5.3 Codex", - "gpt-5.3-codex-spark": "GPT-5.3 Codex Spark", - "gpt-5.4": "GPT-5.4", - "gpt-5.4-mini": "GPT-5.4 Mini", - "gpt-5.4-nano": "GPT-5.4 Nano", - "gpt-5.4-pro": "GPT-5.4 Pro", - "gpt-5.5": "GPT-5.5", - "gpt-5.5-pro": "GPT-5.5 Pro", - "grok-build-0.1": "Grok Build 0.1", - "grok-4.5": "Grok 4.5", - "kimi-k2.5": "Kimi K2.5", - "kimi-k2.6": "Kimi K2.6", - "kimi-k2.7-code": "Kimi K2.7 Code", - "kimi-k3": "Kimi K3", - "laguna-s-2.1-free": "Laguna S 2.1 Free", - "ling-3.0-flash-free": "Ling-3.0-flash Free", - "ling-3.0-tiny-free": "Ling-3.0-tiny Free", - "longcat-2.0-free": "LongCat-2.0 Free", - "mimo-v2.5-free": "MiMo V2.5 Free", - "minimax-m2.5": "MiniMax M2.5", - "minimax-m2.7": "MiniMax M2.7", - "minimax-m3": "MiniMax M3", - "nemotron-3-ultra-free": "Nemotron 3 Ultra Free", - "north-mini-code-free": "North Mini Code Free", - "qwen3.5-plus": "Qwen3.5 Plus", - "qwen3.6-plus": "Qwen3.6 Plus", -}; - type OpencodeZenModelDefinition = ModelDefinitionConfig & { provider: typeof PROVIDER_ID; api: NonNullable; @@ -392,78 +37,94 @@ type FetchOpencodeZenLiveModelIdsParams = { signal?: AbortSignal; }; -type OpencodeZenTransport = { - api: ModelApi; - baseUrl: string; +type OpencodeZenModelLifecycle = { + status?: "deprecated"; + replacedBy?: string; }; -function resolveOpencodeZenTransport(modelId: string): OpencodeZenTransport { - const lower = modelId.toLowerCase(); - if (lower.startsWith("gpt-") || lower.startsWith("grok-")) { - return { api: "openai-responses", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL }; - } - if (lower.startsWith("claude-") || lower.startsWith("qwen")) { - return { api: "anthropic-messages", baseUrl: OPENCODE_ZEN_ANTHROPIC_BASE_URL }; - } - if (lower.startsWith("gemini-")) { - return { api: "google-generative-ai", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL }; - } - return { api: "openai-completions", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL }; -} - -function buildOpencodeZenModel(modelId: ZenModelId): OpencodeZenModelDefinition { - const capabilities = MODEL_CAPABILITIES[modelId]; - if (!capabilities) { - throw new Error(`missing OpenCode Zen capability metadata for ${modelId}`); - } - const transport = resolveOpencodeZenTransport(modelId); - return normalizeModelCompat({ - id: modelId, - name: MODEL_NAMES[modelId], - api: transport.api, +const OPENCODE_ZEN_MANIFEST_PROVIDER = manifest.modelCatalog.providers.opencode; +const OPENCODE_ZEN_SEED_MODELS = OPENCODE_ZEN_MANIFEST_PROVIDER.models.map((model) => + normalizeModelCompat({ + ...model, provider: PROVIDER_ID, - baseUrl: transport.baseUrl, - reasoning: true, - input: [...capabilities.input], - cost: MODEL_COSTS[modelId], - contextWindow: capabilities.contextWindow, - ...(capabilities.contextTokens ? { contextTokens: capabilities.contextTokens } : {}), - maxTokens: capabilities.maxTokens, - ...(transport.api === "openai-responses" && !capabilities.reasoningEfforts?.includes("none") - ? { thinkingLevelMap: { off: null } } - : {}), - compat: { - supportsUsageInStreaming: true, - ...(capabilities.reasoningEfforts - ? { - supportsReasoningEffort: true, - supportedReasoningEfforts: [...capabilities.reasoningEfforts], - } + api: model.api ?? OPENCODE_ZEN_MANIFEST_PROVIDER.api, + baseUrl: model.baseUrl ?? OPENCODE_ZEN_MANIFEST_PROVIDER.baseUrl, + } as OpencodeZenModelDefinition), +) as OpencodeZenModelDefinition[]; +const OPENCODE_ZEN_MODEL_BY_ID = new Map( + OPENCODE_ZEN_SEED_MODELS.map((model) => [model.id, model]), +); +const OPENCODE_ZEN_MODEL_LIFECYCLE_BY_ID = new Map( + OPENCODE_ZEN_MANIFEST_PROVIDER.models.map((model) => [ + model.id, + { + ...("status" in model && model.status === "deprecated" + ? { status: "deprecated" as const } : {}), - maxTokensField: "max_tokens", - ...(transport.api === "openai-completions" - ? { supportsDeveloperRole: false, supportsStrictMode: false } + ...("replacedBy" in model && typeof model.replacedBy === "string" + ? { replacedBy: model.replacedBy } : {}), }, - }) as OpencodeZenModelDefinition; + ]), +); + +function isActiveOpencodeZenModel(model: OpencodeZenModelDefinition): boolean { + return OPENCODE_ZEN_MODEL_LIFECYCLE_BY_ID.get(model.id)?.status !== "deprecated"; } -const OPENCODE_ZEN_RESOLVABLE_MODELS = MODEL_CAPABILITY_ROWS.map(([modelId]) => - buildOpencodeZenModel(modelId), -); -const OPENCODE_ZEN_MODELS = OPENCODE_ZEN_RESOLVABLE_MODELS.filter( - (model) => MODEL_CAPABILITIES[model.id]?.status !== "deprecated", -); -const OPENCODE_ZEN_MODEL_BY_ID = new Map( - OPENCODE_ZEN_RESOLVABLE_MODELS.map((model) => [model.id, model]), -); +function listStaticOpencodeZenModels(): OpencodeZenModelDefinition[] { + return OPENCODE_ZEN_SEED_MODELS.filter(isActiveOpencodeZenModel); +} + +function cacheUpstreamOpencodeZenModels(catalog: UpstreamProviderCatalog): void { + OPENCODE_ZEN_MODEL_BY_ID.clear(); + OPENCODE_ZEN_MODEL_LIFECYCLE_BY_ID.clear(); + for (const model of OPENCODE_ZEN_SEED_MODELS) { + OPENCODE_ZEN_MODEL_BY_ID.set(model.id, model); + } + for (const upstreamModel of Object.values(catalog.models)) { + const projected = projectUpstreamProviderCatalogModel({ + providerId: PROVIDER_ID, + provider: catalog, + model: upstreamModel, + anthropicBaseUrl: OPENCODE_ZEN_ANTHROPIC_BASE_URL, + defaultBaseUrl: OPENCODE_ZEN_OPENAI_BASE_URL, + }); + if (!projected) { + continue; + } + const model = normalizeModelCompat(projected) as OpencodeZenModelDefinition; + OPENCODE_ZEN_MODEL_BY_ID.set(model.id.toLowerCase(), model); + if (upstreamModel.status === "deprecated") { + OPENCODE_ZEN_MODEL_LIFECYCLE_BY_ID.set(model.id, { status: "deprecated" }); + } + } +} + +export async function prepareOpencodeZenModel(params: { + modelId: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const catalog = await getCachedUpstreamProviderCatalog({ + endpoint: OPENCODE_UPSTREAM_CATALOG_ENDPOINT, + providerId: PROVIDER_ID, + fetchGuard: params.fetchGuard, + signal: params.signal, + }); + if (!catalog) { + return undefined; + } + cacheUpstreamOpencodeZenModels(catalog); + return resolveOpencodeZenModel(params.modelId); +} export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProviderConfig { return { api: "openai-completions", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL, ...(apiKey ? { apiKey } : {}), - models: OPENCODE_ZEN_MODELS, + models: listStaticOpencodeZenModels(), }; } @@ -490,19 +151,16 @@ function readLiveModelId(row: unknown): string | undefined { if (!row || typeof row !== "object" || Array.isArray(row)) { return undefined; } - const candidate = row as { id?: unknown; object?: unknown }; - if (candidate.object !== undefined && candidate.object !== "model") { + if ("object" in row && row.object !== undefined && row.object !== "model") { return undefined; } - if (typeof candidate.id !== "string") { + if (!("id" in row) || typeof row.id !== "string") { return undefined; } - const modelId = candidate.id.trim().toLowerCase(); - return modelId || undefined; + return row.id.trim().toLowerCase() || undefined; } function projectOpencodeZenLiveModels(rows: readonly unknown[]): OpencodeZenModelDefinition[] { - const staticModels = new Map(OPENCODE_ZEN_MODELS.map((model) => [model.id, model])); const seen = new Set(); const models: OpencodeZenModelDefinition[] = []; for (const row of rows) { @@ -511,8 +169,8 @@ function projectOpencodeZenLiveModels(rows: readonly unknown[]): OpencodeZenMode continue; } seen.add(modelId); - const model = staticModels.get(modelId); - if (model) { + const model = OPENCODE_ZEN_MODEL_BY_ID.get(modelId); + if (model && isActiveOpencodeZenModel(model)) { models.push(model); } } @@ -522,6 +180,23 @@ function projectOpencodeZenLiveModels(rows: readonly unknown[]): OpencodeZenMode export async function buildOpencodeZenLiveProviderConfig( params: FetchOpencodeZenLiveModelIdsParams = {}, ): Promise { + const fallbackModels = listStaticOpencodeZenModels(); + if (!params.apiKey && !params.discoveryApiKey) { + return buildStaticOpencodeZenProviderConfig(); + } + try { + const upstream = await getCachedUpstreamProviderCatalog({ + endpoint: OPENCODE_UPSTREAM_CATALOG_ENDPOINT, + providerId: PROVIDER_ID, + fetchGuard: params.fetchGuard, + signal: params.signal, + }); + if (upstream) { + cacheUpstreamOpencodeZenModels(upstream); + } + } catch { + // The offline seed remains usable when authoritative metadata is unavailable. + } return await buildLiveModelProviderConfig({ providerId: PROVIDER_ID, endpoint: OPENCODE_ZEN_MODELS_ENDPOINT, @@ -529,7 +204,7 @@ export async function buildOpencodeZenLiveProviderConfig( api: "openai-completions", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL, }, - models: OPENCODE_ZEN_MODELS, + models: fallbackModels, apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, @@ -542,9 +217,9 @@ export async function buildOpencodeZenLiveProviderConfig( } export function listOpencodeZenModelCatalogEntries(): ModelCatalogEntry[] { - return OPENCODE_ZEN_RESOLVABLE_MODELS.map((model) => { - const lifecycle = MODEL_CAPABILITIES[model.id]; - const entry: ModelCatalogEntry = { + return Array.from(OPENCODE_ZEN_MODEL_BY_ID.values(), (model) => { + const lifecycle = OPENCODE_ZEN_MODEL_LIFECYCLE_BY_ID.get(model.id); + return { provider: model.provider, id: model.id, name: model.name, @@ -555,20 +230,14 @@ export function listOpencodeZenModelCatalogEntries(): ModelCatalogEntry[] { contextWindow: model.contextWindow, contextTokens: model.contextTokens, compat: model.compat, + ...(lifecycle?.status ? { status: lifecycle.status } : {}), + ...(lifecycle?.replacedBy ? { replacedBy: lifecycle.replacedBy } : {}), }; - if (lifecycle?.status) { - entry.status = lifecycle.status; - } - if (lifecycle?.replacedBy) { - entry.replacedBy = lifecycle.replacedBy; - } - return entry; }); } export function resolveOpencodeZenModel(modelId: string): ProviderRuntimeModel | undefined { - const normalizedModelId = modelId.trim().toLowerCase(); - return OPENCODE_ZEN_MODEL_BY_ID.get(normalizedModelId); + return OPENCODE_ZEN_MODEL_BY_ID.get(modelId.trim().toLowerCase()); } function normalizeBaseUrl(baseUrl: string | undefined): string { diff --git a/src/plugin-sdk/provider-catalog-live-normalize.internal.ts b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts index 4c3fac484e1d..3a3f7aec4835 100644 --- a/src/plugin-sdk/provider-catalog-live-normalize.internal.ts +++ b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts @@ -1,6 +1,52 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { ModelDefinitionConfig, ModelProviderConfig } from "./provider-model-shared.js"; +export type UpstreamProviderCatalogModel = { + id: string; + name: string; + status?: string; + reasoning?: boolean; + tool_call?: boolean; + attachment?: boolean; + reasoning_options?: ReadonlyArray<{ type: string; values?: ReadonlyArray }>; + modalities?: { input?: readonly string[]; output?: readonly string[] }; + provider?: { npm?: string; api?: string }; + limit: { context: number; input?: number; output: number }; + cost?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + tiers?: ReadonlyArray<{ + input: number; + output: number; + cache_read?: number; + cache_write?: number; + tier: { type: string; size: number }; + }>; + context_over_200k?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; +}; + +export type UpstreamProviderCatalog = { + id: string; + api?: string; + npm?: string; + models: Record; +}; + +export type ProjectedUpstreamProviderCatalogModel = ModelDefinitionConfig & { + provider: string; + api: NonNullable; + baseUrl: string; + input: Array<"text" | "image">; +}; + export function readLiveModelCatalogRecord(body: unknown): Record | undefined { return asOptionalRecord(body); } @@ -47,6 +93,18 @@ export function readLiveModelCatalogPositiveSafeIntegerField( return undefined; } +export function isUpstreamProviderCatalogModel( + value: unknown, +): value is UpstreamProviderCatalogModel { + const model = readLiveModelCatalogRecord(value); + const limits = readLiveModelCatalogRecord(model?.limit); + return Boolean( + readLiveModelCatalogStringField(model, "id") && + readLiveModelCatalogPositiveSafeIntegerField(limits, "context") && + readLiveModelCatalogPositiveSafeIntegerField(limits, "output"), + ); +} + function readLiveModelPositiveIntegerFromRecords( records: readonly (Record | undefined)[], keys: readonly string[], @@ -318,3 +376,163 @@ export function buildOpenAICompatibleLiveModels( a.id.localeCompare(b.id), ); } + +function readUpstreamProviderCatalogCostValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function buildUpstreamProviderCatalogCost( + rawCost: UpstreamProviderCatalogModel["cost"], +): ModelDefinitionConfig["cost"] { + const cost = { + input: readUpstreamProviderCatalogCostValue(rawCost?.input), + output: readUpstreamProviderCatalogCostValue(rawCost?.output), + cacheRead: readUpstreamProviderCatalogCostValue(rawCost?.cache_read), + cacheWrite: readUpstreamProviderCatalogCostValue(rawCost?.cache_write), + }; + const upstreamTiers = (rawCost?.tiers ?? []) + .filter( + (tier) => + tier.tier?.type === "context" && Number.isSafeInteger(tier.tier.size) && tier.tier.size > 0, + ) + .toSorted((left, right) => left.tier.size - right.tier.size); + if (upstreamTiers.length === 0 && rawCost?.context_over_200k) { + upstreamTiers.push({ + ...rawCost.context_over_200k, + tier: { type: "context", size: 200_000 }, + }); + } + const firstTier = upstreamTiers[0]; + if (!firstTier) { + return cost; + } + const tieredPricing: NonNullable = [ + { ...cost, range: [0, firstTier.tier.size] }, + ]; + for (const [index, tier] of upstreamTiers.entries()) { + const nextThreshold = upstreamTiers[index + 1]?.tier.size; + tieredPricing.push({ + input: readUpstreamProviderCatalogCostValue(tier.input), + output: readUpstreamProviderCatalogCostValue(tier.output), + cacheRead: readUpstreamProviderCatalogCostValue(tier.cache_read), + cacheWrite: readUpstreamProviderCatalogCostValue(tier.cache_write), + range: nextThreshold ? [tier.tier.size, nextThreshold] : [tier.tier.size], + }); + } + return { ...cost, tieredPricing }; +} + +function parseUpstreamProviderCatalogUrl(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +/** Projects authoritative provider-owned model metadata into its runtime transport and capabilities. */ +export function projectUpstreamProviderCatalogModel(params: { + providerId: string; + provider: UpstreamProviderCatalog; + model: UpstreamProviderCatalogModel | undefined; + anthropicBaseUrl?: string; + defaultBaseUrl?: string; +}): ProjectedUpstreamProviderCatalogModel | undefined { + const model = readLiveModelCatalogRecord(params.model); + const limit = readLiveModelCatalogRecord(model?.limit); + const id = readLiveModelCatalogStringField(model, "id"); + const contextWindow = readLiveModelCatalogPositiveSafeIntegerField(limit, "context"); + const maxTokens = readLiveModelCatalogPositiveSafeIntegerField(limit, "output"); + if (!model || !id || !contextWindow || !maxTokens) { + return undefined; + } + + const modelProvider = readLiveModelCatalogRecord(model.provider); + const npm = + readLiveModelCatalogStringField(modelProvider, "npm") ?? + params.provider.npm ?? + "@ai-sdk/openai-compatible"; + const apiByPackage: Record = { + "@ai-sdk/anthropic": "anthropic-messages", + "@ai-sdk/google": "google-generative-ai", + "@ai-sdk/openai": "openai-responses", + "@ai-sdk/openai-compatible": "openai-completions", + }; + const api = apiByPackage[npm]; + if (!api) { + return undefined; + } + const canonicalBaseUrl = params.defaultBaseUrl ?? params.provider.api; + const canonicalOrigin = canonicalBaseUrl + ? parseUpstreamProviderCatalogUrl(canonicalBaseUrl)?.origin + : undefined; + const providerBaseUrl = params.provider.api ?? params.defaultBaseUrl; + const modelBaseUrl = readLiveModelCatalogStringField(modelProvider, "api"); + if ( + !canonicalOrigin || + (providerBaseUrl && + parseUpstreamProviderCatalogUrl(providerBaseUrl)?.origin !== canonicalOrigin) || + (modelBaseUrl && parseUpstreamProviderCatalogUrl(modelBaseUrl)?.origin !== canonicalOrigin) + ) { + // Metadata chooses transport, but must never redirect authenticated inference + // away from the provider endpoint trusted by its owner plugin. + return undefined; + } + const upstreamBaseUrl = modelBaseUrl ?? providerBaseUrl; + const baseUrl = + api === "anthropic-messages" + ? (params.anthropicBaseUrl ?? upstreamBaseUrl?.replace(/\/v1\/?$/, "")) + : upstreamBaseUrl; + if (!baseUrl || parseUpstreamProviderCatalogUrl(baseUrl)?.origin !== canonicalOrigin) { + return undefined; + } + + const modalities = readLiveModelCatalogRecord(model.modalities); + const input: ProjectedUpstreamProviderCatalogModel["input"] = ["text"]; + if (Array.isArray(modalities?.input) && modalities.input.includes("image")) { + input.push("image"); + } + const reasoningOptions = Array.isArray(model.reasoning_options) ? model.reasoning_options : []; + const reasoningEfforts = [ + ...new Set( + reasoningOptions.flatMap((option) => { + const record = readLiveModelCatalogRecord(option); + return record?.type === "effort" && Array.isArray(record.values) + ? record.values.filter( + (value): value is string => typeof value === "string" && Boolean(value), + ) + : []; + }), + ), + ]; + const contextTokens = readLiveModelCatalogPositiveSafeIntegerField(limit, "input"); + return { + id, + name: readLiveModelCatalogStringField(model, "name") ?? id, + provider: params.providerId, + api, + baseUrl, + reasoning: readLiveModelCatalogBooleanField(model, "reasoning") ?? false, + input, + cost: buildUpstreamProviderCatalogCost(params.model?.cost), + contextWindow, + ...(contextTokens && contextTokens <= contextWindow ? { contextTokens } : {}), + maxTokens, + ...(api === "openai-responses" && + reasoningEfforts.length > 0 && + !reasoningEfforts.includes("none") + ? { thinkingLevelMap: { off: null } } + : {}), + compat: { + supportsUsageInStreaming: true, + maxTokensField: "max_tokens", + ...(typeof model.tool_call === "boolean" ? { supportsTools: model.tool_call } : {}), + ...(reasoningEfforts.length > 0 + ? { supportsReasoningEffort: true, supportedReasoningEfforts: reasoningEfforts } + : {}), + ...(api === "openai-completions" + ? { supportsDeveloperRole: false, supportsStrictMode: false } + : {}), + }, + }; +} diff --git a/src/plugin-sdk/provider-catalog-live-runtime.ts b/src/plugin-sdk/provider-catalog-live-runtime.ts index f6b33ea3886b..338e8701e4d1 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.ts @@ -9,10 +9,13 @@ import type { } from "../plugins/types.js"; import { buildOpenAICompatibleLiveModels, + isUpstreamProviderCatalogModel, readLiveModelCatalogBooleanField, readLiveModelCatalogPositiveSafeIntegerField, readLiveModelCatalogRecord, readLiveModelCatalogStringField, + type UpstreamProviderCatalog, + type UpstreamProviderCatalogModel, } from "./provider-catalog-live-normalize.internal.js"; import { buildSingleProviderApiKeyCatalog, @@ -45,6 +48,12 @@ export { readLiveModelCatalogPositiveSafeIntegerField, readLiveModelCatalogStringField, }; +export { projectUpstreamProviderCatalogModel } from "./provider-catalog-live-normalize.internal.js"; +export type { + ProjectedUpstreamProviderCatalogModel, + UpstreamProviderCatalog, + UpstreamProviderCatalogModel, +} from "./provider-catalog-live-normalize.internal.js"; export type FetchLiveProviderModelIdsParams = { providerId: string; @@ -71,6 +80,15 @@ export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams shouldCacheRows?: (rows: readonly unknown[]) => boolean; }; +export type GetCachedUpstreamProviderCatalogParams = { + endpoint: string; + providerId: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; + timeoutMs?: number; + ttlMs?: number; +}; + export type LiveModelRowProjection = ( rows: readonly unknown[], fallback: ModelProviderConfig, @@ -84,6 +102,9 @@ export type LiveModelRowProjection { - const buffer = await readResponseWithLimit(response, LIVE_MODEL_CATALOG_BODY_MAX_BYTES, { +async function readLiveModelCatalogJson( + response: Response, + timeoutMs: number, + bodyMaxBytes = LIVE_MODEL_CATALOG_BODY_MAX_BYTES, +): Promise { + const buffer = await readResponseWithLimit(response, bodyMaxBytes, { chunkTimeoutMs: timeoutMs, onOverflow: ({ size, maxBytes }) => new Error(`Live model catalog response exceeded ${maxBytes} bytes (${size} bytes received)`), @@ -233,6 +258,73 @@ async function readLiveModelCatalogJson(response: Response, timeoutMs: number): return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)); } +/** Loads one provider from a shared public metadata feed only when explicitly requested. */ +export async function getCachedUpstreamProviderCatalog( + params: GetCachedUpstreamProviderCatalogParams, +): Promise { + const body = await getCachedLiveCatalogValue({ + // Provider ids intentionally stay out of this key: sibling providers share + // one upstream document and must not download it once per provider. + keyParts: ["upstream-provider-catalog", params.endpoint], + ttlMs: params.ttlMs ?? 300_000, + load: async () => { + const timeoutMs = params.timeoutMs ?? 15_000; + const { response, release } = await (params.fetchGuard ?? fetchWithSsrFGuard)({ + url: params.endpoint, + init: { headers: { Accept: "application/json" } }, + signal: params.signal, + timeoutMs, + policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint), + requireHttps: true, + auditContext: "upstream-provider-catalog-discovery", + }); + try { + if (!response.ok) { + await cancelUnreadResponseBody(response); + throw new LiveModelCatalogHttpError("upstream-provider-catalog", response.status); + } + const catalog = readLiveModelCatalogRecord( + await readLiveModelCatalogJson( + response, + timeoutMs, + UPSTREAM_PROVIDER_CATALOG_BODY_MAX_BYTES, + ), + ); + if (!catalog) { + throw new Error("Upstream provider catalog response must be an object"); + } + return catalog; + } finally { + await release(); + } + }, + }); + + const provider = readLiveModelCatalogRecord(body[params.providerId]); + const models = readLiveModelCatalogRecord(provider?.models); + if ( + !provider || + !models || + readLiveModelCatalogStringField(provider, "id") !== params.providerId + ) { + return undefined; + } + return { + id: params.providerId, + ...(readLiveModelCatalogStringField(provider, "api") + ? { api: readLiveModelCatalogStringField(provider, "api") } + : {}), + ...(readLiveModelCatalogStringField(provider, "npm") + ? { npm: readLiveModelCatalogStringField(provider, "npm") } + : {}), + models: Object.fromEntries( + Object.entries(models).filter((entry): entry is [string, UpstreamProviderCatalogModel] => + isUpstreamProviderCatalogModel(entry[1]), + ), + ), + }; +} + function readLiveModelCatalogNextUrl(body: unknown): string | undefined { const record = readLiveModelCatalogRecord(body); if (!record) { diff --git a/src/plugin-sdk/provider-catalog-live-upstream.test.ts b/src/plugin-sdk/provider-catalog-live-upstream.test.ts new file mode 100644 index 000000000000..e3c8ef700621 --- /dev/null +++ b/src/plugin-sdk/provider-catalog-live-upstream.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest"; +import { + clearLiveCatalogCacheForTests, + getCachedUpstreamProviderCatalog, + projectUpstreamProviderCatalogModel, + type LiveModelCatalogFetchGuard, + type UpstreamProviderCatalog, +} from "./provider-catalog-live-runtime.js"; + +function buildFetchGuard(body: unknown): { + fetchGuard: MockedFunction; + release: ReturnType; +} { + const release = vi.fn(async () => undefined); + const fetchGuard: MockedFunction = vi.fn(async () => ({ + response: new Response(JSON.stringify(body)), + finalUrl: "https://models.opencode.ai/api.json", + release, + })); + return { fetchGuard, release }; +} + +describe("shared upstream provider metadata catalogs", () => { + beforeEach(() => clearLiveCatalogCacheForTests()); + + it("lazily shares one anonymous upstream metadata download across provider ids", async () => { + const { fetchGuard, release } = buildFetchGuard({ + opencode: { id: "opencode", api: "https://opencode.ai/zen/v1", models: {} }, + "opencode-go": { id: "opencode-go", api: "https://opencode.ai/zen/go/v1", models: {} }, + }); + const endpoint = "https://models.opencode.ai/api.json"; + + expect(fetchGuard).not.toHaveBeenCalled(); + const providers = await Promise.all([ + getCachedUpstreamProviderCatalog({ endpoint, providerId: "missing", fetchGuard }), + getCachedUpstreamProviderCatalog({ endpoint, providerId: "opencode", fetchGuard }), + getCachedUpstreamProviderCatalog({ endpoint, providerId: "opencode-go", fetchGuard }), + ]); + + expect(providers.map((provider) => provider?.id)).toEqual([ + undefined, + "opencode", + "opencode-go", + ]); + expect(fetchGuard).toHaveBeenCalledTimes(1); + expect(fetchGuard.mock.calls[0]?.[0]).toMatchObject({ + url: endpoint, + requireHttps: true, + timeoutMs: 15_000, + auditContext: "upstream-provider-catalog-discovery", + }); + expect( + new Headers(fetchGuard.mock.calls[0]?.[0].init?.headers).get("authorization"), + ).toBeNull(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("accepts shared upstream feeds beyond the ordinary four-megabyte provider limit", async () => { + const { fetchGuard } = buildFetchGuard({ + padding: "x".repeat(4 * 1024 * 1024), + opencode: { id: "opencode", models: {} }, + }); + + await expect( + getCachedUpstreamProviderCatalog({ + endpoint: "https://models.opencode.ai/api.json", + providerId: "opencode", + fetchGuard, + }), + ).resolves.toMatchObject({ id: "opencode" }); + }); + + it("rejects shared upstream feeds beyond their separate eight-megabyte ceiling", async () => { + const { fetchGuard, release } = buildFetchGuard({ + padding: "x".repeat(8 * 1024 * 1024), + opencode: { id: "opencode", models: {} }, + }); + + await expect( + getCachedUpstreamProviderCatalog({ + endpoint: "https://models.opencode.ai/api.json", + providerId: "opencode", + fetchGuard, + }), + ).rejects.toThrow("Live model catalog response exceeded 8388608 bytes"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("projects authoritative upstream pricing, reasoning, tool, and modality metadata", () => { + const provider: UpstreamProviderCatalog = { + id: "opencode-go", + api: "https://opencode.ai/zen/go/v1", + npm: "@ai-sdk/openai-compatible", + models: {}, + }; + const model = projectUpstreamProviderCatalogModel({ + providerId: provider.id, + provider, + model: { + id: "frontier-model", + name: "Frontier Model", + reasoning: true, + tool_call: true, + reasoning_options: [{ type: "effort", values: ["low", "high", "high", null] }], + modalities: { input: ["text", "image", "video"] }, + provider: { npm: "@ai-sdk/openai" }, + limit: { context: 1_000_000, input: 900_000, output: 128_000 }, + cost: { + input: 2, + output: 6, + cache_read: 0.5, + cache_write: 1, + tiers: [ + { + input: 4, + output: 12, + cache_read: 1, + cache_write: 2, + tier: { type: "context", size: 200_000 }, + }, + ], + }, + }, + }); + + expect(model).toEqual({ + id: "frontier-model", + name: "Frontier Model", + provider: "opencode-go", + api: "openai-responses", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_000_000, + contextTokens: 900_000, + maxTokens: 128_000, + thinkingLevelMap: { off: null }, + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 1, + tieredPricing: [ + { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 1, range: [0, 200_000] }, + { input: 4, output: 12, cacheRead: 1, cacheWrite: 2, range: [200_000] }, + ], + }, + compat: { + supportsUsageInStreaming: true, + maxTokensField: "max_tokens", + supportsTools: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "high"], + }, + }); + }); + + it.each([ + ["@ai-sdk/openai-compatible", "openai-completions", "https://opencode.ai/zen/v1"], + ["@ai-sdk/openai", "openai-responses", "https://opencode.ai/zen/v1"], + ["@ai-sdk/anthropic", "anthropic-messages", "https://opencode.ai/zen"], + ["@ai-sdk/google", "google-generative-ai", "https://opencode.ai/zen/v1"], + ])("projects upstream %s transport without guessing from model ids", (npm, api, baseUrl) => { + const provider: UpstreamProviderCatalog = { + id: "opencode", + api: "https://opencode.ai/zen/v1", + npm: "@ai-sdk/openai-compatible", + models: {}, + }; + + expect( + projectUpstreamProviderCatalogModel({ + providerId: provider.id, + provider, + model: { + id: "opaque-preview-id", + name: "Opaque Preview", + provider: { npm }, + limit: { context: 128_000, output: 8192 }, + }, + }), + ).toMatchObject({ api, baseUrl }); + }); + + it("rejects upstream metadata that would redirect authenticated inference to another origin", () => { + const provider: UpstreamProviderCatalog = { + id: "opencode", + api: "https://opencode.ai/zen/v1", + models: {}, + }; + const model = { + id: "opaque-preview-id", + name: "Opaque Preview", + limit: { context: 128_000, output: 8192 }, + }; + + expect( + projectUpstreamProviderCatalogModel({ + providerId: provider.id, + provider, + model: { ...model, provider: { api: "https://attacker.example/v1" } }, + }), + ).toBeUndefined(); + expect( + projectUpstreamProviderCatalogModel({ + providerId: provider.id, + provider: { ...provider, api: "https://attacker.example/v1" }, + model, + defaultBaseUrl: "https://opencode.ai/zen/v1", + }), + ).toBeUndefined(); + }); +});