From 25e93ef9f9b522dcb924c449bc03eef55a5e44fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 02:44:56 +0100 Subject: [PATCH] fix(opencode): list current Zen models in offline discovery (#103197) * fix(opencode): refresh Zen model catalog * fix(opencode): expose static catalog fallback --- CHANGELOG.md | 1 + extensions/opencode/index.test.ts | 89 ++++++++++++++++++++++- extensions/opencode/index.ts | 6 ++ extensions/opencode/opencode.live.test.ts | 6 +- extensions/opencode/provider-catalog.ts | 61 +++++++++++++--- 5 files changed, 150 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0b111089e2..292a49276505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **OpenCode Zen model catalog:** refresh the provider-owned static seed for Claude Sonnet 5, Grok 4.5, Hy3 Free, Kimi K2.7 Code, and MiniMax M3 with verified routing, pricing, limits, and input capabilities, remove retired free-tier rows, and expose the same catalog through unauthenticated model listing. (#103184) - **Gateway startup migrations:** release the shared migration lease before exiting when the selected config changes during startup, allowing immediate retries instead of blocking readiness until the five-minute lease expires. (#103145) - **Apple timeout recovery:** return promptly from shared operation deadlines and caller cancellation even when platform work ignores cancellation, while isolating late Gateway handshakes and cleaning up location and permission waiters. (#103066) Thanks @NianJiuZst. - **CLI plugin listing:** skip state-migration runtime loading when no legacy inputs exist, reducing packaged cold-start memory while preserving migrations for legacy plugin indexes and configured session stores. diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index 21a404d0cfe4..b71cf969fd58 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -89,6 +89,7 @@ describe("opencode provider plugin", () => { "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", @@ -114,13 +115,16 @@ describe("opencode provider plugin", () => { "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", @@ -128,8 +132,7 @@ describe("opencode provider plugin", () => { "big-pickle", "deepseek-v4-flash-free", "mimo-v2.5-free", - "qwen3.6-plus-free", - "minimax-m3-free", + "hy3-free", "nemotron-3-ultra-free", "north-mini-code-free", ]; @@ -186,6 +189,60 @@ describe("opencode provider plugin", () => { maxTokens: 131_072, cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, }); + expect(requireMapEntry(models, "claude-sonnet-5")).toMatchObject({ + name: "Claude Sonnet 5", + api: "anthropic-messages", + baseUrl: "https://opencode.ai/zen", + input: ["text", "image"], + contextWindow: 1_000_000, + maxTokens: 128_000, + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + }); + expect(requireMapEntry(models, "grok-4.5")).toMatchObject({ + name: "Grok 4.5", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + input: ["text", "image"], + contextWindow: 500_000, + maxTokens: 500_000, + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + 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] }, + ], + }, + }); + expect(requireMapEntry(models, "hy3-free")).toMatchObject({ + name: "Hy3 Free", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + input: ["text"], + contextWindow: 256_000, + maxTokens: 64_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + expect(requireMapEntry(models, "kimi-k2.7-code")).toMatchObject({ + name: "Kimi K2.7 Code", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 262_144, + cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, + }); + expect(requireMapEntry(models, "minimax-m3")).toMatchObject({ + name: "MiniMax M3", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + input: ["text", "image"], + contextWindow: 512_000, + maxTokens: 128_000, + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + }); const dynamicModel = requireRecord( provider.resolveDynamicModel?.({ @@ -272,9 +329,13 @@ describe("opencode provider plugin", () => { ["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], ["claude-opus-4-1", { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }], + ["claude-sonnet-5", { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }], ["gpt-5.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 }], + ["hy3-free", { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }], + ["kimi-k2.7-code", { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }], ["minimax-m2.7", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }], + ["minimax-m3", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }], ] as const); for (const [modelId, expectedCost] of verifiedCostExamples) { @@ -311,10 +372,15 @@ describe("opencode provider plugin", () => { throw new Error("expected OpenCode Zen static provider"); } - expect(result.provider.models).toHaveLength(49); + expect(result.provider.models).toHaveLength(52); 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("hy3-free"); + 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.find((model) => model.id === "minimax-m2.7")).toMatchObject({ api: "openai-completions", baseUrl: "https://opencode.ai/zen/v1", @@ -322,6 +388,23 @@ describe("opencode provider plugin", () => { }); }); + it("exposes the offline catalog fallback through the full provider registration", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const result = await provider.staticCatalog?.run({} as never); + if (!result || !("provider" in result)) { + throw new Error("expected registered OpenCode Zen static provider"); + } + + expect(result.provider.models).toHaveLength(52); + expect(result.provider.models.map((model) => model.id)).toContain("claude-sonnet-5"); + expect(result.provider.models.map((model) => model.id)).toContain("minimax-m3"); + expect(result.provider.models.find((model) => model.id === "grok-4.5")).toMatchObject({ + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/v1", + provider: "opencode", + }); + }); + it("skips live OpenCode Zen catalog discovery when no shared key is configured", async () => { const provider = await registerSingleProviderPlugin(plugin); diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index a46e184503de..3f0486851d63 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -121,6 +121,12 @@ export default definePluginEntry({ : undefined; }, resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId), + staticCatalog: { + order: "simple", + run: async () => ({ + provider: buildStaticOpencodeZenProviderConfig(), + }), + }, catalog: { order: "simple", run: async (ctx) => { diff --git a/extensions/opencode/opencode.live.test.ts b/extensions/opencode/opencode.live.test.ts index 71bbd876fd35..c431841b6d30 100644 --- a/extensions/opencode/opencode.live.test.ts +++ b/extensions/opencode/opencode.live.test.ts @@ -71,12 +71,15 @@ async function fetchOpencodeZenModelIds(): Promise { }); expect(response.ok).toBe(true); const json = (await response.json()) as OpencodeModelsResponse; - return (json.data ?? []) + expect(Array.isArray(json.data)).toBe(true); + const modelIds = (json.data ?? []) .filter((model) => model.object === undefined || model.object === "model") .map((model) => model.id) .filter((id): id is string => typeof id === "string" && id.trim().length > 0) .map((id) => id.trim().toLowerCase()) .toSorted(); + expect(new Set(modelIds).size).toBe(modelIds.length); + return modelIds; } function listStaticOpencodeZenModelIds(): string[] { @@ -89,6 +92,7 @@ describeCatalogLive("opencode Zen live catalog drift", () => { it("keeps the provider-owned static seed aligned with the live model ids", 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); diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index 507a1c41b3e4..21012b744abb 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -27,6 +27,22 @@ const FREE_COST: ModelDefinitionConfig["cost"] = { cacheWrite: 0, }; +// Zen publishes route-specific limits that differ from the family defaults below. +const MODEL_LIMITS: Record = { + "claude-sonnet-5": { contextWindow: 1_000_000, maxTokens: 128_000 }, + "glm-5.2": { contextWindow: 1_000_000, maxTokens: 131_072 }, + "grok-4.5": { contextWindow: 500_000, maxTokens: 500_000 }, + "hy3-free": { contextWindow: 256_000, maxTokens: 64_000 }, + "kimi-k2.7-code": { contextWindow: 262_144, maxTokens: 262_144 }, + "minimax-m3": { contextWindow: 512_000, maxTokens: 128_000 }, +}; + +// These rows are the inverse of their family's usual image-input capability. +const MODEL_IMAGE_INPUT_OVERRIDES = new Map([ + ["hy3-free", false], + ["minimax-m3", true], +]); + const MODEL_COSTS: Record = { "big-pickle": FREE_COST, "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, @@ -57,6 +73,7 @@ const MODEL_COSTS: Record = { ], }, "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, "deepseek-v4-flash-free": FREE_COST, "deepseek-v4-pro": { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 }, @@ -111,17 +128,28 @@ const MODEL_COSTS: Record = { }, "gpt-5.5-pro": { input: 30, output: 180, cacheRead: 30, cacheWrite: 0 }, "grok-build-0.1": { input: 1, output: 2, cacheRead: 0.2, cacheWrite: 0 }, + "grok-4.5": { + input: 2, + output: 6, + cacheRead: 0.5, + 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] }, + ], + }, + "hy3-free": FREE_COST, "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 }, "mimo-v2.5-free": FREE_COST, "minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }, "minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }, - "minimax-m3-free": FREE_COST, + "minimax-m3": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, "nemotron-3-ultra-free": FREE_COST, "north-mini-code-free": FREE_COST, "qwen3.5-plus": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, "qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 }, - "qwen3.6-plus-free": FREE_COST, }; const MODEL_NAMES: Record = { @@ -136,6 +164,7 @@ const MODEL_NAMES: Record = { "claude-sonnet-4": "Claude Sonnet 4", "claude-sonnet-4-5": "Claude Sonnet 4.5", "claude-sonnet-4-6": "Claude Sonnet 4.6", + "claude-sonnet-5": "Claude Sonnet 5", "deepseek-v4-flash": "DeepSeek V4 Flash", "deepseek-v4-flash-free": "DeepSeek V4 Flash Free", "deepseek-v4-pro": "DeepSeek V4 Pro", @@ -163,17 +192,19 @@ const MODEL_NAMES: Record = { "gpt-5.5": "GPT-5.5", "gpt-5.5-pro": "GPT-5.5 Pro", "grok-build-0.1": "Grok Build 0.1", + "grok-4.5": "Grok 4.5", + "hy3-free": "Hy3 Free", "kimi-k2.5": "Kimi K2.5", "kimi-k2.6": "Kimi K2.6", + "kimi-k2.7-code": "Kimi K2.7 Code", "mimo-v2.5-free": "MiMo V2.5 Free", "minimax-m2.5": "MiniMax M2.5", "minimax-m2.7": "MiniMax M2.7", - "minimax-m3-free": "MiniMax M3 Free", + "minimax-m3": "MiniMax M3", "nemotron-3-ultra-free": "Nemotron 3 Ultra Free", "north-mini-code-free": "North Mini Code Free", "qwen3.5-plus": "Qwen3.5 Plus", "qwen3.6-plus": "Qwen3.6 Plus", - "qwen3.6-plus-free": "Qwen3.6 Plus Free", }; type OpencodeZenModelDefinition = ModelDefinitionConfig & { @@ -203,6 +234,10 @@ function formatModelName(modelId: string): string { 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") || @@ -213,6 +248,10 @@ function supportsImageInput(modelId: string): boolean { 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; } @@ -225,9 +264,6 @@ function resolveContextWindow(modelId: string): number { if (lower.includes("claude")) { return 200_000; } - if (lower === "glm-5.2") { - return 1_000_000; - } if (lower.includes("glm") || lower.includes("minimax")) { return 204_800; } @@ -239,6 +275,10 @@ function resolveContextWindow(modelId: string): number { 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; } @@ -315,6 +355,7 @@ const OPENCODE_ZEN_MODELS = [ "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", @@ -340,13 +381,16 @@ const OPENCODE_ZEN_MODELS = [ "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", @@ -354,8 +398,7 @@ const OPENCODE_ZEN_MODELS = [ "big-pickle", "deepseek-v4-flash-free", "mimo-v2.5-free", - "qwen3.6-plus-free", - "minimax-m3-free", + "hy3-free", "nemotron-3-ultra-free", "north-mini-code-free", ].map(buildOpencodeZenModel);