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
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:
10
extensions/opencode/api.ts
Normal file
10
extensions/opencode/api.ts
Normal 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";
|
||||
576
extensions/opencode/index.test.ts
Normal file
576
extensions/opencode/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
151
extensions/opencode/index.ts
Normal file
151
extensions/opencode/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
45
extensions/opencode/media-understanding-provider.test.ts
Normal file
45
extensions/opencode/media-understanding-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
40
extensions/opencode/media-understanding-provider.ts
Normal file
40
extensions/opencode/media-understanding-provider.ts
Normal 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),
|
||||
};
|
||||
26
extensions/opencode/onboard.test.ts
Normal file
26
extensions/opencode/onboard.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
30
extensions/opencode/onboard.ts
Normal file
30
extensions/opencode/onboard.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
229
extensions/opencode/openclaw.plugin.json
Normal file
229
extensions/opencode/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
180
extensions/opencode/opencode.live.test.ts
Normal file
180
extensions/opencode/opencode.live.test.ts
Normal 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);
|
||||
});
|
||||
15
extensions/opencode/package.json
Normal file
15
extensions/opencode/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
482
extensions/opencode/provider-catalog.ts
Normal file
482
extensions/opencode/provider-catalog.ts
Normal 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;
|
||||
}
|
||||
18
extensions/opencode/provider-discovery.ts
Normal file
18
extensions/opencode/provider-discovery.ts
Normal 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;
|
||||
45
extensions/opencode/provider-policy-api.test.ts
Normal file
45
extensions/opencode/provider-policy-api.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
6
extensions/opencode/provider-policy-api.ts
Normal file
6
extensions/opencode/provider-policy-api.ts
Normal 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);
|
||||
}
|
||||
16
extensions/opencode/tsconfig.json
Normal file
16
extensions/opencode/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user