fix(opencode): list current Zen models in offline discovery (#103197)

* fix(opencode): refresh Zen model catalog

* fix(opencode): expose static catalog fallback
This commit is contained in:
Peter Steinberger
2026-07-10 02:44:56 +01:00
committed by GitHub
parent 10b50843e7
commit 25e93ef9f9
5 changed files with 150 additions and 13 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -71,12 +71,15 @@ async function fetchOpencodeZenModelIds(): Promise<string[]> {
});
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);

View File

@@ -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<string, { contextWindow: number; maxTokens: number }> = {
"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<string, boolean>([
["hy3-free", false],
["minimax-m3", true],
]);
const MODEL_COSTS: Record<string, ModelDefinitionConfig["cost"]> = {
"big-pickle": FREE_COST,
"claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
@@ -57,6 +73,7 @@ const MODEL_COSTS: Record<string, ModelDefinitionConfig["cost"]> = {
],
},
"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<string, ModelDefinitionConfig["cost"]> = {
},
"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<string, string> = {
@@ -136,6 +164,7 @@ const MODEL_NAMES: Record<string, string> = {
"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<string, string> = {
"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);