fix(opencode): keep API-key onboarding routes usable (#121414)

* fix(opencode): keep API-key onboarding routes usable

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94f-6aac-73c9-995b-ced5336f3230

* refactor(opencode): simplify onboarding compatibility

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94f-6aac-73c9-995b-ced5336f3230

* fix(opencode): avoid unverified onboarding fallbacks

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94f-6aac-73c9-995b-ced5336f3230

* fix(opencode): reconcile dynamic tool deltas

Amp-Thread-ID: https://ampcode.com/threads/T-019fe94f-6aac-73c9-995b-ced5336f3230

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-10 03:23:36 -07:00
committed by GitHub
parent 8e1c238c1c
commit d847a62e5d
20 changed files with 1224 additions and 581 deletions
-10
View File
@@ -1,10 +0,0 @@
// Opencode API module exposes the plugin public contract.
export {
applyOpencodeZenModelDefault,
OPENCODE_ZEN_DEFAULT_MODEL,
} from "openclaw/plugin-sdk/provider-onboard";
export {
applyOpencodeZenConfig,
applyOpencodeZenProviderConfig,
OPENCODE_ZEN_DEFAULT_MODEL_REF,
} from "./onboard.js";
+25 -1
View File
@@ -13,7 +13,10 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { buildOpencodeZenLiveProviderConfig } from "./provider-catalog.js";
import {
buildOpencodeZenLiveProviderConfig,
resolveOpencodeZenStarterModel,
} from "./provider-catalog.js";
const requireRecord = createRequireRecord("record", "expected-label-record");
@@ -795,6 +798,27 @@ describe("opencode provider plugin", () => {
expect(secondCached.models.map((model) => model.id)).toEqual(["gpt-5.6-luna"]);
});
it.each([
[["claude-opus-5"], "opencode/claude-opus-5"],
[["gpt-5.6-sol"], undefined],
])("selects only the advertised preferred onboarding model %#", async (modelIds, expected) => {
const fetchGuard = vi.fn(async () => ({
response: new Response(
JSON.stringify({ data: modelIds.map((id) => ({ id, object: "model" })) }),
),
finalUrl: "https://opencode.ai/zen/v1/models",
release: vi.fn(async () => undefined),
}));
await expect(
resolveOpencodeZenStarterModel({
apiKey: "resolved-opencode-key",
preferredModelRef: "opencode/claude-opus-5",
fetchGuard,
}),
).resolves.toBe(expected);
});
it.each([
["off", undefined],
["max", "max"],
+21 -21
View File
@@ -6,8 +6,8 @@ import {
} from "openclaw/plugin-sdk/provider-model-shared";
import { createOpenAICompatibleCompletionsThinkingOffWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { applyOpencodeZenProviderConfig, OPENCODE_ZEN_DEFAULT_MODEL_REF } from "./onboard.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import {
buildOpencodeZenLiveProviderConfig,
@@ -15,32 +15,25 @@ import {
listOpencodeZenModelCatalogEntries,
normalizeOpencodeZenBaseUrl,
resolveOpencodeZenModel,
resolveOpencodeZenStarterModel,
} from "./provider-catalog.js";
import { resolveThinkingProfile as resolveOpencodeThinkingProfile } from "./provider-policy-api.js";
import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js";
import { wrapOpencodeProviderStream } from "./stream.js";
const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
const OPENCODE_SHARED_HINT = "Shared API key infrastructure for Zen + Go";
type OpencodeZenCatalogAuth = {
apiKey?: string;
discoveryApiKey?: string;
};
function hasCatalogAuth(auth: OpencodeZenCatalogAuth): boolean {
return Boolean(auth.apiKey || auth.discoveryApiKey);
}
type OpencodeZenCatalogAuth = { apiKey?: string; discoveryApiKey?: string };
function resolveOpencodeZenCatalogAuth(
resolveProviderApiKey: (providerId: string) => OpencodeZenCatalogAuth,
): OpencodeZenCatalogAuth | undefined {
const opencodeAuth = resolveProviderApiKey(PROVIDER_ID);
if (hasCatalogAuth(opencodeAuth)) {
return opencodeAuth;
const own = resolveProviderApiKey(PROVIDER_ID);
if (own.apiKey || own.discoveryApiKey) {
return own;
}
const sharedOpencodeGoAuth = resolveProviderApiKey("opencode-go");
return hasCatalogAuth(sharedOpencodeGoAuth) ? sharedOpencodeGoAuth : undefined;
const shared = resolveProviderApiKey("opencode-go");
return shared.apiKey || shared.discoveryApiKey ? shared : undefined;
}
function isModernOpencodeModel(modelId: string): boolean {
@@ -61,12 +54,18 @@ export default defineSingleProviderPluginEntry({
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
manifestAuth: {
hint: OPENCODE_SHARED_HINT,
hint: "Shared API key infrastructure for Zen + Go",
promptMessage: "Enter OpenCode API key",
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
applyConfig: applyOpencodeZenConfig,
profileIds: ["opencode:default", "opencode-go:default"],
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL_REF,
resolveDefaultModel: async ({ apiKey, signal }) =>
await resolveOpencodeZenStarterModel({
apiKey,
preferredModelRef: OPENCODE_ZEN_DEFAULT_MODEL_REF,
...(signal ? { signal } : {}),
}),
expectedProviders: ["opencode", "opencode-go"],
applyConfig: applyOpencodeZenProviderConfig,
noteMessage: [
"One OpenCode API key can authenticate Zen and a separately subscribed Go catalog.",
"Zen provides access to Claude, GPT, Gemini, and more models.",
@@ -137,10 +136,11 @@ export default defineSingleProviderPluginEntry({
baseStreamFn,
ctx.thinkingLevel,
);
return (model, context, options) =>
const thinkingStreamFn: typeof baseStreamFn = (model, context, options) =>
model.provider === PROVIDER_ID && model.id === "kimi-k3"
? thinkingOff(model, context, options)
: baseStreamFn(model, context, options);
return wrapOpencodeProviderStream({ ...ctx, streamFn: thinkingStreamFn });
},
},
register(api) {
+3 -13
View File
@@ -1,12 +1,9 @@
// Opencode tests cover onboard plugin behavior.
import {
expectProviderOnboardAllowlistAlias,
expectProviderOnboardPrimaryAndFallbacks,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { expectProviderOnboardAllowlistAlias } from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, it } from "vitest";
import { applyOpencodeZenConfig, applyOpencodeZenProviderConfig } from "./onboard.js";
import { applyOpencodeZenProviderConfig } from "./onboard.js";
const MODEL_REF = "opencode/claude-opus-4-6";
const MODEL_REF = "opencode/claude-opus-5";
describe("opencode onboard", () => {
it("adds allowlist entry and preserves alias", () => {
@@ -16,11 +13,4 @@ describe("opencode onboard", () => {
alias: "My Opus",
});
});
it("sets primary model and preserves existing model fallbacks", () => {
expectProviderOnboardPrimaryAndFallbacks({
applyConfig: applyOpencodeZenConfig,
modelRef: MODEL_REF,
});
});
});
+2 -13
View File
@@ -1,11 +1,7 @@
// Opencode setup module handles plugin onboarding behavior.
import {
applyAgentDefaultModelPrimary,
withAgentModelAliases,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { withAgentModelAliases, type OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = "opencode/claude-opus-4-6";
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = "opencode/claude-opus-5";
export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return {
@@ -21,10 +17,3 @@ export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawCon
},
};
}
export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpencodeZenProviderConfig(cfg),
OPENCODE_ZEN_DEFAULT_MODEL_REF,
);
}
+20
View File
@@ -3,6 +3,7 @@ import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime";
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import {
buildLiveModelProviderConfig,
fetchLiveProviderModelIds,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
@@ -466,6 +467,25 @@ export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProv
};
}
export async function resolveOpencodeZenStarterModel(params: {
apiKey: string;
preferredModelRef: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<string | undefined> {
const liveModelIds = await fetchLiveProviderModelIds({
providerId: PROVIDER_ID,
endpoint: OPENCODE_ZEN_MODELS_ENDPOINT,
discoveryApiKey: params.apiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
timeoutMs: OPENCODE_ZEN_MODELS_TIMEOUT_MS,
auditContext: "opencode-zen-onboarding-model-discovery",
});
const preferredModelId = params.preferredModelRef.replace(`${PROVIDER_ID}/`, "");
return liveModelIds.includes(preferredModelId) ? params.preferredModelRef : undefined;
}
function readLiveModelId(row: unknown): string | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) {
return undefined;
+525
View File
@@ -0,0 +1,525 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import {
createAssistantMessageEventStream,
type AssistantMessage,
type AssistantMessageEvent,
} from "openclaw/plugin-sdk/llm";
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
function toolCallMessage(
name: string,
argumentsValue: Record<string, unknown> = { query: "OpenClaw" },
): AssistantMessage {
return {
role: "assistant",
api: "openai-responses",
provider: "opencode",
model: "gpt-5.6-sol",
content: [{ type: "toolCall", id: "call_1", name, arguments: argumentsValue }],
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 1,
};
}
describe("OpenCode stream adapter", () => {
it("aliases the reserved web_search function across OpenCode Responses requests", async () => {
const provider = await registerSingleProviderPlugin(plugin);
let capturedPayload: Record<string, unknown> | undefined;
const existingAlias = "openclaw_web_search";
const wireAlias = "openclaw_web_search_2";
let producerPartial: AssistantMessage | undefined;
let producerTerminal: AssistantMessage | undefined;
let releaseTerminal = () => {};
const allowTerminal = new Promise<void>((resolve) => {
releaseTerminal = resolve;
});
const baseStreamFn: StreamFn = async (model, _context, options) => {
const initialPayload = { model: model.id };
const replacement = await options?.onPayload?.(initialPayload, model);
capturedPayload = (replacement ?? initialPayload) as Record<string, unknown>;
const stream = createAssistantMessageEventStream();
const wireArguments = { options: [{ key: "region", value: "us" }] };
producerPartial = toolCallMessage(wireAlias, wireArguments);
producerTerminal = toolCallMessage(wireAlias, wireArguments);
queueMicrotask(() => {
stream.push({
type: "toolcall_start",
contentIndex: 0,
partial: producerPartial as AssistantMessage,
});
void allowTerminal.then(() => {
stream.push({
type: "toolcall_end",
contentIndex: 0,
toolCall: producerPartial?.content[0] as never,
partial: producerPartial as AssistantMessage,
});
stream.push({
type: "done",
reason: "toolUse",
message: producerTerminal as AssistantMessage,
});
});
});
return stream;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn,
providerId: "opencode",
modelId: "gpt-5.6-sol",
} as never);
if (!streamFn) {
throw new Error("expected OpenCode stream wrapper");
}
const stream = await streamFn(
{ provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never,
{ messages: [] } as never,
{
onPayload: () => ({
tools: [
{
type: "function",
name: "web_search",
parameters: {
type: "object",
properties: {
options: {
type: "object",
patternProperties: { "^.*$": { type: "string" } },
},
},
},
},
{ type: "function", name: existingAlias },
{ type: "function", name: "read" },
],
input: [{ type: "function_call", name: "web_search", call_id: "call_0" }],
tool_choice: {
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: "web_search" },
{ type: "function", name: existingAlias },
],
},
}),
},
);
const iterator = stream[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) {
throw new Error("expected staged tool-call event");
}
const events = [first.value];
expect(first.value).toMatchObject({
type: "toolcall_start",
partial: { content: [{ name: "web_search", arguments: { options: { region: "us" } } }] },
});
expect([producerPartial, producerTerminal]).toMatchObject([
{ content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] },
{ content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] },
]);
releaseTerminal();
for (let next = await iterator.next(); !next.done; next = await iterator.next()) {
events.push(next.value);
}
expect(capturedPayload).toMatchObject({
tools: [
{ type: "function", name: wireAlias },
{ type: "function", name: existingAlias },
{ type: "function", name: "read" },
],
input: [{ type: "function_call", name: wireAlias, call_id: "call_0" }],
tool_choice: {
type: "allowed_tools",
mode: "required",
tools: [
{ type: "function", name: wireAlias },
{ type: "function", name: existingAlias },
],
},
});
expect(events[1]).toMatchObject({
type: "toolcall_end",
toolCall: { name: "web_search", arguments: { options: { region: "us" } } },
partial: {
content: [{ name: "web_search", arguments: { options: { region: "us" } } }],
},
});
expect(events[2]).toMatchObject({
type: "done",
message: { content: [{ name: "web_search" }] },
});
await expect(stream.result()).resolves.toMatchObject({
content: [{ name: "web_search", arguments: { options: { region: "us" } } }],
});
expect([producerPartial, producerTerminal]).toMatchObject([
{ content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] },
{ content: [{ name: wireAlias, arguments: { options: [{ key: "region", value: "us" }] } }] },
]);
});
it("does not restore an unaliased OpenCode Responses function name", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const existingAlias = "openclaw_web_search";
const source = createAssistantMessageEventStream();
const payload = { tools: [{ type: "function", name: existingAlias }] };
const baseStreamFn: StreamFn = (model, _context, options) => {
void options?.onPayload?.(payload, model);
queueMicrotask(() => source.end(toolCallMessage(existingAlias)));
return source;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn,
providerId: "opencode",
modelId: "gpt-5.6-sol",
} as never);
const stream = await streamFn?.(
{ provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never,
{ messages: [] } as never,
{},
);
expect(payload.tools[0]?.name).toBe(existingAlias);
await expect(stream?.result()).resolves.toMatchObject({ content: [{ name: existingAlias }] });
});
it("round-trips dynamic record tool arguments through OpenCode-compatible schemas", async () => {
const provider = await registerSingleProviderPlugin(plugin);
let capturedPayload: Record<string, unknown> | undefined;
let producerDelta: Extract<AssistantMessageEvent, { type: "toolcall_delta" }> | undefined;
const baseStreamFn: StreamFn = async (model, _context, options) => {
const initialPayload = { model: model.id };
const replacement = await options?.onPayload?.(initialPayload, model);
capturedPayload = (replacement ?? initialPayload) as Record<string, unknown>;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const execMessage = toolCallMessage("exec", {
command: "node app.js",
env: [{ key: "NODE_ENV", value: "test" }],
});
producerDelta = {
type: "toolcall_delta",
contentIndex: 0,
delta: JSON.stringify({
command: "node app.js",
env: [{ key: "NODE_ENV", value: "test" }],
}),
partial: execMessage,
};
stream.push(producerDelta);
stream.push({
type: "toolcall_end",
contentIndex: 0,
toolCall: execMessage.content[0] as never,
partial: execMessage,
});
const duplicateMessage = toolCallMessage("dashboard", {
props: [
{ key: "title", value: '"first"' },
{ key: "title", value: '"second"' },
],
});
stream.push({
type: "toolcall_end",
contentIndex: 0,
toolCall: duplicateMessage.content[0] as never,
partial: duplicateMessage,
});
const malformedMessage = toolCallMessage("video_generate", {
providerOptions: [{ key: "broken", value: "not-json" }],
});
stream.push({
type: "toolcall_end",
contentIndex: 0,
toolCall: malformedMessage.content[0] as never,
partial: malformedMessage,
});
stream.push({
type: "done",
reason: "toolUse",
message: toolCallMessage("video_generate", {
providerOptions: [
{ key: "label", value: '"42"' },
{ key: "seed", value: "42" },
{ key: "enabled", value: "true" },
{ key: "empty", value: "null" },
],
}),
});
});
return stream;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn,
providerId: "opencode",
modelId: "gpt-5.6-sol",
} as never);
if (!streamFn) {
throw new Error("expected OpenCode stream wrapper");
}
const stream = await streamFn(
{ provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never,
{ messages: [] } as never,
{
onPayload: () => ({
tools: [
{
type: "function",
name: "exec",
parameters: {
type: "object",
properties: {
env: {
type: "object",
patternProperties: { "^.*$": { type: "string" } },
},
},
},
},
{
type: "function",
name: "video_generate",
parameters: {
type: "object",
properties: {
providerOptions: {
type: "object",
patternProperties: { "^.*$": {} },
},
},
},
},
{
type: "function",
name: "dashboard",
parameters: {
type: "object",
properties: {
props: {
type: "object",
patternProperties: { "^.*$": {} },
},
},
},
},
],
input: [
{
type: "function_call",
name: "exec",
arguments: JSON.stringify({ command: "node app.js", env: { NODE_ENV: "test" } }),
},
{
type: "function_call",
name: "video_generate",
arguments: {
providerOptions: { label: "42", seed: 42, enabled: true, empty: null },
},
},
],
}),
},
);
const events = [];
for await (const event of stream) {
events.push(event);
}
const tools = capturedPayload?.tools as Array<Record<string, unknown>>;
const execParameters = tools[0]?.parameters as Record<string, unknown>;
const execProperties = execParameters.properties as Record<string, unknown>;
expect(execProperties.env).toMatchObject({
type: "array",
items: {
properties: { key: { type: "string" }, value: { type: "string" } },
required: ["key", "value"],
additionalProperties: false,
},
});
expect(JSON.stringify(execProperties.env)).not.toContain("patternProperties");
const input = capturedPayload?.input as Array<Record<string, unknown>>;
expect(JSON.parse(input[0]?.arguments as string)).toEqual({
command: "node app.js",
env: [{ key: "NODE_ENV", value: "test" }],
});
expect(input[1]?.arguments).toEqual({
providerOptions: [
{ key: "label", value: '"42"' },
{ key: "seed", value: "42" },
{ key: "enabled", value: "true" },
{ key: "empty", value: "null" },
],
});
expect(events[0]).toMatchObject({
type: "toolcall_delta",
delta: "",
partial: {
content: [{ arguments: { command: "node app.js", env: { NODE_ENV: "test" } } }],
},
});
expect(producerDelta).toMatchObject({
delta: '{"command":"node app.js","env":[{"key":"NODE_ENV","value":"test"}]}',
partial: {
content: [
{
arguments: {
command: "node app.js",
env: [{ key: "NODE_ENV", value: "test" }],
},
},
],
},
});
expect(events[1]).toMatchObject({
type: "toolcall_end",
toolCall: { arguments: { command: "node app.js", env: { NODE_ENV: "test" } } },
});
expect(events[2]).toMatchObject({
type: "toolcall_end",
toolCall: {
arguments: {
props: [
{ key: "title", value: '"first"' },
{ key: "title", value: '"second"' },
],
},
},
});
expect(events[3]).toMatchObject({
type: "toolcall_end",
toolCall: {
arguments: { providerOptions: [{ key: "broken", value: "not-json" }] },
},
});
expect(events[4]).toMatchObject({
type: "done",
message: {
content: [
{
arguments: {
providerOptions: { label: "42", seed: 42, enabled: true, empty: null },
},
},
],
},
});
await expect(stream.result()).resolves.toMatchObject({
content: [
{
arguments: {
providerOptions: { label: "42", seed: 42, enabled: true, empty: null },
},
},
],
});
});
it("rebuilds dynamic record metadata when a Responses request payload is rebuilt", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const firstPayload = {
tools: [
{
type: "function",
name: "exec",
parameters: {
type: "object",
properties: {
env: {
type: "object",
patternProperties: { "^.*$": { type: "string" } },
},
},
},
},
],
};
const secondPayload = {
tools: [
{
type: "function",
name: "exec",
parameters: {
type: "object",
properties: { env: { type: "array", items: { type: "string" } } },
},
},
],
};
const baseStreamFn: StreamFn = async (model, _context, options) => {
await options?.onPayload?.(firstPayload, model);
await options?.onPayload?.(secondPayload, model);
const source = createAssistantMessageEventStream();
queueMicrotask(() =>
source.end(
toolCallMessage("exec", {
env: [{ key: "literal", value: "array" }],
}),
),
);
return source;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn,
providerId: "opencode",
modelId: "gpt-5.6-sol",
} as never);
const stream = await streamFn?.(
{ provider: "opencode", id: "gpt-5.6-sol", api: "openai-responses" } as never,
{ messages: [] } as never,
{},
);
expect(firstPayload.tools[0]?.parameters.properties.env.type).toBe("array");
expect(secondPayload.tools[0]?.parameters.properties.env).toEqual({
type: "array",
items: { type: "string" },
});
await expect(stream?.result()).resolves.toMatchObject({
content: [
{
arguments: { env: [{ key: "literal", value: "array" }] },
},
],
});
});
it("leaves web_search unchanged for non-Responses OpenCode models", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const source = createAssistantMessageEventStream();
const payload = { tools: [{ type: "function", name: "web_search" }] };
const baseStreamFn: StreamFn = (model, _context, options) => {
void options?.onPayload?.(payload, model);
queueMicrotask(() => source.end(toolCallMessage("web_search")));
return source;
};
const streamFn = provider.wrapStreamFn?.({
streamFn: baseStreamFn,
providerId: "opencode",
modelId: "kimi-k2.6",
} as never);
const stream = await streamFn?.(
{ provider: "opencode", id: "kimi-k2.6", api: "openai-completions" } as never,
{ messages: [] } as never,
{},
);
expect(stream).toBe(source);
expect(payload.tools[0]?.name).toBe("web_search");
});
});
+265
View File
@@ -0,0 +1,265 @@
// OpenCode Zen stream adapter handles provider-specific Responses wire compatibility.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import {
streamSimple,
type AssistantMessage,
type AssistantMessageEvent,
} from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const WEB_SEARCH = "web_search";
const WEB_SEARCH_ALIAS = "openclaw_web_search";
type ProviderStream = Awaited<ReturnType<StreamFn>>;
type DynamicFields = Map<string, Array<readonly [name: string, jsonValues: boolean]>>;
type TransformState = { fields: DynamicFields; alias?: string };
function payloadFunctions(payload: Record<string, unknown>): Record<string, unknown>[] {
const choice = isRecord(payload.tool_choice) ? payload.tool_choice : undefined;
const candidates = [
...(Array.isArray(payload.tools) ? payload.tools : []),
...(Array.isArray(payload.input) ? payload.input : []),
...(Array.isArray(choice?.tools) ? choice.tools : []),
choice,
];
return candidates.filter(
(item): item is Record<string, unknown> =>
isRecord(item) &&
(item.type === "function" || item.type === "function_call") &&
typeof item.name === "string",
);
}
function rewriteDynamicRecordSchemas(payload: Record<string, unknown>): DynamicFields {
const fieldsByTool: DynamicFields = new Map();
for (const tool of payloadFunctions(payload)) {
if (tool.type !== "function" || !isRecord(tool.parameters)) {
continue;
}
const properties = tool.parameters.properties;
if (!isRecord(properties)) {
continue;
}
const fields: Array<readonly [string, boolean]> = [];
for (const [name, schema] of Object.entries(properties)) {
if (!isRecord(schema)) {
continue;
}
const patterns = schema.patternProperties;
if (
(isRecord(schema.properties) && Object.keys(schema.properties).length > 0) ||
!isRecord(patterns) ||
Object.keys(patterns).length !== 1 ||
!Object.hasOwn(patterns, "^.*$")
) {
continue;
}
const valueSchema = patterns["^.*$"];
const jsonValues = !isRecord(valueSchema) || valueSchema.type !== "string";
const description = typeof schema.description === "string" ? `${schema.description} ` : "";
properties[name] = {
...schema,
type: "array",
description: `${description}Provide as key/value entries.${jsonValues ? " JSON-encode every value, including strings." : ""}`,
items: {
type: "object",
properties: {
key: { type: "string" },
value: jsonValues
? {
type: "string",
description: "JSON-encoded value, including JSON encoding for string values.",
}
: valueSchema,
},
required: ["key", "value"],
additionalProperties: false,
},
properties: undefined,
patternProperties: undefined,
additionalProperties: undefined,
required: undefined,
};
fields.push([name, jsonValues]);
}
fieldsByTool.set(tool.name as string, fields);
}
return fieldsByTool;
}
function transformArguments(
toolName: string,
args: Record<string, unknown>,
fields: DynamicFields,
toWire: boolean,
): void {
for (const [name, jsonValues] of fields.get(toolName) ?? []) {
const value = args[name];
if (toWire) {
if (isRecord(value)) {
args[name] = Object.entries(value).map(([key, item]) => ({
key,
value: jsonValues || typeof item !== "string" ? (JSON.stringify(item) ?? "null") : item,
}));
}
continue;
}
if (!Array.isArray(value)) {
continue;
}
const entries: Array<[string, unknown]> = [];
const keys = new Set<string>();
let valid = true;
for (const entry of value) {
if (
!isRecord(entry) ||
typeof entry.key !== "string" ||
typeof entry.value !== "string" ||
keys.has(entry.key)
) {
valid = false;
break;
}
keys.add(entry.key);
let item: unknown = entry.value;
if (jsonValues) {
try {
item = JSON.parse(entry.value) as unknown;
} catch {
valid = false;
break;
}
}
entries.push([entry.key, item]);
}
if (valid) {
args[name] = Object.fromEntries(entries);
}
}
}
function transformCall(
call: Record<string, unknown>,
state: TransformState,
toWire: boolean,
): void {
if (typeof call.name !== "string") {
return;
}
let toolName = call.name;
if (!toWire && state.alias && toolName === state.alias) {
call.name = toolName = WEB_SEARCH;
}
const serialized = typeof call.arguments === "string";
try {
const args = serialized
? (JSON.parse(call.arguments as string) as unknown)
: !toWire && isRecord(call.arguments)
? { ...call.arguments }
: call.arguments;
if (isRecord(args)) {
transformArguments(toolName, args, state.fields, toWire);
call.arguments = serialized ? JSON.stringify(args) : args;
}
} catch {
// Leave partial or malformed arguments unchanged for normal validation.
}
}
function aliasWebSearch(payload: Record<string, unknown>): string | undefined {
const functions = payloadFunctions(payload);
const names = new Set(functions.map((item) => item.name as string));
if (!names.has(WEB_SEARCH)) {
return undefined;
}
let alias = WEB_SEARCH_ALIAS;
for (let suffix = 2; names.has(alias); suffix += 1) {
alias = `${WEB_SEARCH_ALIAS}_${suffix}`;
}
for (const item of functions) {
if (item.name === WEB_SEARCH) {
item.name = alias;
}
}
return alias;
}
function restoreMessage(message: AssistantMessage, state: TransformState): AssistantMessage {
const restored = { ...message, content: message.content.map((block) => ({ ...block })) };
for (const block of restored.content) {
if (block.type === "toolCall") {
transformCall(block as unknown as Record<string, unknown>, state, false);
}
}
return restored;
}
function restoreEvent(event: AssistantMessageEvent, state: TransformState): AssistantMessageEvent {
const restored = { ...event };
if ("partial" in restored && restored.partial) {
restored.partial = restoreMessage(restored.partial, state);
}
if (restored.type === "toolcall_delta") {
const call = restored.partial.content[restored.contentIndex];
if (call?.type === "toolCall" && (state.fields.get(call.name)?.length ?? 0) > 0) {
// Dynamic-record wire JSON is not prefix-compatible with restored object JSON.
// Defer argument bytes so consumers emit one canonical payload at toolcall_end.
restored.delta = "";
}
} else if (restored.type === "toolcall_end") {
restored.toolCall = { ...restored.toolCall };
transformCall(restored.toolCall as unknown as Record<string, unknown>, state, false);
} else if (restored.type === "done") {
restored.message = restoreMessage(restored.message, state);
} else if (restored.type === "error") {
restored.error = restoreMessage(restored.error, state);
}
return restored;
}
function wrapResponseStream(stream: ProviderStream, state: TransformState): ProviderStream {
return {
async *[Symbol.asyncIterator]() {
for await (const event of stream) {
yield restoreEvent(event, state);
}
},
async result() {
return restoreMessage(await stream.result(), state);
},
};
}
export function wrapOpencodeProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
const underlying = ctx.streamFn ?? streamSimple;
return (model, context, options) => {
if (model.api !== "openai-responses") {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
const state: TransformState = { fields: new Map() };
const maybeStream = underlying(model, context, {
...options,
async onPayload(payload, payloadModel) {
const finalPayload = (await originalOnPayload?.(payload, payloadModel)) ?? payload;
state.fields = new Map();
state.alias = undefined;
if (isRecord(finalPayload)) {
state.fields = rewriteDynamicRecordSchemas(finalPayload);
for (const call of payloadFunctions(finalPayload)) {
if (call.type === "function_call") {
transformCall(call, state, true);
}
}
state.alias = aliasWebSearch(finalPayload);
}
return finalPayload;
},
});
const wrap = (stream: ProviderStream) => wrapResponseStream(stream, state);
return maybeStream && typeof maybeStream === "object" && "then" in maybeStream
? Promise.resolve(maybeStream).then(wrap)
: wrap(maybeStream);
};
}