fix(opencode): discover Zen and Go models on demand (#129831)

* fix(opencode): discover provider models on demand

* fix(opencode): honor provider-scoped catalog discovery

* fix(opencode): scope Go models to authenticated catalogs

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