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:
28
extensions/opencode-go/api.ts
Normal file
28
extensions/opencode-go/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
516
extensions/opencode-go/index.test.ts
Normal file
516
extensions/opencode-go/index.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
144
extensions/opencode-go/index.ts
Normal file
144
extensions/opencode-go/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
13
extensions/opencode-go/media-understanding-provider.test.ts
Normal file
13
extensions/opencode-go/media-understanding-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
16
extensions/opencode-go/media-understanding-provider.ts
Normal file
16
extensions/opencode-go/media-understanding-provider.ts
Normal 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,
|
||||
};
|
||||
29
extensions/opencode-go/onboard.test.ts
Normal file
29
extensions/opencode-go/onboard.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
18
extensions/opencode-go/onboard.ts
Normal file
18
extensions/opencode-go/onboard.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
113
extensions/opencode-go/openclaw.plugin.json
Normal file
113
extensions/opencode-go/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
extensions/opencode-go/package.json
Normal file
15
extensions/opencode-go/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
510
extensions/opencode-go/provider-catalog.ts
Normal file
510
extensions/opencode-go/provider-catalog.ts
Normal 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;
|
||||
}
|
||||
18
extensions/opencode-go/provider-discovery.ts
Normal file
18
extensions/opencode-go/provider-discovery.ts
Normal 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;
|
||||
61
extensions/opencode-go/reasoning-sanitizer.test.ts
Normal file
61
extensions/opencode-go/reasoning-sanitizer.test.ts
Normal 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]" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
72
extensions/opencode-go/reasoning-sanitizer.ts
Normal file
72
extensions/opencode-go/reasoning-sanitizer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
850
extensions/opencode-go/stream-termination.test.ts
Normal file
850
extensions/opencode-go/stream-termination.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
379
extensions/opencode-go/stream-termination.ts
Normal file
379
extensions/opencode-go/stream-termination.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
68
extensions/opencode-go/stream.ts
Normal file
68
extensions/opencode-go/stream.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
16
extensions/opencode-go/tsconfig.json
Normal file
16
extensions/opencode-go/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