diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 1b8c1b076e..323c9ae33f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,4 @@ -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; +import { isOpenCodeGo, normalizeOpenCodeGoAdditionalTools, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -2363,7 +2363,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); - if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); + if (!forward && isOpenCodeGo(provider.baseUrl)) { + outBody = normalizeOpenCodeGoAgentMessages(outBody); + outBody = normalizeOpenCodeGoAdditionalTools(outBody); + } outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. diff --git a/src/adapters/opencode-go.ts b/src/adapters/opencode-go.ts index 94055a292a..d189d996c7 100644 --- a/src/adapters/opencode-go.ts +++ b/src/adapters/opencode-go.ts @@ -1,3 +1,9 @@ +import { customToolWireName } from "../responses/custom-tool-compat"; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + /** Match the Go destination, including user-renamed provider entries. */ export function isOpenCodeGo(baseUrl: string): boolean { try { @@ -6,6 +12,102 @@ export function isOpenCodeGo(baseUrl: string): boolean { } catch { return false; } } +/** Plaintext part types Console Go accepts inside a converted message. */ +const GO_PLAINTEXT_PART_TYPES = ["input_text", "input_image", "input_file"]; + +/** Wire-safe content part check for Console Go message conversion. */ +function isGoPlaintextPart(part: unknown): boolean { + return !!part && typeof part === "object" && !Array.isArray(part) + && GO_PLAINTEXT_PART_TYPES.includes((part as { type?: unknown }).type as string); +} + +/** Dedupe identity for promoted declarations: type plus wire name (namespace-aware). */ +function toolIdentityKey(tool: unknown): string | undefined { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return undefined; + const rec = tool as { type?: unknown; name?: unknown; namespace?: unknown }; + if (typeof rec.type !== "string" || typeof rec.name !== "string") return undefined; + // Compare by wire identity so a flat declaration and the same tool inside the + // builtin `functions` namespace group dedupe instead of doubling upstream, + // where duplicate function names are rejected. + return `${rec.type}\n${customToolWireName(typeof rec.namespace === "string" ? rec.namespace : undefined, rec.name)}`; +} + +/** + * Promote Codex Desktop's responses-lite `additional_tools` input items to top-level + * `tools` and drop the items. The parser already collects these declarations into the + * tool surface, but the outbound body keeps the item verbatim and Console Go's validator + * rejects the unknown item type (`input[N] did not match any supported type`). Promoting + * preserves every declaration (deduplicated by wire identity, descending into namespace + * groups so a flat declaration and the same tool inside a group do not double upstream) + * in the standard shape the downstream namespace/custom lowering passes already handle. + */ +export function normalizeOpenCodeGoAdditionalTools(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + if (!Array.isArray(record.input)) return body; + const existing = Array.isArray(record.tools) ? (record.tools as unknown[]) : []; + const seen = new Set(); + // Claim a child declaration; returns false for duplicates and unidentifiable + // entries. Claiming inside the filter (rather than after it) keeps two equal + // children of the same container from both surviving. + const claim = (child: unknown): boolean => { + const key = toolIdentityKey(child); + if (key === undefined || seen.has(key)) return false; + seen.add(key); + return true; + }; + // Output buckets: top-level declarations, with at most one container per + // namespace name. Existing containers are copied before merging so the + // caller's declarations are never mutated. + const outTools: unknown[] = []; + const groupSlot = new Map(); + const mergeGroup = (name: string, first: Record, kept: unknown[]): void => { + const slot = groupSlot.get(name); + if (slot === undefined) { + const group = { ...first, tools: [] as unknown[] }; + groupSlot.set(name, outTools.length); + outTools.push(group); + (group.tools as unknown[]).push(...kept); + return; + } + const current = outTools[slot] as Record; + outTools[slot] = { ...current, tools: [...(current.tools as unknown[]), ...kept] }; + }; + let dropped = false; + // Normalize one declaration into the output buckets, dropping duplicates and + // unidentifiable entries. Used for pre-existing top-level tools and promoted + // item tools alike so both paths share one invariant. + const ingest = (tool: unknown): void => { + if (!isRecord(tool)) { dropped = true; return; } + if (tool.type === "namespace" && typeof tool.name === "string" && Array.isArray(tool.tools)) { + const kept = (tool.tools as unknown[]).filter(child => claim(child)); + if (kept.length === 0) { dropped = true; return; } + mergeGroup(tool.name, tool, kept); + return; + } + if (!claim(tool)) { dropped = true; return; } + outTools.push(tool); + }; + for (const tool of existing) ingest(tool); + let changed = false; + const input: unknown[] = []; + for (const item of record.input as unknown[]) { + if (!isRecord(item) + || item.type !== "additional_tools" + || !Array.isArray(item.tools)) { + input.push(item); + continue; + } + changed = true; + // Entries without a type/name identity cannot be matched by any downstream + // pass (namespace/custom lowering and tool_choice filtering all key on them); + // promoting them would only add a guaranteed-400 entry on a closed validator. + for (const tool of item.tools as unknown[]) ingest(tool); + } + if (!changed && !dropped) return body; + return { ...record, input, tools: outTools }; +} + /** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; @@ -16,9 +118,17 @@ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const message = item as Record; if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; - // Genuine ciphertext and unknown part types must retain their existing fail-closed path. - if (!message.content.every(part => part && typeof part === "object" - && ["input_text", "input_image", "input_file"].includes(part.type))) return item; + // Genuine ciphertext and unknown part types must retain their existing fail-closed path + // when no plaintext survives: an empty message would be rejected too. But a MIXED item + // (plaintext task envelope beside inter-agent ciphertext) must not fail the whole + // request: Console Go can never decode ciphertext minted for another Codex agent, and + // its validator rejects the unknown agent_message type outright. Convert carrying only + // the plaintext parts so the task envelope still reaches the model. + const plaintext = (message.content as unknown[]).filter(isGoPlaintextPart); + if (plaintext.length === 0) return item; + const content = plaintext.length === (message.content as unknown[]).length + ? (message.content as unknown[]) + : plaintext; const identities = Object.fromEntries(["author", "recipient"] .filter(key => typeof message[key] === "string") .map(key => [key, message[key]])); @@ -27,7 +137,7 @@ export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { type: "message", role: "user", content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), - ...message.content, + ...content, ], }; }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index ef7cb59e00..c1103e62bb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1616,6 +1616,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Zen Go can close a Chat stream after a fully assembled function call without sending // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. openaiChatEofTolerance: true, + // Console Go rejects replayed reasoning.encrypted_content combined with + // previous_response_id ("reasoning.encrypted_content cannot be used with + // previous_response_id"), so chained tool turns must go out stateless: + // full explicit history, no server-side continuation. Verified live + // (chained 400 without, 200 with). + statelessResponses: true, /* [Decision Log] - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index ce8f9591d1..26a86dc089 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -21,7 +21,8 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function customToolWireName(namespace: string | undefined, name: string): string { +/** Wire name for a routed custom tool: the builtin functions namespace collapses to the bare name. */ +export function customToolWireName(namespace: string | undefined, name: string): string { return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); } diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/providers/opencode-go-agent-messages.test.ts index f79f529a5e..ee7c9c4272 100644 --- a/tests/providers/opencode-go-agent-messages.test.ts +++ b/tests/providers/opencode-go-agent-messages.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { isOpenCodeGo, normalizeOpenCodeGoAdditionalTools, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -30,6 +30,162 @@ test("ciphertext and unknown content are never reclassified as plaintext", () => } }); +test("mixed plaintext and inter-agent ciphertext converts carrying only plaintext", () => { + const text = { type: "input_text", text: "Message Type: NEW_TASK\nPayload" }; + const raw = { input: [{ type: "agent_message", id: "amsg_mixed", author: "/root", recipient: "/root/worker", content: [text, { type: "encrypted_content" }] }] }; + const original = structuredClone(raw); + const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + expect(result).not.toBe(raw); + expect(result.input[0]!.type).toBe("message"); + expect(result.input[0]!.role).toBe("user"); + expect(result.input[0]!.content[0].text).toContain('\"author\":\"/root\"'); + expect(result.input[0]!.content[1]).toBe(text); + expect(result.input[0]!.content).toHaveLength(2); + expect(raw).toEqual(original); +}); + +test("mixed image, text and unknown parts keep only wire-safe parts", () => { + const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; + const text = { type: "input_text", text: "Inspect image" }; + const raw = { input: [{ type: "agent_message", content: [text, image, { type: "future_type", text: "opaque" }] }] }; + const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + expect(result.input[0]!.type).toBe("message"); + expect(result.input[0]!.content).toEqual([text, image]); +}); + +test("additional_tools items promote to top-level tools and leave input", () => { + const exec = { type: "custom", name: "exec", description: "run" }; + const group = { type: "namespace", name: "functions", tools: [exec] }; + const raw = { input: [ + { type: "additional_tools", id: "at_1", role: "developer", tools: [group] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ] }; + const original = structuredClone(raw); + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result).not.toBe(raw); + expect(result.input.map((i: { type: string }) => i.type)).toEqual(["message"]); + expect(result.tools).toEqual([group]); + expect(raw).toEqual(original); +}); + +test("additional_tools merge dedupes against existing top-level tools", () => { + const exec = { type: "custom", name: "exec", description: "run" }; + const raw = { + tools: [{ type: "custom", name: "exec", description: "run" }], + input: [{ type: "additional_tools", tools: [exec, { type: "function", name: "search" }] }], + }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result.tools).toHaveLength(2); + expect(result.tools[1]).toEqual({ type: "function", name: "search" }); + expect(result.input).toEqual([]); +}); + +test("bodies without additional_tools items keep their reference", () => { + const raw = { input: [{ type: "message", role: "user", content: [] }] }; + expect(normalizeOpenCodeGoAdditionalTools(raw)).toBe(raw); + expect(normalizeOpenCodeGoAdditionalTools(null)).toBe(null); +}); + +test("pre-existing top-level duplicates collapse without items", () => { + const exec = { type: "custom", name: "exec", description: "run" }; + const raw = { + tools: [exec, { type: "custom", name: "exec", description: "run" }], + input: [{ type: "message", role: "user", content: [] }], + }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result).not.toBe(raw); + expect(result.tools).toEqual([exec]); + expect(result.input).toEqual(raw.input); +}); + +test("malformed entries without type/name are skipped, not promoted", () => { + const raw = { input: [{ type: "additional_tools", tools: [{ name: "x" }, "nope", 42, null] as unknown[] }] }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([]); + expect(result.tools).toEqual([]); +}); + +test("flat and builtin-namespaced duplicates promote once", () => { + const flat = { type: "custom", name: "exec", description: "run" }; + const grouped = { type: "namespace", name: "functions", tools: [{ type: "custom", name: "exec", description: "run" }] }; + const raw = { + tools: [flat], + input: [{ type: "additional_tools", tools: [grouped] }], + }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result.tools).toEqual([flat]); + expect(result.input).toEqual([]); +}); + +test("namespace groups keep only unseen children", () => { + const grouped = { type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + { type: "custom", name: "apply_patch", description: "patch" }, + ] }; + const raw = { + tools: [{ type: "custom", name: "exec", description: "run" }], + input: [{ type: "additional_tools", tools: [grouped] }], + }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result.tools).toEqual([ + { type: "custom", name: "exec", description: "run" }, + { type: "namespace", name: "functions", tools: [{ type: "custom", name: "apply_patch", description: "patch" }] }, + ]); + expect(result.input).toEqual([]); +}); + +test("matching namespace containers merge across additional_tools items", () => { + const raw = { + input: [ + { type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + ] }] }, + { type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + { type: "custom", name: "apply_patch", description: "patch" }, + ] }] }, + ], + }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.input).toEqual([]); + expect(result.tools).toEqual([ + { type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + { type: "custom", name: "apply_patch", description: "patch" }, + ] }, + ]); +}); + +test("duplicate children inside one container survive once", () => { + const raw = { input: [{ type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + { type: "custom", name: "exec", description: "run" }, + ] }] }] }; + const result = normalizeOpenCodeGoAdditionalTools(raw) as { input: unknown[]; tools: unknown[] }; + expect(result.tools).toEqual([ + { type: "namespace", name: "functions", tools: [{ type: "custom", name: "exec", description: "run" }] }, + ]); +}); + +test("item containers merge into pre-existing top-level containers", () => { + const raw = { + tools: [{ type: "namespace", name: "functions", tools: [{ type: "custom", name: "exec", description: "run" }] }], + input: [{ type: "additional_tools", tools: [{ type: "namespace", name: "functions", tools: [ + { type: "custom", name: "apply_patch", description: "patch" }, + ] }] }], + }; + const original = structuredClone(raw); + const result = normalizeOpenCodeGoAdditionalTools(raw) as typeof raw & { tools: unknown[] }; + expect(result.tools).toEqual([ + { type: "namespace", name: "functions", tools: [ + { type: "custom", name: "exec", description: "run" }, + { type: "custom", name: "apply_patch", description: "patch" }, + ] }, + ]); + expect(result.input).toEqual([]); + expect(raw).toEqual(original); +}); + test("image parts stay intact beside the assignment", () => { const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; @@ -155,23 +311,44 @@ test("Go conversion preserves file payloads beside text without mutating raw rep for (const { name, content } of [ { name: "empty content", content: [] }, +]) test(`Go preserves ${name} without partially converting it`, async () => { + const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; + const original = structuredClone(raw); + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input[0]).toMatchObject({ type: "agent_message", content }); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +for (const { name, content, surviving } of [ { name: "text mixed with an unknown part", content: [ { type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Do not lose this" }, - ] }, + ], surviving: [{ type: "input_text", text: "Known prefix" }] }, { name: "text mixed with ciphertext", content: [ { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, - ] }, -]) test(`Go preserves ${name} without partially converting it`, async () => { + ], surviving: [{ type: "input_text", text: "Routing header" }] }, +]) test(`Go converts ${name} carrying only wire-safe parts`, async () => { const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; const original = structuredClone(raw); - expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); const parsed = parseRequest(raw); const budget = createTranslatorBudget(); try { const request = await createResponsesPassthroughAdapter(base).buildRequest(parsed, { headers: new Headers(), translatorBudget: budget, }); - expect(JSON.parse(request.body as string).input[0]).toMatchObject({ type: "agent_message", content }); + const sent = JSON.parse(request.body as string).input[0]; + expect(sent.type).toBe("message"); + expect(sent.role).toBe("user"); + expect(sent.content.slice(1)).toEqual(surviving); expect(parsed._rawBody).toBe(raw); expect(raw).toEqual(original); } finally { diff --git a/tests/providers/opencode-go-grok46-responses.test.ts b/tests/providers/opencode-go-grok46-responses.test.ts index 19c2addd46..56b4fb90ad 100644 --- a/tests/providers/opencode-go-grok46-responses.test.ts +++ b/tests/providers/opencode-go-grok46-responses.test.ts @@ -68,7 +68,7 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { expect(body.tools).toEqual([functionTool]); }); - test("drops hosted search from an additional_tools-only request", () => { + test("promotes additional_tools-only declarations and drops the rejected item", () => { const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; const body = build("grok-4.6", { input: [{ @@ -77,7 +77,8 @@ describe("OpenCode Go Grok 4.6 Responses compatibility", () => { }], }); - expect(body.input).toEqual([{ type: "additional_tools", tools: [functionTool] }]); + expect(body.tools).toEqual([functionTool]); + expect(body.input).toEqual([]); }); test("disables an explicit choice for a removed hosted tool", () => { diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index 152783b9bd..d8d2879d0a 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -19,6 +19,12 @@ function opencodeGo(overrides: Partial = {}): OcxProviderConf return { ...providerConfigSeed(entry), apiKey: "test-key", ...overrides }; } +describe("OpenCode Go stateless Responses", () => { + test("registry seeds stateless chained turns (no previous_response_id upstream)", () => { + expect(providerConfigSeed(getProviderRegistryEntry("opencode-go")!).statelessResponses).toBe(true); + }); +}); + describe("OpenCode Go GPT 5.6 Luna wire selection (#1482)", () => { test("uses Responses from every inbound surface", () => { for (const inbound of ["responses", "chat", "anthropic"] as const) {