mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
8e1c238c1c
commit
d847a62e5d
@@ -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";
|
||||
@@ -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"],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user