mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
1473333f46
commit
63d9c69c11
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+393
-746
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user