Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,15 @@ collision-safe public function tool. Matching request history and JSON/SSE funct
translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward
keeps the native private type unchanged.

For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other
than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient
metadata. This conversion leaves encrypted or unknown content unchanged and does not apply
to other destinations. Providers using `authMode: "forward"` retain these items unchanged.
See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages)
Requests with `authMode` other than `"forward"` convert plaintext Codex `agent_message`
items into public user messages, preserving content parts and readable author/recipient
metadata. `agent_message` is private to the ChatGPT Codex backend, so a routed destination
that receives one rejects the entire body with
`422 unknown item type "agent_message"` — and because Codex replays sub-agent history on
every turn, that failure repeats for the rest of the thread. This conversion leaves
encrypted or unknown content unchanged. Providers using `authMode: "forward"` retain
these items unchanged.
See [agent messages](/reference/configuration/providers/#routed-agent-messages)
for the separate opt-in encrypted-task recovery behavior.

The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that
Expand Down
18 changes: 10 additions & 8 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -923,13 +923,15 @@ their previous behavior. See the
[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders).
`modelDisplayNames` on a provider controls readable labels without changing wire ids.

## OpenCode Go session and agent messages

With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and
base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items
become user messages when `authMode` is not `"forward"` (for example, `"key"`).
Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including
renamed provider entries; other Responses destinations keep their input unchanged.
## Routed agent messages

With the [`openai-responses` adapter](/reference/adapters/#openai-responses), plaintext
Codex `agent_message` items become user messages when `authMode` is not `"forward"`
(for example, `"key"`). Providers using `authMode: "forward"` retain these items unchanged.
`agent_message` is private to the ChatGPT Codex backend, so any routed destination that
receives one answers the whole request with
`422 unknown item type "agent_message"`; Codex replays sub-agent history on every
subsequent turn, so the thread keeps failing until the item is converted.
Author and recipient remain explicit text metadata, and the content parts are preserved.
Encrypted and unknown content is not normalized; native encrypted tasks still require the
separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery).
Expand All @@ -948,6 +950,6 @@ current tail message (ignoring trailing `compaction_trigger` or `additional_tool
It does not batch-recover unseen historical messages; those remain unchanged. A cache miss
or expiry does not extend the history-recovery contract.

Sender and recipient on Go Responses are context for the receiving model, not a new
Sender and recipient on routed Responses are context for the receiving model, not a new
machine-readable routing protocol. Tool routing continues to use the existing collaboration
contracts.
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,6 @@
"openai-responses-passthrough.test.ts": "responses",
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-agent-messages.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"opencode-go-luna-wire.test.ts": "providers",
Expand Down Expand Up @@ -1072,6 +1071,7 @@
"retry-after-429.test.ts": "server",
"route-decision-trace.test.ts": "server",
"route-explainability.test.ts": "cli",
"routed-agent-messages.test.ts": "adapters",
"router-combo-failover-classification.test.ts": "routing",
"router-discarded-baseurl-warning.test.ts": "routing",
"router-template-baseurl.test.ts": "routing",
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go";
import { normalizeRoutedAgentMessages } from "./routed-agent-messages";
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";
Expand Down Expand Up @@ -2363,7 +2363,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
parsed._rawBody,
forward || parsed._previousResponseInputExpanded === true,
);
if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody);
if (!forward) outBody = normalizeRoutedAgentMessages(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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/** Match the Go destination, including user-renamed provider entries. */
export function isOpenCodeGo(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1";
} catch { return false; }
}

/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */
export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown {
/**
* `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT
* Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so
* once a thread has used sub-agents, a routed Responses destination answers the whole body
* with `422 unknown item type "agent_message"` and every later turn of that thread fails the
* same way. Rewrite the item as the public user message it already is.
*
* Genuine ciphertext and unknown part types keep their existing fail-closed path: the
* encrypted v2 task surface owns those, through `unreadable_encrypted_agent_task` and the
* opt-in recovery route. Providers using `authMode: "forward"` never reach this function.
*/
export function normalizeRoutedAgentMessages(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
if (!Array.isArray(record.input)) return body;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages";
import { parseRequest } from "../../src/responses/parser";
import { routeModel } from "../../src/router";
import { createTranslatorBudget } from "../../src/lib/translator-budget";
Expand All @@ -26,17 +26,25 @@ test("Responses converts plaintext task and peer messages without mutating repla
test("ciphertext and unknown content are never reclassified as plaintext", () => {
for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) {
const raw = { input: [{ type: "agent_message", content: [part] }] };
expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw);
expect(normalizeRoutedAgentMessages(raw)).toBe(raw);
}
});

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] }] };
const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw;
const result = normalizeRoutedAgentMessages(raw) as typeof raw;
expect(result.input[0]!.content[1]).toBe(image);
});

test("a body with no agent messages keeps its exact reference", () => {
const raw = { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }] };
expect(normalizeRoutedAgentMessages(raw)).toBe(raw);
for (const shape of [null, "a string", [1, 2], { input: "not an array" }]) {
expect(normalizeRoutedAgentMessages(shape)).toBe(shape);
}
});

test("native forward keeps agent_message and auth/session headers unchanged", async () => {
const budget = createTranslatorBudget();
const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const };
Expand All @@ -48,11 +56,20 @@ test("native forward keeps agent_message and auth/session headers unchanged", as
budget.dispose();
});

test("other destinations do not get Go normalization or session identity", async () => {
test("an arbitrary routed destination converts too, and gains no session identity", async () => {
// The 422 this guards against is not destination-specific: `agent_message` is private to
// the ChatGPT Codex backend, so any routed Responses target rejects the whole body.
const budget = createTranslatorBudget();
const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget });
expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message");
const raw = body();
const original = structuredClone(raw);
const parsed = parseRequest(raw);
const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parsed, { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget });
const sent = JSON.parse(request.body as string);
expect(sent.input[0]).toMatchObject({ type: "message", role: "user" });
expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content);
expect(new Headers(request.headers).get("x-opencode-session")).toBeNull();
expect(parsed._rawBody).toBe(raw);
expect(raw).toEqual(original);
budget.dispose();
});

Expand Down Expand Up @@ -108,31 +125,29 @@ test.each([
"http://opencode.ai/zen/go/v1",
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/go/v10",
])("Go-like destination %s preserves private agent messages", async baseUrl => {
])("Go-like destination %s converts without inheriting any Go-specific handling", async baseUrl => {
// A spoofed or malformed Go URL is just another routed destination now. It gets the same
// conversion every routed target gets, and still no Go session identity.
const raw = body();
const original = structuredClone(raw);
const parsed = parseRequest(raw);
const budget = createTranslatorBudget();
try {
const request = await createResponsesPassthroughAdapter({ ...base, baseUrl }).buildRequest(parsed, {
headers: new Headers(), translatorBudget: budget,
});
expect(JSON.parse(request.body as string).input[0]).toMatchObject({
type: "agent_message", content: original.input[0]!.content,
headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget,
});
const sent = JSON.parse(request.body as string);
expect(sent.input[0]).toMatchObject({ type: "message", role: "user" });
expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content);
expect(new Headers(request.headers).get("x-opencode-session")).toBeNull();
expect(parsed._rawBody).toBe(raw);
expect(raw).toEqual(original);
} finally {
budget.dispose();
}
});

test.each(["not a URL", "https://", "/zen/go/v1"])(
"malformed destination %s is not classified as Go",
baseUrl => expect(isOpenCodeGo(baseUrl)).toBe(false),
);

test("Go conversion preserves file payloads beside text without mutating raw replay", async () => {
test("conversion preserves file payloads beside text without mutating raw replay", async () => {
const file = { type: "input_file", filename: "assignment.txt", file_data: "data:text/plain;base64,SGVsbG8=" };
const message = body().input[0]!;
const raw = { ...body(), input: [{ ...message, content: [...message.content, file] }] };
Expand Down Expand Up @@ -161,10 +176,10 @@ for (const { name, content } of [
{ 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 () => {
]) test(`routed destinations preserve ${name} without partially converting it`, async () => {
const raw = { ...body(), input: [{ ...body().input[0]!, content }] };
const original = structuredClone(raw);
expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw);
expect(normalizeRoutedAgentMessages(raw)).toBe(raw);
const parsed = parseRequest(raw);
const budget = createTranslatorBudget();
try {
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,6 @@
"openai-responses-passthrough.test.ts": "responses",
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-agent-messages.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"opencode-go-luna-wire.test.ts": "providers",
Expand Down Expand Up @@ -906,6 +905,7 @@
"retry-after-429.test.ts": "server",
"route-decision-trace.test.ts": "server",
"route-explainability.test.ts": "cli",
"routed-agent-messages.test.ts": "adapters",
"router-combo-failover-classification.test.ts": "routing",
"router-discarded-baseurl-warning.test.ts": "routing",
"router-template-baseurl.test.ts": "routing",
Expand Down
57 changes: 21 additions & 36 deletions tests/responses/responses-opaque-blob-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,23 @@ function serializedOutboundWithEncryptedAgentMessage(): string {
return JSON.stringify({ model: "model-a", input: agentMessageReplayInput() });
}

/**
* What a routed destination receives on the retry: recovery has replaced the undecryptable
* part with an omission marker, which leaves the item entirely plaintext, so the adapter
* converts it into the public user message a routed Responses schema can accept.
*/
function recoveredAgentMessage(): Record<string, unknown> {
return {
type: "message",
role: "user",
content: [
{ type: "input_text", text: 'Agent message {"author":"/root/child_task","recipient":"/root"}' },
{ type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" },
{ type: "input_text", text: "[encrypted content omitted]" },
],
};
}

function config(): OcxConfig {
return {
defaultProvider: "first",
Expand Down Expand Up @@ -550,15 +567,7 @@ describe("opaque blob recovery through /v1/responses", () => {

expect(outbound).toHaveLength(4);
const retriedInput = outbound.at(3)?.input as Array<Record<string, unknown>> | undefined;
expect(retriedInput?.at(0)).toEqual({
type: "agent_message",
author: "/root/child_task",
recipient: "/root",
content: [
{ type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" },
{ type: "input_text", text: "[encrypted content omitted]" },
],
});
expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage());
expect(retriedInput?.at(1)).toEqual(agentMessageReplayInput().at(1));
expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]);
});
Expand All @@ -580,15 +589,7 @@ describe("opaque blob recovery through /v1/responses", () => {
expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE);
expect(outbound).toHaveLength(2);
const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined;
expect(retriedInput?.at(0)).toEqual({
type: "agent_message",
author: "/root/child_task",
recipient: "/root",
content: [
{ type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" },
{ type: "input_text", text: "[encrypted content omitted]" },
],
});
expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage());
});

test("recovers a zero-output error-event decrypt failure before client relay", async () => {
Expand All @@ -608,15 +609,7 @@ describe("opaque blob recovery through /v1/responses", () => {
expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE);
expect(outbound).toHaveLength(2);
const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined;
expect(retriedInput?.at(0)).toEqual({
type: "agent_message",
author: "/root/child_task",
recipient: "/root",
content: [
{ type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" },
{ type: "input_text", text: "[encrypted content omitted]" },
],
});
expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage());
});

for (const streamMode of ["legacy-tee", "eager-relay"] as const) {
Expand Down Expand Up @@ -751,15 +744,7 @@ describe("opaque blob recovery through /v1/responses", () => {
expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE);
expect(outbound).toHaveLength(2);
const retriedInput = outbound.at(1)?.input as Array<Record<string, unknown>> | undefined;
expect(retriedInput?.at(0)).toEqual({
type: "agent_message",
author: "/root/child_task",
recipient: "/root",
content: [
{ type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" },
{ type: "input_text", text: "[encrypted content omitted]" },
],
});
expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage());
});

test("absent Content-Type decrypt stream does not recover a non-stream request", async () => {
Expand Down
Loading