Skip to content
Merged
1 change: 1 addition & 0 deletions scripts/test-layout/layout-adapters.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"anthropic-active-limit-routing.test.ts": "adapters/anthropic",
"web-search-eof.test.ts": "web-search",
"web-search-memory-citation.test.ts": "web-search",
"openai-chat-single-system.test.ts": "adapters/openai",
"fix-search-frame-shapes.test.ts": "web-search",
"fix-search-orphan-source.test.ts": "web-search",
Expand Down
13 changes: 11 additions & 2 deletions src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "../responses/thought-signature-replay";
import {
createCitationMarkerFilter,
escapeMemoryCitationMentions,
stripCitationMarkers,
type CitationMarkerFilter,
} from "../responses/citation-markers";
Expand Down Expand Up @@ -99,6 +100,12 @@ function buildResponseJSONWithBudget(
bareCustomToolNames?: ReadonlySet<string>;
/** See `bridgeToResponsesSSE`: enforcement is separate from normalization (#4735). */
enforceDeclaredToolNames?: boolean;
/**
* Escape a quoted `<oai-mem-citation>` tag in visible text (default true). The Codex client
* parses the tag and hides the rest of the answer; Chat Completions and Messages deliveries,
* whose clients never parse it, pass false so their text stays byte-for-byte.
*/
escapeMemoryCitationTags?: boolean;
/** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
freeformToolNames?: Set<string>;
Expand Down Expand Up @@ -256,7 +263,9 @@ function buildResponseJSONWithBudget(
// ChatGPT-backend citation markers arrive as literal private-use characters that the
// Codex TUI prints verbatim (#3150). Strip them here rather than at the accumulator so
// the retained byte accounting above still describes what the upstream actually sent.
const text = stripCitationMarkers(currentTextStr);
// A quoted memory-citation tag is escaped for the Codex client only (citation-markers.ts).
const stripped = stripCitationMarkers(currentTextStr);
const text = options?.escapeMemoryCitationTags === false ? stripped : escapeMemoryCitationMentions(stripped);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
const annotations = pendingWebSources.map(s => ({
type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
Expand Down Expand Up @@ -406,7 +415,7 @@ function buildResponseJSONWithBudget(
flushSummaryReasoning();
flushRawReasoning();
flushToolCall();
for (const item of anthropicProgressItems(e.text, e.anthropicThinkingSignature, budget)) pushOutput(item as OutputItem);
for (const item of anthropicProgressItems(e.text, e.anthropicThinkingSignature, budget, options?.escapeMemoryCitationTags !== false)) pushOutput(item as OutputItem);
break;
}
// Only flush on an explicit phase change. A later delta that omits `phase` must keep
Expand Down
51 changes: 43 additions & 8 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ import {
import { resolveStallTimeoutSec } from "../stall-timeout";
import {
createCitationMarkerFilter,
createMemoryCitationEscaper,
escapeMemoryCitationMentions,
passthroughMemoryCitationEscaper,
stripCitationMarkers,
type CitationMarkerFilter,
type MemoryCitationEscaper,
} from "../responses/citation-markers";
import { declaresCodeModeExec, normalizeDeclaredToolName } from "../types";
import { appendSafeWebSearchSource, safeWebSearchSources } from "../web-search/sources";
Expand Down Expand Up @@ -132,6 +136,12 @@ export function bridgeToResponsesSSE(
* would reach the client under the invented name instead of the normalized one.
*/
enforceDeclaredToolNames?: boolean;
/**
* Escape a quoted `<oai-mem-citation>` tag in visible text (default true). The Codex client
* parses the tag and hides the rest of the answer; Chat Completions and Messages deliveries,
* whose clients never parse it, pass false so their text stays byte-for-byte.
*/
escapeMemoryCitationTags?: boolean;
/**
* Deliver a non-retryable upstream refusal as `response.failed` with a terminal-refusal code
* instead of `response.incomplete` (see `./terminal-refusal`). Set only for the Responses
Expand Down Expand Up @@ -167,6 +177,8 @@ export function bridgeToResponsesSSE(
};
},
): ReadableStream<Uint8Array> {
const escapeTags = options?.escapeMemoryCitationTags !== false;
const escapeVisibleTags = (text: string): string => escapeTags ? escapeMemoryCitationMentions(text) : text;
const proxyIncompleteContext = options?.proxyErrorContext ?? {};
const replayCacheScope = options?.replayCacheScope;
const maxOutputCompletion = createMaxOutputCompletion(options);
Expand Down Expand Up @@ -381,6 +393,9 @@ export function bridgeToResponsesSSE(
outputIndex: number;
text: StringChunks;
citationFilter: CitationMarkerFilter;
memoryCitationEscaper: MemoryCitationEscaper;
/** Bytes the escaper currently holds, charged to the translator budget. */
heldBytes: number;
phase?: OcxMessagePhase;
} | null = null;
let currentReasoning: { itemId: string; outputIndex: number; text: StringChunks } | null = null;
Expand Down Expand Up @@ -570,13 +585,20 @@ export function bridgeToResponsesSSE(
// accumulated text: closeCurrentMessage re-sends it in output_text.done and
// output_item.done, so filtering only the deltas would leave the markers in both.
const trailing = currentMsg.citationFilter.flush();
if (trailing) {
const escapedTrailing = trailing ? currentMsg.memoryCitationEscaper.push(trailing) : "";
// A held tail is a partial tag whose whole-message context is now known: resolve it once
// against the accumulated text so a real block's trailing close tag survives.
const heldTail = currentMsg.memoryCitationEscaper.pending();
const resolvedTail = heldTail ? escapeVisibleTags(heldTail) : "";
currentMsg.heldBytes = replaceRetainedString(currentMsg.heldBytes, "", "live_transient");
const finalDelta = escapedTrailing + resolvedTail;
if (finalDelta) {
emit("response.output_text.delta", {
item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
content_index: 0, delta: trailing,
content_index: 0, delta: finalDelta,
});
}
const messageText = stripCitationMarkers(joinChunks(currentMsg.text));
const messageText = escapeVisibleTags(stripCitationMarkers(joinChunks(currentMsg.text)));
// Chat Completions has no message-phase field. Keep its live item provisional, then
// classify it only when the next adapter event proves whether this text led into more
// work or completed the turn. Explicit adapter phases always outrank this inference.
Expand Down Expand Up @@ -982,13 +1004,15 @@ export function bridgeToResponsesSSE(
flushHiddenRawReasoning();
flushHiddenReasoningEnvelope();
if (currentToolCall) closeCurrentToolCall();
for (const item of anthropicProgressItems(event.text, event.anthropicThinkingSignature, budget)) {
for (const item of anthropicProgressItems(event.text, event.anthropicThinkingSignature, budget, escapeTags)) {
emit("response.output_item.added", { output_index: outputIndex, item: { ...item, ...(item.type === "message" ? { status: "in_progress", content: [] } : {}) } });
if (item.type === "message") {
const part = item.content![0];
emit("response.content_part.added", { item_id: item.id, output_index: outputIndex, content_index: 0, part: { ...part, text: "" } });
emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: event.text });
emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: event.text });
// The visible copy is escaped; the carrier envelope above keeps the raw text.
const visibleText = escapeVisibleTags(event.text);
emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: visibleText });
emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: visibleText });
emit("response.content_part.done", { item_id: item.id, output_index: outputIndex, content_index: 0, part });
}
emit("response.output_item.done", { output_index: outputIndex, item });
Expand Down Expand Up @@ -1026,6 +1050,8 @@ export function bridgeToResponsesSSE(
currentMsg = {
itemId, outputIndex, text: emptyChunks(),
citationFilter: createCitationMarkerFilter(),
memoryCitationEscaper: escapeTags ? createMemoryCitationEscaper() : passthroughMemoryCitationEscaper,
heldBytes: 0,
...(event.phase ? { phase: event.phase } : {}),
};
}
Expand All @@ -1038,10 +1064,19 @@ export function bridgeToResponsesSSE(
// unterminated tail and releases it at close (#3150). The accumulator above
// keeps the raw text; it is stripped once in closeCurrentMessage.
const visible = currentMsg.citationFilter.push(event.text);
if (visible) {
// Escape a literal memory-citation tag: the Codex client hides everything from an
// opening tag to the first closing one (or to the end when none follows), so one
// quoted mention in prose swallows the rest of the answer and reads as a truncated
// turn. This runs per
// delta, so it uses the streaming escaper: a tag arrives split across deltas, and a
// whole-string match inside a single delta would miss it.
const emitted = visible ? currentMsg.memoryCitationEscaper.push(visible) : "";
currentMsg.heldBytes = replaceRetainedString(
currentMsg.heldBytes, currentMsg.memoryCitationEscaper.pending(), "live_transient");
if (emitted) {
emit("response.output_text.delta", {
item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
content_index: 0, delta: visible,
content_index: 0, delta: emitted,
});
}
break;
Expand Down
3 changes: 3 additions & 0 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ export interface ImageBridgeDeps {
forceEmptyResponseId?: boolean;
/** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */
terminalRefusalAsFailed?: boolean;
/** False for Chat/Messages inbound: their clients never parse `<oai-mem-citation>`, so text stays raw. */
escapeMemoryCitationTags?: boolean;
}

/**
Expand Down Expand Up @@ -1221,6 +1223,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
...(deps.clientToolCatalogRestriction ? { ...deps.clientToolCatalogRestriction, enforceDeclaredToolNames: false } : {}),
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
...(deps.terminalRefusalAsFailed ? { terminalRefusalAsFailed: true } : {}),
...(deps.escapeMemoryCitationTags === false ? { escapeMemoryCitationTags: false } : {}),
hideThinkingSummary: parsed.options.hideThinkingSummary,
chatRawReasoningVisible: deps.chatRawReasoningVisible,
hideRawReasoning: parsed.options.hideRawReasoning,
Expand Down
14 changes: 10 additions & 4 deletions src/responses/anthropic-progress.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { escapeMemoryCitationMentions } from "./citation-markers";
import { encodeReasoningEnvelope, decodeReasoningEnvelope, type ReasoningEnvelope } from "./reasoning-envelope";
import type { TranslatorBudget } from "../lib/translator-budget";

/** A visible update is a presentation copy of one original signed thinking block. */
export function anthropicProgressItems(text: string, signature: string, budget?: TranslatorBudget) {
/**
* A visible update is a presentation copy of one original signed thinking block. The copy is
* escaped for a quoted memory-citation tag; the envelope keeps the raw text so the signed payload
* is unchanged.
*/
export function anthropicProgressItems(text: string, signature: string, budget?: TranslatorBudget, escapeVisible = true) {
const id = `msg_${crypto.randomUUID().replaceAll("-", "")}`;
return [
{ type: "reasoning", id: `rs_${crypto.randomUUID().replaceAll("-", "")}`, summary: [],
encrypted_content: encodeReasoningEnvelope({ sig: signature, txt: text, upd: id }, budget) },
{ type: "message", id, role: "assistant", status: "completed", phase: "commentary",
content: [{ type: "output_text", text, annotations: [] }] },
content: [{ type: "output_text", text: escapeVisible ? escapeMemoryCitationMentions(text) : text, annotations: [] }] },
];
}

Expand All @@ -22,7 +27,8 @@ export function isAnthropicProgressMirror(item: unknown, envelope: ReasoningEnve
if (msg.role !== "assistant" || msg.phase !== "commentary" || (msg.id !== undefined && msg.id !== envelope.upd)) return false;
if (!Array.isArray(msg.content) || msg.content.length !== 1) return false;
const part = msg.content[0] as { type?: unknown; text?: unknown } | undefined;
return part?.type === "output_text" && part.text === envelope.txt;
return part?.type === "output_text"
&& (part.text === envelope.txt || part.text === escapeMemoryCitationMentions(envelope.txt));
}

export function anthropicProgressEnvelope(item: unknown, budget?: TranslatorBudget): ReasoningEnvelope | null {
Expand Down
Loading
Loading