From c7cf69a11060c70f43c7a1fa63e818d73eb24848 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 04:05:47 -0700 Subject: [PATCH] fix(opencode): refresh hosted model catalogs (#121030) * fix(opencode): refresh hosted model catalogs Align Zen and Go availability, pricing, limits, transports, lifecycle metadata, and model-specific reasoning controls with current provider contracts. Co-authored-by: samson1357924 Co-authored-by: xialonglee * fix(opencode): align catalog metadata with runtime contracts --------- Co-authored-by: xialonglee --- docs/providers/opencode-go.md | 74 +-- docs/providers/opencode.md | 52 ++- extensions/opencode-go/index.test.ts | 384 ++++++++++++---- extensions/opencode-go/index.ts | 4 +- extensions/opencode-go/openclaw.plugin.json | 84 +++- .../opencode-go/opencode-go.live.test.ts | 74 +++ extensions/opencode-go/provider-catalog.ts | 334 +++++++++----- extensions/opencode-go/provider-policy-api.ts | 101 ++++- .../opencode-go/stream-termination.test.ts | 48 +- extensions/opencode-go/stream-termination.ts | 29 +- extensions/opencode-go/stream.ts | 62 ++- extensions/opencode/index.test.ts | 421 +++++++++++------ extensions/opencode/index.ts | 19 +- extensions/opencode/openclaw.plugin.json | 127 +++++- extensions/opencode/opencode.live.test.ts | 47 +- extensions/opencode/provider-catalog.ts | 426 +++++++++--------- .../opencode/provider-policy-api.test.ts | 31 ++ extensions/opencode/provider-policy-api.ts | 51 ++- src/plugins/provider-public-artifacts.test.ts | 10 +- 19 files changed, 1686 insertions(+), 692 deletions(-) create mode 100644 extensions/opencode-go/opencode-go.live.test.ts diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 57665588f088..534fda33fa6c 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -6,12 +6,12 @@ read_when: title: "OpenCode Go" --- -OpenCode Go is the Go catalog inside [OpenCode](/providers/opencode). It shares -the `OPENCODE_API_KEY` credential with the Zen catalog, but keeps its own -runtime provider id (`opencode-go`) so upstream per-model routing stays -correct. OpenCode Go is bundled in the OpenClaw package for this release, so -onboarding and configuration are sufficient; no separate plugin install is -required. +OpenCode Go is a separate paid subscription inside [OpenCode](/providers/opencode). +It uses the same `OPENCODE_API_KEY` credential infrastructure as Zen, but a Zen +key does not automatically include Go entitlement. Go keeps its own runtime +provider id (`opencode-go`) so upstream per-model routing stays correct. +OpenCode Go is bundled in the OpenClaw package for this release, so onboarding +and configuration are sufficient; no separate plugin install is required. | Property | Value | | ---------------- | -------------------------------------------------- | @@ -35,7 +35,7 @@ interactive onboarding or pass the shared OpenCode API key directly. ```bash - openclaw config set agents.defaults.model.primary "opencode-go/kimi-k2.6" + openclaw config set agents.defaults.model.primary "opencode-go/kimi-k3" ``` @@ -67,35 +67,46 @@ interactive onboarding or pass the shared OpenCode API key directly. ```json5 { env: { OPENCODE_API_KEY: "YOUR_API_KEY_HERE" }, // pragma: allowlist secret - agents: { defaults: { model: { primary: "opencode-go/kimi-k2.6" } } }, + agents: { defaults: { model: { primary: "opencode-go/kimi-k3" } } }, } ``` ## Catalog Run `openclaw models list --provider opencode-go` for the current model list. -Current rows: +Current active rows: -| Model ref | Name | Context | Max output | Image input | -| ------------------------------- | ----------------- | --------- | ---------- | ----------- | -| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro | 1M | 384K | No | -| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash | 1M | 384K | No | -| `opencode-go/glm-5` | GLM-5 | 202,752 | 32,768 | No | -| `opencode-go/glm-5.1` | GLM-5.1 | 202,752 | 32,768 | No | -| `opencode-go/glm-5.2` | GLM-5.2 | 1M | 131,072 | No | -| `opencode-go/hy3-preview` | HY3 Preview | 262,144 | 32,768 | No | -| `opencode-go/kimi-k2.5` | Kimi K2.5 | 262,144 | 65,536 | Yes | -| `opencode-go/kimi-k2.6` | Kimi K2.6 | 262,144 | 65,536 | Yes | -| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code | 262,144 | 262,144 | Yes | -| `opencode-go/mimo-v2.5` | MiMo V2.5 | 1M | 128,000 | Yes | -| `opencode-go/mimo-v2.5-pro` | MiMo V2.5 Pro | 1,048,576 | 128,000 | No | -| `opencode-go/minimax-m2.5` | MiniMax M2.5 | 204,800 | 65,536 | No | -| `opencode-go/minimax-m2.7` | MiniMax M2.7 | 204,800 | 131,072 | No | -| `opencode-go/minimax-m3` | MiniMax M3 | 204,800 | 131,072 | No | -| `opencode-go/qwen3.5-plus` | Qwen3.5 Plus | 262,144 | 65,536 | Yes | -| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus | 262,144 | 65,536 | Yes | -| `opencode-go/qwen3.7-max` | Qwen3.7 Max | 1M | 65,536 | No | -| `opencode-go/qwen3.7-plus` | Qwen3.7 Plus | 1M | 65,536 | Yes | +| Model ref | Context | Max output | Inputs | Transport | +| ------------------------------- | --------- | ---------- | ----------- | --------- | +| `opencode-go/deepseek-v4-flash` | 1M | 384K | Text | Chat | +| `opencode-go/deepseek-v4-pro` | 1M | 384K | Text | Chat | +| `opencode-go/glm-5.1` | 202,752 | 32,768 | Text | Chat | +| `opencode-go/glm-5.2` | 1M | 131,072 | Text | Chat | +| `opencode-go/gpt-5.6-luna` | 1.05M | 128,000 | Text, image | Responses | +| `opencode-go/grok-4.5` | 500,000 | 500,000 | Text, image | Chat | +| `opencode-go/hy3` | 256,000 | 64,000 | Text | Chat | +| `opencode-go/kimi-k2.6` | 262,144 | 65,536 | Text, image | Chat | +| `opencode-go/kimi-k2.7-code` | 262,144 | 262,144 | Text, image | Chat | +| `opencode-go/kimi-k3` | 1,048,576 | 131,072 | Text, image | Chat | +| `opencode-go/mimo-v2.5` | 1M | 128,000 | Text, image | Chat | +| `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/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. + +## 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 Go privacy table](https://opencode.ai/docs/go/#privacy) before using a +model, because provider policy can change independently of OpenClaw. ## Advanced configuration @@ -111,8 +122,9 @@ Current rows: - One `OPENCODE_API_KEY` covers both the Zen and Go catalogs. Entering the - key during setup stores credentials for both runtime providers. + The same `OPENCODE_API_KEY` can authenticate both runtime providers, so + setup may store both profiles. Go access still requires a separate paid + subscription in the OpenCode console. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index b5f3f2acf907..e63529582f01 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -13,10 +13,10 @@ OpenCode exposes two hosted catalogs in OpenClaw: | **Zen** | `opencode/...` | `opencode` | | **Go** | `opencode-go/...` | `opencode-go` | -Both catalogs share one OpenCode API key (`OPENCODE_API_KEY`, alias -`OPENCODE_ZEN_API_KEY`). OpenClaw keeps the runtime provider ids split so -upstream per-model routing stays correct, but onboarding and docs treat them as -one OpenCode setup. +Both catalogs use the same OpenCode API key infrastructure (`OPENCODE_API_KEY`, +alias `OPENCODE_ZEN_API_KEY`). Go still requires its own paid subscription; +having a Zen key does not by itself grant Go access. OpenClaw keeps the runtime +provider ids split so upstream per-model routing stays correct. ## Getting started @@ -52,7 +52,8 @@ one OpenCode setup. - **Best for:** the OpenCode-hosted Kimi, GLM, MiniMax, Qwen, and DeepSeek lineup. + **Best for:** the separately subscribed Go lineup across DeepSeek, GLM, GPT, + Grok, Hy3, Kimi, MiMo, MiniMax, and Qwen. @@ -72,7 +73,7 @@ one OpenCode setup. ```bash - openclaw config set agents.defaults.model.primary "opencode-go/kimi-k2.6" + openclaw config set agents.defaults.model.primary "opencode-go/kimi-k3" ``` @@ -98,23 +99,30 @@ one OpenCode setup. ### Zen -| Property | Value | -| ---------------- | ------------------------------------------------------------------------------------------------- | -| Runtime provider | `opencode` | -| Example models | `opencode/gpt-5.6-sol`, `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` | -Run `openclaw models list --provider opencode` for the full current list, which -also includes the currently promoted free-tier rows `opencode/big-pickle`, +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-flash-free`, `opencode/mimo-v2.5-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`. +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. + ### Go -| Property | Value | -| ---------------- | ------------------------------------------------------------------------ | -| Runtime provider | `opencode-go` | -| Example models | `opencode-go/kimi-k2.6`, `opencode-go/glm-5`, `opencode-go/minimax-m2.5` | +| Property | Value | +| ---------------- | ---------------------------------------------------------------------------- | +| Runtime provider | `opencode-go` | +| Example models | `opencode-go/kimi-k3`, `opencode-go/gpt-5.6-luna`, `opencode-go/qwen3.8-max` | See [OpenCode Go](/providers/opencode-go) for the full Go model table. @@ -126,8 +134,9 @@ See [OpenCode Go](/providers/opencode-go) for the full Go model table. - Entering one OpenCode key during setup stores credentials for both runtime - providers. You do not need to onboard each catalog separately. + Entering one OpenCode key during setup can store credentials for both + runtime providers. It does not create a Go subscription or grant Go + entitlement; subscribe to Go in the OpenCode console before using it. @@ -145,6 +154,11 @@ See [OpenCode Go](/providers/opencode-go) for the full Go model table. Non-Gemini OpenCode refs keep the minimal OpenAI-compatible replay policy. + + Billing, retention, and training policies are model-specific. Check the + current [OpenCode Zen pricing and policy](https://opencode.ai/docs/zen/) + before selecting a route. Free models may be temporary feedback programs. + ## Related diff --git a/extensions/opencode-go/index.test.ts b/extensions/opencode-go/index.test.ts index e28e5bff5b48..4232bc1b52f7 100644 --- a/extensions/opencode-go/index.test.ts +++ b/extensions/opencode-go/index.test.ts @@ -1,4 +1,3 @@ -import { clampThinkingLevel } from "openclaw/plugin-sdk/llm"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { registerProviderPlugin, @@ -7,6 +6,7 @@ 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"; +import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime"; // Opencode Go tests cover index plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -39,36 +39,43 @@ function requireCatalogEntry(entries: readonly unknown[] | null | undefined, id: return requireRecord(entry, `supplemental catalog entry ${id}`); } -const deepSeekV4ThinkingProfileLevelIds = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; -const deepSeekV4ThinkingProfile = { - levels: deepSeekV4ThinkingProfileLevelIds.map((id) => ({ id })), - defaultLevel: "high", -}; -const deepSeekV4ThinkingLevelMap = { - minimal: "high", - low: "high", - medium: "high", - high: "high", - xhigh: "max", - max: "max", -}; - -function expectDeepSeekV4ThinkingLevels(model: ProviderRuntimeModel) { - expect(model.thinkingLevelMap).toEqual(deepSeekV4ThinkingLevelMap); - expect(clampThinkingLevel(model, "off")).toBe("off"); - expect(clampThinkingLevel(model, "high")).toBe("high"); - expect(clampThinkingLevel(model, "xhigh")).toBe("xhigh"); - expect(clampThinkingLevel(model, "max")).toBe("max"); +function runtimeCompatFields(value: unknown): Record | undefined { + if (!value) { + return undefined; + } + const { codeMode: _codeMode, ...compat } = requireRecord(value, "model compat"); + 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; + describe("opencode-go provider plugin", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); @@ -84,7 +91,7 @@ describe("opencode-go provider plugin", () => { expect(provider.auth[0]?.wizard).toMatchObject({ choiceLabel: "OpenCode Go catalog", groupId: "opencode", - groupHint: "Shared API key for Zen + Go catalogs", + groupHint: "Shared API key infrastructure for Zen + Go", }); }); @@ -126,26 +133,8 @@ describe("opencode-go provider plugin", () => { const provider = await registerSingleProviderPlugin(plugin); expect(provider.catalog).toBeDefined(); - const expectedModelIds = [ - "deepseek-v4-flash", - "deepseek-v4-pro", - "glm-5", - "glm-5.1", - "glm-5.2", - "hy3-preview", - "kimi-k2.5", - "kimi-k2.6", - "kimi-k2.7-code", - "mimo-v2.5", - "mimo-v2.5-pro", - "minimax-m2.5", - "minimax-m2.7", - "minimax-m3", - "qwen3.5-plus", - "qwen3.6-plus", - "qwen3.7-max", - "qwen3.7-plus", - ]; + 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); @@ -154,18 +143,79 @@ describe("opencode-go provider plugin", () => { } models.set(model.id, model); } - expect([...models.keys()]).toEqual(expectedModelIds); - expectDeepSeekV4ThinkingLevels(requireMapEntry(models, "deepseek-v4-pro")); - expectDeepSeekV4ThinkingLevels(requireMapEntry(models, "deepseek-v4-flash")); + expect([...models.keys()].toSorted()).toEqual(expectedModelIds.toSorted()); expect( - provider.resolveThinkingProfile?.({ provider: "opencode-go", modelId: "deepseek-v4-pro" }), - ).toEqual(deepSeekV4ThinkingProfile); + provider.resolveThinkingProfile?.({ + 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( - provider.resolveThinkingProfile?.({ provider: "opencode-go", modelId: "deepseek-v4-flash" }), - ).toEqual(deepSeekV4ThinkingProfile); + provider.resolveThinkingProfile?.({ + provider: "opencode-go", + modelId: "deepseek-v4-flash", + api: "openai-completions", + reasoning: true, + compat: { supportedReasoningEfforts: ["low", "high", "max"] }, + }), + ).toEqual({ + levels: [{ id: "off" }, { id: "low" }, { id: "high" }, { id: "max" }], + defaultLevel: "high", + }); expect( - provider.resolveThinkingProfile?.({ provider: "opencode-go", modelId: "glm-5" }), - ).toBeUndefined(); + 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", + modelId: "minimax-m2.7", + api: "anthropic-messages", + 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, @@ -173,12 +223,23 @@ describe("opencode-go provider plugin", () => { 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"); @@ -194,6 +255,15 @@ describe("opencode-go provider plugin", () => { cacheWrite: 0, }); + expect(requireMapEntry(models, "kimi-k3")).toMatchObject({ + api: "openai-completions", + input: ["text", "image"], + contextWindow: 1_048_576, + maxTokens: 131_072, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, + compat: { supportsReasoningEffort: true, supportedReasoningEfforts: ["max"] }, + }); + const kimi = requireMapEntry(models, "kimi-k2.6"); expect(kimi.api).toBe("openai-completions"); expect(kimi.baseUrl).toBe("https://opencode.ai/zen/go/v1"); @@ -226,7 +296,8 @@ describe("opencode-go provider plugin", () => { expect(minimaxM3.api).toBe("anthropic-messages"); expect(minimaxM3.baseUrl).toBe("https://opencode.ai/zen/go"); expect(minimaxM3.reasoning).toBe(true); - expect(minimaxM3.contextWindow).toBe(204_800); + 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"); @@ -291,7 +362,7 @@ describe("opencode-go provider plugin", () => { expect(compat.maxTokensField).toBe("max_tokens"); }); - it("loads OpenCode Go model discovery through the provider runtime", () => { + it("loads model discovery and keeps every promoted row identical to runtime", async () => { expect(manifest.providerCatalogEntry).toBe("./provider-discovery.ts"); expect(manifest.modelCatalog.discovery["opencode-go"]).toBe("runtime"); const manifestProvider = requireRecord( @@ -301,12 +372,42 @@ describe("opencode-go provider plugin", () => { if (!Array.isArray(manifestProvider.models)) { throw new Error("expected manifest models"); } - expect( - requireCatalogEntry(manifestProvider.models, "deepseek-v4-pro").thinkingLevelMap, - ).toEqual(deepSeekV4ThinkingLevelMap); - expect( - requireCatalogEntry(manifestProvider.models, "deepseek-v4-flash").thinkingLevelMap, - ).toEqual(deepSeekV4ThinkingLevelMap); + const manifestIds = manifestProvider.models.map((model) => + String(requireRecord(model, "manifest model").id), + ); + expect(new Set(manifestIds).size).toBe(manifestIds.length); + const provider = await registerSingleProviderPlugin(plugin); + for (const manifestModel of manifestProvider.models) { + const model = requireRecord(manifestModel, "manifest model"); + const modelId = String(model.id); + const runtime = requireRecord( + provider.resolveDynamicModel?.({ modelId } as never), + `runtime model ${modelId}`, + ); + expect({ + api: model.api ?? manifestProvider.api, + baseUrl: model.baseUrl ?? manifestProvider.baseUrl, + reasoning: model.reasoning, + input: model.input, + contextWindow: model.contextWindow, + contextTokens: model.contextTokens, + maxTokens: model.maxTokens, + thinkingLevelMap: model.thinkingLevelMap, + cost: model.cost, + compat: runtimeCompatFields(model.compat), + }).toEqual({ + api: runtime.api, + baseUrl: runtime.baseUrl, + reasoning: runtime.reasoning, + input: runtime.input, + contextWindow: runtime.contextWindow, + contextTokens: runtime.contextTokens, + maxTokens: runtime.maxTokens, + thinkingLevelMap: runtime.thinkingLevelMap, + cost: runtime.cost, + compat: runtimeCompatFields(runtime.compat), + }); + } }); it("exposes the complete offline catalog through provider discovery", async () => { @@ -318,18 +419,20 @@ describe("opencode-go provider plugin", () => { const deepSeekFlash = result.provider.models.find((model) => model.id === "deepseek-v4-flash"); const glm52 = result.provider.models.find((model) => model.id === "glm-5.2"); - expect(result.provider.models).toHaveLength(18); + 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()); expect(deepSeekPro).toMatchObject({ provider: "opencode-go", contextWindow: 1_000_000, maxTokens: 384_000, - thinkingLevelMap: deepSeekV4ThinkingLevelMap, + compat: { supportedReasoningEfforts: ["high", "max"] }, }); expect(deepSeekFlash).toMatchObject({ provider: "opencode-go", contextWindow: 1_000_000, maxTokens: 384_000, - thinkingLevelMap: deepSeekV4ThinkingLevelMap, + compat: { supportedReasoningEfforts: ["low", "high", "max"] }, }); expect(glm52).toMatchObject({ provider: "opencode-go", @@ -351,18 +454,26 @@ describe("opencode-go provider plugin", () => { ).resolves.toBeNull(); }); - it("keeps deprecated upstream MiMo aliases out of static and live catalogs", async () => { - const deprecatedModelIds = ["mimo-v2-omni", "mimo-v2-pro"]; + 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(staticModelIds).toEqual(expect.arrayContaining(activeModelIds)); - expect(staticModelIds).toEqual(expect.not.arrayContaining(deprecatedModelIds)); + 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: [...deprecatedModelIds, ...activeModelIds].map((id) => ({ id, object: "model" })), + data: [...compatibilityModelIds, ...activeModelIds].map((id) => ({ + id, + object: "model", + })), }), ), finalUrl: "https://opencode.ai/zen/go/v1/models", @@ -436,16 +547,9 @@ describe("opencode-go provider plugin", () => { expect(fetchGuard).toHaveBeenCalledTimes(1); expect(first.apiKey).toBe("OPENCODE_API_KEY"); - expect(first.models.map((model) => model.id)).toEqual([ - "minimax-m3", - "qwen3.7-max", - "qwen3.7-plus", - ]); - expect(second.models.map((model) => model.id)).toEqual([ - "minimax-m3", - "qwen3.7-max", - "qwen3.7-plus", - ]); + 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")); @@ -455,8 +559,9 @@ describe("opencode-go provider plugin", () => { fetchGuard, }); expect(fallback.apiKey).toBe("OPENCODE_API_KEY"); - expect(fallback.models.map((model) => model.id)).toContain("deepseek-v4-pro"); - expect(fallback.models.map((model) => model.id)).toContain("minimax-m3"); + expect(fallback.models.map((model) => model.id).toSorted()).toEqual( + ACTIVE_MODEL_IDS.toSorted(), + ); }); it.each(["deepseek-v4-pro", "deepseek-v4-flash"] as const)( @@ -497,11 +602,41 @@ describe("opencode-go provider plugin", () => { ); it.each([ - ["minimal", "high"], - ["low", "high"], - ["medium", "high"], + ["glm-5.2", "max", undefined], + ["grok-4.5", "high", undefined], + ["hy3", "low", "none"], + ] 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); + if (!model) { + throw new Error(`expected ${modelId}`); + } + const context = { + systemPrompt: "", + messages: [{ role: "user", content: "test", timestamp: 1 }], + } as never; + + const offPayload = buildOpenAICompletionsParams(model as never, context, { + reasoning: "off", + } as never); + if (offEffort === undefined) { + expect(offPayload).not.toHaveProperty("reasoning_effort"); + } else { + expect(offPayload).toHaveProperty("reasoning_effort", offEffort); + } + expect( + buildOpenAICompletionsParams(model as never, context, { + reasoning: enabledEffort, + } as never), + ).toHaveProperty("reasoning_effort", enabledEffort); + }, + ); + + it.each([ + ["low", "low"], ["high", "high"], - ["xhigh", "max"], ["max", "max"], ] as const)( "maps OpenCode Go DeepSeek V4 %s thinking to %s reasoning effort", @@ -600,6 +735,73 @@ describe("opencode-go provider plugin", () => { ]); }); + it.each(["minimax-m2.5", "minimax-m2.7"])( + "keeps fixed-reasoning %s on the provider default wire path", + async (modelId) => { + const provider = await registerSingleProviderPlugin(plugin); + const capturedPayloads: Record[] = []; + const baseStreamFn = (_model: unknown, _context: unknown, options: unknown) => { + const payload = { + model: modelId, + thinking: { type: "enabled", budget_tokens: 8192 }, + output_config: { effort: "high" }, + }; + (options as { onPayload?: (payload: Record) => void })?.onPayload?.( + payload, + ); + capturedPayloads.push(payload); + return {} as never; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn as never, + providerId: "opencode-go", + modelId, + thinkingLevel: "high", + } as never); + + await streamFn?.( + { provider: "opencode-go", id: modelId, api: "anthropic-messages" } as never, + {} as never, + {}, + ); + expect(capturedPayloads).toEqual([{ model: modelId }]); + }, + ); + + it.each([ + ["off", undefined], + ["max", "max"], + ] as const)("keeps Kimi K3 reasoning %s exact", async (thinkingLevel, expectedEffort) => { + 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", + }; + (options as { onPayload?: (payload: Record) => void })?.onPayload?.(payload); + capturedPayloads.push(payload); + return {} as never; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn as never, + providerId: "opencode-go", + modelId: "kimi-k3", + thinkingLevel, + } as never); + + await streamFn?.( + { provider: "opencode-go", id: "kimi-k3", api: "openai-completions" } as never, + {} as never, + {}, + ); + expect(capturedPayloads).toEqual([ + expectedEffort === undefined + ? { model: "kimi-k3" } + : { model: "kimi-k3", reasoning_effort: expectedEffort }, + ]); + }); + it("canonicalizes stale OpenCode Go base URLs", async () => { const provider = await registerSingleProviderPlugin(plugin); diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 994a8c16952a..2f69995b9cb4 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -17,7 +17,7 @@ import { createOpencodeGoWrapper } from "./stream.js"; const PROVIDER_ID = "opencode-go"; const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; -const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs"; +const OPENCODE_SHARED_HINT = "Shared API key infrastructure for Zen + Go"; type OpencodeGoCatalogAuth = { apiKey?: string; discoveryApiKey?: string; @@ -55,7 +55,7 @@ export default defineSingleProviderPluginEntry({ applyConfig: applyOpencodeGoConfig, expectedProviders: ["opencode", "opencode-go"], noteMessage: [ - "OpenCode uses one API key across the Zen and Go catalogs.", + "OpenCode Go is a separate paid subscription that uses the shared OpenCode API key.", "Go focuses on Kimi, GLM, and MiniMax coding models.", "Get your API key at: https://opencode.ai/auth", ].join("\n"), diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 86aa330edbf4..72ba9395d038 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -30,14 +30,6 @@ "id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro", "reasoning": true, - "thinkingLevelMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max", - "max": "max" - }, "input": ["text"], "contextWindow": 1000000, "maxTokens": 384000, @@ -50,7 +42,10 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["high", "max"], "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsStrictMode": false, "codeMode": "capable" } }, @@ -58,14 +53,6 @@ "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", "reasoning": true, - "thinkingLevelMap": { - "minimal": "high", - "low": "high", - "medium": "high", - "high": "high", - "xhigh": "max", - "max": "max" - }, "input": ["text"], "contextWindow": 1000000, "maxTokens": 384000, @@ -78,7 +65,70 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "high", "max"], "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsStrictMode": false, + "codeMode": "capable" + } + }, + { + "id": "kimi-k3", + "name": "Kimi K3", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048576, + "maxTokens": 131072, + "cost": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite": 0 }, + "compat": { + "supportsUsageInStreaming": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["max"], + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsStrictMode": false, + "codeMode": "capable" + } + }, + { + "id": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "api": "openai-responses", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1050000, + "contextTokens": 922000, + "maxTokens": 128000, + "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, 272000] }, + { "input": 0.4, "output": 1.8, "cacheRead": 0.04, "cacheWrite": 0.5, "range": [272000] } + ] + }, + "compat": { + "supportsUsageInStreaming": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["none", "low", "medium", "high", "xhigh", "max"], + "maxTokensField": "max_tokens", + "codeMode": "capable" + } + }, + { + "id": "qwen3.8-max", + "name": "Qwen3.8 Max", + "api": "anthropic-messages", + "baseUrl": "https://opencode.ai/zen/go", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1000000, + "maxTokens": 131072, + "cost": { "input": 2, "output": 6, "cacheRead": 0.25, "cacheWrite": 2.5 }, + "compat": { + "thinkingFormat": "qwen", "codeMode": "capable" } } @@ -106,7 +156,7 @@ "choiceLabel": "OpenCode Go catalog", "groupId": "opencode", "groupLabel": "OpenCode", - "groupHint": "Shared API key for Zen + Go catalogs", + "groupHint": "Shared API key infrastructure for Zen + Go", "optionKey": "opencodeGoApiKey", "cliFlag": "--opencode-go-api-key", "cliOption": "--opencode-go-api-key ", diff --git a/extensions/opencode-go/opencode-go.live.test.ts b/extensions/opencode-go/opencode-go.live.test.ts new file mode 100644 index 000000000000..fc9607e1a8a5 --- /dev/null +++ b/extensions/opencode-go/opencode-go.live.test.ts @@ -0,0 +1,74 @@ +import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; +import { describe, expect, it } from "vitest"; +import { + buildStaticOpencodeGoProviderConfig, + listOpencodeGoModelCatalogEntries, +} from "./provider-catalog.js"; + +const OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models"; +const OPENCODE_API_KEY = + process.env.OPENCODE_API_KEY?.trim() || process.env.OPENCODE_ZEN_API_KEY?.trim() || ""; +const LIVE = isLiveTestEnabled(["OPENCODE_GO_LIVE_TEST"]) && OPENCODE_API_KEY.length > 0; +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 () => { + const response = await fetch(OPENCODE_GO_MODELS_URL, { + headers: { + accept: "application/json", + authorization: `Bearer ${OPENCODE_API_KEY}`, + "accept-encoding": "identity", + }, + }); + expect(response.ok).toBe(true); + const body = (await response.json()) as ModelsResponse; + const liveIds = (body.data ?? []) + .filter((row) => row.object === undefined || row.object === "model") + .map((row) => row.id) + .filter((id): id is string => typeof id === "string" && id.trim().length > 0) + .map((id) => id.trim().toLowerCase()) + .toSorted(); + 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(trustedRows.find((row) => row.id === "hy3-preview")?.status).toBe("preview"); + }, 30_000); +}); diff --git a/extensions/opencode-go/provider-catalog.ts b/extensions/opencode-go/provider-catalog.ts index c705d664ea22..cecb2f4604c9 100644 --- a/extensions/opencode-go/provider-catalog.ts +++ b/extensions/opencode-go/provider-catalog.ts @@ -15,6 +15,21 @@ const PROVIDER_ID = "opencode-go"; const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1"; const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"; +const OPENAI_COMPLETIONS_MODEL = { + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, +} as const; +const ANTHROPIC_MESSAGES_MODEL = { + api: "anthropic-messages", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, +} as const; +const OPENAI_RESPONSES_MODEL = { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: OPENCODE_GO_OPENAI_BASE_URL, +} as const; const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set([ "kimi-k2.5", "kimi-k2.6", @@ -23,17 +38,6 @@ const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set([ const OPENCODE_GO_MODELS_ENDPOINT = "https://opencode.ai/zen/go/v1/models"; const OPENCODE_GO_MODELS_TIMEOUT_MS = 5_000; const OPENCODE_GO_MODELS_CACHE_TTL_MS = 60_000; -// OpenCode Go exposes only high/max provider effort for DeepSeek V4. Lower -// OpenClaw levels retain their existing high-effort behavior. -const OPENCODE_GO_DEEPSEEK_V4_THINKING_LEVEL_MAP = { - minimal: "high", - low: "high", - medium: "high", - high: "high", - xhigh: "max", - max: "max", -} as const; - type OpencodeGoModelDefinition = ModelDefinitionConfig & { provider: typeof PROVIDER_ID; api: NonNullable; @@ -41,21 +45,18 @@ type OpencodeGoModelDefinition = ModelDefinitionConfig & { input: Array<"text" | "image">; }; -const OPENCODE_GO_MODELS = ( +const OPENCODE_GO_RESOLVABLE_MODELS = ( [ { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, - thinkingLevelMap: OPENCODE_GO_DEEPSEEK_V4_THINKING_LEVEL_MAP, input: ["text"], cost: { - input: 1.74, - output: 3.48, - cacheRead: 0.145, + input: 0.435, + output: 0.87, + cacheRead: 0.003625, cacheWrite: 0, }, contextWindow: 1_000_000, @@ -63,22 +64,20 @@ const OPENCODE_GO_MODELS = ( compat: { supportsUsageInStreaming: true, supportsReasoningEffort: true, + supportedReasoningEfforts: ["high", "max"], maxTokensField: "max_tokens", }, }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, - thinkingLevelMap: OPENCODE_GO_DEEPSEEK_V4_THINKING_LEVEL_MAP, input: ["text"], cost: { input: 0.14, output: 0.28, - cacheRead: 0.028, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1_000_000, @@ -86,15 +85,14 @@ const OPENCODE_GO_MODELS = ( compat: { supportsUsageInStreaming: true, supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "high", "max"], maxTokensField: "max_tokens", }, }, { id: "glm-5", name: "GLM-5", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text"], cost: { @@ -109,9 +107,7 @@ const OPENCODE_GO_MODELS = ( { id: "glm-5.1", name: "GLM-5.1", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text"], cost: { @@ -126,9 +122,7 @@ const OPENCODE_GO_MODELS = ( { id: "glm-5.2", name: "GLM-5.2", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text"], cost: { @@ -139,13 +133,75 @@ const OPENCODE_GO_MODELS = ( }, contextWindow: 1_000_000, maxTokens: 131_072, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["high", "max"], + maxTokensField: "max_tokens", + }, + }, + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + ...OPENAI_RESPONSES_MODEL, + reasoning: true, + input: ["text", "image"], + 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] }, + ], + }, + contextWindow: 1_050_000, + contextTokens: 922_000, + maxTokens: 128_000, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + maxTokensField: "max_tokens", + }, + }, + { + id: "grok-4.5", + name: "Grok 4.5", + ...OPENAI_COMPLETIONS_MODEL, + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high"], + maxTokensField: "max_tokens", + }, + }, + { + id: "hy3", + name: "Hy3", + ...OPENAI_COMPLETIONS_MODEL, + reasoning: true, + input: ["text"], + cost: { input: 0.14, output: 0.58, cacheRead: 0.035, cacheWrite: 0 }, + contextWindow: 256_000, + maxTokens: 64_000, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["none", "low", "high"], + maxTokensField: "max_tokens", + }, }, { id: "hy3-preview", name: "HY3 Preview", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text"], cost: { @@ -160,9 +216,7 @@ const OPENCODE_GO_MODELS = ( { id: "kimi-k2.5", name: "Kimi K2.5", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text", "image"], cost: { @@ -177,9 +231,7 @@ const OPENCODE_GO_MODELS = ( { id: "kimi-k2.6", name: "Kimi K2.6", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text", "image"], cost: { @@ -194,9 +246,7 @@ const OPENCODE_GO_MODELS = ( { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text", "image"], cost: { @@ -208,18 +258,61 @@ const OPENCODE_GO_MODELS = ( contextWindow: 262_144, maxTokens: 262_144, }, + { + id: "kimi-k3", + name: "Kimi K3", + ...OPENAI_COMPLETIONS_MODEL, + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 131_072, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: ["max"], + maxTokensField: "max_tokens", + }, + }, + { + id: "mimo-v2-omni", + name: "MiMo V2 Omni", + ...OPENAI_COMPLETIONS_MODEL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0.4, output: 2, cacheRead: 0.08, cacheWrite: 0 }, + contextWindow: 262_144, + maxTokens: 128_000, + }, + { + id: "mimo-v2-pro", + name: "MiMo V2 Pro", + ...OPENAI_COMPLETIONS_MODEL, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3, + cacheRead: 0.2, + cacheWrite: 0, + tieredPricing: [ + { input: 1, output: 3, cacheRead: 0.2, cacheWrite: 0, range: [0, 256_000] }, + { input: 2, output: 6, cacheRead: 0.4, cacheWrite: 0, range: [256_000] }, + ], + }, + contextWindow: 1_048_576, + maxTokens: 128_000, + }, { id: "mimo-v2.5", name: "MiMo V2.5", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text", "image"], cost: { - input: 0.4, - output: 2, - cacheRead: 0.08, + input: 0.14, + output: 0.28, + cacheRead: 0.0028, cacheWrite: 0, }, contextWindow: 1_000_000, @@ -228,15 +321,13 @@ const OPENCODE_GO_MODELS = ( { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...OPENAI_COMPLETIONS_MODEL, reasoning: true, input: ["text"], cost: { - input: 1, - output: 3, - cacheRead: 0.2, + input: 0.435, + output: 0.87, + cacheRead: 0.003625, cacheWrite: 0, }, contextWindow: 1_048_576, @@ -245,15 +336,13 @@ const OPENCODE_GO_MODELS = ( { id: "minimax-m2.5", name: "MiniMax M2.5", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, reasoning: true, input: ["text"], cost: { input: 0.3, output: 1.2, - cacheRead: 0.03, + cacheRead: 0.06, cacheWrite: 0.375, }, contextWindow: 204_800, @@ -262,9 +351,7 @@ const OPENCODE_GO_MODELS = ( { id: "minimax-m2.7", name: "MiniMax M2.7", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, reasoning: true, input: ["text"], cost: { @@ -279,26 +366,26 @@ const OPENCODE_GO_MODELS = ( { id: "minimax-m3", name: "MiniMax M3", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.6, - output: 2.4, - cacheRead: 0.12, - cacheWrite: 0.75, + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + tieredPricing: [ + { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0, range: [0, 512_000] }, + { input: 0.6, output: 2.4, cacheRead: 0.12, cacheWrite: 0, range: [512_000] }, + ], }, - contextWindow: 204_800, + contextWindow: 1_000_000, maxTokens: 131_072, }, { id: "qwen3.5-plus", name: "Qwen3.5 Plus", - api: "openai-completions", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_OPENAI_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, compat: { thinkingFormat: "qwen" }, reasoning: true, input: ["text", "image"], @@ -314,9 +401,7 @@ const OPENCODE_GO_MODELS = ( { id: "qwen3.7-max", name: "Qwen3.7 Max", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, compat: { thinkingFormat: "qwen" }, reasoning: true, input: ["text"], @@ -332,9 +417,7 @@ const OPENCODE_GO_MODELS = ( { id: "qwen3.7-plus", name: "Qwen3.7 Plus", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, compat: { thinkingFormat: "qwen" }, reasoning: true, input: ["text", "image"], @@ -343,16 +426,34 @@ const OPENCODE_GO_MODELS = ( output: 1.6, cacheRead: 0.04, cacheWrite: 0.5, + tieredPricing: [ + { input: 0.4, output: 1.6, cacheRead: 0.04, cacheWrite: 0.5, range: [0, 256_000] }, + { input: 1.2, output: 4.8, cacheRead: 0.12, cacheWrite: 1.5, range: [256_000] }, + ], }, contextWindow: 1_000_000, maxTokens: 65_536, }, + { + id: "qwen3.8-max", + name: "Qwen3.8 Max", + ...ANTHROPIC_MESSAGES_MODEL, + compat: { thinkingFormat: "qwen" }, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.25, + cacheWrite: 2.5, + }, + contextWindow: 1_000_000, + maxTokens: 131_072, + }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", - api: "anthropic-messages", - provider: PROVIDER_ID, - baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL, + ...ANTHROPIC_MESSAGES_MODEL, compat: { thinkingFormat: "qwen" }, reasoning: true, input: ["text", "image"], @@ -361,13 +462,34 @@ const OPENCODE_GO_MODELS = ( output: 3, cacheRead: 0.05, cacheWrite: 0.625, + tieredPricing: [ + { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625, range: [0, 256_000] }, + { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 2.5, range: [256_000] }, + ], }, - contextWindow: 262_144, + contextWindow: 1_000_000, maxTokens: 65_536, }, ] satisfies OpencodeGoModelDefinition[] ).map((model) => normalizeModelCompat(model) as OpencodeGoModelDefinition); +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"], +]); + +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), +); + type FetchOpencodeGoLiveModelIdsParams = { apiKey?: string; discoveryApiKey?: string; @@ -375,22 +497,15 @@ type FetchOpencodeGoLiveModelIdsParams = { signal?: AbortSignal; }; -function buildOpencodeGoProviderConfig( - models: OpencodeGoModelDefinition[], - apiKey?: string, -): ModelProviderConfig { +export function buildStaticOpencodeGoProviderConfig(apiKey?: string): ModelProviderConfig { return { api: "openai-completions", baseUrl: OPENCODE_GO_OPENAI_BASE_URL, ...(apiKey ? { apiKey } : {}), - models, + models: OPENCODE_GO_MODELS, }; } -export function buildStaticOpencodeGoProviderConfig(apiKey?: string): ModelProviderConfig { - return buildOpencodeGoProviderConfig(OPENCODE_GO_MODELS, apiKey); -} - export async function buildOpencodeGoLiveProviderConfig( params: FetchOpencodeGoLiveModelIdsParams = {}, ): Promise { @@ -413,19 +528,30 @@ export async function buildOpencodeGoLiveProviderConfig( } export function listOpencodeGoModelCatalogEntries(): ModelCatalogEntry[] { - return OPENCODE_GO_MODELS.map((model) => ({ - provider: model.provider, - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - contextWindow: model.contextWindow, - })); + return OPENCODE_GO_RESOLVABLE_MODELS.map((model) => { + const entry: ModelCatalogEntry = { + provider: model.provider, + id: model.id, + name: model.name, + api: model.api, + baseUrl: model.baseUrl, + reasoning: model.reasoning, + input: model.input, + contextWindow: model.contextWindow, + contextTokens: model.contextTokens, + compat: model.compat, + }; + const status = OPENCODE_GO_MODEL_STATUS.get(model.id); + if (status) { + entry.status = status; + } + return entry; + }); } export function resolveOpencodeGoModel(modelId: string): ProviderRuntimeModel | undefined { const normalizedModelId = modelId.trim().toLowerCase(); - return OPENCODE_GO_MODELS.find((model) => model.id === normalizedModelId); + return OPENCODE_GO_MODEL_BY_ID.get(normalizedModelId); } export function isOpencodeGoKimiNoReasoningModelId(modelId: unknown): boolean { diff --git a/extensions/opencode-go/provider-policy-api.ts b/extensions/opencode-go/provider-policy-api.ts index 7306f704854e..fff6f01d4498 100644 --- a/extensions/opencode-go/provider-policy-api.ts +++ b/extensions/opencode-go/provider-policy-api.ts @@ -4,40 +4,105 @@ import type { ProviderThinkingProfile, } from "openclaw/plugin-sdk/plugin-entry"; -const OPENCODE_GO_DEEPSEEK_V4_MODEL_IDS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]); -const OPENCODE_GO_DEEPSEEK_V4_THINKING_LEVEL_IDS = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; -const OPENCODE_GO_DEEPSEEK_V4_THINKING_PROFILE = { - levels: OPENCODE_GO_DEEPSEEK_V4_THINKING_LEVEL_IDS.map((id) => ({ id })), +const KIMI_K2_THINKING_PROFILE = { + levels: [{ id: "off" }], + defaultLevel: "off", +} as const satisfies ProviderThinkingProfile; +const BINARY_REASONING_PROFILE = { + levels: [{ id: "off" }, { id: "high", label: "on" }], defaultLevel: "high", -} satisfies ProviderThinkingProfile; +} as const satisfies ProviderThinkingProfile; +const FIXED_REASONING_PROFILE = { + levels: [{ id: "off", label: "always on" }], + defaultLevel: "off", +} as const satisfies ProviderThinkingProfile; +const FIXED_ANTHROPIC_REASONING_PROFILE = { + levels: [{ id: "high", label: "always on" }], + defaultLevel: "high", +} as const satisfies ProviderThinkingProfile; +const KIMI_K2_MODEL_IDS = new Set(["kimi-k2.5", "kimi-k2.6", "kimi-k2.7-code"]); +const FIXED_ANTHROPIC_REASONING_MODEL_IDS = new Set(["minimax-m2.5", "minimax-m2.7"]); +const BINARY_REASONING_MODEL_IDS = new Set(["minimax-m3"]); +const THINKING_LEVEL_IDS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +function resolveEffortThinkingProfile( + efforts: readonly string[] | null | undefined, +): ProviderThinkingProfile | undefined { + if (!efforts || efforts.length === 0) { + return undefined; + } + const acceptedLevelIds = ["off", ...efforts.map((effort) => (effort === "none" ? "off" : effort))] + .filter((id) => THINKING_LEVEL_IDS.has(id)) + .filter((id, index, values) => values.indexOf(id) === index) as Array< + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + >; + const levels = acceptedLevelIds.map((id) => ({ id })); + const levelIdSet = new Set(acceptedLevelIds); + const defaultLevel = levelIdSet.has("medium") + ? "medium" + : levelIdSet.has("high") + ? "high" + : levelIdSet.has("low") + ? "low" + : "off"; + return { levels, defaultLevel }; +} export function isOpencodeGoDeepSeekV4ModelId(modelId: unknown): boolean { + const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : ""; + return normalized === "deepseek-v4-flash" || normalized === "deepseek-v4-pro"; +} + +export function isOpencodeGoFixedAnthropicReasoningModelId(modelId: unknown): boolean { return ( typeof modelId === "string" && - OPENCODE_GO_DEEPSEEK_V4_MODEL_IDS.has(modelId.trim().toLowerCase()) + FIXED_ANTHROPIC_REASONING_MODEL_IDS.has(modelId.trim().toLowerCase()) ); } export function resolveOpencodeGoThinkingProfile( modelId: string, + context?: Pick, ): ProviderThinkingProfile | undefined { - return isOpencodeGoDeepSeekV4ModelId(modelId) - ? OPENCODE_GO_DEEPSEEK_V4_THINKING_PROFILE - : undefined; + const normalized = modelId.trim().toLowerCase(); + if (normalized === "deepseek-v4-flash") { + return { + levels: [{ id: "off" }, { id: "low" }, { id: "high" }, { id: "max" }], + defaultLevel: "high", + }; + } + if (normalized === "deepseek-v4-pro") { + return { + levels: [{ id: "off" }, { id: "high" }, { id: "max" }], + defaultLevel: "high", + }; + } + if (normalized === "kimi-k3") { + return { levels: [{ id: "off" }, { id: "max" }], defaultLevel: "off" }; + } + if (KIMI_K2_MODEL_IDS.has(normalized)) { + return KIMI_K2_THINKING_PROFILE; + } + const effortProfile = resolveEffortThinkingProfile(context?.compat?.supportedReasoningEfforts); + if (effortProfile) { + return effortProfile; + } + if (BINARY_REASONING_MODEL_IDS.has(normalized)) { + return BINARY_REASONING_PROFILE; + } + if (FIXED_ANTHROPIC_REASONING_MODEL_IDS.has(normalized)) { + return FIXED_ANTHROPIC_REASONING_PROFILE; + } + if (context?.reasoning === true && context.api === "openai-completions") { + return FIXED_REASONING_PROFILE; + } + return undefined; } export function resolveThinkingProfile( context: ProviderDefaultThinkingPolicyContext, ): ProviderThinkingProfile | undefined { return context.provider.trim().toLowerCase() === "opencode-go" - ? resolveOpencodeGoThinkingProfile(context.modelId) + ? resolveOpencodeGoThinkingProfile(context.modelId, context) : undefined; } diff --git a/extensions/opencode-go/stream-termination.test.ts b/extensions/opencode-go/stream-termination.test.ts index 425943f7fdac..e1af71020e03 100644 --- a/extensions/opencode-go/stream-termination.test.ts +++ b/extensions/opencode-go/stream-termination.test.ts @@ -130,7 +130,12 @@ async function createStreamHarness(options: StreamHarnessOptions = {}) { }); const downstream = await Promise.resolve( wrapper( - options.model ?? ({ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel), + options.model ?? + ({ + api: "openai-completions", + provider: "opencode-go", + id: "deepseek-v4-flash", + } as ProviderModel), {} as ProviderContext, options.callOptions ?? ({} as ProviderCallOptions), ), @@ -400,7 +405,13 @@ describe("createOpencodeGoStalledStreamWrapper", () => { }); it("aborts and releases the underlying stream when no first event arrives", async () => { - const { downstream, getReturnCalls, capturedSignals, wasAborted } = await createStreamHarness(); + const { downstream, getReturnCalls, capturedSignals, wasAborted } = await createStreamHarness({ + model: asProviderModel({ + api: "openai-responses", + provider: "opencode-go", + id: "gpt-5.6-luna", + }), + }); expect(downstream).toBeDefined(); if (!downstream) { return; @@ -414,11 +425,42 @@ describe("createOpencodeGoStalledStreamWrapper", () => { expect(capturedSignals).toHaveLength(1); expect(wasAborted()).toBe(true); expect(getReturnCalls()).toBe(1); - expect(received.some((event) => event.type === "error" && event.reason === "error")).toBe(true); + const error = received.find((event): event is ErrorEvent => event.type === "error"); + expect(error?.error).toMatchObject({ + api: "openai-responses", + provider: "opencode-go", + model: "gpt-5.6-luna", + }); await consumer; }); + it("preserves Anthropic model identity when a stream ends before its first event", async () => { + const { controller, downstream } = await createStreamHarness({ + model: asProviderModel({ + api: "anthropic-messages", + provider: "opencode-go", + id: "qwen3.8-max", + }), + }); + expect(downstream).toBeDefined(); + if (!downstream) { + return; + } + + const received: AnyEvent[] = []; + const consumer = consumeStream(downstream, received); + controller.end(); + await consumer; + + const error = received.find((event): event is ErrorEvent => event.type === "error"); + expect(error?.error).toMatchObject({ + api: "anthropic-messages", + provider: "opencode-go", + model: "qwen3.8-max", + }); + }); + it("aborts stream creation when the upstream stream promise never resolves", async () => { const { downstream, wasAborted } = await createStreamHarness({ source: new Promise(() => { diff --git a/extensions/opencode-go/stream-termination.ts b/extensions/opencode-go/stream-termination.ts index 80667a587ecf..76af4d49cff1 100644 --- a/extensions/opencode-go/stream-termination.ts +++ b/extensions/opencode-go/stream-termination.ts @@ -66,7 +66,10 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean { const STALLED_STREAM_ERROR_MESSAGE = "opencode-go stream timed out after provider-owned SSE boundary stalled"; -function buildStalledErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent { +function buildStalledErrorEvent( + partial: AssistantMessage | undefined, + model: Parameters[0], +): AssistantMessageEvent { if (partial) { return { type: "error", @@ -81,11 +84,14 @@ function buildStalledErrorEvent(partial: AssistantMessage | undefined): Assistan return { type: "error", reason: "error", - error: synthesizeMinimalAssistantMessage(STALLED_STREAM_ERROR_MESSAGE, "error"), + error: synthesizeMinimalAssistantMessage(STALLED_STREAM_ERROR_MESSAGE, "error", model), }; } -function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent { +function buildUnterminatedErrorEvent( + partial: AssistantMessage | undefined, + model: Parameters[0], +): AssistantMessageEvent { if (partial) { return { type: "error", @@ -103,6 +109,7 @@ function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): Ass error: synthesizeMinimalAssistantMessage( "opencode-go stream ended without a terminal event", "error", + model, ), }; } @@ -110,6 +117,7 @@ function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): Ass function buildCaughtErrorEvent( partial: AssistantMessage | undefined, error: unknown, + model: Parameters[0], ): AssistantMessageEvent { const message = error instanceof Error ? error.message : String(error); if (partial) { @@ -126,20 +134,21 @@ function buildCaughtErrorEvent( return { type: "error", reason: "error", - error: synthesizeMinimalAssistantMessage(message, "error"), + error: synthesizeMinimalAssistantMessage(message, "error", model), }; } function synthesizeMinimalAssistantMessage( errorMessage: string, stopReason: AssistantMessage["stopReason"], + model: Parameters[0], ): AssistantMessage { return { role: "assistant", content: [], - api: "openai-completions", - provider: "opencode-go", - model: "", + api: model.api, + provider: model.provider, + model: model.id, usage: { input: 0, output: 0, @@ -251,7 +260,7 @@ export function createOpencodeGoStalledStreamWrapper( clearIdleTimer(); controller.abort(new Error("opencode-go stream stalled")); releaseBaseStream(); - output.push(buildStalledErrorEvent(lastSeenPartial)); + output.push(buildStalledErrorEvent(lastSeenPartial, model)); output.end(); }; @@ -306,7 +315,7 @@ export function createOpencodeGoStalledStreamWrapper( return; } if (result.done) { - finishWith(buildUnterminatedErrorEvent(lastSeenPartial)); + finishWith(buildUnterminatedErrorEvent(lastSeenPartial, model)); return; } const event = result.value; @@ -323,7 +332,7 @@ export function createOpencodeGoStalledStreamWrapper( } } catch (error) { if (!settled) { - finishWith(buildCaughtErrorEvent(lastSeenPartial, error)); + finishWith(buildCaughtErrorEvent(lastSeenPartial, error, model)); } } finally { cleanup(); diff --git a/extensions/opencode-go/stream.ts b/extensions/opencode-go/stream.ts index 12fdb1dfceae..19033fcbbfc6 100644 --- a/extensions/opencode-go/stream.ts +++ b/extensions/opencode-go/stream.ts @@ -2,10 +2,11 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, + createOpenAICompatibleCompletionsThinkingOffWrapper, createPayloadPatchStreamWrapper, } from "openclaw/plugin-sdk/provider-stream-shared"; import { isOpencodeGoKimiNoReasoningModelId } from "./provider-catalog.js"; -import { isOpencodeGoDeepSeekV4ModelId } from "./provider-policy-api.js"; +import { isOpencodeGoFixedAnthropicReasoningModelId } from "./provider-policy-api.js"; import { stripOpencodeGoKimiReasoningPayload } from "./reasoning-sanitizer.js"; import { createOpencodeGoStalledStreamWrapper, @@ -17,16 +18,18 @@ function createOpencodeGoDeepSeekV4Wrapper( baseStreamFn: ProviderWrapStreamFnContext["streamFn"], thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"], ): ProviderWrapStreamFnContext["streamFn"] { - return createDeepSeekV4OpenAICompatibleThinkingWrapper({ + const flashWrapped = createDeepSeekV4OpenAICompatibleThinkingWrapper({ baseStreamFn, thinkingLevel, shouldPatchModel: (model) => - model.provider === "opencode-go" && isOpencodeGoDeepSeekV4ModelId(model.id), + model.provider === "opencode-go" && model.id === "deepseek-v4-flash", + resolveReasoningEffort: (level) => (level === "low" ? "low" : level === "max" ? "max" : "high"), + }); + return createDeepSeekV4OpenAICompatibleThinkingWrapper({ + baseStreamFn: flashWrapped, + thinkingLevel, + shouldPatchModel: (model) => model.provider === "opencode-go" && model.id === "deepseek-v4-pro", }); -} - -function stripReasoningParams(payloadObj: Record): void { - stripOpencodeGoKimiReasoningPayload(payloadObj); } function createOpencodeGoKimiNoReasoningWrapper( @@ -37,7 +40,7 @@ function createOpencodeGoKimiNoReasoningWrapper( } return createPayloadPatchStreamWrapper( baseStreamFn, - ({ payload }) => stripReasoningParams(payload), + ({ payload }) => stripOpencodeGoKimiReasoningPayload(payload), { shouldPatch: ({ model }) => model.provider === "opencode-go" && isOpencodeGoKimiNoReasoningModelId(model.id), @@ -45,6 +48,42 @@ function createOpencodeGoKimiNoReasoningWrapper( ); } +function createOpencodeGoFixedAnthropicReasoningWrapper( + baseStreamFn: ProviderWrapStreamFnContext["streamFn"], +): ProviderWrapStreamFnContext["streamFn"] { + if (!baseStreamFn) { + return undefined; + } + return createPayloadPatchStreamWrapper( + baseStreamFn, + ({ payload }) => { + delete payload.thinking; + delete payload.output_config; + }, + { + shouldPatch: ({ model }) => + model.provider === "opencode-go" && isOpencodeGoFixedAnthropicReasoningModelId(model.id), + }, + ); +} + +function createOpencodeGoKimiK3ThinkingOffWrapper( + baseStreamFn: ProviderWrapStreamFnContext["streamFn"], + thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"], +): ProviderWrapStreamFnContext["streamFn"] { + if (!baseStreamFn) { + return undefined; + } + const thinkingOff = createOpenAICompatibleCompletionsThinkingOffWrapper( + baseStreamFn, + thinkingLevel, + ); + return (model, context, options) => + model.provider === "opencode-go" && model.id === "kimi-k3" + ? thinkingOff(model, context, options) + : baseStreamFn(model, context, options); +} + export function createOpencodeGoWrapper( baseStreamFn: ProviderWrapStreamFnContext["streamFn"], thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"], @@ -53,8 +92,13 @@ export function createOpencodeGoWrapper( return undefined; } const kimiWrapped = createOpencodeGoKimiNoReasoningWrapper(baseStreamFn) ?? baseStreamFn; + const kimiK3Wrapped = + createOpencodeGoKimiK3ThinkingOffWrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped; + const fixedAnthropicWrapped = + createOpencodeGoFixedAnthropicReasoningWrapper(kimiK3Wrapped) ?? kimiK3Wrapped; const deepSeekWrapped = - createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped; + createOpencodeGoDeepSeekV4Wrapper(fixedAnthropicWrapped, thinkingLevel) ?? + fixedAnthropicWrapped; // Outermost layer: provider-owned stalled SSE termination so the underlying // OpenAI SDK request is aborted at the raw opencode-go boundary instead of // waiting for the shared runtime stuck-session recovery. diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index d8bb73f09ef2..3d8695e8282e 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -36,6 +36,88 @@ function requireCatalogEntry(entries: readonly unknown[] | null | undefined, 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", + "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", + "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; + +const REPLACED_BY = new Map([ + ["claude-opus-4-8", "claude-opus-5"], + ["gpt-5.5", "gpt-5.6-sol"], + ["minimax-m2.7", "minimax-m3"], +]); + describe("opencode provider plugin", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); @@ -51,7 +133,7 @@ describe("opencode provider plugin", () => { expect(provider.auth[0]?.wizard).toMatchObject({ choiceLabel: "OpenCode Zen catalog", groupId: "opencode", - groupHint: "Shared API key for Zen + Go catalogs", + groupHint: "Shared API key infrastructure for Zen + Go", }); }); @@ -93,67 +175,8 @@ describe("opencode provider plugin", () => { const provider = await registerSingleProviderPlugin(plugin); expect(provider.catalog).toBeDefined(); - const expectedModelIds = [ - "claude-fable-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-sonnet-4", - "claude-haiku-4-5", - "gemini-3.6-flash", - "gemini-3.5-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-pro", - "gemini-3-flash", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "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.2-codex", - "gpt-5.1", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-codex", - "gpt-5-nano", - "grok-build-0.1", - "grok-4.5", - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.2", - "glm-5.1", - "glm-5", - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "kimi-k2.7-code", - "kimi-k2.6", - "kimi-k2.5", - "qwen3.6-plus", - "qwen3.5-plus", - "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", - ]; + 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); @@ -162,7 +185,7 @@ describe("opencode provider plugin", () => { } models.set(model.id, model); } - expect([...models.keys()]).toEqual(expectedModelIds); + expect([...models.keys()].toSorted()).toEqual(expectedModelIds.toSorted()); const supplemental = await provider.augmentModelCatalog?.({ entries: [...models.values()].map((model) => ({ @@ -171,17 +194,28 @@ describe("opencode provider plugin", () => { 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(); + } + 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(200_000); - expect(opus46.maxTokens).toBe(65_536); + expect(opus46.contextWindow).toBe(1_000_000); + expect(opus46.maxTokens).toBe(128_000); expect(requireMapEntry(models, "gpt-5.5")).toMatchObject({ api: "openai-responses", @@ -195,13 +229,13 @@ describe("opencode provider plugin", () => { contextWindow: 1_050_000, maxTokens: 128_000, cost: { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, tieredPricing: [ - { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25, range: [0, 272_000] }, - { input: 2, output: 9, cacheRead: 0.2, cacheWrite: 2.5, range: [272_000] }, + { 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: { @@ -212,7 +246,7 @@ describe("opencode provider plugin", () => { name: "GPT-5.6 Terra", contextWindow: 1_050_000, maxTokens: 128_000, - cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 }, + cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, }); expect(requireMapEntry(models, "gpt-5.6-sol")).toMatchObject({ name: "GPT-5.6 Sol", @@ -231,7 +265,7 @@ describe("opencode provider plugin", () => { 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", + 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 }, @@ -263,7 +297,7 @@ describe("opencode provider plugin", () => { }); expect(requireMapEntry(models, "grok-4.5")).toMatchObject({ name: "Grok 4.5", - api: "openai-completions", + api: "openai-responses", baseUrl: "https://opencode.ai/zen/v1", input: ["text", "image"], contextWindow: 500_000, @@ -271,11 +305,11 @@ describe("opencode provider plugin", () => { cost: { input: 2, output: 6, - cacheRead: 0.5, + cacheRead: 0.3, cacheWrite: 0, tieredPricing: [ - { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0, range: [0, 200_000] }, - { input: 4, output: 12, cacheRead: 1, cacheWrite: 0, range: [200_000] }, + { 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] }, ], }, }); @@ -288,6 +322,16 @@ describe("opencode provider plugin", () => { 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", @@ -321,15 +365,19 @@ describe("opencode provider plugin", () => { if (!Array.isArray(manifestModels)) { throw new Error("expected manifest opencode models"); } - expect(manifestModels.map((model) => requireRecord(model, "manifest model").id)).toEqual([ + 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", @@ -337,6 +385,8 @@ describe("opencode provider plugin", () => { "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( @@ -345,19 +395,19 @@ describe("opencode provider plugin", () => { "manifest claude-opus-4-8", ); expect(manifestClaude48).toMatchObject({ - status: "deprecated", - replacedBy: "claude-opus-5", 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", - contextWindow: 1_050_000, }); const manifestMiniMax = requireRecord( manifestModels.find((model) => requireRecord(model, "manifest model").id === "minimax-m2.7"), @@ -366,9 +416,24 @@ describe("opencode provider plugin", () => { 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", - cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + }); + 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, }); }); @@ -400,6 +465,7 @@ describe("opencode provider plugin", () => { 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"); @@ -425,36 +491,36 @@ describe("opencode provider plugin", () => { [ "gpt-5.6-luna", { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, tieredPricing: [ - { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25, range: [0, 272_000] }, - { input: 2, output: 9, cacheRead: 0.2, cacheWrite: 2.5, range: [272_000] }, + { 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.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 3.125, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, tieredPricing: [ { - input: 2.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 3.125, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, range: [0, 272_000], }, { - input: 5, - output: 22.5, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 4, + output: 18, + cacheRead: 0.4, + cacheWrite: 5, range: [272_000], }, ], @@ -476,6 +542,7 @@ describe("opencode provider plugin", () => { ["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 }], @@ -501,7 +568,34 @@ describe("opencode provider plugin", () => { provider.resolveDynamicModel?.({ modelId } as never), `runtime manifest anchor ${modelId}`, ); - expect(manifestModelRecord.cost).toEqual(runtimeModel.cost); + 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, + }); } }); @@ -517,21 +611,9 @@ describe("opencode provider plugin", () => { throw new Error("expected OpenCode Zen static provider"); } - expect(result.provider.models).toHaveLength(59); - expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-5"); - expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-8"); - expect(result.provider.models.map((model) => model.id)).toContain("claude-sonnet-5"); - expect(result.provider.models.map((model) => model.id)).toContain("glm-5.2"); - expect(result.provider.models.map((model) => model.id)).toContain("grok-4.5"); - expect(result.provider.models.map((model) => model.id)).toContain("kimi-k2.7-code"); - expect(result.provider.models.map((model) => model.id)).toContain("minimax-m2.7"); - expect(result.provider.models.map((model) => model.id)).toContain("minimax-m3"); - expect(result.provider.models.map((model) => model.id)).toContain("gpt-5.6-luna"); - expect(result.provider.models.find((model) => model.id === "minimax-m2.7")).toMatchObject({ - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - provider: "opencode", - }); + 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 () => { @@ -541,13 +623,9 @@ describe("opencode provider plugin", () => { throw new Error("expected registered OpenCode Zen static provider"); } - expect(result.provider.models).toHaveLength(59); - expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-5"); - expect(result.provider.models.map((model) => model.id)).toContain("claude-sonnet-5"); - expect(result.provider.models.map((model) => model.id)).toContain("gpt-5.6-sol"); - expect(result.provider.models.map((model) => model.id)).toContain("minimax-m3"); + expect(result.provider.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); expect(result.provider.models.find((model) => model.id === "grok-4.5")).toMatchObject({ - api: "openai-completions", + api: "openai-responses", baseUrl: "https://opencode.ai/zen/v1", provider: "opencode", }); @@ -591,7 +669,7 @@ describe("opencode provider plugin", () => { 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-8"); + expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-7"); expect(fetchMock).not.toHaveBeenCalled(); } finally { fetchMock.mockRestore(); @@ -603,7 +681,13 @@ describe("opencode provider plugin", () => { 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" }, ], }), @@ -625,9 +709,15 @@ describe("opencode provider plugin", () => { expect(fetchGuard).toHaveBeenCalledTimes(1); expect(first.apiKey).toBe("OPENCODE_API_KEY"); - expect(first.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]); - expect(second.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]); - const claudeModel = first.models.find((model) => model.id === "claude-opus-4-8"); + 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({ + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + contextWindow: 1_048_576, + maxTokens: 131_072, + }); + const claudeModel = first.models.find((model) => model.id === "claude-opus-4-7"); expect(claudeModel).toMatchObject({ api: "anthropic-messages", baseUrl: "https://opencode.ai/zen", @@ -651,7 +741,7 @@ describe("opencode provider plugin", () => { discoveryApiKey: "resolved-opencode-key", fetchGuard, }); - expect(unknownOnly.models.map((model) => model.id)).toContain("claude-opus-4-8"); + expect(unknownOnly.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); clearLiveCatalogCacheForTests(); fetchGuard.mockRejectedValueOnce(new Error("network unavailable")); @@ -661,8 +751,7 @@ describe("opencode provider plugin", () => { fetchGuard, }); expect(fallback.apiKey).toBe("OPENCODE_API_KEY"); - expect(fallback.models.map((model) => model.id)).toContain("claude-opus-4-8"); - expect(fallback.models.map((model) => model.id)).toContain("claude-opus-4-6"); + expect(fallback.models.map((model) => model.id)).toEqual(ACTIVE_MODEL_IDS); }); it("keeps live OpenCode Zen discovery caches scoped to discovery credentials", async () => { @@ -670,13 +759,13 @@ describe("opencode provider plugin", () => { .fn() .mockResolvedValueOnce({ response: new Response( - JSON.stringify({ data: [{ id: "claude-opus-4-8", object: "model" }] }), + 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.5", object: "model" }] })), + 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), }); @@ -699,11 +788,45 @@ describe("opencode provider plugin", () => { expect(fetchGuard).toHaveBeenCalledTimes(2); expect(first.apiKey).toBe("runtime-a"); - expect(first.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]); + 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.5"]); + 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.5"]); + expect(secondCached.models.map((model) => model.id)).toEqual(["gpt-5.6-luna"]); + }); + + it.each([ + ["off", undefined], + ["max", "max"], + ] as const)("keeps Kimi K3 reasoning %s exact", async (thinkingLevel, expectedEffort) => { + 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", + }; + (options as { onPayload?: (payload: Record) => void })?.onPayload?.(payload); + capturedPayloads.push(payload); + return {} as never; + }; + const streamFn = provider.wrapStreamFn?.({ + streamFn: baseStreamFn as never, + providerId: "opencode", + modelId: "kimi-k3", + thinkingLevel, + } as never); + + await streamFn?.( + { provider: "opencode", id: "kimi-k3", api: "openai-completions" } as never, + {} as never, + {}, + ); + expect(capturedPayloads).toEqual([ + expectedEffort === undefined + ? { model: "kimi-k3" } + : { model: "kimi-k3", reasoning_effort: expectedEffort }, + ]); }); it("canonicalizes stale OpenCode Zen base URLs", async () => { @@ -818,5 +941,35 @@ describe("opencode provider plugin", () => { expect(gpt56LevelIds).not.toContain("minimal"); expect(gpt56LevelIds).toContain("xhigh"); expect(gpt56LevelIds).toContain("max"); + + expect( + resolveThinkingProfile({ + provider: "opencode", + modelId: "kimi-k3", + api: "openai-completions", + reasoning: true, + 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", + modelId: "big-pickle", + api: "openai-completions", + reasoning: true, + }), + ).toEqual({ levels: [{ id: "off", label: "always on" }], defaultLevel: "off" }); }); }); diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 1f566b56dc38..3df16c0deca2 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -4,6 +4,7 @@ import { buildProviderReplayFamilyHooks, matchesExactOrPrefix, } from "openclaw/plugin-sdk/provider-model-shared"; +import { createOpenAICompatibleCompletionsThinkingOffWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js"; import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js"; @@ -21,7 +22,7 @@ import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js"; const PROVIDER_ID = "opencode"; const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const; const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const; -const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs"; +const OPENCODE_SHARED_HINT = "Shared API key infrastructure for Zen + Go"; type OpencodeZenCatalogAuth = { apiKey?: string; discoveryApiKey?: string; @@ -67,7 +68,7 @@ export default defineSingleProviderPluginEntry({ applyConfig: applyOpencodeZenConfig, expectedProviders: ["opencode", "opencode-go"], noteMessage: [ - "OpenCode uses one API key across the Zen and Go catalogs.", + "One OpenCode API key can authenticate Zen and a separately subscribed Go catalog.", "Zen provides access to Claude, GPT, Gemini, and more models.", "Get your API key at: https://opencode.ai/auth", "Choose the Zen catalog when you want the curated multi-model proxy.", @@ -127,6 +128,20 @@ export default defineSingleProviderPluginEntry({ ...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }), isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId), resolveThinkingProfile: resolveOpencodeThinkingProfile, + wrapStreamFn: (ctx) => { + if (!ctx.streamFn) { + return undefined; + } + const baseStreamFn = ctx.streamFn; + const thinkingOff = createOpenAICompatibleCompletionsThinkingOffWrapper( + baseStreamFn, + ctx.thinkingLevel, + ); + return (model, context, options) => + model.provider === PROVIDER_ID && model.id === "kimi-k3" + ? thinkingOff(model, context, options) + : baseStreamFn(model, context, options); + }, }, register(api) { api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider); diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index fa5d5a317b83..accaa1c729c2 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -52,6 +52,7 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "medium", "high", "xhigh", "max"], "maxTokensField": "max_tokens", "codeMode": "capable" } @@ -78,12 +79,51 @@ "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", @@ -123,6 +163,7 @@ ] }, "contextWindow": 1050000, + "contextTokens": 922000, "maxTokens": 128000, "compat": { "supportsUsageInStreaming": true, @@ -178,10 +219,12 @@ ] }, "contextWindow": 1050000, + "contextTokens": 922000, "maxTokens": 128000, "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["none", "low", "medium", "high", "xhigh"], "maxTokensField": "max_tokens", "codeMode": "capable" }, @@ -210,12 +253,13 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["minimal", "low", "medium", "high"], "maxTokensField": "max_tokens" } }, { "id": "gemini-3.1-pro", - "name": "Gemini 3.1 Pro", + "name": "Gemini 3.1 Pro Preview", "api": "google-generative-ai", "provider": "opencode", "baseUrl": "https://opencode.ai/zen/v1", @@ -256,6 +300,7 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "medium", "high"], "maxTokensField": "max_tokens" } }, @@ -280,7 +325,6 @@ "maxTokens": 128000, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -306,7 +350,6 @@ "maxTokens": 131072, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -314,6 +357,35 @@ "status": "deprecated", "replacedBy": "minimax-m3" }, + { + "id": "kimi-k3", + "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": 1048576, + "maxTokens": 131072, + "compat": { + "supportsUsageInStreaming": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["max"], + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsStrictMode": false, + "codeMode": "capable" + } + }, { "id": "big-pickle", "name": "Big Pickle", @@ -331,10 +403,10 @@ "cacheWrite": 0 }, "contextWindow": 200000, + "contextTokens": 160000, "maxTokens": 32000, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -361,6 +433,7 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "high", "max"], "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -387,7 +460,6 @@ "maxTokens": 32000, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -414,6 +486,7 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "medium", "high"], "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -440,10 +513,12 @@ "compat": { "supportsUsageInStreaming": true, "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["low", "medium", "high"], "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false - } + }, + "status": "deprecated" }, { "id": "nemotron-3-ultra-free", @@ -465,7 +540,6 @@ "maxTokens": 128000, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true, "maxTokensField": "max_tokens", "supportsDeveloperRole": false, "supportsStrictMode": false @@ -492,6 +566,43 @@ "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 @@ -524,7 +635,7 @@ "choiceLabel": "OpenCode Zen catalog", "groupId": "opencode", "groupLabel": "OpenCode", - "groupHint": "Shared API key for Zen + Go catalogs", + "groupHint": "Shared API key infrastructure for Zen + Go", "optionKey": "opencodeZenApiKey", "cliFlag": "--opencode-zen-api-key", "cliOption": "--opencode-zen-api-key ", diff --git a/extensions/opencode/opencode.live.test.ts b/extensions/opencode/opencode.live.test.ts index 08f95872779c..3cfede769f0e 100644 --- a/extensions/opencode/opencode.live.test.ts +++ b/extensions/opencode/opencode.live.test.ts @@ -8,7 +8,10 @@ import { import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { buildStaticOpencodeZenProviderConfig } from "./provider-catalog.js"; +import { + buildStaticOpencodeZenProviderConfig, + listOpencodeZenModelCatalogEntries, +} from "./provider-catalog.js"; const OPENCODE_ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models"; const OPENCODE_API_KEY = @@ -89,24 +92,46 @@ function listStaticOpencodeZenModelIds(): string[] { } describeCatalogLive("opencode Zen live catalog drift", () => { - it("keeps the provider-owned static seed aligned with the live model ids", async () => { + it("covers every global live id with trusted metadata and filters deprecated rows", async () => { const liveIds = await fetchOpencodeZenModelIds(); const staticIds = listStaticOpencodeZenModelIds(); expect(new Set(staticIds).size).toBe(staticIds.length); - const staticIdSet = new Set(staticIds); - const liveIdSet = new Set(liveIds); - const missingStaticMetadata = liveIds.filter((id) => !staticIdSet.has(id)); - const staleStaticRows = staticIds.filter((id) => !liveIdSet.has(id)); + 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)); expect( - { missingStaticMetadata, staleStaticRows }, + { missingTrustedMetadata, deprecatedLiveIds, staticIds }, [ - "OpenCode Zen live catalog drifted from the provider-owned static seed.", - "Add routing/baseUrl/cost/context/capability metadata for missing live ids,", - "or remove stale static rows if OpenCode retired them.", + "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({ missingStaticMetadata: [], staleStaticRows: [] }); + ).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, + }); }, 30_000); }); diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index 7b81ca31cc65..b43cc3c47183 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -27,29 +27,146 @@ const FREE_COST: ModelDefinitionConfig["cost"] = { cacheWrite: 0, }; -// Zen publishes route-specific limits that differ from the family defaults below. -const MODEL_LIMITS: Record = { - "claude-opus-5": { contextWindow: 1_000_000, maxTokens: 128_000 }, - "claude-sonnet-5": { contextWindow: 1_000_000, maxTokens: 128_000 }, - "gpt-5.6-luna": { contextWindow: 1_050_000, maxTokens: 128_000 }, - "gpt-5.6-sol": { contextWindow: 1_050_000, maxTokens: 128_000 }, - "gpt-5.6-terra": { contextWindow: 1_050_000, maxTokens: 128_000 }, - "glm-5.2": { contextWindow: 1_000_000, maxTokens: 131_072 }, - "grok-4.5": { contextWindow: 500_000, maxTokens: 500_000 }, - "kimi-k2.7-code": { contextWindow: 262_144, maxTokens: 262_144 }, - "laguna-s-2.1-free": { contextWindow: 256_000, maxTokens: 32_000 }, - "ling-3.0-flash-free": { contextWindow: 262_144, maxTokens: 32_768 }, - "minimax-m3": { contextWindow: 512_000, maxTokens: 128_000 }, +type ZenModelCapabilities = { + contextWindow: number; + contextTokens?: number; + maxTokens: number; + input: ReadonlyArray<"text" | "image">; + reasoningEfforts?: readonly string[]; + status?: "deprecated"; + replacedBy?: string; }; -// These rows are the inverse of their family's usual image-input capability. -const MODEL_IMAGE_INPUT_OVERRIDES = new Map([ - ["laguna-s-2.1-free", false], - ["ling-3.0-flash-free", false], - ["minimax-m3", true], -]); +const T = ["text"] as const; +const TI = ["text", "image"] as const; -const MODEL_COSTS: Record = { +// 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 }, @@ -99,13 +216,13 @@ const MODEL_COSTS: Record = { "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: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, tieredPricing: [ - { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25, range: [0, 272_000] }, - { input: 2, output: 9, cacheRead: 0.2, cacheWrite: 2.5, range: [272_000] }, + { 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": { @@ -119,13 +236,13 @@ const MODEL_COSTS: Record = { ], }, "gpt-5.6-terra": { - input: 2.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 3.125, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, tieredPricing: [ - { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125, range: [0, 272_000] }, - { input: 5, output: 22.5, cacheRead: 0.5, cacheWrite: 6.25, range: [272_000] }, + { 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 }, @@ -170,18 +287,21 @@ const MODEL_COSTS: Record = { "grok-4.5": { input: 2, output: 6, - cacheRead: 0.5, + cacheRead: 0.3, cacheWrite: 0, tieredPricing: [ - { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0, range: [0, 200_000] }, - { input: 4, output: 12, cacheRead: 1, cacheWrite: 0, range: [200_000] }, + { 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 }, @@ -192,7 +312,7 @@ const MODEL_COSTS: Record = { "qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 }, }; -const MODEL_NAMES: Record = { +const MODEL_NAMES: Record = { "big-pickle": "Big Pickle", "claude-fable-5": "Claude Fable 5", "claude-haiku-4-5": "Claude Haiku 4.5", @@ -210,9 +330,9 @@ const MODEL_NAMES: Record = { "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", + "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.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", @@ -242,8 +362,11 @@ const MODEL_NAMES: Record = { "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", @@ -254,9 +377,6 @@ const MODEL_NAMES: Record = { "qwen3.6-plus": "Qwen3.6 Plus", }; -const GPT_56_MODEL_IDS = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]); -const GPT_56_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"]; - type OpencodeZenModelDefinition = ModelDefinitionConfig & { provider: typeof PROVIDER_ID; api: NonNullable; @@ -271,84 +391,6 @@ type FetchOpencodeZenLiveModelIdsParams = { signal?: AbortSignal; }; -function formatModelName(modelId: string): string { - const exact = MODEL_NAMES[modelId]; - if (exact) { - return exact; - } - return modelId - .split("-") - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" "); -} - -function supportsImageInput(modelId: string): boolean { - const lower = modelId.toLowerCase(); - const override = MODEL_IMAGE_INPUT_OVERRIDES.get(lower); - if (override !== undefined) { - return override; - } - return !( - lower.includes("deepseek") || - lower.includes("glm") || - lower.includes("minimax") || - lower.includes("qwen") - ); -} - -function resolveContextWindow(modelId: string): number { - const lower = modelId.toLowerCase(); - const limits = MODEL_LIMITS[lower]; - if (limits) { - return limits.contextWindow; - } - if (lower.includes("gemini")) { - return 1_048_576; - } - if (lower.includes("gpt") || lower.includes("codex")) { - return 400_000; - } - if (lower.includes("deepseek")) { - return 1_000_000; - } - if (lower.includes("claude")) { - return 200_000; - } - if (lower.includes("glm") || lower.includes("minimax")) { - return 204_800; - } - if (lower.includes("kimi") || lower.includes("mimo") || lower.includes("qwen")) { - return 262_144; - } - return 128_000; -} - -function resolveMaxTokens(modelId: string): number { - const lower = modelId.toLowerCase(); - const limits = MODEL_LIMITS[lower]; - if (limits) { - return limits.maxTokens; - } - if (lower.includes("deepseek")) { - return 384_000; - } - if (lower.includes("glm") || lower.includes("minimax")) { - return 131_072; - } - if (lower.includes("gpt") || lower.includes("codex")) { - return 128_000; - } - if ( - lower.includes("claude") || - lower.includes("gemini") || - lower.includes("kimi") || - lower.includes("qwen") - ) { - return 65_536; - } - return 8_192; -} - type OpencodeZenTransport = { api: ModelApi; baseUrl: string; @@ -356,7 +398,7 @@ type OpencodeZenTransport = { function resolveOpencodeZenTransport(modelId: string): OpencodeZenTransport { const lower = modelId.toLowerCase(); - if (lower.startsWith("gpt-")) { + if (lower.startsWith("gpt-") || lower.startsWith("grok-")) { return { api: "openai-responses", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL }; } if (lower.startsWith("claude-") || lower.startsWith("qwen")) { @@ -368,100 +410,52 @@ function resolveOpencodeZenTransport(modelId: string): OpencodeZenTransport { return { api: "openai-completions", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL }; } -function resolveModelCost(modelId: string): ModelDefinitionConfig["cost"] { - const cost = MODEL_COSTS[modelId]; - if (!cost) { - throw new Error(`missing OpenCode Zen cost metadata for ${modelId}`); +function buildOpencodeZenModel(modelId: ZenModelId): OpencodeZenModelDefinition { + const capabilities = MODEL_CAPABILITIES[modelId]; + if (!capabilities) { + throw new Error(`missing OpenCode Zen capability metadata for ${modelId}`); } - return cost; -} - -function buildOpencodeZenModel(modelId: string): OpencodeZenModelDefinition { - const normalizedModelId = modelId.trim().toLowerCase(); - const transport = resolveOpencodeZenTransport(normalizedModelId); + const transport = resolveOpencodeZenTransport(modelId); return normalizeModelCompat({ - id: normalizedModelId, - name: formatModelName(normalizedModelId), + id: modelId, + name: MODEL_NAMES[modelId], api: transport.api, provider: PROVIDER_ID, baseUrl: transport.baseUrl, reasoning: true, - input: supportsImageInput(normalizedModelId) ? ["text", "image"] : ["text"], - cost: resolveModelCost(normalizedModelId), - contextWindow: resolveContextWindow(normalizedModelId), - maxTokens: resolveMaxTokens(normalizedModelId), + 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, - supportsReasoningEffort: true, - ...(GPT_56_MODEL_IDS.has(normalizedModelId) - ? { supportedReasoningEfforts: GPT_56_REASONING_EFFORTS } + ...(capabilities.reasoningEfforts + ? { + supportsReasoningEffort: true, + supportedReasoningEfforts: [...capabilities.reasoningEfforts], + } : {}), maxTokensField: "max_tokens", + ...(transport.api === "openai-completions" + ? { supportsDeveloperRole: false, supportsStrictMode: false } + : {}), }, }) as OpencodeZenModelDefinition; } -const OPENCODE_ZEN_MODELS = [ - "claude-fable-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-sonnet-4", - "claude-haiku-4-5", - "gemini-3.6-flash", - "gemini-3.5-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-pro", - "gemini-3-flash", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "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.2-codex", - "gpt-5.1", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-codex", - "gpt-5-nano", - "grok-build-0.1", - "grok-4.5", - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.2", - "glm-5.1", - "glm-5", - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "kimi-k2.7-code", - "kimi-k2.6", - "kimi-k2.5", - "qwen3.6-plus", - "qwen3.5-plus", - "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", -].map(buildOpencodeZenModel); +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]), +); export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProviderConfig { return { @@ -528,19 +522,33 @@ export async function buildOpencodeZenLiveProviderConfig( } export function listOpencodeZenModelCatalogEntries(): ModelCatalogEntry[] { - return OPENCODE_ZEN_MODELS.map((model) => ({ - provider: model.provider, - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - contextWindow: model.contextWindow, - })); + return OPENCODE_ZEN_RESOLVABLE_MODELS.map((model) => { + const lifecycle = MODEL_CAPABILITIES[model.id]; + const entry: ModelCatalogEntry = { + provider: model.provider, + id: model.id, + name: model.name, + api: model.api, + baseUrl: model.baseUrl, + reasoning: model.reasoning, + input: model.input, + contextWindow: model.contextWindow, + contextTokens: model.contextTokens, + compat: model.compat, + }; + 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_MODELS.find((model) => model.id === normalizedModelId); + return OPENCODE_ZEN_MODEL_BY_ID.get(normalizedModelId); } function normalizeBaseUrl(baseUrl: string | undefined): string { diff --git a/extensions/opencode/provider-policy-api.test.ts b/extensions/opencode/provider-policy-api.test.ts index c45dbcd18267..519d0e41a073 100644 --- a/extensions/opencode/provider-policy-api.test.ts +++ b/extensions/opencode/provider-policy-api.test.ts @@ -48,6 +48,9 @@ describe("opencode provider policy public artifact", () => { resolveThinkingProfile({ provider: "opencode", modelId: "gpt-5.6-luna", + compat: { + supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + }, }), ).toEqual({ levels: [ @@ -61,4 +64,32 @@ describe("opencode provider policy public artifact", () => { defaultLevel: "medium", }); }); + + it("derives non-Claude profiles only from exact provider effort metadata", () => { + expect( + resolveThinkingProfile({ + provider: "opencode", + modelId: "grok-4.5", + compat: { supportedReasoningEfforts: ["low", "medium", "high"] }, + }), + ).toEqual({ + levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }], + defaultLevel: "medium", + }); + expect( + resolveThinkingProfile({ + provider: "opencode", + modelId: "kimi-k3", + compat: { supportedReasoningEfforts: ["max"] }, + }), + ).toEqual({ levels: [{ id: "off" }, { id: "max" }], defaultLevel: "off" }); + expect( + resolveThinkingProfile({ + provider: "opencode", + modelId: "big-pickle", + api: "openai-completions", + reasoning: true, + }), + ).toEqual({ levels: [{ id: "off", label: "always on" }], defaultLevel: "off" }); + }); }); diff --git a/extensions/opencode/provider-policy-api.ts b/extensions/opencode/provider-policy-api.ts index 4262e4e61bfb..20d468f3573b 100644 --- a/extensions/opencode/provider-policy-api.ts +++ b/extensions/opencode/provider-policy-api.ts @@ -5,25 +5,46 @@ import type { } from "openclaw/plugin-sdk/plugin-entry"; import { resolveClaudeThinkingProfile } from "openclaw/plugin-sdk/provider-model-shared"; -const GPT_56_THINKING_PROFILE = { - levels: [ - { id: "off" }, - { id: "low" }, - { id: "medium" }, - { id: "high" }, - { id: "xhigh" }, - { id: "max" }, - ], - defaultLevel: "medium", +const FIXED_REASONING_PROFILE = { + levels: [{ id: "off", label: "always on" }], + defaultLevel: "off", } as const satisfies ProviderThinkingProfile; -function isGpt56Model(modelId: string): boolean { - return /^gpt-5\.6(?:-|$)/u.test(modelId.trim().toLowerCase()); +const THINKING_LEVEL_IDS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +function resolveEffortThinkingProfile( + efforts: readonly string[] | null | undefined, +): ProviderThinkingProfile | undefined { + if (!efforts || efforts.length === 0) { + return undefined; + } + const acceptedLevelIds = ["off", ...efforts.map((effort) => (effort === "none" ? "off" : effort))] + .filter((id) => THINKING_LEVEL_IDS.has(id)) + .filter((id, index, values) => values.indexOf(id) === index) as Array< + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + >; + const levels = acceptedLevelIds.map((id) => ({ id })); + const levelIdSet = new Set(acceptedLevelIds); + const defaultLevel = levelIdSet.has("medium") + ? "medium" + : levelIdSet.has("high") + ? "high" + : levelIdSet.has("low") + ? "low" + : "off"; + return { levels, defaultLevel }; } export function resolveThinkingProfile(params: ProviderDefaultThinkingPolicyContext) { - if (isGpt56Model(params.modelId)) { - return GPT_56_THINKING_PROFILE; + const modelId = params.modelId.trim().toLowerCase(); + if (modelId.startsWith("claude-")) { + return resolveClaudeThinkingProfile(modelId); } - return resolveClaudeThinkingProfile(params.modelId); + const effortProfile = resolveEffortThinkingProfile(params.compat?.supportedReasoningEfforts); + if (effortProfile) { + return effortProfile; + } + return params.reasoning === true && params.api !== "anthropic-messages" + ? FIXED_REASONING_PROFILE + : undefined; } diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index 075711ac43e6..67c9c00cda03 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -177,15 +177,7 @@ describe("provider public artifacts", () => { modelId: "deepseek-v4-pro", }), ).toEqual({ - levels: [ - { id: "off" }, - { id: "minimal" }, - { id: "low" }, - { id: "medium" }, - { id: "high" }, - { id: "xhigh" }, - { id: "max" }, - ], + levels: [{ id: "off" }, { id: "high" }, { id: "max" }], defaultLevel: "high", }); expect(