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,28 @@
// Opencode Go API module exposes the plugin public contract.
import {
applyAgentDefaultModelPrimary,
resolveAgentModelPrimaryValue,
} from "openclaw/plugin-sdk/provider-onboard";
import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./onboard.js";
export {
applyOpencodeGoConfig,
applyOpencodeGoProviderConfig,
OPENCODE_GO_DEFAULT_MODEL_REF,
} from "./onboard.js";
export function applyOpencodeGoModelDefault(
cfg: import("openclaw/plugin-sdk/provider-onboard").OpenClawConfig,
): {
next: import("openclaw/plugin-sdk/provider-onboard").OpenClawConfig;
changed: boolean;
} {
const current = resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model);
if (current === OPENCODE_GO_DEFAULT_MODEL_REF) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, OPENCODE_GO_DEFAULT_MODEL_REF),
changed: true,
};
}

View File

@@ -0,0 +1,516 @@
// Opencode Go tests cover index plugin behavior.
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import {
registerProviderPlugin,
registerSingleProviderPlugin,
} 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 { buildOpencodeGoLiveProviderConfig } from "./provider-catalog.js";
import opencodeGoProviderDiscovery from "./provider-discovery.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-go provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
});
it("registers image media understanding through the OpenCode Go plugin", async () => {
const { mediaProviders } = await registerProviderPlugin({
plugin,
id: "opencode-go",
name: "OpenCode Go Provider",
});
const mediaProvider = mediaProviders.find((provider) => provider.id === "opencode-go");
if (!mediaProvider) {
throw new Error("Expected opencode-go media provider");
}
expect(mediaProvider.capabilities).toEqual(["image"]);
expect(mediaProvider.defaultModels).toEqual({ image: "kimi-k2.6" });
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-go",
modelId: "gemini-2.5-pro",
sanitizeThoughtSignatures: true,
});
});
it("keeps non-Gemini replay policy minimal on passthrough routes", async () => {
await expectPassthroughReplayPolicy({
plugin,
providerId: "opencode-go",
modelId: "qwen3-coder",
});
});
it("keeps OpenCode Go catalog coverage aligned with upstream", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider.catalog).toBeDefined();
const expectedModelIds = [
"deepseek-v4-flash",
"deepseek-v4-pro",
"glm-5",
"glm-5.1",
"glm-5.2",
"hy3-preview",
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2.7-code",
"mimo-v2-omni",
"mimo-v2.5",
"mimo-v2-pro",
"mimo-v2.5-pro",
"minimax-m2.5",
"minimax-m2.7",
"minimax-m3",
"qwen3.5-plus",
"qwen3.6-plus",
"qwen3.7-max",
"qwen3.7-plus",
];
const models = new Map<string, ProviderRuntimeModel>();
for (const modelId of expectedModelIds) {
const model = provider.resolveDynamicModel?.({ modelId } as never);
if (!model) {
throw new Error(`expected OpenCode Go model ${modelId}`);
}
models.set(model.id, model);
}
expect([...models.keys()]).toEqual(expectedModelIds);
const supplemental = await provider.augmentModelCatalog?.({
entries: [...models.values()].map((model) => ({
provider: model.provider,
id: model.id,
name: model.name,
})),
} as never);
const deepSeekPro = requireCatalogEntry(supplemental, "deepseek-v4-pro");
expect(deepSeekPro.provider).toBe("opencode-go");
expect(deepSeekPro.name).toBe("DeepSeek V4 Pro");
const deepSeekFlash = requireCatalogEntry(supplemental, "deepseek-v4-flash");
expect(deepSeekFlash.provider).toBe("opencode-go");
expect(deepSeekFlash.name).toBe("DeepSeek V4 Flash");
const glm52 = requireMapEntry(models, "glm-5.2");
expect(glm52.api).toBe("openai-completions");
expect(glm52.baseUrl).toBe("https://opencode.ai/zen/go/v1");
expect(glm52.input).toEqual(["text"]);
expect(glm52.reasoning).toBe(true);
expect(glm52.contextWindow).toBe(1_000_000);
expect(glm52.maxTokens).toBe(131_072);
expect(glm52.cost).toEqual({
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
});
const kimi = requireMapEntry(models, "kimi-k2.6");
expect(kimi.api).toBe("openai-completions");
expect(kimi.baseUrl).toBe("https://opencode.ai/zen/go/v1");
expect(kimi.input).toEqual(["text", "image"]);
expect(kimi.reasoning).toBe(true);
expect(kimi.contextWindow).toBe(262_144);
expect(kimi.maxTokens).toBe(65_536);
const kimiCode = requireMapEntry(models, "kimi-k2.7-code");
expect(kimiCode.api).toBe("openai-completions");
expect(kimiCode.baseUrl).toBe("https://opencode.ai/zen/go/v1");
expect(kimiCode.input).toEqual(["text", "image"]);
expect(kimiCode.contextWindow).toBe(262_144);
expect(kimiCode.maxTokens).toBe(262_144);
expect(kimiCode.cost).toEqual({
input: 0.95,
output: 4,
cacheRead: 0.19,
cacheWrite: 0,
});
const minimax = requireMapEntry(models, "minimax-m2.7");
expect(minimax.api).toBe("anthropic-messages");
expect(minimax.baseUrl).toBe("https://opencode.ai/zen/go");
expect(minimax.reasoning).toBe(true);
expect(minimax.contextWindow).toBe(204_800);
expect(minimax.maxTokens).toBe(131_072);
const minimaxM3 = requireMapEntry(models, "minimax-m3");
expect(minimaxM3.api).toBe("anthropic-messages");
expect(minimaxM3.baseUrl).toBe("https://opencode.ai/zen/go");
expect(minimaxM3.reasoning).toBe(true);
expect(minimaxM3.contextWindow).toBe(204_800);
expect(minimaxM3.maxTokens).toBe(131_072);
const mimoPro = requireMapEntry(models, "mimo-v2.5-pro");
expect(mimoPro.api).toBe("openai-completions");
expect(mimoPro.baseUrl).toBe("https://opencode.ai/zen/go/v1");
expect(mimoPro.input).toEqual(["text"]);
expect(mimoPro.reasoning).toBe(true);
expect(mimoPro.contextWindow).toBe(1_048_576);
expect(mimoPro.maxTokens).toBe(128_000);
const mimo = requireMapEntry(models, "mimo-v2.5");
expect(mimo.input).toEqual(["text", "image"]);
expect(mimo.reasoning).toBe(true);
expect(mimo.contextWindow).toBe(1_000_000);
expect(mimo.maxTokens).toBe(128_000);
const qwenMax = requireMapEntry(models, "qwen3.7-max");
expect(qwenMax.api).toBe("anthropic-messages");
expect(qwenMax.baseUrl).toBe("https://opencode.ai/zen/go");
expect(qwenMax.input).toEqual(["text"]);
expect(qwenMax.reasoning).toBe(true);
expect(qwenMax.contextWindow).toBe(1_000_000);
expect(qwenMax.maxTokens).toBe(65_536);
expect(requireRecord(qwenMax.compat, "Qwen3.7 compat")).toMatchObject({
thinkingFormat: "qwen",
});
const qwenPlus = requireMapEntry(models, "qwen3.6-plus");
expect(qwenPlus.api).toBe("anthropic-messages");
expect(qwenPlus.baseUrl).toBe("https://opencode.ai/zen/go");
const qwen37Plus = requireMapEntry(models, "qwen3.7-plus");
expect(qwen37Plus.api).toBe("anthropic-messages");
expect(qwen37Plus.baseUrl).toBe("https://opencode.ai/zen/go");
expect(qwen37Plus.input).toEqual(["text", "image"]);
expect(qwen37Plus.reasoning).toBe(true);
expect(qwen37Plus.contextWindow).toBe(1_000_000);
expect(qwen37Plus.maxTokens).toBe(65_536);
expect(qwen37Plus.cost).toMatchObject({
input: 0.4,
output: 1.6,
cacheRead: 0.04,
cacheWrite: 0.5,
});
const dynamicModel = requireRecord(
provider.resolveDynamicModel?.({
modelId: "deepseek-v4-pro",
} as never),
"dynamic model",
);
expect(dynamicModel.id).toBe("deepseek-v4-pro");
expect(dynamicModel.api).toBe("openai-completions");
expect(dynamicModel.provider).toBe("opencode-go");
expect(dynamicModel.baseUrl).toBe("https://opencode.ai/zen/go/v1");
expect(dynamicModel.reasoning).toBe(true);
expect(dynamicModel.contextWindow).toBe(1_000_000);
expect(dynamicModel.maxTokens).toBe(384_000);
const compat = requireRecord(dynamicModel.compat, "dynamic model compat");
expect(compat.supportsUsageInStreaming).toBe(true);
expect(compat.supportsReasoningEffort).toBe(true);
expect(compat.maxTokensField).toBe("max_tokens");
});
it("loads OpenCode Go model discovery through the provider runtime", () => {
expect(manifest.providerCatalogEntry).toBe("./provider-discovery.ts");
expect(manifest.modelCatalog.discovery["opencode-go"]).toBe("runtime");
});
it("exposes the complete offline catalog through provider discovery", async () => {
const result = await opencodeGoProviderDiscovery.staticCatalog?.run({} as never);
if (!result || !("provider" in result)) {
throw new Error("expected OpenCode Go static provider");
}
const deepSeekPro = result.provider.models.find((model) => model.id === "deepseek-v4-pro");
const glm52 = result.provider.models.find((model) => model.id === "glm-5.2");
expect(result.provider.models).toHaveLength(20);
expect(deepSeekPro).toMatchObject({
provider: "opencode-go",
contextWindow: 1_000_000,
maxTokens: 384_000,
});
expect(glm52).toMatchObject({
provider: "opencode-go",
contextWindow: 1_000_000,
maxTokens: 131_072,
});
});
it("skips live OpenCode Go 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-go"
? {
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 Go provider result");
}
expect(result.provider.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
expect(result.provider.models.map((model) => model.id)).toContain("deepseek-v4-pro");
expect(fetchMock).not.toHaveBeenCalled();
} finally {
fetchMock.mockRestore();
}
});
it("uses cached live OpenCode Go discovery and falls back to static rows on failure", async () => {
const fetchGuard = vi.fn(async () => ({
response: new Response(
JSON.stringify({
data: [
{ id: "minimax-m3", object: "model" },
{ id: "qwen3.7-max", object: "model" },
{ id: "qwen3.7-plus", object: "model" },
],
}),
),
finalUrl: "https://opencode.ai/zen/go/v1/models",
release: vi.fn(async () => undefined),
}));
const first = await buildOpencodeGoLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
const second = await buildOpencodeGoLiveProviderConfig({
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([
"minimax-m3",
"qwen3.7-max",
"qwen3.7-plus",
]);
expect(second.models.map((model) => model.id)).toEqual([
"minimax-m3",
"qwen3.7-max",
"qwen3.7-plus",
]);
clearLiveCatalogCacheForTests();
fetchGuard.mockRejectedValueOnce(new Error("network unavailable"));
const fallback = await buildOpencodeGoLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("OPENCODE_API_KEY");
expect(fallback.models.map((model) => model.id)).toContain("deepseek-v4-pro");
expect(fallback.models.map((model) => model.id)).toContain("minimax-m3");
});
it("disables invalid DeepSeek V4 reasoning_effort off payloads on OpenCode Go", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const capturedPayloads: Record<string, unknown>[] = [];
const baseStreamFn = (_model: unknown, _context: unknown, options: unknown) => {
const payload = {
model: "deepseek-v4-flash",
reasoning_effort: "off",
reasoning: "off",
};
(options as { onPayload?: (payload: Record<string, unknown>) => void })?.onPayload?.(payload);
capturedPayloads.push(payload);
return {} as never;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn as never,
providerId: "opencode-go",
modelId: "deepseek-v4-flash",
thinkingLevel: "off",
} as never);
expect(streamFn).toBeTypeOf("function");
await streamFn?.(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as never,
{} as never,
{},
);
expect(capturedPayloads).toEqual([
{
model: "deepseek-v4-flash",
thinking: { type: "disabled" },
},
]);
});
it("strips unsupported Kimi reasoning payloads on OpenCode Go", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const capturedPayloads: Record<string, unknown>[] = [];
const baseStreamFn = (_model: unknown, _context: unknown, options: unknown) => {
const payload = {
model: "kimi-k2.6",
reasoning_effort: "high",
reasoning: { effort: "high" },
reasoningEffort: "high",
};
(options as { onPayload?: (payload: Record<string, unknown>) => void })?.onPayload?.(payload);
capturedPayloads.push(payload);
return {} as never;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn as never,
providerId: "opencode-go",
modelId: "kimi-k2.6",
thinkingLevel: "high",
} as never);
expect(streamFn).toBeTypeOf("function");
await streamFn?.(
{ provider: "opencode-go", id: "kimi-k2.6", api: "openai-completions" } as never,
{} as never,
{},
);
expect(capturedPayloads).toEqual([
{
model: "kimi-k2.6",
},
]);
});
it("canonicalizes stale OpenCode Go base URLs", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const normalizedConfig = requireRecord(
provider.normalizeConfig?.({
provider: "opencode-go",
providerConfig: {
api: "openai-completions",
baseUrl: "https://opencode.ai/go/v1/",
models: [],
},
} as never),
"normalized config",
);
expect(normalizedConfig.baseUrl).toBe("https://opencode.ai/zen/go/v1");
const normalizedModel = requireRecord(
provider.normalizeResolvedModel?.({
provider: "opencode-go",
model: {
provider: "opencode-go",
id: "kimi-k2.5",
name: "Kimi K2.5",
api: "openai-completions",
baseUrl: "https://opencode.ai/go/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262_144,
maxTokens: 65_536,
},
} as never),
"normalized model",
);
expect(normalizedModel.baseUrl).toBe("https://opencode.ai/zen/go/v1");
const normalizedKimi = requireRecord(
provider.normalizeResolvedModel?.({
provider: "opencode-go",
model: {
provider: "opencode-go",
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/go/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262_144,
maxTokens: 262_144,
},
} as never),
"normalized Kimi model",
);
expect(normalizedKimi.reasoning).toBe(false);
expect(requireRecord(normalizedKimi.compat, "normalized Kimi compat")).toMatchObject({
supportsReasoningEffort: false,
});
expect(
provider.normalizeTransport?.({
provider: "opencode-go",
api: "openai-completions",
baseUrl: "https://opencode.ai/go/v1",
} as never),
).toEqual({
api: "openai-completions",
baseUrl: "https://opencode.ai/zen/go/v1",
});
expect(
provider.normalizeTransport?.({
provider: "opencode-go",
api: "anthropic-messages",
baseUrl: "https://opencode.ai/go",
} as never),
).toEqual({
api: "anthropic-messages",
baseUrl: "https://opencode.ai/zen/go",
});
});
});

View File

@@ -0,0 +1,144 @@
// Opencode Go 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 { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js";
import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js";
import {
buildOpencodeGoLiveProviderConfig,
buildStaticOpencodeGoProviderConfig,
listOpencodeGoModelCatalogEntries,
normalizeOpencodeGoBaseUrl,
normalizeOpencodeGoResolvedModel,
resolveOpencodeGoModel,
} from "./provider-catalog.js";
import { createOpencodeGoWrapper } from "./stream.js";
const PROVIDER_ID = "opencode-go";
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 OpencodeGoCatalogAuth = {
apiKey?: string;
discoveryApiKey?: string;
};
function hasCatalogAuth(auth: OpencodeGoCatalogAuth): boolean {
return Boolean(auth.apiKey || auth.discoveryApiKey);
}
function resolveOpencodeGoCatalogAuth(
resolveProviderApiKey: (providerId: string) => OpencodeGoCatalogAuth,
): OpencodeGoCatalogAuth | undefined {
const opencodeGoAuth = resolveProviderApiKey(PROVIDER_ID);
if (hasCatalogAuth(opencodeGoAuth)) {
return opencodeGoAuth;
}
const sharedOpencodeAuth = resolveProviderApiKey("opencode");
return hasCatalogAuth(sharedOpencodeAuth) ? sharedOpencodeAuth : undefined;
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "OpenCode Go Provider",
description: "Bundled OpenCode Go provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Go",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenCode Go catalog",
hint: OPENCODE_SHARED_HINT,
optionKey: "opencodeGoApiKey",
flagName: "--opencode-go-api-key",
envVar: "OPENCODE_API_KEY",
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyOpencodeGoConfig(cfg),
expectedProviders: ["opencode", "opencode-go"],
noteMessage: [
"OpenCode uses one API key across the Zen and Go catalogs.",
"Go focuses on Kimi, GLM, and MiniMax coding models.",
"Get your API key at: https://opencode.ai/auth",
].join("\n"),
noteTitle: "OpenCode",
wizard: {
choiceId: "opencode-go",
choiceLabel: "OpenCode Go catalog",
...OPENCODE_SHARED_WIZARD_GROUP,
},
}),
],
normalizeConfig: ({ providerConfig }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: providerConfig.api,
baseUrl: providerConfig.baseUrl,
});
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
? { ...providerConfig, baseUrl: normalizedBaseUrl }
: undefined;
},
normalizeResolvedModel: ({ model }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
api: model.api,
baseUrl: model.baseUrl,
});
const baseUrlNormalized =
normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
? { ...model, baseUrl: normalizedBaseUrl }
: model;
const modelNormalized = normalizeOpencodeGoResolvedModel(baseUrlNormalized);
if (modelNormalized) {
return modelNormalized;
}
return baseUrlNormalized !== model ? baseUrlNormalized : undefined;
},
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({ api: apiLocal, baseUrl });
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
? {
api: apiLocal,
baseUrl: normalizedBaseUrl,
}
: undefined;
},
resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId),
catalog: {
order: "simple",
run: async (ctx) => {
const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey);
if (!auth) {
return null;
}
if (!auth.discoveryApiKey) {
return {
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
};
}
return {
provider: await buildOpencodeGoLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
},
},
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel),
isModernModelRef: () => true,
});
api.registerMediaUnderstandingProvider(opencodeGoMediaUnderstandingProvider);
},
});

View File

@@ -0,0 +1,13 @@
// Opencode Go tests cover media understanding provider plugin behavior.
import { describe, expect, it } from "vitest";
import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js";
describe("opencode-go media understanding provider", () => {
it("declares image understanding support", () => {
expect(opencodeGoMediaUnderstandingProvider.id).toBe("opencode-go");
expect(opencodeGoMediaUnderstandingProvider.capabilities).toEqual(["image"]);
expect(opencodeGoMediaUnderstandingProvider.defaultModels).toEqual({ image: "kimi-k2.6" });
expect(typeof opencodeGoMediaUnderstandingProvider.describeImage).toBe("function");
expect(typeof opencodeGoMediaUnderstandingProvider.describeImages).toBe("function");
});
});

View File

@@ -0,0 +1,16 @@
// Opencode Go provider module implements model/runtime integration.
import {
describeImageWithModel,
describeImagesWithModel,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
export const opencodeGoMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "opencode-go",
capabilities: ["image"],
defaultModels: {
image: "kimi-k2.6",
},
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
};

View File

@@ -0,0 +1,29 @@
// Opencode Go tests cover onboard plugin behavior.
import { expectProviderOnboardPrimaryAndFallbacks } from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { applyOpencodeGoConfig, applyOpencodeGoProviderConfig } from "./onboard.js";
const MODEL_REF = "opencode-go/kimi-k2.6";
describe("opencode-go onboard", () => {
it("leaves model aliases to the OpenClaw catalog", () => {
const cfg = {
agents: {
defaults: {
models: {
[MODEL_REF]: { alias: "Kimi" },
},
},
},
};
expect(applyOpencodeGoProviderConfig(cfg)).toBe(cfg);
});
it("sets primary model and preserves existing model fallbacks", () => {
expectProviderOnboardPrimaryAndFallbacks({
applyConfig: applyOpencodeGoConfig,
modelRef: MODEL_REF,
});
});
});

View File

@@ -0,0 +1,18 @@
// Opencode Go setup module handles plugin onboarding behavior.
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.6";
export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return cfg;
}
export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpencodeGoProviderConfig(cfg),
OPENCODE_GO_DEFAULT_MODEL_REF,
);
}

View File

@@ -0,0 +1,113 @@
{
"id": "opencode-go",
"icon": "https://cdn.simpleicons.org/opencode",
"activation": {
"onStartup": false
},
"providerCatalogEntry": "./provider-discovery.ts",
"enabledByDefault": true,
"providers": ["opencode-go"],
"providerEndpoints": [
{
"endpointClass": "opencode-native",
"hostSuffixes": ["opencode.ai"]
}
],
"providerRequest": {
"providers": {
"opencode-go": {
"family": "opencode"
}
}
},
"modelCatalog": {
"providers": {
"opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1",
"api": "openai-completions",
"models": [
{
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 384000,
"cost": {
"input": 1.74,
"output": 3.48,
"cacheRead": 0.145,
"cacheWrite": 0
},
"compat": {
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens"
}
},
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 384000,
"cost": {
"input": 0.14,
"output": 0.28,
"cacheRead": 0.028,
"cacheWrite": 0
},
"compat": {
"supportsUsageInStreaming": true,
"supportsReasoningEffort": true,
"maxTokensField": "max_tokens"
}
}
]
}
},
"discovery": {
"opencode-go": "runtime"
}
},
"setup": {
"providers": [
{
"id": "opencode-go",
"envVars": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "opencode-go",
"method": "api-key",
"choiceId": "opencode-go",
"choiceLabel": "OpenCode Go catalog",
"groupId": "opencode",
"groupLabel": "OpenCode",
"groupHint": "Shared API key for Zen + Go catalogs",
"optionKey": "opencodeGoApiKey",
"cliFlag": "--opencode-go-api-key",
"cliOption": "--opencode-go-api-key <key>",
"cliDescription": "OpenCode API key (Go catalog)"
}
],
"contracts": {
"mediaUnderstandingProviders": ["opencode-go"]
},
"mediaUnderstandingProviderMetadata": {
"opencode-go": {
"capabilities": ["image"],
"defaultModels": {
"image": "kimi-k2.6"
}
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

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

View File

@@ -0,0 +1,510 @@
// Opencode Go provider module implements model/runtime integration.
import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime";
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import {
buildLiveModelProviderConfig,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
const PROVIDER_ID = "opencode-go";
const OPENCODE_GO_OPENAI_BASE_URL = "https://opencode.ai/zen/go/v1";
const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go";
const OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS = new Set([
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2.7-code",
]);
const OPENCODE_GO_MODELS_ENDPOINT = "https://opencode.ai/zen/go/v1/models";
const OPENCODE_GO_MODELS_TIMEOUT_MS = 5_000;
const OPENCODE_GO_MODELS_CACHE_TTL_MS = 60_000;
type OpencodeGoModelDefinition = ModelDefinitionConfig & {
provider: typeof PROVIDER_ID;
api: NonNullable<ModelDefinitionConfig["api"]>;
baseUrl: string;
input: Array<"text" | "image">;
};
const OPENCODE_GO_MODELS = (
[
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 1.74,
output: 3.48,
cacheRead: 0.145,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0.14,
output: 0.28,
cacheRead: 0.028,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 384_000,
compat: {
supportsUsageInStreaming: true,
supportsReasoningEffort: true,
maxTokensField: "max_tokens",
},
},
{
id: "glm-5",
name: "GLM-5",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 1,
output: 3.2,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 202_752,
maxTokens: 32_768,
},
{
id: "glm-5.1",
name: "GLM-5.1",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
contextWindow: 202_752,
maxTokens: 32_768,
},
{
id: "glm-5.2",
name: "GLM-5.2",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 131_072,
},
{
id: "hy3-preview",
name: "HY3 Preview",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262_144,
maxTokens: 32_768,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.6,
output: 3,
cacheRead: 0.1,
cacheWrite: 0,
},
contextWindow: 262_144,
maxTokens: 65_536,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.95,
output: 4,
cacheRead: 0.16,
cacheWrite: 0,
},
contextWindow: 262_144,
maxTokens: 65_536,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.95,
output: 4,
cacheRead: 0.19,
cacheWrite: 0,
},
contextWindow: 262_144,
maxTokens: 262_144,
},
{
id: "mimo-v2-omni",
name: "MiMo V2 Omni",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262_144,
maxTokens: 32_000,
},
{
id: "mimo-v2.5",
name: "MiMo V2.5",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0.08,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
},
{
id: "mimo-v2-pro",
name: "MiMo V2 Pro",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1_048_576,
maxTokens: 32_000,
},
{
id: "mimo-v2.5-pro",
name: "MiMo V2.5 Pro",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 1,
output: 3,
cacheRead: 0.2,
cacheWrite: 0,
},
contextWindow: 1_048_576,
maxTokens: 128_000,
},
{
id: "minimax-m2.5",
name: "MiniMax M2.5",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0.03,
cacheWrite: 0.375,
},
contextWindow: 204_800,
maxTokens: 65_536,
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
contextWindow: 204_800,
maxTokens: 131_072,
},
{
id: "minimax-m3",
name: "MiniMax M3",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
reasoning: true,
input: ["text"],
cost: {
input: 0.6,
output: 2.4,
cacheRead: 0.12,
cacheWrite: 0.75,
},
contextWindow: 204_800,
maxTokens: 131_072,
},
{
id: "qwen3.5-plus",
name: "Qwen3.5 Plus",
api: "openai-completions",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
compat: { thinkingFormat: "qwen" },
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.2,
output: 1.2,
cacheRead: 0.02,
cacheWrite: 0.25,
},
contextWindow: 262_144,
maxTokens: 65_536,
},
{
id: "qwen3.7-max",
name: "Qwen3.7 Max",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
compat: { thinkingFormat: "qwen" },
reasoning: true,
input: ["text"],
cost: {
input: 2.5,
output: 7.5,
cacheRead: 0.5,
cacheWrite: 3.125,
},
contextWindow: 1_000_000,
maxTokens: 65_536,
},
{
id: "qwen3.7-plus",
name: "Qwen3.7 Plus",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
compat: { thinkingFormat: "qwen" },
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.4,
output: 1.6,
cacheRead: 0.04,
cacheWrite: 0.5,
},
contextWindow: 1_000_000,
maxTokens: 65_536,
},
{
id: "qwen3.6-plus",
name: "Qwen3.6 Plus",
api: "anthropic-messages",
provider: PROVIDER_ID,
baseUrl: OPENCODE_GO_ANTHROPIC_BASE_URL,
compat: { thinkingFormat: "qwen" },
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0.05,
cacheWrite: 0.625,
},
contextWindow: 262_144,
maxTokens: 65_536,
},
] satisfies OpencodeGoModelDefinition[]
).map((model) => normalizeModelCompat(model) as OpencodeGoModelDefinition);
export type FetchOpencodeGoLiveModelIdsParams = {
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
};
function buildOpencodeGoProviderConfig(
models: OpencodeGoModelDefinition[],
apiKey?: string,
): ModelProviderConfig {
return {
api: "openai-completions",
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
...(apiKey ? { apiKey } : {}),
models,
};
}
export function buildStaticOpencodeGoProviderConfig(apiKey?: string): ModelProviderConfig {
return buildOpencodeGoProviderConfig(OPENCODE_GO_MODELS, apiKey);
}
export async function buildOpencodeGoLiveProviderConfig(
params: FetchOpencodeGoLiveModelIdsParams = {},
): Promise<ModelProviderConfig> {
return await buildLiveModelProviderConfig({
providerId: PROVIDER_ID,
endpoint: OPENCODE_GO_MODELS_ENDPOINT,
providerConfig: {
api: "openai-completions",
baseUrl: OPENCODE_GO_OPENAI_BASE_URL,
},
models: OPENCODE_GO_MODELS,
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
timeoutMs: OPENCODE_GO_MODELS_TIMEOUT_MS,
ttlMs: OPENCODE_GO_MODELS_CACHE_TTL_MS,
auditContext: "opencode-go-model-discovery",
});
}
export function listOpencodeGoModelCatalogEntries(): ModelCatalogEntry[] {
return OPENCODE_GO_MODELS.map((model) => ({
provider: model.provider,
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
contextWindow: model.contextWindow,
}));
}
export function resolveOpencodeGoModel(modelId: string): ProviderRuntimeModel | undefined {
const normalizedModelId = modelId.trim().toLowerCase();
return OPENCODE_GO_MODELS.find((model) => model.id === normalizedModelId);
}
export function isOpencodeGoKimiNoReasoningModelId(modelId: unknown): boolean {
return (
typeof modelId === "string" &&
OPENCODE_GO_KIMI_NO_REASONING_MODEL_IDS.has(modelId.trim().toLowerCase())
);
}
export function normalizeOpencodeGoResolvedModel(
model: ProviderRuntimeModel,
): ProviderRuntimeModel | undefined {
if (!isOpencodeGoKimiNoReasoningModelId(model.id)) {
return undefined;
}
const compat =
model.compat && typeof model.compat === "object" && !Array.isArray(model.compat)
? model.compat
: undefined;
if (!model.reasoning && !compat?.supportsReasoningEffort) {
return undefined;
}
return {
...model,
reasoning: false,
compat: {
...compat,
supportsReasoningEffort: false,
},
};
}
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeOpencodeGoBaseUrl(params: {
api?: string | null;
baseUrl?: string;
}): string | undefined {
const normalized = normalizeBaseUrl(params.baseUrl);
if (!normalized) {
return undefined;
}
if (normalized === OPENCODE_GO_OPENAI_BASE_URL) {
return OPENCODE_GO_OPENAI_BASE_URL;
}
if (normalized === OPENCODE_GO_ANTHROPIC_BASE_URL) {
return OPENCODE_GO_ANTHROPIC_BASE_URL;
}
if (normalized === "https://opencode.ai/go") {
return OPENCODE_GO_ANTHROPIC_BASE_URL;
}
if (normalized === "https://opencode.ai/go/v1") {
return params.api === "anthropic-messages"
? OPENCODE_GO_ANTHROPIC_BASE_URL
: OPENCODE_GO_OPENAI_BASE_URL;
}
return undefined;
}

View File

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

View File

@@ -0,0 +1,61 @@
// Opencode Go tests cover reasoning sanitizer plugin behavior.
import { describe, expect, it } from "vitest";
import { stripOpencodeGoKimiReasoningPayload } from "./reasoning-sanitizer.js";
describe("OpenCode Go Kimi reasoning payload sanitizer", () => {
it("strips unsupported replay reasoning fields from messages and input", () => {
const payload = {
model: "kimi-k2.6",
reasoning_effort: "high",
reasoning: { effort: "high" },
reasoningEffort: "high",
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "done" },
{ type: "thinking", reasoning_details: [{ text: "private thought" }] },
{ type: "redacted_thinking", data: "opaque" },
],
reasoning_details: [{ text: "private thought" }],
reasoning_content: "private thought",
reasoning_text: "private thought",
},
],
input: [
{
role: "assistant",
content: "done",
reasoning_details: [{ text: "private thought" }],
},
{ type: "reasoning", summary: [] },
{
role: "assistant",
content: [{ type: "thinking", reasoning_details: [{ text: "private thought" }] }],
},
],
};
stripOpencodeGoKimiReasoningPayload(payload);
expect(payload).toEqual({
model: "kimi-k2.6",
messages: [
{
role: "assistant",
content: [{ type: "text", text: "done" }],
},
],
input: [
{
role: "assistant",
content: "done",
},
{
role: "assistant",
content: [{ type: "text", text: "[assistant reasoning omitted]" }],
},
],
});
});
});

View File

@@ -0,0 +1,72 @@
// Opencode Go plugin module implements reasoning sanitizer behavior.
const REASONING_REPLAY_FIELDS = [
"reasoning_details",
"reasoning_content",
"reasoning",
"reasoning_text",
] as const;
const OMITTED_ASSISTANT_REASONING_TEXT = "[assistant reasoning omitted]";
function isReasoningReplayPart(value: unknown): boolean {
if (!value || typeof value !== "object") {
return false;
}
const type = (value as { type?: unknown }).type;
return type === "thinking" || type === "redacted_thinking" || type === "reasoning";
}
function stripReasoningReplayFields(value: unknown): void {
if (!value || typeof value !== "object") {
return;
}
const record = value as Record<string, unknown>;
for (const field of REASONING_REPLAY_FIELDS) {
delete record[field];
}
const content = record.content;
if (Array.isArray(content)) {
const nextContent = [];
for (const part of content) {
if (isReasoningReplayPart(part)) {
continue;
}
stripReasoningReplayFields(part);
nextContent.push(part);
}
record.content =
nextContent.length > 0
? nextContent
: [{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }];
}
}
function stripReasoningReplayFieldsFromList(value: unknown): unknown {
if (!Array.isArray(value)) {
return value;
}
const nextItems = [];
for (const item of value) {
if (isReasoningReplayPart(item)) {
continue;
}
stripReasoningReplayFields(item);
nextItems.push(item);
}
return nextItems;
}
export function stripOpencodeGoKimiReasoningPayload(payloadObj: Record<string, unknown>): void {
stripReasoningReplayFields(payloadObj);
delete payloadObj.reasoning_effort;
delete payloadObj.reasoningEffort;
if ("messages" in payloadObj) {
payloadObj.messages = stripReasoningReplayFieldsFromList(payloadObj.messages);
}
if ("input" in payloadObj) {
payloadObj.input = stripReasoningReplayFieldsFromList(payloadObj.input);
}
}

View File

@@ -0,0 +1,850 @@
// Opencode Go stream termination wrapper tests cover provider-owned raw SSE
// boundary behavior for stalled OpenAI-compatible streams.
import type {
AssistantMessageEvent,
AssistantMessageEventStreamContract,
} from "openclaw/plugin-sdk/llm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createOpencodeGoStalledStreamWrapper } from "./stream-termination.js";
type AnyEvent = AssistantMessageEvent;
type StreamLike = AssistantMessageEventStreamContract;
interface FakeStreamController {
emit(event: AnyEvent): void;
end(): void;
}
function createFakeBaseStream(): {
stream: StreamLike;
controller: FakeStreamController;
getReturnCalls: () => number;
} {
const queued: IteratorResult<AnyEvent>[] = [];
const waiters: ((result: IteratorResult<AnyEvent>) => void)[] = [];
let finished = false;
let returnCalls = 0;
const iterator: AsyncIterator<AnyEvent> = {
next(): Promise<IteratorResult<AnyEvent>> {
if (queued.length > 0) {
return Promise.resolve(queued.shift()!);
}
if (finished) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
waiters.push(resolve);
});
},
return(): Promise<IteratorResult<AnyEvent>> {
returnCalls += 1;
finished = true;
while (waiters.length > 0) {
waiters.shift()!({ value: undefined, done: true });
}
return Promise.resolve({ value: undefined, done: true });
},
};
const stream: StreamLike = {
[Symbol.asyncIterator]() {
return iterator;
},
push() {
// unused: the wrapper pushes its own events into a separate stream.
},
end() {
// unused: the wrapper ends its own stream.
},
result() {
return Promise.reject(new Error("fake base stream result not used"));
},
};
const controller: FakeStreamController = {
emit(event: AnyEvent) {
const waiter = waiters.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
queued.push({ value: event, done: false });
}
},
end() {
finished = true;
while (waiters.length > 0) {
waiters.shift()!({ value: undefined, done: true });
}
},
};
return { stream, controller, getReturnCalls: () => returnCalls };
}
function disableAbortSignalAny(): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any");
Object.defineProperty(AbortSignal, "any", {
configurable: true,
value: undefined,
});
return descriptor;
}
function restoreAbortSignalAny(descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(AbortSignal, "any", descriptor);
} else {
Reflect.deleteProperty(AbortSignal, "any");
}
}
describe("createOpencodeGoStalledStreamWrapper", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("aborts underlying stream when progress stalls after first delta (raw SSE boundary)", async () => {
// Arrange: a fake base stream that emits a start + one text_delta, then stalls.
const { stream: baseStream, controller } = createFakeBaseStream();
void baseStream;
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
// Drain wrapper events in the background.
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
// Emit a start + one text delta — that proves the provider side has produced tokens.
const partial = {
role: "assistant",
content: [{ type: "text", text: "hi" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hi",
partial,
} as any);
// Advance wall clock beyond idleTimeoutMs without any new progress.
await vi.advanceTimersByTimeAsync(6_000);
// Assert: wrapper called abort on its injected AbortController (forwarded as options.signal).
expect(capturedSignals).toHaveLength(1);
expect(abortCalled).toBe(true);
// And it pushed a terminal error event to the downstream consumer.
const terminal = received.find(
(event) => event.type === "error" && (event as any).reason === "error",
);
expect(terminal).toBeDefined();
expect((terminal as any)?.error).toMatchObject({
stopReason: "error",
errorMessage: "opencode-go stream timed out after provider-owned SSE boundary stalled",
});
// Cleanup: end base stream so consumer promise resolves.
controller.end();
await consumer;
});
it("uses a longer first-event timeout than the inter-event idle timeout", async () => {
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
await vi.advanceTimersByTimeAsync(5_000);
expect(abortCalled).toBe(true);
await consumer;
});
it("keeps the first-event window after an openai-completions synthetic start", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: {
...partial,
content: [{ type: "text", text: "hello" }],
},
} as any);
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
},
} as any);
await consumer;
expect(abortCalled).toBe(false);
expect(received.some((event) => event.type === "text_delta")).toBe(true);
expect(received.some((event) => event.type === "done")).toBe(true);
});
it("keeps the first-event window after synthetic block-start events until a provider delta", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "text_start", contentIndex: 0, partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
const message = {
...partial,
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
};
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: message,
} as any);
controller.emit({ type: "done", reason: "stop", message } as any);
await consumer;
expect(abortCalled).toBe(false);
expect(received.some((event) => event.type === "text_delta")).toBe(true);
expect(received.some((event) => event.type === "done")).toBe(true);
});
it("honors explicit opencode-go provider request timeout above the wrapper idle default", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 10_000 } as any,
{} as any,
{} as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "slow" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
await vi.advanceTimersByTimeAsync(5_000);
expect(abortCalled).toBe(true);
await consumer;
});
it("preserves the provider-owned first-event timeout when core passes a shorter generic value", async () => {
const { stream: baseStream, controller } = createFakeBaseStream();
const underlying = vi.fn((_model, _context, _options) => baseStream);
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 120_000,
firstEventTimeoutMs: 300_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ firstEventTimeoutMs: 30_000 } as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
expect(underlying).toHaveBeenCalledTimes(1);
expect(underlying.mock.calls[0]?.[2]).toMatchObject({
firstEventTimeoutMs: 300_000,
});
controller.end();
await consumer;
});
it("honors explicit opencode-go provider request timeout below wrapper defaults", async () => {
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 2_000 } as any,
{} as any,
{} as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(2_500);
expect(abortCalled).toBe(true);
await consumer;
});
it("aborts and releases the underlying stream when no first event arrives", async () => {
const { stream: baseStream, getReturnCalls } = createFakeBaseStream();
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(capturedSignals).toHaveLength(1);
expect(abortCalled).toBe(true);
expect(getReturnCalls()).toBe(1);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
await consumer;
});
it("aborts stream creation when the upstream stream promise never resolves", async () => {
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return new Promise<StreamLike>(() => {
// keep pending
});
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(true);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
await consumer;
});
it("aborts through the fallback combined signal when no first event arrives", async () => {
const abortSignalAnyDescriptor = disableAbortSignalAny();
const { stream: baseStream } = createFakeBaseStream();
let abortCalled = false;
try {
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: new AbortController().signal } as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const consumer = (async () => {
for await (const event of downstream) {
void event;
}
})();
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(true);
await consumer;
} finally {
restoreAbortSignalAny(abortSignalAnyDescriptor);
}
});
it("cleans up fallback AbortSignal listeners after natural completion", async () => {
const abortSignalAnyDescriptor = disableAbortSignalAny();
const sourceController = new AbortController();
const addEventListener = vi.spyOn(sourceController.signal, "addEventListener");
const removeEventListener = vi.spyOn(sourceController.signal, "removeEventListener");
const { stream: baseStream, controller } = createFakeBaseStream();
try {
const wrapper = createOpencodeGoStalledStreamWrapper(vi.fn(() => baseStream) as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: sourceController.signal } as any,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "done" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "done", reason: "stop", message: partial } as any);
await consumer;
expect(received.some((event) => event.type === "done")).toBe(true);
expect(addEventListener).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function));
} finally {
restoreAbortSignalAny(abortSignalAnyDescriptor);
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
it("preserves normal delayed usage-only completion without aborting", async () => {
// Arrange: a fake base stream that streams a normal completion, including
// a long quiet gap before the final usage-only delta — but well within the
// idle timeout. The wrapper must not abort.
const { stream: baseStream, controller } = createFakeBaseStream();
void baseStream;
let abortCalled = false;
const capturedSignals: AbortSignal[] = [];
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
capturedSignals.push(options.signal);
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = {
role: "assistant",
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial,
} as any);
// Simulate a delayed final chunk after a short (sub-timeout) quiet gap.
await vi.advanceTimersByTimeAsync(2_000);
// Final completion event arrives before idle timeout fires.
controller.emit({
type: "done",
reason: "stop",
message: partial,
} as any);
// Advance well past the idle timeout — wrapper should NOT have fired.
await vi.advanceTimersByTimeAsync(10_000);
expect(abortCalled).toBe(false);
// Downstream must contain all forwarded events including the done event.
const doneEvent = received.find((event) => event.type === "done");
expect(doneEvent).toBeDefined();
// Cleanup
controller.end();
await consumer;
});
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
// Regression for https://github.com/openclaw/openclaw/issues/96518:
// the idle timer must re-arm on block-boundary events (text_end,
// thinking_end, toolcall_start, toolcall_end), not only on token
// deltas. A stream that keeps producing boundary events between
// deltas is demonstrably alive and must not be aborted.
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const idleTimeoutMs = 5_000;
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "toolcall_delta",
contentIndex: 0,
delta: "{",
partial,
} as any);
await vi.advanceTimersByTimeAsync(0);
// The model finalizes the tool call and deliberates on the next one,
// emitting real block-boundary events that prove the SSE socket is alive.
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
} as any);
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_start",
contentIndex: 1,
partial,
} as any);
// Advance to 5s after the last delta, but only 2s after the last
// boundary event. The idle timer should have been re-armed by the
// boundary events, so it must NOT fire yet.
await vi.advanceTimersByTimeAsync(1_000);
// The provider's completed answer arrives right after.
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "final answer" }],
stopReason: "stop",
},
} as any);
controller.end();
await vi.advanceTimersByTimeAsync(0);
await consumer;
const hasDone = received.some((e) => e.type === "done");
const hasStalledError = received.some(
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
);
expect(abortCalled).toBe(false);
expect(hasDone).toBe(true);
expect(hasStalledError).toBe(false);
});
});

View File

@@ -0,0 +1,379 @@
// Opencode Go stream termination wrapper aborts stalled OpenAI-compatible
// SSE streams at the provider-owned raw boundary, before the shared runtime
// stuck-session recovery kicks in.
import type { AssistantMessage, AssistantMessageEvent } from "openclaw/plugin-sdk/llm";
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
type ProviderStreamFn = NonNullable<ProviderWrapStreamFnContext["streamFn"]>;
export interface OpencodeGoStalledStreamWrapperOptions {
/**
* Provider id this wrapper applies to. Calls whose model.provider does not
* match are forwarded untouched so the wrapper stays provider-scoped.
*/
provider: string;
/**
* Maximum idle window between two stream events before the wrapper treats
* the underlying SSE as stalled and aborts it. Must be > 0.
*/
idleTimeoutMs: number;
/**
* Maximum window for stream creation and first event delivery. Must be > 0.
*/
firstEventTimeoutMs?: number;
}
/**
* Default idle window used in production. Matches the runtime's shared
* `DEFAULT_LLM_IDLE_TIMEOUT_MS` (120s) so non-cron interactive runs see
* no behavior change versus the existing watchdog, while cron runs — for
* which the runtime disables its idle watchdog entirely
* (`resolveLlmIdleTimeoutMs` returns 0 when `trigger === "cron"` and no
* explicit timeout is set) — finally get a provider-owned termination
* well before the ~622s stuck-session recovery kicks in.
*/
export const OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT = 120_000;
export const OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT = 300_000;
function isOpencodeGoModel(model: unknown, providerId: string): boolean {
return Boolean(model) && typeof model === "object"
? (model as { provider?: unknown }).provider === providerId
: false;
}
function validTimeoutMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function resolveTimeoutMs(model: unknown, fallbackMs: number): number {
return validTimeoutMs((model as { requestTimeoutMs?: unknown })?.requestTimeoutMs) ?? fallbackMs;
}
function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
return (
event.type === "text_delta" ||
event.type === "thinking_delta" ||
event.type === "toolcall_delta" ||
event.type === "text_end" ||
event.type === "thinking_end" ||
event.type === "toolcall_start" ||
event.type === "toolcall_end"
);
}
function combineAbortSignals(signals: (AbortSignal | undefined)[]): {
signal: AbortSignal;
cleanup(): void;
} {
const present = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (present.length === 0) {
return { signal: new AbortController().signal, cleanup: () => undefined };
}
if (present.length === 1) {
return { signal: present[0], cleanup: () => undefined };
}
const anyFn = (
AbortSignal as unknown as {
any?: (signals: AbortSignal[]) => AbortSignal;
}
).any;
if (typeof anyFn === "function") {
return { signal: anyFn(present), cleanup: () => undefined };
}
const controller = new AbortController();
const alreadyAborted = present.find((signal) => signal.aborted);
if (alreadyAborted) {
controller.abort((alreadyAborted as { reason?: unknown }).reason);
return { signal: controller.signal, cleanup: () => undefined };
}
const unsubscribe: Array<() => void> = [];
for (const signal of present) {
const onAbort = () => controller.abort((signal as { reason?: unknown }).reason);
signal.addEventListener("abort", onAbort, { once: true });
unsubscribe.push(() => signal.removeEventListener("abort", onAbort));
}
return {
signal: controller.signal,
cleanup() {
for (const remove of unsubscribe) {
remove();
}
unsubscribe.length = 0;
},
};
}
const STALLED_STREAM_ERROR_MESSAGE =
"opencode-go stream timed out after provider-owned SSE boundary stalled";
function buildStalledErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: STALLED_STREAM_ERROR_MESSAGE,
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(STALLED_STREAM_ERROR_MESSAGE, "error"),
};
}
function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: "opencode-go stream ended without a terminal event",
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(
"opencode-go stream ended without a terminal event",
"error",
),
};
}
function buildCaughtErrorEvent(
partial: AssistantMessage | undefined,
error: unknown,
): AssistantMessageEvent {
const message = error instanceof Error ? error.message : String(error);
if (partial) {
return {
type: "error",
reason: "error",
error: {
...partial,
stopReason: "error",
errorMessage: message,
},
};
}
return {
type: "error",
reason: "error",
error: synthesizeMinimalAssistantMessage(message, "error"),
};
}
function synthesizeMinimalAssistantMessage(
errorMessage: string,
stopReason: AssistantMessage["stopReason"],
): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-completions",
provider: "opencode-go",
model: "",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
errorMessage,
timestamp: Date.now(),
};
}
/**
* Wraps an opencode-go provider stream function so that an SSE socket that
* fails to deliver a first event or stops producing progress is aborted at the
* provider-owned raw boundary via the injected AbortSignal, instead of waiting
* for the much later shared runtime stuck-session recovery.
*
* Behavior:
* - Provider-scoped: only applies when `model.provider === options.provider`.
* - Idle-based: the timer covers stream creation, first event delivery, and
* every gap after provider progress begins; if no event arrives within
* `idleTimeoutMs`, the wrapper calls `controller.abort()` on the AbortSignal
* injected into the underlying call (so the OpenAI SDK request is genuinely
* interrupted, not just the iterator) and pushes a terminal `error` event
* downstream.
* - Terminal-safe: when the underlying stream emits `done` or `error`, the
* wrapper forwards the event, clears all timers, and ends the stream.
*
* The wrapper never shortens the natural end of a normal completion, because
* provider progress refreshes the idle timer and a terminal event cancels it entirely.
*/
export function createOpencodeGoStalledStreamWrapper(
underlying: ProviderStreamFn,
options: OpencodeGoStalledStreamWrapperOptions,
): ProviderStreamFn {
if (!options || options.idleTimeoutMs <= 0) {
throw new Error("createOpencodeGoStalledStreamWrapper requires idleTimeoutMs > 0");
}
if (options.firstEventTimeoutMs !== undefined && options.firstEventTimeoutMs <= 0) {
throw new Error("createOpencodeGoStalledStreamWrapper requires firstEventTimeoutMs > 0");
}
const providerId = options.provider;
const idleTimeoutMsDefault = options.idleTimeoutMs;
const firstEventTimeoutMsDefault = options.firstEventTimeoutMs ?? options.idleTimeoutMs;
return (model, context, callOptions) => {
if (!isOpencodeGoModel(model, providerId)) {
return underlying(model, context, callOptions);
}
const output = createAssistantMessageEventStream();
const idleTimeoutMs = resolveTimeoutMs(model, idleTimeoutMsDefault);
const firstEventTimeoutMs = resolveTimeoutMs(model, firstEventTimeoutMsDefault);
const controller = new AbortController();
const combinedSignal = combineAbortSignals([
(callOptions as { signal?: AbortSignal } | undefined)?.signal,
controller.signal,
]);
const wrappedOptions = {
...callOptions,
// This provider owns the raw SSE stall policy. Preserve that longer first
// event window when delegating to OpenAI-compatible streams so the generic
// embedded-runner default cannot shorten opencode-go prompt evaluation.
firstEventTimeoutMs,
signal: combinedSignal.signal,
};
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let lastSeenPartial: AssistantMessage | undefined;
let settled = false;
let baseIterator: AsyncIterator<AssistantMessageEvent> | undefined;
const clearIdleTimer = () => {
if (idleTimer !== undefined) {
clearTimeout(idleTimer);
idleTimer = undefined;
}
};
const cleanup = () => {
clearIdleTimer();
combinedSignal.cleanup();
};
const releaseBaseStream = () => {
if (baseIterator?.return) {
void Promise.resolve(baseIterator.return()).catch(() => undefined);
}
};
const finishWith = (event: AssistantMessageEvent) => {
if (settled) {
return;
}
settled = true;
cleanup();
output.push(event);
output.end(
event.type === "done" ? (event as { message: AssistantMessage }).message : undefined,
);
};
const abortStalledStream = () => {
if (settled) {
return;
}
settled = true;
clearIdleTimer();
controller.abort(new Error("opencode-go stream stalled"));
combinedSignal.cleanup();
releaseBaseStream();
output.push(buildStalledErrorEvent(lastSeenPartial));
output.end();
};
const armTimer = (timeoutMs: number) => {
clearIdleTimer();
idleTimer = setTimeout(abortStalledStream, timeoutMs);
idleTimer.unref?.();
};
const armFirstEventTimer = () => armTimer(firstEventTimeoutMs);
const armIdleTimer = () => armTimer(idleTimeoutMs);
const trackPartial = (event: AssistantMessageEvent) => {
const partial =
(event as { partial?: AssistantMessage; message?: AssistantMessage }).partial ??
(event as { message?: AssistantMessage }).message;
if (partial) {
lastSeenPartial = partial;
}
};
const releaseResolvedStream = (baseStream: AsyncIterable<AssistantMessageEvent>) => {
const iterator = baseStream[Symbol.asyncIterator]();
if (iterator.return) {
void Promise.resolve(iterator.return()).catch(() => undefined);
}
};
armFirstEventTimer();
let baseStreamResult: ReturnType<ProviderStreamFn>;
try {
baseStreamResult = underlying(model, context, wrappedOptions);
} catch (error) {
cleanup();
throw error;
}
void (async () => {
try {
const baseStream = await Promise.resolve(
baseStreamResult as Awaited<ReturnType<ProviderStreamFn>>,
);
if (settled) {
releaseResolvedStream(baseStream as AsyncIterable<AssistantMessageEvent>);
return;
}
baseIterator = (baseStream as AsyncIterable<AssistantMessageEvent>)[Symbol.asyncIterator]();
for (;;) {
const result = await baseIterator.next();
if (settled) {
return;
}
if (result.done) {
finishWith(buildUnterminatedErrorEvent(lastSeenPartial));
return;
}
const event = result.value;
if (event.type === "done" || event.type === "error") {
trackPartial(event);
finishWith(event);
return;
}
trackPartial(event);
output.push(event);
if (isProviderProgressEvent(event)) {
armIdleTimer();
}
}
} catch (error) {
if (!settled) {
finishWith(buildCaughtErrorEvent(lastSeenPartial, error));
}
} finally {
cleanup();
}
})();
return output;
};
}

View File

@@ -0,0 +1,68 @@
// Opencode Go plugin module implements stream behavior.
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
createDeepSeekV4OpenAICompatibleThinkingWrapper,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { isOpencodeGoKimiNoReasoningModelId } from "./provider-catalog.js";
import { stripOpencodeGoKimiReasoningPayload } from "./reasoning-sanitizer.js";
import {
createOpencodeGoStalledStreamWrapper,
OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
} from "./stream-termination.js";
function isOpencodeGoDeepSeekV4ModelId(modelId: unknown): boolean {
return modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro";
}
export function createOpencodeGoDeepSeekV4Wrapper(
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): ProviderWrapStreamFnContext["streamFn"] {
return createDeepSeekV4OpenAICompatibleThinkingWrapper({
baseStreamFn,
thinkingLevel,
shouldPatchModel: (model) =>
model.provider === "opencode-go" && isOpencodeGoDeepSeekV4ModelId(model.id),
});
}
function stripReasoningParams(payloadObj: Record<string, unknown>): void {
stripOpencodeGoKimiReasoningPayload(payloadObj);
}
export function createOpencodeGoKimiNoReasoningWrapper(
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
): ProviderWrapStreamFnContext["streamFn"] {
if (!baseStreamFn) {
return undefined;
}
const underlying = baseStreamFn;
return (model, context, options) => {
if (model.provider !== "opencode-go" || !isOpencodeGoKimiNoReasoningModelId(model.id)) {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, stripReasoningParams);
};
}
export function createOpencodeGoWrapper(
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
): ProviderWrapStreamFnContext["streamFn"] {
if (!baseStreamFn) {
return undefined;
}
const kimiWrapped = createOpencodeGoKimiNoReasoningWrapper(baseStreamFn) ?? baseStreamFn;
const deepSeekWrapped =
createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped;
// Outermost layer: provider-owned stalled SSE termination so the underlying
// OpenAI SDK request is aborted at the raw opencode-go boundary instead of
// waiting for the shared runtime stuck-session recovery.
return createOpencodeGoStalledStreamWrapper(deepSeekWrapped, {
provider: "opencode-go",
idleTimeoutMs: OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
firstEventTimeoutMs: OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
});
}

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"
]
}