Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(api): add `encrypted_function_args: []` plaintext marker to collaboration function_call items in the Responses translators (#14154)
2 changes: 2 additions & 0 deletions open-sse/handlers/chatCore/nonStreamingClientTranslate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { isStripReasoningRequested } from "./headers.ts";
import { applyClientUsageBuffer } from "./clientUsageBuffer.ts";
import { resolveRequestToolIdentity } from "../../translator/response/openai-responses/requestToolIdentity.ts";
import { plaintextCollaborationFields } from "../../translator/response/openai-responses/collaborationPlaintextMarker.ts";

export type { NonStreamingClientTranslateInput, NonStreamingClientTranslateResult };

Expand Down Expand Up @@ -180,6 +181,7 @@ export function translateNonStreamingClientResponse(
item.namespace = identity.namespace;
item.name = identity.name;
}
Object.assign(item, plaintextCollaborationFields(item.namespace, item.name));
}
}
} else if (clientResponseFormat === FORMATS.OPENAI) {
Expand Down
13 changes: 12 additions & 1 deletion open-sse/handlers/responsesHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export async function handleResponsesCore({
modelInfo?.model
);

// #14154 — capture the #7936 {namespace, name} identity ledger BEFORE
// handleChatCore dispatches: extractRequestToolIdentityMap() deletes this
// side channel from the same object once the request is translated, so it
// must be read here to reach the response transform stream below.
const requestToolIdentityMap =
(convertedBody as { _namespaceToolIdentityMap?: Map<string, unknown> })
._namespaceToolIdentityMap ?? null;

// Ensure stream is enabled
convertedBody.stream = true;

Expand Down Expand Up @@ -86,7 +94,10 @@ export async function handleResponsesCore({
}

// Transform SSE stream to Responses API format (no logging in worker)
const transformStream = createResponsesApiTransformStream(null, undefined, { customToolNames });
const transformStream = createResponsesApiTransformStream(null, undefined, {
customToolNames,
requestToolIdentityMap,
});
const transformedBody = response.body.pipeThrough(transformStream).pipeThrough(
createSseHeartbeatTransform({
signal,
Expand Down
24 changes: 23 additions & 1 deletion open-sse/transformer/responsesTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from "../utils/reasoningPlaceholder.ts";
import * as fs from "fs";
import * as path from "path";
import { resolveRequestToolIdentity } from "../translator/response/openai-responses/requestToolIdentity.ts";
import { plaintextCollaborationFields } from "../translator/response/openai-responses/collaborationPlaintextMarker.ts";

// #10223: threshold for detecting corrupted request_id fields. Normal
// request IDs are <100 chars. DeepSeek's SSE encoder bug produces 200+
Expand Down Expand Up @@ -192,9 +194,18 @@ export function createResponsesLogger(model, logsDir = null) {
export function createResponsesApiTransformStream(
logger = null,
keepaliveIntervalMs = 3000,
options: { customToolNames?: Iterable<string> } = {}
options: {
customToolNames?: Iterable<string>;
requestToolIdentityMap?: ReadonlyMap<string, unknown> | null;
} = {}
) {
const customToolNames = new Set(options.customToolNames || []);
// #14154 — #7936-style {namespace, name} identity restoration was missing
// entirely on this emitter (unlike the streaming translator / non-streaming
// client translator). Carried through so function_call/custom_tool_call
// items round-trip their namespace, and so the collaboration plaintext
// marker below can be gated on the restored namespace.
const requestToolIdentityMap = options.requestToolIdentityMap ?? null;
const state = {
seq: 0,
responseId: `resp_${Date.now()}`,
Expand Down Expand Up @@ -526,6 +537,17 @@ export function createResponsesApiTransformStream(
};
}

// #14154 — restore the request-declared {namespace, name} identity (matching
// the streaming translator / non-streaming client translator, #7936) and, when
// the restored identity is a Codex collaboration call, stamp the
// encrypted_function_args:[] plaintext-delivery marker Codex requires.
const identity = resolveRequestToolIdentity(requestToolIdentityMap, toolName);
if (identity) {
funcItem.namespace = identity.namespace;
funcItem.name = identity.name;
}
Object.assign(funcItem, plaintextCollaborationFields(funcItem.namespace, funcItem.name));

emit(controller, "response.output_item.done", {
type: "response.output_item.done",
output_index: normalizedIndex,
Expand Down
15 changes: 3 additions & 12 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts";
import { applyFunctionCallIdentity } from "./openai-responses/functionCallIdentity.ts";
import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts";
import {
synthesizeCompletedToolCalls,
Expand Down Expand Up @@ -741,18 +742,8 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
status: "completed",
};

// #7936 identity closure: rewrite the function_call item's `name` back to
// its bare leaf and stamp the original `namespace` alongside it, matching
// the codex ResponseItem::FunctionCall schema (independent `namespace`
// field, NOT a `__` split on `name`).
const fnIdentity = resolveRequestToolIdentity(
state.requestToolIdentityMap,
state.funcNames[idx] || ""
);
if (fnIdentity) {
funcItem.namespace = fnIdentity.namespace;
funcItem.name = fnIdentity.name;
}
// #7936/#14154 identity closure + collaboration plaintext marker.
applyFunctionCallIdentity(funcItem, state.requestToolIdentityMap, state.funcNames[idx] || "");

emit("response.output_item.done", {
type: "response.output_item.done",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// #14154 — Codex MultiAgent V2 normally treats a collaboration function_call's
// arguments as backend ciphertext. An empty `encrypted_function_args` list is
// the protocol's explicit plaintext-delivery marker (codex-rs
// ToolCall::direct_source). The vendored ChatGPT-web bridge already emits it
// (open-sse/vendor/codex-chatgpt-web/bridge.ts) for its own three call sites;
// this shared helper gives the general Responses-API output emitters
// (streaming translator, non-streaming client translator, Responses
// transformer) the same contract so every non-OpenAI chat-format upstream
// gets the marker too.

const PLAINTEXT_COLLABORATION_CALLS = new Set(["spawn_agent", "send_message", "followup_task"]);

/**
* Return `{ encrypted_function_args: [] }` when `namespace`/`name` identify a
* collaboration call that Codex expects delivered as plaintext, otherwise an
* empty object — safe to spread onto any function_call item unconditionally.
*/
export function plaintextCollaborationFields(
namespace: string | undefined,
name: string | undefined
): Record<string, unknown> {
return namespace === "collaboration" && !!name && PLAINTEXT_COLLABORATION_CALLS.has(name)
? { encrypted_function_args: [] }
: {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { resolveRequestToolIdentity } from "./requestToolIdentity.ts";
import { plaintextCollaborationFields } from "./collaborationPlaintextMarker.ts";

interface FunctionCallItemLike {
namespace?: string;
name?: string;
encrypted_function_args?: unknown[];
[key: string]: unknown;
}

/**
* #7936 identity closure: rewrite a function_call item's `name` back to its
* bare leaf and stamp the original `namespace` alongside it, matching the
* codex ResponseItem::FunctionCall schema (independent `namespace` field, NOT
* a `__` split on `name`). #14154: once the identity is restored, also stamp
* the `encrypted_function_args: []` plaintext-delivery marker Codex requires
* for collaboration spawn_agent/send_message/followup_task calls.
*
* Mutates `funcItem` in place and returns it for convenience at call sites.
*/
export function applyFunctionCallIdentity<T extends FunctionCallItemLike>(
funcItem: T,
identityMap: unknown,
toolName: string
): T {
const identity = resolveRequestToolIdentity(identityMap, toolName);
if (identity) {
funcItem.namespace = identity.namespace;
funcItem.name = identity.name;
}
Object.assign(funcItem, plaintextCollaborationFields(funcItem.namespace, funcItem.name));
return funcItem;
}
16 changes: 1 addition & 15 deletions open-sse/vendor/codex-chatgpt-web/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { encodeCompactionSummary } from "./responses/compaction";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
import { resolveStallTimeoutSec } from "./stall-timeout";
import { usageDisplayTotalTokens } from "./usage/totals";
import { plaintextCollaborationFields } from "../../translator/response/openai-responses/collaborationPlaintextMarker";

function uuid(): string {
return crypto.randomUUID().replace(/-/g, "");
Expand Down Expand Up @@ -85,21 +86,6 @@ interface OutputItem {
[key: string]: unknown;
}

const PLAINTEXT_COLLABORATION_CALLS = new Set(["spawn_agent", "send_message", "followup_task"]);

/**
* Codex MultiAgent V2 normally treats collaboration message arguments as backend ciphertext.
* An empty encrypted_function_args list is the protocol's explicit plaintext-delivery marker.
*/
function plaintextCollaborationFields(
namespace: string | undefined,
name: string
): Record<string, unknown> {
return namespace === "collaboration" && PLAINTEXT_COLLABORATION_CALLS.has(name)
? { encrypted_function_args: [] }
: {};
}

export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";

export function bridgeToResponsesSSE(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import test from "node:test";
import assert from "node:assert/strict";

// #14154 — Codex MultiAgent V2 treats a collaboration function_call's arguments as
// backend ciphertext UNLESS the item carries an explicit `encrypted_function_args: []`
// plaintext-delivery marker (codex-rs ToolCall::direct_source). The vendored ChatGPT-web
// bridge already emits that marker for spawn_agent/send_message/followup_task. The general
// Responses translator used for every non-OpenAI chat-format upstream does NOT.

const { openaiResponsesToOpenAIRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
const { openaiToOpenAIResponsesResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");

type NamespaceIdentity = { namespace: string; name: string };
type RuntimeState = ReturnType<typeof initState> & {
requestToolIdentityMap?: Map<string, NamespaceIdentity>;
};

function identityMapFor(namespace: string, name: string) {
const request = openaiResponsesToOpenAIRequest(
"any-model",
{
input: [
{
type: "additional_tools",
tools: [{ type: "namespace", name: namespace, tools: [{ name }] }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] },
],
},
false,
{ provider: "any-provider" }
) as { _toolNameMap?: Map<string, NamespaceIdentity> };
assert.ok(request._toolNameMap instanceof Map);
return request._toolNameMap;
}

function collectToolEvents(
name: string,
callId: string,
requestToolIdentityMap?: Map<string, NamespaceIdentity>
) {
const state = initState(FORMATS.OPENAI_RESPONSES) as RuntimeState;
state.requestToolIdentityMap = requestToolIdentityMap;
return openaiToOpenAIResponsesResponse(
{
id: "chatcmpl-14154",
model: "deepseek-chat",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: callId,
type: "function",
function: { name, arguments: '{"message":"Reply with exactly the word READY"}' },
},
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
state
);
}

type ResponseItem = {
type: string;
name: string;
namespace?: string;
encrypted_function_args?: unknown[];
};
type ResponseEvent = {
event: string;
data: { item?: ResponseItem; response?: { output?: ResponseItem[] } };
};

function doneItem(events: ResponseEvent[]) {
const done = events.find((event) => event.event === "response.output_item.done");
assert.ok(done?.data.item, "expected response.output_item.done");
return done.data.item as ResponseItem;
}

test("collaboration spawn_agent function_call carries encrypted_function_args:[] plaintext marker (#14154)", () => {
const wireName = "collaboration__spawn_agent";
const events = collectToolEvents(
wireName,
"call_spawn_agent",
identityMapFor("collaboration", "spawn_agent")
) as ResponseEvent[];
const item = doneItem(events);

assert.deepEqual(
{ namespace: item.namespace, name: item.name },
{ namespace: "collaboration", name: "spawn_agent" }
);

assert.deepEqual(
item.encrypted_function_args,
[],
"function_call item for a collaboration spawn_agent/send_message/followup_task call " +
"must carry encrypted_function_args: [] so Codex delivers the message as plaintext"
);
});

test("non-collaboration namespace function_call never carries encrypted_function_args (#14154)", () => {
const wireName = "search__web_search";
const events = collectToolEvents(
wireName,
"call_web_search",
identityMapFor("search", "web_search")
) as ResponseEvent[];
const item = doneItem(events);

assert.deepEqual(
{ namespace: item.namespace, name: item.name },
{ namespace: "search", name: "web_search" }
);
assert.equal(item.encrypted_function_args, undefined);
});

test("bare non-namespaced function_call never carries encrypted_function_args (#14154)", () => {
const events = collectToolEvents("get_weather", "call_weather", undefined) as ResponseEvent[];
const item = doneItem(events);

assert.equal(item.namespace, undefined);
assert.equal(item.encrypted_function_args, undefined);
});
Loading