Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
// Opencode API module exposes the plugin public contract.
export {
applyOpencodeZenModelDefault,
OPENCODE_ZEN_DEFAULT_MODEL,
} from "openclaw/plugin-sdk/provider-onboard";
export {
applyOpencodeZenConfig,
applyOpencodeZenProviderConfig,
OPENCODE_ZEN_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -0,0 +1,576 @@
// Opencode tests cover index plugin behavior.
import { readFileSync } from "node:fs";
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import {
registerProviderPlugin,
registerSingleProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime";
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { expectPassthroughReplayPolicy } from "openclaw/plugin-sdk/provider-test-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { buildOpencodeZenLiveProviderConfig } from "./provider-catalog.js";
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
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");
}
const entry = entries.find((candidate) => requireRecord(candidate, "catalog entry").id === id);
if (!entry) {
throw new Error(`expected supplemental catalog entry ${id}`);
}
return requireRecord(entry, `supplemental catalog entry ${id}`);
}
describe("opencode provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
});
it("registers image media understanding through the OpenCode plugin", async () => {
const { mediaProviders } = await registerProviderPlugin({
plugin,
id: "opencode",
name: "OpenCode Zen Provider",
});
const mediaProvider = mediaProviders.find((provider) => provider.id === "opencode");
if (!mediaProvider) {
throw new Error("Expected opencode media provider");
}
expect(mediaProvider.capabilities).toEqual(["image"]);
expect(mediaProvider.defaultModels).toEqual({ image: "gpt-5-nano" });
expect(typeof mediaProvider.describeImage).toBe("function");
expect(typeof mediaProvider.describeImages).toBe("function");
});
it("owns passthrough-gemini replay policy for Gemini-backed models", async () => {
await expectPassthroughReplayPolicy({
plugin,
providerId: "opencode",
modelId: "gemini-2.5-pro",
sanitizeThoughtSignatures: true,
});
});
it("keeps non-Gemini replay policy minimal on passthrough routes", async () => {
await expectPassthroughReplayPolicy({
plugin,
providerId: "opencode",
modelId: "claude-opus-4.6",
});
});
it("keeps OpenCode Zen catalog coverage aligned with the curated seed", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.catalog).toBeDefined();
const expectedModelIds = [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
"claude-opus-4-1",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
"claude-haiku-4-5",
"gemini-3.5-flash",
"gemini-3.1-pro",
"gemini-3-flash",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex-spark",
"gpt-5.3-codex",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
"grok-build-0.1",
"deepseek-v4-pro",
"deepseek-v4-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"minimax-m2.7",
"minimax-m2.5",
"kimi-k2.6",
"kimi-k2.5",
"qwen3.6-plus",
"qwen3.5-plus",
"big-pickle",
"deepseek-v4-flash-free",
"mimo-v2.5-free",
"qwen3.6-plus-free",
"minimax-m3-free",
"nemotron-3-ultra-free",
"north-mini-code-free",
];
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 Zen model ${modelId}`);
}
models.set(model.id, model);
}
expect([...models.keys()]).toEqual(expectedModelIds);
const supplemental = await provider.augmentModelCatalog?.({
entries: [...models.values()].map((model) => ({
provider: model.provider,
id: model.id,
name: model.name,
})),
} as never);
const opus48 = requireCatalogEntry(supplemental, "claude-opus-4-8");
expect(opus48.provider).toBe("opencode");
expect(opus48.name).toBe("Claude Opus 4.8");
const opus46 = requireMapEntry(models, "claude-opus-4-6");
expect(opus46.api).toBe("anthropic-messages");
expect(opus46.baseUrl).toBe("https://opencode.ai/zen");
expect(opus46.input).toEqual(["text", "image"]);
expect(opus46.reasoning).toBe(true);
expect(opus46.contextWindow).toBe(200_000);
expect(opus46.maxTokens).toBe(65_536);
expect(requireMapEntry(models, "gpt-5.5")).toMatchObject({
api: "openai-responses",
baseUrl: "https://opencode.ai/zen/v1",
});
expect(requireMapEntry(models, "gemini-3.5-flash")).toMatchObject({
api: "google-generative-ai",
baseUrl: "https://opencode.ai/zen/v1",
});
expect(requireMapEntry(models, "minimax-m2.7")).toMatchObject({
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
});
expect(requireMapEntry(models, "qwen3.6-plus")).toMatchObject({
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen",
});
expect(requireMapEntry(models, "glm-5.2")).toMatchObject({
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
input: ["text"],
contextWindow: 1_000_000,
maxTokens: 131_072,
cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
});
const dynamicModel = requireRecord(
provider.resolveDynamicModel?.({
modelId: "claude-opus-4-8",
} as never),
"dynamic model",
);
expect(dynamicModel.id).toBe("claude-opus-4-8");
expect(dynamicModel.api).toBe("anthropic-messages");
expect(dynamicModel.provider).toBe("opencode");
expect(dynamicModel.baseUrl).toBe("https://opencode.ai/zen");
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 manifestProvider = requireRecord(
manifest.modelCatalog.providers.opencode,
"manifest provider",
);
const manifestModels = manifestProvider.models;
if (!Array.isArray(manifestModels)) {
throw new Error("expected manifest opencode models");
}
expect(manifestModels.map((model) => requireRecord(model, "manifest model").id)).toEqual([
"claude-opus-4-8",
"gpt-5.5",
"gemini-3.1-pro",
"minimax-m2.7",
]);
const manifestMiniMax = requireRecord(
manifestModels.find((model) => requireRecord(model, "manifest model").id === "minimax-m2.7"),
"manifest minimax-m2.7",
);
expect(manifestMiniMax.api).toBe("openai-completions");
expect(manifestMiniMax.baseUrl).toBe("https://opencode.ai/zen/v1");
});
it("keeps documented OpenCode Zen example models resolvable", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const docs = readFileSync("docs/providers/opencode.md", "utf8");
const exampleRow = docs.match(/^\| Example models\s+\| (?<examples>.+) \|$/m);
if (!exampleRow?.groups?.examples) {
throw new Error("expected OpenCode Zen example model row");
}
const exampleModelRefs = [...exampleRow.groups.examples.matchAll(/`opencode\/(.*?)`/g)].map(
(match) => match[1],
);
expect(exampleModelRefs.length).toBeGreaterThan(0);
for (const modelId of exampleModelRefs) {
expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({ id: modelId });
}
});
it("keeps every OpenCode Zen row within the required cost contract", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const manifestProvider = requireRecord(
manifest.modelCatalog.providers.opencode,
"manifest provider",
);
const manifestModels = manifestProvider.models;
if (!Array.isArray(manifestModels)) {
throw new Error("expected manifest opencode models");
}
for (const manifestModel of manifestModels) {
const manifestModelRecord = requireRecord(manifestModel, "manifest model");
const modelId = manifestModelRecord.id;
if (typeof modelId !== "string") {
throw new Error("expected manifest model id");
}
requireRecord(manifestModelRecord.cost, `manifest cost ${modelId}`);
const runtimeModel = requireRecord(
provider.resolveDynamicModel?.({ modelId } as never),
`runtime model ${modelId}`,
);
requireRecord(runtimeModel.cost, `runtime cost ${modelId}`);
}
const verifiedCostExamples = new Map([
["claude-fable-5", { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }],
["claude-opus-4-8", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }],
["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }],
["claude-opus-4-1", { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }],
["gpt-5.4-mini", { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }],
["glm-5.2", { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }],
["minimax-m2.7", { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 }],
] as const);
for (const [modelId, expectedCost] of verifiedCostExamples) {
const verifiedCostModel = requireRecord(
provider.resolveDynamicModel?.({ modelId } as never),
`verified cost model ${modelId}`,
);
expect(verifiedCostModel.cost).toEqual(expectedCost);
}
for (const manifestModel of manifestModels) {
const manifestModelRecord = requireRecord(manifestModel, "manifest model");
const modelId = manifestModelRecord.id;
if (typeof modelId !== "string") {
throw new Error("expected manifest model id");
}
const runtimeModel = requireRecord(
provider.resolveDynamicModel?.({ modelId } as never),
`runtime manifest anchor ${modelId}`,
);
expect(manifestModelRecord.cost).toEqual(runtimeModel.cost);
}
});
it("loads OpenCode Zen model discovery through the provider runtime", () => {
expect(manifest.providerCatalogEntry).toBe("./provider-discovery.ts");
expect(manifest.modelCatalog.discovery.opencode).toBe("runtime");
});
it("exposes the complete offline OpenCode Zen catalog through provider discovery", async () => {
const { default: opencodeProviderDiscovery } = await import("./provider-discovery.js");
const result = await opencodeProviderDiscovery.staticCatalog?.run({} as never);
if (!result || !("provider" in result)) {
throw new Error("expected OpenCode Zen static provider");
}
expect(result.provider.models).toHaveLength(49);
expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-8");
expect(result.provider.models.map((model) => model.id)).toContain("glm-5.2");
expect(result.provider.models.map((model) => model.id)).toContain("minimax-m2.7");
expect(result.provider.models.find((model) => model.id === "minimax-m2.7")).toMatchObject({
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
provider: "opencode",
});
});
it("skips live OpenCode Zen catalog discovery when no shared key is configured", async () => {
const provider = await registerSingleProviderPlugin(plugin);
await expect(
provider.catalog?.run({
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
} as never),
).resolves.toBeNull();
});
it("does not mix provider-specific runtime auth with shared discovery auth", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch"));
try {
const result = await provider.catalog?.run({
config: {},
env: {},
resolveProviderApiKey: (providerId: string) =>
providerId === "opencode"
? {
apiKey: NON_ENV_SECRETREF_MARKER,
discoveryApiKey: undefined,
}
: {
apiKey: "shared-opencode-key",
discoveryApiKey: "shared-opencode-key",
},
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
} as never);
if (!result || !("provider" in result)) {
throw new Error("expected OpenCode Zen provider result");
}
expect(result.provider.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-8");
expect(fetchMock).not.toHaveBeenCalled();
} finally {
fetchMock.mockRestore();
}
});
it("uses cached live OpenCode Zen discovery and filters live-only rows", async () => {
const fetchGuard = vi.fn(async () => ({
response: new Response(
JSON.stringify({
data: [
{ id: "claude-opus-4-8", object: "model" },
{ id: "gpt-6-experimental", object: "model" },
],
}),
),
finalUrl: "https://opencode.ai/zen/v1/models",
release: vi.fn(async () => undefined),
}));
const first = await buildOpencodeZenLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
const second = await buildOpencodeZenLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
expect(fetchGuard).toHaveBeenCalledTimes(1);
expect(first.apiKey).toBe("OPENCODE_API_KEY");
expect(first.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]);
expect(second.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]);
const claudeModel = first.models.find((model) => model.id === "claude-opus-4-8");
expect(claudeModel).toMatchObject({
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen",
provider: "opencode",
});
const liveOnlyModel = first.models.find((model) => model.id === "gpt-6-experimental");
expect(liveOnlyModel).toBeUndefined();
clearLiveCatalogCacheForTests();
fetchGuard.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
data: [{ id: "gpt-6-experimental", object: "model" }],
}),
),
finalUrl: "https://opencode.ai/zen/v1/models",
release: vi.fn(async () => undefined),
});
const unknownOnly = await buildOpencodeZenLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
expect(unknownOnly.models.map((model) => model.id)).toContain("claude-opus-4-8");
clearLiveCatalogCacheForTests();
fetchGuard.mockRejectedValueOnce(new Error("network unavailable"));
const fallback = await buildOpencodeZenLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("OPENCODE_API_KEY");
expect(fallback.models.map((model) => model.id)).toContain("claude-opus-4-8");
expect(fallback.models.map((model) => model.id)).toContain("claude-opus-4-6");
});
it("keeps live OpenCode Zen discovery caches scoped to discovery credentials", async () => {
const fetchGuard = vi
.fn()
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({ data: [{ id: "claude-opus-4-8", object: "model" }] }),
),
finalUrl: "https://opencode.ai/zen/v1/models",
release: vi.fn(async () => undefined),
})
.mockResolvedValueOnce({
response: new Response(JSON.stringify({ data: [{ id: "gpt-5.5", object: "model" }] })),
finalUrl: "https://opencode.ai/zen/v1/models",
release: vi.fn(async () => undefined),
});
const first = await buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-a",
discoveryApiKey: "discovery-a",
fetchGuard,
});
const second = await buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-b",
discoveryApiKey: "discovery-b",
fetchGuard,
});
const secondCached = await buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-c",
discoveryApiKey: "discovery-b",
fetchGuard,
});
expect(fetchGuard).toHaveBeenCalledTimes(2);
expect(first.apiKey).toBe("runtime-a");
expect(first.models.map((model) => model.id)).toEqual(["claude-opus-4-8"]);
expect(second.apiKey).toBe("runtime-b");
expect(second.models.map((model) => model.id)).toEqual(["gpt-5.5"]);
expect(secondCached.apiKey).toBe("runtime-c");
expect(secondCached.models.map((model) => model.id)).toEqual(["gpt-5.5"]);
});
it("canonicalizes stale OpenCode Zen base URLs", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const normalizedConfig = requireRecord(
provider.normalizeConfig?.({
provider: "opencode",
providerConfig: {
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/",
models: [],
},
} as never),
"normalized config",
);
expect(normalizedConfig.baseUrl).toBe("https://opencode.ai/zen/v1");
const normalizedModel = requireRecord(
provider.normalizeResolvedModel?.({
provider: "opencode",
model: {
provider: "opencode",
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 65_536,
},
} as never),
"normalized model",
);
expect(normalizedModel.baseUrl).toBe("https://opencode.ai/zen");
expect(
provider.normalizeTransport?.({
provider: "opencode",
api: "openai-completions",
baseUrl: "https://opencode.ai/zen",
} as never),
).toEqual({
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/v1",
});
expect(
provider.normalizeTransport?.({
provider: "opencode",
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen/v1",
} as never),
).toEqual({
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen",
});
});
it("exposes Anthropic thinking levels for proxied Claude models", async () => {
const { providers } = await registerProviderPlugin({
plugin,
id: "opencode",
name: "OpenCode Zen Provider",
});
const provider = requireRegisteredProvider(providers, "opencode");
const resolveThinkingProfile = provider.resolveThinkingProfile;
if (!resolveThinkingProfile) {
throw new Error("Expected OpenCode provider resolveThinkingProfile");
}
const opus47Profile = resolveThinkingProfile({
provider: "opencode",
modelId: "claude-opus-4-7",
});
const opus47LevelIds = opus47Profile?.levels.map((level) => level.id) ?? [];
expect(opus47Profile?.defaultLevel).toBe("off");
expect(opus47LevelIds).toContain("xhigh");
expect(opus47LevelIds).toContain("adaptive");
expect(opus47LevelIds).toContain("max");
const opus46Profile = resolveThinkingProfile({
provider: "opencode",
modelId: "claude-opus-4.6",
});
const opus46LevelIds = opus46Profile?.levels.map((level) => level.id) ?? [];
expect(opus46Profile?.defaultLevel).toBe("adaptive");
expect(opus46LevelIds).toContain("adaptive");
expect(opus46LevelIds).not.toContain("xhigh");
expect(opus46LevelIds).not.toContain("max");
const sonnet46Profile = resolveThinkingProfile({
provider: "opencode",
modelId: "claude-sonnet-4-6",
});
const sonnet46LevelIds = sonnet46Profile?.levels.map((level) => level.id) ?? [];
expect(sonnet46Profile?.defaultLevel).toBe("adaptive");
expect(sonnet46LevelIds).toContain("adaptive");
expect(sonnet46LevelIds).not.toContain("xhigh");
expect(sonnet46LevelIds).not.toContain("max");
});
});

View File

@@ -0,0 +1,151 @@
// Opencode plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
matchesExactOrPrefix,
PASSTHROUGH_GEMINI_REPLAY_HOOKS,
resolveClaudeThinkingProfile,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
import {
buildOpencodeZenLiveProviderConfig,
buildStaticOpencodeZenProviderConfig,
listOpencodeZenModelCatalogEntries,
normalizeOpencodeZenBaseUrl,
resolveOpencodeZenModel,
} from "./provider-catalog.js";
const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs";
const OPENCODE_SHARED_WIZARD_GROUP = {
groupId: "opencode",
groupLabel: "OpenCode",
groupHint: OPENCODE_SHARED_HINT,
} as const;
type OpencodeZenCatalogAuth = {
apiKey?: string;
discoveryApiKey?: string;
};
function hasCatalogAuth(auth: OpencodeZenCatalogAuth): boolean {
return Boolean(auth.apiKey || auth.discoveryApiKey);
}
function resolveOpencodeZenCatalogAuth(
resolveProviderApiKey: (providerId: string) => OpencodeZenCatalogAuth,
): OpencodeZenCatalogAuth | undefined {
const opencodeAuth = resolveProviderApiKey(PROVIDER_ID);
if (hasCatalogAuth(opencodeAuth)) {
return opencodeAuth;
}
const sharedOpencodeGoAuth = resolveProviderApiKey("opencode-go");
return hasCatalogAuth(sharedOpencodeGoAuth) ? sharedOpencodeGoAuth : undefined;
}
function isModernOpencodeModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
if (lower.endsWith("-free") || lower === "alpha-glm-4.7") {
return false;
}
return !matchesExactOrPrefix(lower, MINIMAX_MODERN_MODEL_MATCHERS);
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "OpenCode Zen Provider",
description: "Bundled OpenCode Zen provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Zen",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenCode Zen catalog",
hint: OPENCODE_SHARED_HINT,
optionKey: "opencodeZenApiKey",
flagName: "--opencode-zen-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
applyConfig: (cfg) => applyOpencodeZenConfig(cfg),
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
"Choose the Zen catalog when you want the curated multi-model proxy.",
].join("\n"),
noteTitle: "OpenCode",
wizard: {
choiceId: "opencode-zen",
choiceLabel: "OpenCode Zen catalog",
...OPENCODE_SHARED_WIZARD_GROUP,
},
}),
],
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId),
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
if (!auth.discoveryApiKey) {
return {
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
};
}
return {
provider: await buildOpencodeZenLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
},
},
augmentModelCatalog: () => listOpencodeZenModelCatalogEntries(),
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
resolveThinkingProfile: ({ modelId }) => resolveClaudeThinkingProfile(modelId),
});
api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider);
},
});

View File

@@ -0,0 +1,45 @@
// Opencode tests cover media understanding provider plugin behavior.
import { describe, expect, it } from "vitest";
import {
opencodeMediaUnderstandingProvider,
stripOpencodeDisabledResponsesReasoningPayload,
} from "./media-understanding-provider.js";
describe("opencode media understanding provider", () => {
it("strips disabled Responses reasoning payloads", () => {
const payload = {
reasoning: { effort: "none" },
include: ["reasoning.encrypted_content"],
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
expect(payload).toEqual({
include: ["reasoning.encrypted_content"],
store: false,
});
});
it("keeps supported Responses reasoning payloads", () => {
const payload = {
reasoning: { effort: "low" },
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
expect(payload).toEqual({
reasoning: { effort: "low" },
store: false,
});
});
it("declares OpenCode image understanding support", () => {
expect(opencodeMediaUnderstandingProvider.id).toBe("opencode");
expect(opencodeMediaUnderstandingProvider.capabilities).toEqual(["image"]);
expect(opencodeMediaUnderstandingProvider.defaultModels).toEqual({ image: "gpt-5-nano" });
expect(typeof opencodeMediaUnderstandingProvider.describeImage).toBe("function");
expect(typeof opencodeMediaUnderstandingProvider.describeImages).toBe("function");
});
});

View File

@@ -0,0 +1,40 @@
// Opencode provider module implements model/runtime integration.
import type { ProviderStreamOptions } from "openclaw/plugin-sdk/llm";
import {
describeImageWithModelPayloadTransform,
describeImagesWithModelPayloadTransform,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function stripOpencodeDisabledResponsesReasoningPayload(payload: unknown): void {
if (!isRecord(payload)) {
return;
}
const reasoning = payload.reasoning;
if (reasoning === "none") {
delete payload.reasoning;
return;
}
if (!isRecord(reasoning) || reasoning.effort !== "none") {
return;
}
delete payload.reasoning;
}
const stripDisabledResponsesReasoning: ProviderStreamOptions["onPayload"] = (payload) => {
stripOpencodeDisabledResponsesReasoningPayload(payload);
return undefined;
};
export const opencodeMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "opencode",
capabilities: ["image"],
defaultModels: {
image: "gpt-5-nano",
},
describeImage: (request) =>
describeImageWithModelPayloadTransform(request, stripDisabledResponsesReasoning),
describeImages: (request) =>
describeImagesWithModelPayloadTransform(request, stripDisabledResponsesReasoning),
};

View File

@@ -0,0 +1,26 @@
// Opencode tests cover onboard plugin behavior.
import {
expectProviderOnboardAllowlistAlias,
expectProviderOnboardPrimaryAndFallbacks,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, it } from "vitest";
import { applyOpencodeZenConfig, applyOpencodeZenProviderConfig } from "./onboard.js";
const MODEL_REF = "opencode/claude-opus-4-6";
describe("opencode onboard", () => {
it("adds allowlist entry and preserves alias", () => {
expectProviderOnboardAllowlistAlias({
applyProviderConfig: applyOpencodeZenProviderConfig,
modelRef: MODEL_REF,
alias: "My Opus",
});
});
it("sets primary model and preserves existing model fallbacks", () => {
expectProviderOnboardPrimaryAndFallbacks({
applyConfig: applyOpencodeZenConfig,
modelRef: MODEL_REF,
});
});
});

View File

@@ -0,0 +1,30 @@
// Opencode setup module handles plugin onboarding behavior.
import {
applyAgentDefaultModelPrimary,
withAgentModelAliases,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = "opencode/claude-opus-4-6";
export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models: withAgentModelAliases(cfg.agents?.defaults?.models, [
{ modelRef: OPENCODE_ZEN_DEFAULT_MODEL_REF, alias: "Opus" },
]),
},
},
};
}
export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpencodeZenProviderConfig(cfg),
OPENCODE_ZEN_DEFAULT_MODEL_REF,
);
}

View File

@@ -0,0 +1,229 @@
{
"id": "opencode",
"icon": "https://cdn.simpleicons.org/opencode",
"activation": {
"onStartup": false
},
"providerCatalogEntry": "./provider-discovery.ts",
"enabledByDefault": true,
"providers": [
"opencode"
],
"providerEndpoints": [
{
"endpointClass": "opencode-native",
"hostSuffixes": [
"opencode.ai"
]
}
],
"providerRequest": {
"providers": {
"opencode": {
"family": "opencode"
}
}
},
"modelCatalog": {
"providers": {
"opencode": {
"baseUrl": "https://opencode.ai/zen/v1",
"api": "openai-completions",
"models": [
{
"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": 200000,
"maxTokens": 65536,
"compat": {
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens"
}
},
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"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": 400000,
"maxTokens": 128000,
"compat": {
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens"
}
},
{
"id": "gemini-3.1-pro",
"name": "Gemini 3.1 Pro",
"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,
"maxTokensField": "max_tokens"
}
},
{
"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.375
},
"contextWindow": 204800,
"maxTokens": 131072,
"compat": {
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens",
"supportsDeveloperRole": false,
"supportsStrictMode": false
}
}
]
}
},
"discovery": {
"opencode": "runtime"
}
},
"setup": {
"providers": [
{
"id": "opencode",
"envVars": [
"OPENCODE_API_KEY",
"OPENCODE_ZEN_API_KEY"
]
}
]
},
"providerAuthChoices": [
{
"provider": "opencode",
"method": "api-key",
"choiceId": "opencode-zen",
"choiceLabel": "OpenCode Zen catalog",
"groupId": "opencode",
"groupLabel": "OpenCode",
"groupHint": "Shared API key for Zen + Go catalogs",
"optionKey": "opencodeZenApiKey",
"cliFlag": "--opencode-zen-api-key",
"cliOption": "--opencode-zen-api-key <key>",
"cliDescription": "OpenCode API key (Zen catalog)"
}
],
"contracts": {
"mediaUnderstandingProviders": [
"opencode"
]
},
"mediaUnderstandingProviderMetadata": {
"opencode": {
"capabilities": [
"image"
],
"defaultModels": {
"image": "gpt-5-nano"
}
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,180 @@
// Opencode tests cover opencode plugin behavior.
import {
completeSimple,
type AssistantMessage,
type Model,
type Tool,
} from "openclaw/plugin-sdk/llm";
import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { buildStaticOpencodeZenProviderConfig } from "./provider-catalog.js";
const OPENCODE_ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models";
const OPENCODE_API_KEY =
process.env.OPENCODE_API_KEY?.trim() || process.env.OPENCODE_ZEN_API_KEY?.trim() || "";
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;
type OpencodeModelsResponse = {
data?: Array<{ id?: unknown; object?: unknown }>;
};
function resolveOpencodeDeepSeekLiveModel(): Model<"openai-completions"> {
return {
id: LIVE_MODEL_ID,
name: LIVE_MODEL_ID,
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: 65_536,
maxTokens: 8192,
};
}
function liveEchoTool(): Tool {
return {
name: "live_echo",
description: "Return the supplied value.",
parameters: Type.Object(
{
value: Type.String(),
},
{ additionalProperties: false },
),
};
}
function requireToolCall(message: AssistantMessage) {
const toolCall = message.content.find((block) => block.type === "toolCall");
if (toolCall?.type !== "toolCall") {
throw new Error(`OpenCode DeepSeek live model did not call a tool: ${message.stopReason}`);
}
return toolCall;
}
function hasReasoningContentReplay(message: AssistantMessage): boolean {
return message.content.some(
(block) => block.type === "thinking" && block.thinkingSignature === "reasoning_content",
);
}
async function fetchOpencodeZenModelIds(): Promise<string[]> {
const response = await fetch(OPENCODE_ZEN_MODELS_URL, {
headers: { "accept-encoding": "identity" },
});
expect(response.ok).toBe(true);
const json = (await response.json()) as OpencodeModelsResponse;
return (json.data ?? [])
.filter((model) => model.object === undefined || model.object === "model")
.map((model) => model.id)
.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
.map((id) => id.trim().toLowerCase())
.toSorted();
}
function listStaticOpencodeZenModelIds(): string[] {
return buildStaticOpencodeZenProviderConfig()
.models.map((model) => model.id)
.toSorted();
}
describeCatalogLive("opencode Zen live catalog drift", () => {
it("keeps the provider-owned static seed aligned with the live model ids", async () => {
const liveIds = await fetchOpencodeZenModelIds();
const staticIds = listStaticOpencodeZenModelIds();
const staticIdSet = new Set(staticIds);
const liveIdSet = new Set(liveIds);
const missingStaticMetadata = liveIds.filter((id) => !staticIdSet.has(id));
const staleStaticRows = staticIds.filter((id) => !liveIdSet.has(id));
expect(
{ missingStaticMetadata, staleStaticRows },
[
"OpenCode Zen live catalog drifted from the provider-owned static seed.",
"Add routing/baseUrl/cost/context/capability metadata for missing live ids,",
"or remove stale static rows if OpenCode retired them.",
].join(" "),
).toEqual({ missingStaticMetadata: [], staleStaticRows: [] });
}, 30_000);
});
describeLive("opencode plugin live", () => {
it("accepts DeepSeek V4 tier-suffixed thinking replay after a tool call", async () => {
const model = resolveOpencodeDeepSeekLiveModel();
const tool = liveEchoTool();
const firstOptions = {
apiKey: OPENCODE_API_KEY,
reasoning: "low",
maxTokens: 128,
} as const;
const first = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "You must call the live_echo tool with value ok. Do not answer directly.",
timestamp: Date.now(),
},
],
tools: [tool],
},
firstOptions,
);
if (first.stopReason === "error") {
throw new Error(first.errorMessage || "OpenCode DeepSeek first turn returned an error");
}
const toolCall = requireToolCall(first);
expect(hasReasoningContentReplay(first)).toBe(true);
const second = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "You must call the live_echo tool with value ok. Do not answer directly.",
timestamp: Date.now() - 3,
},
first,
{
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now() - 1,
},
{
role: "user",
content: "Reply with exactly: ok",
timestamp: Date.now(),
},
],
tools: [tool],
},
{
apiKey: OPENCODE_API_KEY,
reasoning: "low",
maxTokens: 64,
},
);
if (second.stopReason === "error") {
throw new Error(second.errorMessage || "OpenCode DeepSeek replay returned an error");
}
expect(extractNonEmptyAssistantText(second.content)).toMatch(/^ok[.!]?$/i);
}, 120_000);
});

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/opencode-provider",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw OpenCode Zen provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,482 @@
// 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 {
getCachedLiveProviderModelRows,
type LiveModelCatalogFetchGuard,
} 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";
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_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,
};
const MODEL_COSTS: Record<string, 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-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 },
"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 },
"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 },
"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 },
"mimo-v2.5-free": FREE_COST,
"minimax-m2.5": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
"minimax-m2.7": { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
"minimax-m3-free": FREE_COST,
"nemotron-3-ultra-free": FREE_COST,
"north-mini-code-free": FREE_COST,
"qwen3.5-plus": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"qwen3.6-plus": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0.625 },
"qwen3.6-plus-free": FREE_COST,
};
const MODEL_NAMES: Record<string, string> = {
"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-sonnet-4": "Claude Sonnet 4",
"claude-sonnet-4-5": "Claude Sonnet 4.5",
"claude-sonnet-4-6": "Claude Sonnet 4.6",
"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",
"gemini-3.5-flash": "Gemini 3.5 Flash",
"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",
"kimi-k2.5": "Kimi K2.5",
"kimi-k2.6": "Kimi K2.6",
"mimo-v2.5-free": "MiMo V2.5 Free",
"minimax-m2.5": "MiniMax M2.5",
"minimax-m2.7": "MiniMax M2.7",
"minimax-m3-free": "MiniMax M3 Free",
"nemotron-3-ultra-free": "Nemotron 3 Ultra Free",
"north-mini-code-free": "North Mini Code Free",
"qwen3.5-plus": "Qwen3.5 Plus",
"qwen3.6-plus": "Qwen3.6 Plus",
"qwen3.6-plus-free": "Qwen3.6 Plus Free",
};
type OpencodeZenModelDefinition = ModelDefinitionConfig & {
provider: typeof PROVIDER_ID;
api: NonNullable<ModelDefinitionConfig["api"]>;
baseUrl: string;
input: Array<"text" | "image">;
};
export type FetchOpencodeZenLiveModelIdsParams = {
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
};
function formatModelName(modelId: string): string {
const exact = MODEL_NAMES[modelId];
if (exact) {
return exact;
}
return modelId
.split("-")
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ");
}
function supportsImageInput(modelId: string): boolean {
const lower = modelId.toLowerCase();
return !(
lower.includes("deepseek") ||
lower.includes("glm") ||
lower.includes("minimax") ||
lower.includes("qwen")
);
}
function resolveContextWindow(modelId: string): number {
const lower = modelId.toLowerCase();
if (lower.includes("gemini")) {
return 1_048_576;
}
if (lower.includes("gpt") || lower.includes("codex")) {
return 400_000;
}
if (lower.includes("deepseek")) {
return 1_000_000;
}
if (lower.includes("claude")) {
return 200_000;
}
if (lower === "glm-5.2") {
return 1_000_000;
}
if (lower.includes("glm") || lower.includes("minimax")) {
return 204_800;
}
if (lower.includes("kimi") || lower.includes("mimo") || lower.includes("qwen")) {
return 262_144;
}
return 128_000;
}
function resolveMaxTokens(modelId: string): number {
const lower = modelId.toLowerCase();
if (lower.includes("deepseek")) {
return 384_000;
}
if (lower.includes("glm") || lower.includes("minimax")) {
return 131_072;
}
if (lower.includes("gpt") || lower.includes("codex")) {
return 128_000;
}
if (
lower.includes("claude") ||
lower.includes("gemini") ||
lower.includes("kimi") ||
lower.includes("qwen")
) {
return 65_536;
}
return 8_192;
}
type OpencodeZenTransport = {
api: ModelApi;
baseUrl: string;
};
function resolveOpencodeZenTransport(modelId: string): OpencodeZenTransport {
const lower = modelId.toLowerCase();
if (lower.startsWith("gpt-")) {
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 resolveModelCost(modelId: string): ModelDefinitionConfig["cost"] {
const cost = MODEL_COSTS[modelId];
if (!cost) {
throw new Error(`missing OpenCode Zen cost metadata for ${modelId}`);
}
return cost;
}
function buildOpencodeZenModel(modelId: string): OpencodeZenModelDefinition {
const normalizedModelId = modelId.trim().toLowerCase();
const transport = resolveOpencodeZenTransport(normalizedModelId);
return normalizeModelCompat({
id: normalizedModelId,
name: formatModelName(normalizedModelId),
api: transport.api,
provider: PROVIDER_ID,
baseUrl: transport.baseUrl,
reasoning: true,
input: supportsImageInput(normalizedModelId) ? ["text", "image"] : ["text"],
cost: resolveModelCost(normalizedModelId),
contextWindow: resolveContextWindow(normalizedModelId),
maxTokens: resolveMaxTokens(normalizedModelId),
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
}) as OpencodeZenModelDefinition;
}
const OPENCODE_ZEN_MODELS = [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
"claude-opus-4-1",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
"claude-haiku-4-5",
"gemini-3.5-flash",
"gemini-3.1-pro",
"gemini-3-flash",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex-spark",
"gpt-5.3-codex",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
"grok-build-0.1",
"deepseek-v4-pro",
"deepseek-v4-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"minimax-m2.7",
"minimax-m2.5",
"kimi-k2.6",
"kimi-k2.5",
"qwen3.6-plus",
"qwen3.5-plus",
"big-pickle",
"deepseek-v4-flash-free",
"mimo-v2.5-free",
"qwen3.6-plus-free",
"minimax-m3-free",
"nemotron-3-ultra-free",
"north-mini-code-free",
].map(buildOpencodeZenModel);
function buildOpencodeZenProviderConfig(
models: OpencodeZenModelDefinition[],
apiKey?: string,
): ModelProviderConfig {
return {
api: "openai-completions",
baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL,
...(apiKey ? { apiKey } : {}),
models,
};
}
export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProviderConfig {
return buildOpencodeZenProviderConfig(OPENCODE_ZEN_MODELS, apiKey);
}
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") {
return undefined;
}
if (typeof candidate.id !== "string") {
return undefined;
}
const modelId = candidate.id.trim().toLowerCase();
return modelId || undefined;
}
async function fetchOpencodeZenLiveModelIds(
params: FetchOpencodeZenLiveModelIdsParams = {},
): Promise<string[]> {
const rows = await getCachedLiveProviderModelRows({
providerId: PROVIDER_ID,
endpoint: OPENCODE_ZEN_MODELS_ENDPOINT,
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
timeoutMs: OPENCODE_ZEN_MODELS_TIMEOUT_MS,
ttlMs: OPENCODE_ZEN_MODELS_CACHE_TTL_MS,
auditContext: "opencode-zen-model-discovery",
});
const seen = new Set<string>();
const modelIds: string[] = [];
for (const row of rows) {
const modelId = readLiveModelId(row);
if (!modelId || seen.has(modelId)) {
continue;
}
seen.add(modelId);
modelIds.push(modelId);
}
return modelIds;
}
function buildDiscoveredOpencodeZenModels(modelIds: string[]): OpencodeZenModelDefinition[] {
const staticModels = new Map(OPENCODE_ZEN_MODELS.map((model) => [model.id, model]));
return modelIds.flatMap((modelId) => {
const model = staticModels.get(modelId);
return model ? [model] : [];
});
}
export async function buildOpencodeZenLiveProviderConfig(
params: FetchOpencodeZenLiveModelIdsParams = {},
): Promise<ModelProviderConfig> {
try {
const liveModelIds = await fetchOpencodeZenLiveModelIds(params);
if (liveModelIds.length > 0) {
const liveModels = buildDiscoveredOpencodeZenModels(liveModelIds);
if (liveModels.length > 0) {
return buildOpencodeZenProviderConfig(liveModels, params.apiKey);
}
}
} catch {
// Live discovery is advisory; keep the provider-owned static seed visible.
}
return buildStaticOpencodeZenProviderConfig(params.apiKey);
}
export function listOpencodeZenModelCatalogEntries(): ModelCatalogEntry[] {
return OPENCODE_ZEN_MODELS.map((model) => ({
provider: model.provider,
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
contextWindow: model.contextWindow,
}));
}
export function resolveOpencodeZenModel(modelId: string): ProviderRuntimeModel | undefined {
const normalizedModelId = modelId.trim().toLowerCase();
return OPENCODE_ZEN_MODELS.find((model) => model.id === normalizedModelId);
}
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeOpencodeZenBaseUrl(params: {
api?: string | null;
baseUrl?: string;
}): string | undefined {
const normalized = normalizeBaseUrl(params.baseUrl);
if (!normalized) {
return undefined;
}
const isAnthropicRoute = params.api === "anthropic-messages";
if (normalized === OPENCODE_ZEN_ANTHROPIC_BASE_URL) {
return isAnthropicRoute ? OPENCODE_ZEN_ANTHROPIC_BASE_URL : OPENCODE_ZEN_OPENAI_BASE_URL;
}
if (normalized === OPENCODE_ZEN_OPENAI_BASE_URL) {
return isAnthropicRoute ? OPENCODE_ZEN_ANTHROPIC_BASE_URL : OPENCODE_ZEN_OPENAI_BASE_URL;
}
return undefined;
}

View File

@@ -0,0 +1,18 @@
// Opencode Zen provider module exposes offline catalog metadata to core discovery.
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { buildStaticOpencodeZenProviderConfig } from "./provider-catalog.js";
const opencodeProviderDiscovery: ProviderPlugin = {
id: "opencode",
label: "OpenCode Zen",
docsPath: "/providers/models",
auth: [],
staticCatalog: {
order: "simple",
run: async () => ({
provider: buildStaticOpencodeZenProviderConfig(),
}),
},
};
export default opencodeProviderDiscovery;

View File

@@ -0,0 +1,45 @@
// Opencode tests cover provider policy api plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveThinkingProfile } from "./provider-policy-api.js";
describe("opencode provider policy public artifact", () => {
it("exposes Claude Opus 4.7 thinking levels without loading the full provider plugin", () => {
expect(
resolveThinkingProfile({
provider: "opencode",
modelId: "claude-opus-4-7",
}),
).toEqual({
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "xhigh" },
{ id: "adaptive" },
{ id: "max" },
],
defaultLevel: "off",
});
});
it("keeps adaptive-only Claude profiles aligned with Anthropic", () => {
const profile = resolveThinkingProfile({
provider: "opencode",
modelId: "claude-opus-4-6",
});
expect(profile).toEqual({
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "adaptive" },
],
defaultLevel: "adaptive",
});
});
});

View File

@@ -0,0 +1,6 @@
// Opencode API module exposes the plugin public contract.
import { resolveClaudeThinkingProfile } from "openclaw/plugin-sdk/provider-model-shared";
export function resolveThinkingProfile(params: { provider?: string; modelId: string }) {
return resolveClaudeThinkingProfile(params.modelId);
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}