From 73f1d2c1357a71e2d8952431941dc31d5e20eb01 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:59:54 +0900 Subject: [PATCH 1/7] fix(chat): preserve refusal across completion projections --- .../content/docs/reference/proxy-formats.md | 8 + src/chat/outbound.ts | 259 ++++++++-- src/server/chat-native-sse.ts | 1 + structure/04_transports-and-sidecars.md | 19 + tests/responses/chat-refusal.test.ts | 451 ++++++++++++++++++ 5 files changed, 711 insertions(+), 27 deletions(-) create mode 100644 tests/responses/chat-refusal.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index d2167bbfa45..7008194e194 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -260,6 +260,14 @@ It does not issue an additional inference request. An incomplete response caused token limit or content filtering retains `length` or `content_filter`, even if it includes tool output. Other incomplete boundaries return an upstream error instead of claiming a normal finish. +Refusal text stays separate from answer text: JSON completions use nullable `message.refusal`, +and streaming chunks use `delta.refusal`. Native Chat JSON-to-SSE and SSE-to-JSON conversions +preserve that field; native streaming relay preserves the provider's refusal deltas. On translated +Responses streams, refusal parts are buffered until the terminal event and emitted once in their +original output/content order. Compatible repeated or sparse snapshots do not duplicate or erase +text. Contradictory refusal snapshots and buffer overflow produce a typed error without a successful +finish or `[DONE]`. This preserves the upstream refusal; it does not introduce a proxy policy decision. + Because the internal execution path is Responses-based, a provider adapter can impose a narrower feature set. For example, a request feature that cannot be represented by the selected adapter is returned as an error instead of silently changing its meaning. diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 812f1b553f8..7646b63efba 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -8,7 +8,11 @@ type Rec = Record; import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder"; -import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; +import { + isTranslatorBudgetExceededError, + type TranslatorBudget, + type TranslatorTransientReservation, +} from "../lib/translator-budget"; import { classifyError, cyberPolicyErrorType, @@ -156,6 +160,14 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st return nextBytes; } +function refusalTranslationError(): ChatCompletionsStreamError { + // Never include provider-controlled refusal text or correlation IDs in diagnostics. + return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", { + type: "upstream_error", + code: "invalid_refusal", + }); +} + /** * Streaming: Responses SSE bytes -> Chat Completions SSE bytes. */ @@ -188,6 +200,115 @@ export function responsesSseToChatCompletionsSse( let emittedFrames = 0; let stepping = false; let decoderStarted = false; + // Raw output/content positions are the ordering authority; IDs only constrain identity. + // Charge a fixed entry allowance as well as keys/IDs so empty parts remain bounded. + const refusalEntryBytes = 64; + const refusalItems = new Map; + }>(); + let refusalMetadataBytes = 0; + let refusalTextBytes = 0; + const releaseRefusals = () => { + refusalItems.clear(); + translatorBudget.releaseRetained(refusalMetadataBytes, { kind: "item_ids" }); + translatorBudget.releaseRetained(refusalTextBytes, { kind: "retained_collectors" }); + refusalMetadataBytes = 0; + refusalTextBytes = 0; + }; + const position = (value: unknown): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw refusalTranslationError(); + } + return value; + }; + const chargeRefusalMetadata = (bytes: number) => { + translatorBudget.chargeRetained(bytes, { kind: "item_ids" }); + refusalMetadataBytes += bytes; + }; + const refusalItem = (outputIndex: unknown, source: Rec, idField: string) => { + const index = position(outputIndex); + const hasId = Object.hasOwn(source, idField); + const candidate = source[idField]; + if (hasId && typeof candidate !== "string") throw refusalTranslationError(); + let item = refusalItems.get(index); + if (!item) { + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index))); + item = { parts: new Map() }; + refusalItems.set(index, item); + } + if (hasId && typeof candidate === "string") { + if (item.id !== undefined && item.id !== candidate) throw refusalTranslationError(); + if (item.id === undefined) { + chargeRefusalMetadata(Buffer.byteLength(candidate)); + item.id = candidate; + } + } + return item; + }; + const retainRefusal = (outputIndex: unknown, contentIndex: unknown, source: Rec, + idField: string, evidence: Rec, field: string, delta = false) => { + const item = refusalItem(outputIndex, source, idField); + const index = position(contentIndex); + let part = item.parts.get(index); + if (!part) { + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index))); + part = { text: "", bytes: 0, present: false }; + item.parts.set(index, part); + } + if (!Object.hasOwn(evidence, field)) { + if (delta) throw refusalTranslationError(); + return; + } + const candidate = evidence[field]; + if (typeof candidate !== "string") throw refusalTranslationError(); + part.present = true; + if (!delta) { + // Equal, empty, and stale-prefix snapshots add no evidence; never erase deltas. + if (part.text.startsWith(candidate)) return; + if (!candidate.startsWith(part.text)) throw refusalTranslationError(); + } + const nextBytes = delta ? appendedUtf8Bytes(part.text, part.bytes, candidate) : Buffer.byteLength(candidate); + const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + try { + const next = delta ? part.text + candidate : candidate; + reservation.commitRetained(); + translatorBudget.releaseRetained(part.bytes, { kind: "retained_collectors" }); + refusalTextBytes += nextBytes - part.bytes; + part.text = next; + part.bytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => { + const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined; + if (item.type !== "message") { + if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError(); + return; + } + // Unrelated sparse text messages historically need no position metadata. + if (outputIndex === undefined && (!Array.isArray(item.content) + || !item.content.some(part => isRec(part) && part.type === "refusal"))) return; + const known = refusalItem(outputIndex, item, "id"); + if (!Array.isArray(item.content)) return; + item.content.forEach((part: unknown, contentIndex: number) => { + if (!isRec(part)) return; + if (part.type === "refusal") { + retainRefusal(outputIndex, contentIndex, item, "id", part, "refusal"); + } else if (part.type !== undefined && known.parts.has(contentIndex)) { + throw refusalTranslationError(); + } + }); + }; + const snapshotRefusals = (response: Rec) => { + if (!Array.isArray(response.output)) return; + response.output.forEach((item: unknown, outputIndex: number) => { + if (isRec(item)) snapshotRefusalItem(outputIndex, item); + }); + }; + let terminalBatch: Array<{ frame: Uint8Array; reservation: TranslatorTransientReservation }> | undefined; const queuedLiveFrameBytes: number[] = []; const enqueueLiveFrame = (frame: Uint8Array) => { const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" }); @@ -235,8 +356,20 @@ export function responsesSseToChatCompletionsSse( }; const emit = (payload: Rec | "[DONE]") => { if (failed) return; - enqueueLiveFrame(encoder.encode(dataFrame(payload))); - emittedFrames++; + if (terminalBatch) { + const serialized = dataFrame(payload); + const stringReservation = translatorBudget.reserveTransient(Buffer.byteLength(serialized), { kind: "live_transient" }); + try { + const frame = encoder.encode(serialized); + const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" }); + terminalBatch.push({ frame, reservation }); + } finally { + stringReservation.release(); + } + } else { + enqueueLiveFrame(encoder.encode(dataFrame(payload))); + emittedFrames++; + } }; const ensureRole = () => { if (started) return; @@ -298,38 +431,68 @@ export function responsesSseToChatCompletionsSse( }; const finish = (finishReason: string, usage: unknown) => { if (terminated) return; - // A valid completed/incomplete terminal frame may arrive without output_item.done. - // Preserve any known tool call before emitting its finish reason. - flushPendingToolCalls(); + // Admit every pending role/tool/refusal/finish/DONE frame before exposing any + // of this terminal batch. Serialization and encoded bytes coexist and both count. + const batch: NonNullable = []; + terminalBatch = batch; + try { + flushPendingToolCalls(); + ensureRole(); + for (const [, item] of [...refusalItems.entries()].sort(([a], [b]) => a - b)) { + for (const [, part] of [...item.parts.entries()].sort(([a], [b]) => a - b)) { + if (!part.present) continue; + const refusal = chunkBase(id, model, created); + refusal.choices = [{ index: 0, delta: { refusal: part.text }, finish_reason: null }]; + emit(refusal); + } + } + const frame = chunkBase(id, model, created); + frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; + if (usage) frame.usage = chatCompletionsUsage(usage); + emit(frame); + emit("[DONE]"); + } catch (error) { + for (const staged of batch) staged.reservation.release(); + throw error; + } finally { + terminalBatch = undefined; + } + for (const staged of batch) { + controller.enqueue(staged.frame); + staged.reservation.commitRetained(); + queuedLiveFrameBytes.push(staged.frame.byteLength); + emittedFrames++; + } terminated = true; - ensureRole(); - const frame = chunkBase(id, model, created); - frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; - if (usage) frame.usage = chatCompletionsUsage(usage); - emit(frame); - emit("[DONE]"); + releaseRefusals(); }; const fail = (message: string, details?: { code?: string | null; type?: string; status?: number }) => { if (terminated) return; terminated = true; failed = true; + releaseRefusals(); + closeToolCalls(); + upstreamAbort.abort(new Error("upstream chat translation failed")); + try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ } // OpenAI-compatible clients need a real error event, not a success completion // that embeds `[error] ...` text followed by a clean [DONE]. // Deliver the error frame then close the stream abnormally (no [DONE]). // Do not controller.error() — that can drop already-enqueued bytes from consumers // like response.text(). - const safeMessage = redactSecretString(message); + const translatorOverflow = details?.code === "translation_buffer_limit"; + const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit" + : details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent" + : redactSecretString(message); const statusHint = details?.status ?? streamErrorStatus(safeMessage); const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage); - const translatorOverflow = details?.code === "translation_buffer_limit"; if (translatorOverflow) { - upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit")); - closeToolCalls(); - try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ } classified.code = "translation_buffer_limit"; // Provider-controlled overflow is an upstream failure on every path: // streaming frame, collector, and defensive JSON agree on 502. classified.type = "upstream_error"; + } else if (details?.code === "invalid_refusal") { + classified.code = details.code; + classified.type = "upstream_error"; } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(details?.type); @@ -345,9 +508,9 @@ export function responsesSseToChatCompletionsSse( code: classified.code, }, })); - // The budget is already exhausted. This bounded emergency frame is the sole - // typed overflow closure and therefore cannot reserve from that budget again. - if (translatorOverflow) controller.enqueue(frame); + // These fixed, bounded failures must survive even when decoder-owned input + // still fills the budget. They contain no provider text or IDs. + if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame); else enqueueLiveFrame(frame); emittedFrames++; } catch { @@ -371,8 +534,29 @@ export function responsesSseToChatCompletionsSse( if (typeof data.delta === "string") emitReasoning(data.delta); break; } + case "response.refusal.delta": + case "response.refusal.done": { + const delta = eventName === "response.refusal.delta"; + retainRefusal(data.output_index, data.content_index, data, "item_id", data, delta ? "delta" : "refusal", delta); + break; + } + case "response.content_part.added": + case "response.content_part.done": { + const part = isRec(data.part) ? data.part : null; + if (part?.type === "refusal") { + retainRefusal(data.output_index, data.content_index, data, "item_id", part, "refusal"); + } else if (typeof data.output_index === "number" && refusalItems.has(data.output_index)) { + const item = refusalItem(data.output_index, data, "item_id"); + if (part?.type !== undefined && item.parts.has(position(data.content_index))) throw refusalTranslationError(); + } + break; + } case "response.output_item.added": { const item = isRec(data.item) ? data.item : null; + if (item?.type === "message") { + snapshotRefusalItem(data.output_index, item); + if (Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id"); + } if (!item || item.type !== "function_call") break; ensureRole(); sawToolUse = true; @@ -416,6 +600,8 @@ export function responsesSseToChatCompletionsSse( case "response.output_item.done": { const item = isRec(data.item) ? data.item : null; if (!item) break; + snapshotRefusalItem(data.output_index, item); + if (item.type === "message" && Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id"); if (item.type === "function_call") { sawToolUse = true; const callId = typeof item.call_id === "string" ? item.call_id : ""; @@ -445,6 +631,7 @@ export function responsesSseToChatCompletionsSse( } case "response.completed": { const response = isRec(data.response) ? data.response : {}; + snapshotRefusals(response); finish(sawToolUse ? "tool_calls" : "stop", response.usage); break; } @@ -456,6 +643,7 @@ export function responsesSseToChatCompletionsSse( : undefined; if (reason !== undefined) { // Truthful OpenAI-compatible finish reasons: the turn ended, just early. + snapshotRefusals(response); finish(reason, response.usage); } else { // upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are @@ -500,6 +688,7 @@ export function responsesSseToChatCompletionsSse( while (!cancelled && emittedFrames === emittedAtStart) { decoderStarted = true; const next = await sseIterator!.next(); + if (cancelled) break; if (next.done) { if (!cancelled && !terminated) { fail("upstream stream ended before a terminal frame (truncated response)"); @@ -525,6 +714,8 @@ export function responsesSseToChatCompletionsSse( upstreamAbort.abort(err); closeToolCalls(); fail(err.message, { status: 502, type: "upstream_error", code: err.code }); + } else if (isChatCompletionsStreamError(err)) { + fail(err.message, { status: err.status, type: err.type, code: err.code }); } else { fail(err instanceof Error ? err.message : String(err)); } @@ -545,6 +736,7 @@ export function responsesSseToChatCompletionsSse( }, cancel(reason) { cancelled = true; + releaseRefusals(); while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame(); closeToolCalls(); // Abort first: it cancels the decoder's underlying reader, settling any in-flight @@ -573,6 +765,8 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran } const output = Array.isArray(body.output) ? body.output : []; let content = ""; + let refusal: string | null = null; + let refusalBytes = 0; let reasoning = ""; let contentBytes = 0; let reasoningBytes = 0; @@ -599,6 +793,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran for (const part of raw.content) { if (isRec(part) && part.type === "output_text" && typeof part.text === "string") { ({ text: content, bytes: contentBytes } = append(content, contentBytes, part.text)); + } else if (isRec(part) && part.type === "refusal" && Object.hasOwn(part, "refusal")) { + if (typeof part.refusal !== "string") throw refusalTranslationError(); + const next = append(refusal ?? "", refusalBytes, part.refusal); + refusal = next.text; + refusalBytes = next.bytes; } } } else if (raw.type === "reasoning") { @@ -645,6 +844,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran const message: Rec = { role: "assistant", content: content || null, + refusal, }; if (reasoning) message.reasoning_content = reasoning; if (toolCalls.length > 0) message.tool_calls = toolCalls; @@ -673,6 +873,7 @@ export async function collectChatCompletion( const decoder = new TextDecoder(); let buffer = ""; let content = ""; + let refusal: string | null = null; let reasoning = ""; const toolCalls = new Map(); // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the @@ -680,7 +881,6 @@ export async function collectChatCompletion( const callScope = (index: number) => `chat_collect_${index}`; let finishReason = "stop"; let usage: unknown; - let streamError: ChatCompletionsStreamError | null = null; const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => { const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind }); reservation.commitRetained(); @@ -733,12 +933,12 @@ export async function collectChatCompletion( : code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message) ? 400 : streamErrorStatus(message); - streamError = new ChatCompletionsStreamError(message, { + const streamError = new ChatCompletionsStreamError(message, { status, type: code === "translation_buffer_limit" ? "upstream_error" : type, code, }); - continue; + throw streamError; } if (parsed.usage) usage = parsed.usage; const choices = Array.isArray(parsed.choices) ? parsed.choices : []; @@ -748,6 +948,10 @@ export async function collectChatCompletion( const delta = isRec(choice.delta) ? choice.delta : null; if (!delta) continue; if (typeof delta.content === "string") content = replaceRetained(content, content + delta.content, "retained_collectors"); + if (delta.refusal !== undefined && delta.refusal !== null) { + if (typeof delta.refusal !== "string") throw refusalTranslationError(); + refusal = replaceRetained(refusal ?? "", (refusal ?? "") + delta.refusal, "retained_collectors"); + } if (typeof delta.reasoning_content === "string") reasoning = replaceRetained(reasoning, reasoning + delta.reasoning_content, "retained_collectors"); if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) { @@ -785,6 +989,10 @@ export async function collectChatCompletion( } } } catch (error) { + // Processing may fail between reads; cancel while we still own the lock so the + // upstream translator releases its maps and stops any pending provider read. + try { await reader.cancel(error); } catch { /* preserve the original failure */ } + translatorBudget.releaseRetained(Buffer.byteLength(refusal ?? ""), { kind: "retained_collectors" }); // Never leak an open call scope on the error path; the turn budget's // dispose is a backstop, not the owner of this transfer. for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index)); @@ -801,14 +1009,11 @@ export async function collectChatCompletion( } finally { reader.releaseLock(); } - if (streamError) { - for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index)); - throw streamError; - } const message: Rec = { role: "assistant", content: content || null, + refusal, }; if (reasoning) message.reasoning_content = reasoning; if (toolCalls.size > 0) { diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index 6570e62a4b6..0d807330574 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -77,6 +77,7 @@ export function jsonCompletionSse(value: Rec, requestedModel: string, budget?: T }]; const delta: Rec = {}; if (typeof message.content === "string" && message.content.length > 0) delta.content = message.content; + if (typeof message.refusal === "string") delta.refusal = message.refusal; if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) { delta.reasoning_content = message.reasoning_content; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 0c87f3f50d0..f0c855bea1b 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1323,6 +1323,25 @@ release on consumption or cancellation. Actual upstream SSE and native Chat bypa - 다른 대안 대신 이 방식을 선택한 이유: One conversion authority prevents the streaming fallback from drifting from non-streaming semantics without changing routing or retry behavior. - 장점, 단점 및 영향: No additional upstream request or dependency; this remains buffered delivery, not token-by-token upstream streaming. Handler regressions cover tools, reasoning, length, ordinary and empty completions, and budget release. +### Chat refusal projection + +`src/chat/outbound.ts` keeps Responses refusal parts separate from ordinary content. JSON output +and the stream collector expose nullable `message.refusal`; `jsonCompletionSse` preserves it as +`delta.refusal`, while the native SSE relay remains opaque. The translated live stream keys refusal +state by raw `output_index` / `content_index`, validates present item IDs as correlation constraints, +and emits buffered parts in that order only at a valid completed/incomplete terminal. Deltas append; +equal, empty, absent, and shorter-prefix snapshots preserve existing text; extending snapshots add +only new text. Non-string or contradictory snapshots fail with a content-free typed error. + +The existing turn budget accounts for refusal text and map metadata, including empty entries, and +releases that state on terminal, failure, or cancellation. Pending role/tool/refusal/finish/`[DONE]` +frames form one terminal batch: all serialized strings and encoded frames must be admitted before +any batch frame is enqueued. Admission failure releases the batch and refusal state, cancels upstream, +and emits only the bounded overflow error. Collector processing failures cancel their reader before +releasing its lock, so upstream translation cannot continue after failed JSON collection. The outer +response finalizer continues to own retained response bytes. These are projection rules, not new +refusal policy or changes to ordinary content/tool semantics. + ## Parallel tool calls (default-on for chat providers) The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to diff --git a/tests/responses/chat-refusal.test.ts b/tests/responses/chat-refusal.test.ts new file mode 100644 index 00000000000..4b22f690d13 --- /dev/null +++ b/tests/responses/chat-refusal.test.ts @@ -0,0 +1,451 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + ChatCompletionsStreamError, + collectChatCompletion, + responsesJsonToChatCompletion, + responsesSseToChatCompletionsSse, +} from "../../src/chat/outbound"; +import { jsonCompletionSse, nativeChatSse } from "../../src/server/chat-native-sse"; +import type { OcxConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; + +type Rec = Record; +type Frame = { + error?: { code: string; type: string; message: string }; + choices?: Array<{ delta?: Rec; finish_reason?: string | null }>; +}; +const encoder = new TextEncoder(); +const model = "refusal-fixture/model"; +const event = (type: string, fields: Rec = {}): Rec => ({ type, ...fields }); +const part = (refusal: unknown): Rec => ({ type: "refusal", refusal }); +const message = (content: unknown[], id?: unknown): Rec => ({ + type: "message", role: "assistant", content, ...(id === undefined ? {} : { id }), +}); +const terminal = (output?: unknown[], reason?: string): Rec => event( + reason ? "response.incomplete" : "response.completed", + { response: { + status: reason ? "incomplete" : "completed", + ...(output ? { output } : {}), + ...(reason ? { incomplete_details: { reason } } : {}), + } }, +); +const refusalDelta = (delta: unknown, output_index = 0, content_index = 0, ids: Rec = {}): Rec => + event("response.refusal.delta", { output_index, content_index, delta, ...ids }); +const refusalDone = (refusal: unknown, output_index = 0, content_index = 0, ids: Rec = {}): Rec => + event("response.refusal.done", { output_index, content_index, refusal, ...ids }); +function wireEvent(value: Rec): string { + return `event: ${value.type}\ndata: ${JSON.stringify(value)}\n\n`; +} +function bytesSource(chunks: string[], onCancel = () => {}, close = true): ReadableStream { + let next = 0; + return new ReadableStream({ + pull(controller) { + if (next < chunks.length) controller.enqueue(encoder.encode(chunks[next++]!)); + else if (close) controller.close(); + }, + cancel: onCancel, + }, { highWaterMark: 0 }); +} +function translated(events: Rec[], budget = createTestTranslatorBudget(), onCancel = () => {}, close = true) { + return responsesSseToChatCompletionsSse(bytesSource(events.map(wireEvent), onCancel, close), model, { + translatorBudget: budget, + }); +} +function frames(wire: string): Frame[] { + return wire.split("\n\n").filter(block => block.startsWith("data: ") && block !== "data: [DONE]") + .map(block => JSON.parse(block.slice(6)) as Frame); +} +function refusalText(wire: string): string { + return frames(wire).map(frame => frame.choices?.[0]?.delta?.refusal ?? "").join(""); +} +function firstChoice(completion: Rec) { + return (completion.choices as Array<{ message: Rec; finish_reason: string }>)[0]!; +} +function expectSuccess(wire: string, refusal: string, reason = "stop") { + expect(refusalText(wire)).toBe(refusal); + expect(frames(wire).filter(frame => frame.error)).toHaveLength(0); + expect(frames(wire).filter(frame => frame.choices?.[0]?.finish_reason)).toEqual([ + expect.objectContaining({ choices: [{ index: 0, delta: {}, finish_reason: reason }] }), + ]); + expect(wire.match(/data: \[DONE\]/g)).toHaveLength(1); +} +function expectFailure(wire: string, code: string) { + expect(frames(wire).filter(frame => frame.error)).toEqual([ + { error: expect.objectContaining({ type: "upstream_error", code }) }, + ]); + expect(frames(wire).some(frame => frame.choices?.[0]?.finish_reason)).toBe(false); + expect(wire).not.toContain("data: [DONE]"); +} + +// Independent oracle: OpenAI SDK ChatCompletionMessage.refusal: string | null, +// Choice.Delta.refusal?: string | null; Responses refusal.delta.delta and +// refusal.done.refusal, keyed by raw output_index/content_index. All text is inert. +describe("Chat refusal projection", () => { + test("JSON keeps ordered refusal separate from answer, reasoning and tools", () => { + const completion = responsesJsonToChatCompletion({ output: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "reason" }] }, + message([{ type: "output_text", text: "answer" }, part("fixture A"), part(" + B")]), + { type: "function_call", call_id: "call_fixture", name: "fixture", arguments: "{}" }, + message([part(" + C")]), + ] }, model); + expect(firstChoice(completion)).toMatchObject({ + message: { content: "answer", refusal: "fixture A + B + C", reasoning_content: "reason", + tool_calls: [{ id: "call_fixture", function: { name: "fixture", arguments: "{}" } }] }, + finish_reason: "tool_calls", + }); + expect(firstChoice(responsesJsonToChatCompletion({ output: [] }, model)).message.refusal).toBeNull(); + expect(firstChoice(responsesJsonToChatCompletion({ output: [message([part("")])] }, model)).message.refusal).toBe(""); + expect(() => responsesJsonToChatCompletion({ output: [message([part(null)])] }, model)) + .toThrow(ChatCompletionsStreamError); + }); + + test("split deltas and all repeated final representations contribute each suffix once", async () => { + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 2, item: message([], "item_fixture") }), + refusalDelta("fixture ", 2, 1, { item_id: "item_fixture" }), + refusalDelta("A", 2, 1), + refusalDone("fixture A", 2, 1), + event("response.content_part.done", { output_index: 2, content_index: 1, part: part("fixture AB") }), + event("response.output_item.done", { output_index: 2, + item: message([{ type: "output_text", text: "" }, part("fixture AB")], "item_fixture") }), + terminal([{}, { type: "reasoning" }, message([{}, part("fixture ABC")], "item_fixture")]), + ])).text(); + expectSuccess(wire, "fixture ABC"); + expect(frames(wire).filter(frame => frame.choices?.[0]?.delta?.refusal !== undefined)).toHaveLength(1); + }); + + for (const representation of ["done", "part", "item", "terminal"] as const) { + test(`${representation}-only refusal survives without deltas`, async () => { + const item = message([part("fixture")]); + const events = representation === "done" ? [refusalDone("fixture")] + : representation === "part" ? [event("response.content_part.done", { output_index: 0, content_index: 0, part: part("fixture") })] + : representation === "item" ? [event("response.output_item.done", { output_index: 0, item })] : []; + events.push(terminal(representation === "terminal" ? [item] : undefined)); + expectSuccess(await new Response(translated(events)).text(), "fixture"); + }); + } + + test("interleaved parts emit in raw output/content order and leave text live", async () => { + const wire = await new Response(translated([ + refusalDelta("C", 3, 0), refusalDelta("B", 1, 2), refusalDelta("A", 1, 0), + event("response.output_text.delta", { delta: "answer" }), + refusalDelta("2", 1, 2), refusalDelta("1", 1, 0), + terminal([{}, message([part("A1"), { type: "output_text", text: "answer" }, part("B2")]), {}, message([part("C")])]), + ])).text(); + expectSuccess(wire, "A1B2C"); + const deltas = frames(wire).flatMap(frame => frame.choices?.map(choice => choice.delta) ?? []); + expect(deltas.filter(delta => delta?.refusal !== undefined).map(delta => delta?.refusal)).toEqual(["A1", "B2", "C"]); + expect(deltas.filter(delta => delta?.content).map(delta => delta?.content)).toEqual(["answer"]); + expect(deltas.findIndex(delta => delta?.content === "answer")).toBeLessThan(deltas.findIndex(delta => delta?.refusal === "A1")); + }); + + test("missing, empty and stale-prefix snapshots preserve text and split Unicode", async () => { + const wire = await new Response(translated([ + refusalDelta("fixture \ud83d"), refusalDelta("\ude00"), + event("response.refusal.done", { output_index: 0, content_index: 0 }), + refusalDone(""), refusalDone("fixture"), + event("response.content_part.done", { output_index: 0, content_index: 0, part: { type: "refusal" } }), + event("response.output_item.done", { output_index: 0, item: message([]) }), + terminal([message([part("fixture ")])]), + ])).text(); + expectSuccess(wire, "fixture 😀"); + }); + + for (const reason of ["max_output_tokens", "content_filter"]) { + test(`valid incomplete ${reason} flushes refusal with truthful live finish`, async () => { + expectSuccess(await new Response(translated([ + refusalDelta("fixture"), terminal([message([part("fixture suffix")])], reason), + ])).text(), "fixture suffix", reason === "max_output_tokens" ? "length" : "content_filter"); + }); + } + + const invalidEvents: Array<[string, Rec]> = [ + ["contradictory done", refusalDone("other")], + ["nonstring delta", refusalDelta(42)], + ["nonstring done", refusalDone(null)], + ["nonstring content part", event("response.content_part.done", { output_index: 0, content_index: 0, part: part([]) })], + ["contradictory item", event("response.output_item.done", { output_index: 0, item: message([part("other")]) })], + ["contradictory terminal", terminal([message([part("other")])])], + ["nonstring terminal", terminal([message([part({})])])], + ["delta ID mismatch", refusalDelta("suffix", 0, 0, { item_id: "other" })], + ["nonstring event ID", refusalDone("fixture", 0, 0, { item_id: null })], + ["snapshot ID mismatch", terminal([message([part("fixture")], "other")])], + ["nonstring snapshot ID", terminal([message([part("fixture")], 5)])], + ["different part type", terminal([message([{ type: "output_text", text: "fixture" }])])], + ["negative position", refusalDelta("fixture", -1)], + ["fractional position", refusalDelta("fixture", 0, 0.5)], + ]; + for (const [label, invalid] of invalidEvents) { + test(`${label} fails without refusal or success terminal and cancels upstream`, async () => { + let cancelled = 0; + const wire = await new Response(translated([ + refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), invalid, terminal(), + ], createTestTranslatorBudget(), () => { cancelled++; }, false)).text(); + expectFailure(wire, "invalid_refusal"); + expect(refusalText(wire)).toBe(""); + expect(cancelled).toBe(1); + }); + } + + test("failure and unknown incomplete terminals discard buffered refusal", async () => { + for (const end of [terminal(undefined, "adapter_eof"), event("response.failed", { + response: { error: { message: "fixture failure" } }, + })]) { + const wire = await new Response(translated([refusalDelta("fixture"), end])).text(); + expect(refusalText(wire)).toBe(""); + expect(frames(wire).filter(frame => frame.error)).toHaveLength(1); + expect(wire).not.toContain("data: [DONE]"); + expect(frames(wire).some(frame => frame.choices?.[0]?.finish_reason)).toBe(false); + } + }); + + test("one item's optional ID constrains all of its content parts", async () => { + const wire = await new Response(translated([ + refusalDelta("A", 0, 0, { item_id: "first" }), + refusalDelta("B", 0, 1, { item_id: "second" }), terminal(), + ])).text(); + expectFailure(wire, "invalid_refusal"); + }); + + test("refusal text overflow is bounded and cancels the source", async () => { + let cancelled = 0; + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const wire = await new Response(translated([ + ...Array.from({ length: 50 }, () => refusalDelta("x".repeat(100))), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(budget.snapshot().highWaterBytes).toBeLessThanOrEqual(4096); + expect(cancelled).toBe(1); + }); + + test("zero-length parts consume metadata budget", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 2048 }); + let cancelled = 0; + const wire = await new Response(translated([ + ...Array.from({ length: 100 }, (_, index) => refusalDone("", 0, index)), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(budget.snapshot().overflows).toBe(1); + expect(cancelled).toBe(1); + }); + + test("small turn budget rejects the whole final batch, including pending role", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 900 }); + let cancelled = 0; + const wire = await new Response(translated([refusalDone("fixture"), terminal()], budget, + () => { cancelled++; }, false)).text(); + expectFailure(wire, "translation_buffer_limit"); + expect(frames(wire).filter(frame => frame.choices)).toHaveLength(0); + expect(cancelled).toBe(1); + }); + + test("a reservation failure at DONE cannot leak pending tool/refusal/finish frames", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 4096 }); + const reserve = budget.reserveTransient.bind(budget); + let rejectedDone = false; + budget.reserveTransient = (bytes, scope) => { + if (bytes === encoder.encode("data: [DONE]\n\n").byteLength) { + rejectedDone = true; + // Exhaust the real configured budget at this precise admission boundary. + return reserve(4097, scope); + } + return reserve(bytes, scope); + }; + let cancelled = 0; + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 0, + item: { type: "function_call", id: "tool_fixture", call_id: "call_fixture", name: "f", arguments: "{}" } }), + refusalDone("fixture", 1), terminal(), + ], budget, () => { cancelled++; }, false)).text(); + expect(rejectedDone).toBe(true); + expectFailure(wire, "translation_buffer_limit"); + expect(frames(wire).flatMap(frame => frame.choices ?? []).every(choice => + !choice.delta?.tool_calls && choice.delta?.refusal === undefined)).toBe(true); + expect(budget.snapshot().activeCalls).toBe(0); + expect(cancelled).toBe(1); + }); + + test("successful terminal flush preserves pending tools and releases charged metadata", async () => { + const budget = createTestTranslatorBudget(); + const charge = budget.chargeRetained.bind(budget); + const release = budget.releaseRetained.bind(budget); + let metadataCharged = 0; + let metadataReleased = 0; + budget.chargeRetained = (bytes, scope) => { + charge(bytes, scope); + if (scope.kind === "item_ids") metadataCharged += bytes; + }; + budget.releaseRetained = (bytes, scope) => { + release(bytes, scope); + if (scope.kind === "item_ids") metadataReleased += bytes; + }; + const wire = await new Response(translated([ + event("response.output_item.added", { output_index: 0, + item: { type: "function_call", id: "tool_fixture", call_id: "call_fixture", name: "fixture" } }), + event("response.function_call_arguments.delta", { item_id: "tool_fixture", delta: "{}" }), + refusalDelta("fixture", 2, 0, { item_id: "message_fixture" }), terminal(), + ], budget)).text(); + expectSuccess(wire, "fixture", "tool_calls"); + expect(frames(wire).flatMap(frame => frame.choices?.[0]?.delta?.tool_calls ?? [])).toEqual([ + { index: 0, id: "call_fixture", type: "function", function: { name: "fixture", arguments: "{}" } }, + ]); + expect(metadataCharged).toBeGreaterThan(0); + expect(metadataReleased).toBe(metadataCharged); + }); + + test("cancellation releases buffered refusal text and map metadata", async () => { + const budget = createTestTranslatorBudget(); + let cancelled = 0; + const reader = translated([ + refusalDelta("fixture"), event("response.heartbeat"), + ], budget, () => { cancelled++; }, false).getReader(); + await reader.read(); // Heartbeat role proves the preceding refusal was retained. + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + await reader.cancel(); + reader.releaseLock(); + expect(cancelled).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); + +describe("Chat refusal collection and native serialization", () => { + test("collector preserves nullable refusal and native JSON-to-SSE round trip", async () => { + const completion = responsesJsonToChatCompletion({ output: [message([part("fixture")])] }, model); + const wire = jsonCompletionSse(completion, model); + expectSuccess(wire, "fixture"); + const collected = await collectChatCompletion(bytesSource([wire]), model, createTestTranslatorBudget()); + expect(firstChoice(collected).message).toMatchObject({ content: null, refusal: "fixture" }); + const empty = await collectChatCompletion(bytesSource([jsonCompletionSse({ choices: [{ message: { content: "answer", refusal: null } }] }, model)]), model, createTestTranslatorBudget()); + expect(firstChoice(empty).message).toMatchObject({ content: "answer", refusal: null }); + }); + + test("absent-only refusal evidence stays null while explicit empty refusal stays empty", async () => { + for (const evidence of [{ type: "refusal" }, part("")]) { + const budget = createTestTranslatorBudget(); + const completion = await collectChatCompletion(translated([terminal([message([evidence])])], budget), model, budget); + expect(firstChoice(completion).message.refusal).toBe(Object.hasOwn(evidence, "refusal") ? "" : null); + } + }); + + test("translated SSE collection uses the same ordered refusal contract", async () => { + const budget = createTestTranslatorBudget(); + const completion = await collectChatCompletion(translated([ + refusalDelta("B", 1), refusalDelta("A", 0), terminal(), + ], budget), model, budget); + expect(firstChoice(completion).message).toMatchObject({ content: null, refusal: "AB" }); + }); + + test("native SSE relay leaves refusal deltas intact", async () => { + const budget = createTestTranslatorBudget(); + const wire = [ + 'data: {"choices":[{"index":0,"delta":{"content":"answer","refusal":"fixture"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", + ].join(""); + const relayed = nativeChatSse(bytesSource([wire]), { + requestedModel: model, translatorBudget: budget, signal: new AbortController().signal, onUsage() {}, + }); + expectSuccess(await new Response(relayed).text(), "fixture"); + }); + + test("collector processing overflow cancels its reader and never returns partial JSON", async () => { + let cancelled = 0; + const budget = createTestTranslatorBudget({ maxTurnBytes: 1024 }); + const reserve = budget.reserveTransient.bind(budget); + let failedKind = ""; + budget.reserveTransient = (bytes, scope) => { + try { return reserve(bytes, scope); } catch (error) { failedKind = scope.kind; throw error; } + }; + const chunk = `data: ${JSON.stringify({ choices: [{ delta: { refusal: "x".repeat(100) } }] })}\n\n`; + await expect(collectChatCompletion(bytesSource(Array(20).fill(chunk), () => { cancelled++; }, false), model, budget)) + .rejects.toMatchObject({ status: 502, type: "upstream_error", code: "translation_buffer_limit" }); + expect(failedKind).toBe("retained_collectors"); + expect(cancelled).toBe(1); + }); + + test("collector error cancellation reaches an upstream translator", async () => { + const translatorBudget = createTestTranslatorBudget(); + const collectorBudget = createTestTranslatorBudget({ maxTurnBytes: 100 }); + let cancelled = 0; + const stream = translated([refusalDelta("fixture"), event("response.heartbeat")], translatorBudget, + () => { cancelled++; }, false); + await expect(collectChatCompletion(stream, model, collectorBudget)) + .rejects.toMatchObject({ code: "translation_buffer_limit" }); + expect(cancelled).toBe(1); + expect(translatorBudget.snapshot().currentBytes).toBe(0); + }); + + test("malformed native refusal and typed error frames cancel without partial JSON", async () => { + for (const payload of [ + { choices: [{ delta: { refusal: 17 } }] }, + { error: { message: "fixture error", type: "upstream_error", code: "fixture_error" } }, + ]) { + let cancelled = 0; + await expect(collectChatCompletion(bytesSource([`data: ${JSON.stringify(payload)}\n\n`], () => { cancelled++; }, false), + model, createTestTranslatorBudget())).rejects.toBeInstanceOf(ChatCompletionsStreamError); + expect(cancelled).toBe(1); + } + }); +}); + +// Handler coverage uses only an external fetch stub, never a mocked converter/handler. +// It also exercises #3770's shared JSON fallback after the parent commit is applied. +describe("refusal handler delivery matrix", () => { + const originalFetch = globalThis.fetch; + let isolatedHome: IsolatedCodexHome | undefined; + let previousOcxHome: string | undefined; + beforeEach(() => { + previousOcxHome = process.env.OPENCODEX_HOME; + isolatedHome = installIsolatedCodexHome("ocx-refusal-fixture-"); + process.env.OPENCODEX_HOME = isolatedHome.path; + globalThis.fetch = (async () => { throw new Error("unstubbed external transport"); }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + resetProviderRequestPacingForTest(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + isolatedHome?.restore(); + }); + for (const native of [true, false]) { + for (const upstreamSse of [true, false]) { + for (const clientSse of [true, false]) { + test(`${native ? "native" : "translated"} upstream ${upstreamSse ? "SSE" : "JSON"} -> client ${clientSse ? "SSE" : "JSON"}`, async () => { + const { handleChatCompletions } = await import("../../src/server/chat-completions"); + const responseJson = { id: "resp_fixture", status: "completed", output: [message([part("fixture")], "item_fixture")] }; + const chatJson = { id: "chatcmpl_fixture", object: "chat.completion", created: 1, model, + choices: [{ index: 0, message: { role: "assistant", content: null, refusal: "fixture" }, finish_reason: "stop" }] }; + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.origin).toBe("https://refusal.example.test"); + seen.push(url.pathname); + if (!upstreamSse) return Response.json(native ? chatJson : responseJson); + const wire = native ? [ + 'data: {"choices":[{"index":0,"delta":{"refusal":"fixture"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", + ].join("") : [refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), terminal(responseJson.output)].map(wireEvent).join(""); + return new Response(wire, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, defaultProvider: "refusal-fixture", providers: { + "refusal-fixture": { adapter: native ? "openai-chat" : "openai-responses", + baseUrl: "https://refusal.example.test/v1", apiKey: "fixture-key", authMode: "key" }, + }, + } as OcxConfig; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, stream: clientSse, messages: [{ role: "user", content: "inert fixture" }] }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + if (clientSse) expectSuccess(await response.text(), "fixture"); + else expect(firstChoice(await response.json() as Rec).message).toMatchObject({ content: null, refusal: "fixture" }); + expect(seen).toEqual([native ? "/v1/chat/completions" : "/v1/responses"]); + }); + } + } + } +}); From fd10390985c0000929b219cfebb793e55fcf4160 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:01:57 +0900 Subject: [PATCH 2/7] test(chat): register refusal regression coverage --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2e2b806deea..3d84e77bd27 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -271,6 +271,7 @@ "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", + "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a1f0198e44a..dc1bb60a246 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -106,6 +106,7 @@ "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", + "chat-refusal.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", "chatgpt-oauth.test.ts": "oauth", "chatgpt-token-expiry.test.ts": "oauth", From 660967be4ec3f055fcdaa6225075b13faf87cd3f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:09:13 +0900 Subject: [PATCH 3/7] fix(chat): constrain sparse refusal identities across output positions --- src/chat/outbound.ts | 9 ++++++++- tests/responses/chat-refusal.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 7646b63efba..be9cbc7c11a 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -207,10 +207,12 @@ export function responsesSseToChatCompletionsSse( id?: string; parts: Map; }>(); + const refusalIndexById = new Map(); let refusalMetadataBytes = 0; let refusalTextBytes = 0; const releaseRefusals = () => { refusalItems.clear(); + refusalIndexById.clear(); translatorBudget.releaseRetained(refusalMetadataBytes, { kind: "item_ids" }); translatorBudget.releaseRetained(refusalTextBytes, { kind: "retained_collectors" }); refusalMetadataBytes = 0; @@ -238,10 +240,13 @@ export function responsesSseToChatCompletionsSse( refusalItems.set(index, item); } if (hasId && typeof candidate === "string") { + const knownIndex = refusalIndexById.get(candidate); + if (knownIndex !== undefined && knownIndex !== index) throw refusalTranslationError(); if (item.id !== undefined && item.id !== candidate) throw refusalTranslationError(); if (item.id === undefined) { - chargeRefusalMetadata(Buffer.byteLength(candidate)); + chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(candidate)); item.id = candidate; + refusalIndexById.set(candidate, index); } } return item; @@ -284,6 +289,8 @@ export function responsesSseToChatCompletionsSse( }; const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => { const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined; + // Sparse final snapshots may omit type/content but cannot change a known ID. + if (existing && Object.hasOwn(item, "id")) refusalItem(outputIndex, item, "id"); if (item.type !== "message") { if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError(); return; diff --git a/tests/responses/chat-refusal.test.ts b/tests/responses/chat-refusal.test.ts index 4b22f690d13..fc178da01ae 100644 --- a/tests/responses/chat-refusal.test.ts +++ b/tests/responses/chat-refusal.test.ts @@ -173,6 +173,9 @@ describe("Chat refusal projection", () => { ["nonstring event ID", refusalDone("fixture", 0, 0, { item_id: null })], ["snapshot ID mismatch", terminal([message([part("fixture")], "other")])], ["nonstring snapshot ID", terminal([message([part("fixture")], 5)])], + ["sparse snapshot ID mismatch", terminal([{ id: "other" }])], + ["sparse nonstring snapshot ID", terminal([{ id: null }])], + ["same ID at another position", terminal([{}, {}, message([part("fixture")], "item_fixture")])], ["different part type", terminal([message([{ type: "output_text", text: "fixture" }])])], ["negative position", refusalDelta("fixture", -1)], ["fractional position", refusalDelta("fixture", 0, 0.5)], @@ -449,3 +452,22 @@ describe("refusal handler delivery matrix", () => { } } }); + +test("sparse terminal preserves a matching refusal ID", async () => { + const wire = await new Response(translated([ + refusalDelta("fixture", 0, 0, { item_id: "item_fixture" }), + terminal([{ id: "item_fixture" }]), + ])).text(); + expectSuccess(wire, "fixture"); +}); + +test("JSON refusal charges joined surrogate bytes and rejects retained overflow", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 16 }); + const completion = responsesJsonToChatCompletion({ output: [message([part("\ud83d"), part("\ude00")])] }, model, budget); + expect(firstChoice(completion).message.refusal).toBe("😀"); + expect(budget.snapshot().currentBytes).toBe(4); + const small = createTestTranslatorBudget({ maxTurnBytes: 4 }); + expect(() => responsesJsonToChatCompletion({ output: [message([part("fixture")])] }, model, small)).toThrow(); + expect(small.snapshot().overflows).toBe(1); + expect(small.snapshot().currentBytes).toBe(0); +}); From 14ee44bc2cb61d7c904ea6b164fe68b06b623aea Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:12:54 +0900 Subject: [PATCH 4/7] fix(chat): validate known refusal IDs in sparse moved snapshots [skip ci] --- src/chat/outbound.ts | 5 ++++- tests/responses/chat-refusal.test.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index be9cbc7c11a..e69d03f499d 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -290,7 +290,10 @@ export function responsesSseToChatCompletionsSse( const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => { const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined; // Sparse final snapshots may omit type/content but cannot change a known ID. - if (existing && Object.hasOwn(item, "id")) refusalItem(outputIndex, item, "id"); + if (Object.hasOwn(item, "id") + && (existing || (typeof item.id === "string" && refusalIndexById.has(item.id)))) { + refusalItem(outputIndex, item, "id"); + } if (item.type !== "message") { if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError(); return; diff --git a/tests/responses/chat-refusal.test.ts b/tests/responses/chat-refusal.test.ts index fc178da01ae..27f6ff86efd 100644 --- a/tests/responses/chat-refusal.test.ts +++ b/tests/responses/chat-refusal.test.ts @@ -176,6 +176,7 @@ describe("Chat refusal projection", () => { ["sparse snapshot ID mismatch", terminal([{ id: "other" }])], ["sparse nonstring snapshot ID", terminal([{ id: null }])], ["same ID at another position", terminal([{}, {}, message([part("fixture")], "item_fixture")])], + ["sparse same ID at another position", terminal([{}, {}, { id: "item_fixture" }])], ["different part type", terminal([message([{ type: "output_text", text: "fixture" }])])], ["negative position", refusalDelta("fixture", -1)], ["fractional position", refusalDelta("fixture", 0, 0.5)], From 4d982ab3fa27a3303771df8a6f5ae398df2f2c82 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:59:57 +0900 Subject: [PATCH 5/7] feat(claude): gate translated Messages with a conservative compatibility policy Reimplements PR #3730 from source head 18d64748ade8001e05327726b2ae4b22e8393418 under the accepted track2 roadmap. Apply one policy before translation, retain only closed diagnostic codes, reject invalid configured modes, and persist shadow evidence through hydration. Validation: NOT RUN by explicit user instruction (no local tests, typecheck, build, install, prepush or live proxy). Parent owns test-layout registration, exact-head remote CI and independent implementation security review. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../docs/reference/configuration/server.md | 20 ++ src/claude/compatibility.ts | 192 +++++++++++++++ src/server/claude-messages.ts | 27 ++ src/server/request-log.ts | 16 +- src/types/config.ts | 2 + src/usage/log.ts | 22 ++ .../claude-compatibility.test.ts | 113 +++++++++ .../claude-messages-endpoint.test.ts | 231 +++++++++++++++++- tests/usage/request-log.test.ts | 49 ++++ tests/usage/usage-log.test.ts | 41 ++++ 10 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 src/claude/compatibility.ts create mode 100644 tests/claude-integration/claude-compatibility.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b793f159c1e..d80d6991af2 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,12 +317,32 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional compatibility admission for translated `/v1/messages` requests. `shadow` records unsupported features and continues; `enforce` returns an Anthropic-shaped 400 before inference. Native Anthropic passthrough remains unchanged. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | | `claudeCode.classifierFallbacks?` | `string[]` | unset | Ordered classifier targets used when `classifierModel` is not set. Same qualified `provider/model` form; the first usable entry wins. An explicit `modelMap` entry for the classifier model still outranks both. | | `claudeCode.subagentEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | inherit | Effort written to generated `~/.claude/agents/ocx-*.md`; separate from Codex guidance and proxy caps. Restart through `ocx claude` to regenerate. | +The compatibility policy applies to Claude Code, Desktop and other clients using translated +Messages, including `?beta=true` and non-streaming requests. Every translated target uses the +same conservative policy, including Anthropic and native Responses adapters. It rejects +document content, thinking/redacted-thinking replay, hosted search and execution tools, +tool-search references, active deferred loading, strict tools, non-default caller modes, +structured-output formats, explicit service-tier intent, MCP connector features, context +management, containers, inference placement and unsupported protocol fields or blocks. + +Unset preserves legacy translation. Cache hints, tool input examples and ordinary +thinking/effort settings are deliberately admitted with possible degradation: this setting +does not guarantee cache breakpoints or TTL, retained examples, exact thinking budgets or +lossless translation. Beta headers alone are not validated for feature support. Shadow +evidence contains only fixed protocol codes and derived reasons, retained in request logs +and `usage.jsonl` and restored on restart. An invalid non-unset mode returns a fixed 503 +configuration error on translated Messages. Configure the value in `config.json` and +restart the proxy to load it; there is no dedicated GUI setter. Count-tokens and direct +Responses/Chat APIs are outside this policy; successful token counting does not imply +Messages admission. This setting does not add a global authorization boundary. + Auto auth selects subscription when stored Claude auth is found, proxy when none is found, and subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts new file mode 100644 index 00000000000..75ba3c13550 --- /dev/null +++ b/src/claude/compatibility.ts @@ -0,0 +1,192 @@ +/** Opt-in admission for the translated Messages path; no adapter or credential state. */ +export type ClaudeCompatibilityMode = "shadow" | "enforce"; + +// False means deliberately tolerated degradation, not lossless representation. +const FEATURES = { + cache_control: false, + input_examples: false, + thinking_settings: false, + unknown_beta: false, + thinking_replay: true, + documents: true, + web_search_tool: true, + tool_search: true, + tool_reference: true, + deferred_tools: true, + strict_tools: true, + caller_mode: true, + structured_output: true, + service_tier: true, + mcp_tool: true, + code_execution: true, + computer_use: true, + server_tool: true, + context_management: true, + container: true, + inference_geo: true, + user_profile: true, + unknown_body_field: true, + unknown_content_block: true, +} as const; + +export type ClaudeFeatureCode = keyof typeof FEATURES; +const FEATURE_CODES = Object.keys(FEATURES) as ClaudeFeatureCode[]; +const MAX_FEATURE_CODES = 32; +const MAX_REASON_LENGTH = 512; +type Rec = Record; +const isRec = (value: unknown): value is Rec => + value !== null && typeof value === "object" && !Array.isArray(value); + +export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { + return value === "shadow" || value === "enforce"; +} + +/** Project only closed codes, including when reading an untrusted persisted row. */ +export function normalizeClaudeFeatureCodes(value: unknown): ClaudeFeatureCode[] { + if (!Array.isArray(value)) return []; + const codes = new Set(); + for (const code of value) { + if (typeof code === "string" && Object.hasOwn(FEATURES, code)) codes.add(code as ClaudeFeatureCode); + } + return FEATURE_CODES.filter(code => codes.has(code)).sort().slice(0, MAX_FEATURE_CODES); +} + +/** Never accept a caller-supplied reason, header value, model name or tool name. */ +export function claudeCompatibilityReason(codes: readonly ClaudeFeatureCode[], shadow: boolean): string | undefined { + const unsupported = codes.filter(code => FEATURES[code]); + if (unsupported.length === 0) return undefined; + return `${shadow ? "shadow: would reject" : "unsupported translated Claude features"}: ${unsupported.join(", ")}` + .slice(0, MAX_REASON_LENGTH); +} + +const BODY_FIELDS = new Set([ + "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", + "output_config", "metadata", "service_tier", "stop_sequences", "stream", + "temperature", "top_p", "top_k", "cache_control", "context_management", + "container", "inference_geo", "user_profile_id", "mcp_servers", "defer_tools", "deferred_tools", +]); + +function activeDeferred(value: unknown): boolean { + return value === true || (Array.isArray(value) ? value.length > 0 : isRec(value) && Object.keys(value).length > 0); +} + +function nonDirectCaller(value: unknown): boolean { + return value !== undefined && !(Array.isArray(value) && value.length === 1 && value[0] === "direct"); +} + +/** Complete finite detection. Only protocol content positions are visited, never schemas/arguments. */ +function detectFeatures(body: unknown, anthropicBeta?: string): Set { + const codes = new Set(); + // Header-only beta semantics are outside this policy. No header bytes become codes. + if (anthropicBeta?.trim()) codes.add("unknown_beta"); + if (!isRec(body)) return codes; // The existing Messages parser owns malformed top-level input. + if (Object.keys(body).some(key => !BODY_FIELDS.has(key))) codes.add("unknown_body_field"); + for (const [field, code] of [ + ["cache_control", "cache_control"], ["context_management", "context_management"], + ["container", "container"], ["inference_geo", "inference_geo"], + ["user_profile_id", "user_profile"], ["mcp_servers", "mcp_tool"], + ] as const) { + if (Object.hasOwn(body, field)) codes.add(code); + } + if (body.service_tier !== undefined && body.service_tier !== null) codes.add("service_tier"); + if (isRec(body.thinking)) codes.add("thinking_settings"); + if (isRec(body.output_config) + && (body.output_config.format != null || body.output_config.output_format != null)) codes.add("structured_output"); + if (activeDeferred(body.defer_tools) || activeDeferred(body.deferred_tools)) codes.add("deferred_tools"); + + if (Array.isArray(body.tools)) { + for (const tool of body.tools) { + if (!isRec(tool)) continue; + if (Object.hasOwn(tool, "cache_control")) codes.add("cache_control"); + if (Object.hasOwn(tool, "input_examples")) codes.add("input_examples"); + if (tool.strict === true) codes.add("strict_tools"); + if (tool.defer === true || tool.defer_loading === true) codes.add("deferred_tools"); + if (nonDirectCaller(tool.allowed_callers)) codes.add("caller_mode"); + const type = tool.type; + // Ordinary client function names do not convey hosted execution semantics. + if (type === undefined || type === "function" || type === "custom") continue; + if (type === "mcp_toolset") codes.add("mcp_tool"); + else if (typeof type === "string" && /^web_search_\d{8}$/.test(type)) codes.add("web_search_tool"); + else if (typeof type === "string" && /^tool_search(?:_tool_(?:regex|bm25))?(?:_\d{8})?$/.test(type)) codes.add("tool_search"); + else if (typeof type === "string" && /^code_execution_\d{8}$/.test(type)) codes.add("code_execution"); + else if (typeof type === "string" && /^computer(?:_toolset)?_\d{8}$/.test(type)) codes.add("computer_use"); + else codes.add("server_tool"); + } + } + + const scanBlock = (block: unknown, position: "message" | "system" | "result") => { + if (!isRec(block)) return; + if (Object.hasOwn(block, "cache_control")) codes.add("cache_control"); + if (position === "system" && block.type !== "text") codes.add("unknown_content_block"); + if (position === "result" && (typeof block.type !== "string" || !["text", "image", "document", "tool_reference"].includes(block.type))) { + codes.add("unknown_content_block"); + } + switch (block.type) { + case "text": + case "image": break; + case "document": codes.add("documents"); break; + case "thinking": + case "redacted_thinking": codes.add("thinking_replay"); break; + case "tool_reference": codes.add("tool_reference"); break; + case "tool_search_tool_result": codes.add("tool_search"); break; + case "web_search_tool_result": codes.add("web_search_tool"); break; + case "code_execution_tool_result": + case "bash_code_execution_tool_result": + case "text_editor_code_execution_tool_result": codes.add("code_execution"); break; + case "mcp_tool_use": + case "mcp_tool_result": codes.add("mcp_tool"); break; + case "tool_use": + if (block.caller !== undefined && (!isRec(block.caller) || block.caller.type !== "direct")) codes.add("caller_mode"); + break; + case "server_tool_use": + switch (block.name) { + case "tool_search": + case "tool_search_tool_regex": + case "tool_search_tool_bm25": codes.add("tool_search"); break; + case "web_search": codes.add("web_search_tool"); break; + case "code_execution": codes.add("code_execution"); break; + case "computer": codes.add("computer_use"); break; + default: codes.add("server_tool"); + } + break; + case "tool_result": break; // Children are visited below at the one supported nesting level. + default: codes.add("unknown_content_block"); + } + }; + if (Array.isArray(body.system)) for (const block of body.system) scanBlock(block, "system"); + if (Array.isArray(body.messages)) { + for (const message of body.messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + scanBlock(block, "message"); + if (isRec(block) && block.type === "tool_result" && Array.isArray(block.content)) { + for (const child of block.content) scanBlock(child, "result"); + } + } + } + } + return codes; +} + +export interface ClaudeCompatibilityResult { + featureCodes: ClaudeFeatureCode[]; + compatible: boolean; + decision: "allow" | "shadow" | "reject"; + reason?: string; +} + +/** All translated targets share this policy; native passthrough never calls it. */ +export function analyzeClaudeCompatibility( + body: unknown, + opts: { mode: ClaudeCompatibilityMode; anthropicBeta?: string }, +): ClaudeCompatibilityResult { + const detected = detectFeatures(body, opts.anthropicBeta); + const compatible = !FEATURE_CODES.some(code => detected.has(code) && FEATURES[code]); + const featureCodes = normalizeClaudeFeatureCodes([...detected]); + return { + featureCodes, + compatible, + decision: compatible ? "allow" : opts.mode === "shadow" ? "shadow" : "reject", + ...(!compatible ? { reason: claudeCompatibilityReason(featureCodes, opts.mode === "shadow") } : {}), + }; +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index cda2e3e45d6..20bc14e195a 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -16,6 +16,7 @@ import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -719,6 +720,32 @@ async function handleClaudeMessagesWithBudget( if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } + // Capture source semantics before effort rewriting or translation drops fields. + // This policy is uniform across translated targets, including later fallback attempts. + const compatibilityMode: unknown = config.claudeCode?.compatibility; + if (compatibilityMode !== undefined) { + if (!isClaudeCompatibilityMode(compatibilityMode)) { + logCtx.errorCode = "claude_compatibility_configuration"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 503, { closeReason: "non_stream" }); + return anthropicErrorResponse(503, "Invalid claudeCode.compatibility setting", "api_error"); + } + const compatibility = analyzeClaudeCompatibility(anthropicBody, { + mode: compatibilityMode, + anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, + }); + if (compatibility.decision === "reject") { + logCtx.errorCode = "claude_compatibility_unsupported"; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, compatibility.reason!, "invalid_request_error"); + } + if (compatibility.decision === "shadow") { + logCtx.claudeCompatibility = { + decision: "shadow", + featureCodes: compatibility.featureCodes, + reason: compatibility.reason, + }; + } + } if (isRec(anthropicBody) && effortOverride) { anthropicBody.output_config = { ...(isRec(anthropicBody.output_config) ? anthropicBody.output_config : {}), diff --git a/src/server/request-log.ts b/src/server/request-log.ts index a4c942bd883..bd0f7e5b18d 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -24,6 +24,7 @@ import { isKnownUsageSurface, isCodexUsageAccountLogLabel, isValidReasoningWireValue, + normalizeClaudeCompatibilityUsageLog, readRecentUsageEntries, usageForFinalLog, usageStatusForFinalLog, @@ -31,6 +32,7 @@ import { type AttemptRecoveryKind, type PersistedUsageAttempt, type PersistedUsageEntry, + type PersistedClaudeCompatibilityLog, type UsageStatus, } from "../usage/log"; import { @@ -138,6 +140,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Opt-in shadow evidence, normalized again at the logging boundary. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } export interface RequestLogEntry { @@ -203,6 +207,8 @@ export interface RequestLogEntry { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes; no request or header values. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const requestLog: RequestLogEntry[] = []; @@ -271,6 +277,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const terminalStatus = asTerminalStatus(entry.terminalStatus); const closeReason = asCloseReason(entry.closeReason); const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); return { requestId: entry.requestId, timestamp: entry.timestamp, @@ -313,6 +320,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } @@ -368,10 +376,13 @@ export function addRequestLog(entry: RequestLogEntry) { // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a // sanitization bug because the safe surface is the one you check. const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); - const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); + const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined ? entry : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; + if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility; + else if (retained !== entry) delete retained.claudeCompatibility; entry = retained; retainRequestLogEntry(entry); try { @@ -431,6 +442,7 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), + ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}), }); } catch { /* request logging must never fail a user request */ @@ -985,6 +997,7 @@ export function addFinalRequestLog( // means a future caller cannot reintroduce the hole by forgetting to sanitize first, and // the in-memory /api/logs row matches what usage.jsonl already stores. const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility); addLog({ requestId, timestamp: start, @@ -1034,6 +1047,7 @@ export function addFinalRequestLog( ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }); if (isUsageDebugEnabled()) { appendUsageDebug({ diff --git a/src/types/config.ts b/src/types/config.ts index e8dec5dda68..ee97cdf9ac6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,8 @@ import type { CodexAccount } from "./accounts"; * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. */ export interface OcxClaudeCodeConfig { + /** Opt-in translated Messages admission; unset keeps legacy behavior. Native passthrough is exempt. */ + compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ enabled?: boolean; /** diff --git a/src/usage/log.ts b/src/usage/log.ts index 257cb86d3ef..fd8408cc199 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -9,6 +9,24 @@ import { usageDisplayTotalTokens } from "./totals"; import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; +import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; + +export interface PersistedClaudeCompatibilityLog { + decision: "shadow"; + featureCodes: ClaudeFeatureCode[]; + reason?: string; +} + +/** Disk and in-memory callers share a closed-code projection; free-form reasons are discarded. */ +export function normalizeClaudeCompatibilityUsageLog(value: unknown): PersistedClaudeCompatibilityLog | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const row = value as Record; + if (row.decision !== "shadow") return undefined; + const featureCodes = normalizeClaudeFeatureCodes(row.featureCodes); + const reason = claudeCompatibilityReason(featureCodes, true); + if (!reason) return undefined; + return { decision: "shadow", featureCodes, reason }; +} export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; /** @@ -160,6 +178,8 @@ export interface PersistedUsageEntry { * contains prompts, credentials, or hidden reasoning. */ routeDecision?: RouteDecisionTraceV1; + /** Closed Claude protocol codes only; absent on older rows. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const KNOWN_USAGE_SURFACES = new Set>([ @@ -490,6 +510,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const callerServiceTier = sanitizeLogMetadataString(entry.callerServiceTier); const responseServiceTier = sanitizeLogMetadataString(entry.responseServiceTier); const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; @@ -563,6 +584,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts new file mode 100644 index 00000000000..874fcba405c --- /dev/null +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../../src/claude/compatibility"; + +// Shapes from Anthropic's thinking, tool-search, strict-tool-use and Messages docs: +// https://platform.claude.com/docs/en/build-with-claude/thinking +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use +// https://platform.claude.com/docs/en/api/http/messages/create +const userBlock = (block: Record) => ({ messages: [{ role: "user", content: [block] }] }); +const assistantBlock = (block: Record) => ({ messages: [{ role: "assistant", content: [block] }] }); +const functionTool = { name: "lookup", input_schema: { type: "object", properties: {} } }; + +describe("Claude translated compatibility", () => { + const rejected: Array<[string, Record, string[]]> = [ + ["document", userBlock({ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }), ["documents"]], + ["nested document", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }), ["documents"]], + ["thinking replay", assistantBlock({ type: "thinking", thinking: "private-fixture", signature: "private-signature" }), ["thinking_replay"]], + ["redacted replay", assistantBlock({ type: "redacted_thinking", data: "opaque-fixture" }), ["thinking_replay"]], + ["hosted search", { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, ["tool_search"]], + ["hosted search history", assistantBlock({ type: "server_tool_use", name: "tool_search", id: "srv1", input: {} }), ["tool_search"]], + ["search result", userBlock({ type: "tool_search_tool_result", tool_use_id: "srv1", content: { type: "tool_search_tool_search_result", tool_references: [] } }), ["tool_search"]], + ["client search reference", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }), ["tool_reference"]], + ["deferred tool", { tools: [{ ...functionTool, defer_loading: true }] }, ["deferred_tools"]], + ["strict tool", { tools: [{ ...functionTool, strict: true }] }, ["strict_tools"]], + ["programmatic caller", { tools: [{ ...functionTool, allowed_callers: ["code_execution_20260120"] }] }, ["caller_mode"]], + ["caller replay", assistantBlock({ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "code_execution_20260120", tool_id: "srv1" } }), ["caller_mode"]], + ["structured output", { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, ["structured_output"]], + ["service tier", { service_tier: "standard_only" }, ["service_tier"]], + ["MCP connector", { mcp_servers: [{ type: "url", url: "https://example.invalid/mcp", authorization_token: "private-fixture" }] }, ["mcp_tool"]], + ["MCP toolset", { tools: [{ type: "mcp_toolset", mcp_server_name: "example" }] }, ["mcp_tool"]], + ["MCP replay", assistantBlock({ type: "mcp_tool_use", id: "t1", name: "lookup" }), ["mcp_tool"]], + ["web search controls", { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"], max_uses: 1 }] }, ["web_search_tool"]], + ["web search result", userBlock({ type: "web_search_tool_result", content: [] }), ["web_search_tool"]], + ["code execution", { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, ["code_execution"]], + ["computer toolset", { tools: [{ type: "computer_toolset_20260801", name: "computer" }] }, ["computer_use"]], + ["context editing", { context_management: { edits: [{ type: "clear_thinking_20251015", keep: "all" }] } }, ["context_management"]], + ["container", { container: "private-container" }, ["container"]], + ["placement", { inference_geo: "us" }, ["inference_geo"]], + ["profile", { user_profile_id: "private-profile" }, ["user_profile"]], + ["unknown body field", { future_option: "private-fixture" }, ["unknown_body_field"]], + ["unknown content", userBlock({ type: "future_block", payload: "private-fixture" }), ["unknown_content_block"]], + ["nested unknown content", userBlock({ type: "tool_result", tool_use_id: "t1", content: [{ type: "future_block" }] }), ["unknown_content_block"]], + ["name cannot hide unsupported type", { tools: [{ type: "future_server_tool", name: "tool_search", input_schema: {} }] }, ["server_tool"]], + ]; + for (const [name, body, featureCodes] of rejected) { + test(`rejects ${name} in enforce and observes it in shadow`, () => { + const enforce = analyzeClaudeCompatibility(body, { mode: "enforce" }); + expect(enforce).toMatchObject({ decision: "reject", compatible: false, featureCodes }); + const shadow = analyzeClaudeCompatibility(body, { mode: "shadow" }); + expect(shadow).toMatchObject({ decision: "shadow", compatible: false, featureCodes }); + for (const result of [enforce, shadow]) { + expect(result.reason?.length).toBeLessThanOrEqual(512); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(JSON.stringify(result)).not.toContain("example.invalid"); + } + }); + } + + test("ordinary client names, schemas and arguments are not protocol declarations", () => { + for (const name of ["mcp_lookup", "tool_search", "tool_search_tool_local", "safe_code_execution", "computer"]) { + const result = analyzeClaudeCompatibility({ + tools: [{ ...functionTool, name, type: "function", input_schema: { + type: "object", properties: { cache_control: { type: "string" }, strict: { const: true } }, + } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name, id: "t1", input: { + type: "document", defer_loading: true, mcp_servers: [], + } }] }], + }, { mode: "enforce" }); + expect(result).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); + } + }); + + test("inactive flags and direct callers remain ordinary tools", () => { + expect(analyzeClaudeCompatibility({ + defer_tools: false, deferred_tools: [], + tools: [{ ...functionTool, strict: false, defer_loading: false, allowed_callers: ["direct"] }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "lookup", id: "t1", input: {}, caller: { type: "direct" } }] }], + }, { mode: "enforce" })).toEqual({ decision: "allow", compatible: true, featureCodes: [] }); + }); + + test("cache hints, examples and thinking settings are explicitly tolerated degradation", () => { + for (const thinking of [{ type: "disabled" }, { type: "enabled", budget_tokens: 2048 }, { type: "adaptive" }]) { + expect(analyzeClaudeCompatibility({ + thinking, + system: [{ type: "text", text: "private-fixture", cache_control: { type: "ephemeral", ttl: "1h" } }], + tools: [{ ...functionTool, input_examples: [{ value: "private-example" }] }], + }, { mode: "enforce" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["cache_control", "input_examples", "thinking_settings"], + }); + } + }); + + test("header volume cannot hide semantic rejection or persist header text", () => { + const result = analyzeClaudeCompatibility(userBlock({ type: "document" }), { + mode: "enforce", anthropicBeta: Array.from({ length: 100 }, (_, i) => `private-header-${i}`).join(","), + }); + expect(result).toEqual({ + decision: "reject", compatible: false, featureCodes: ["documents", "unknown_beta"], + reason: "unsupported translated Claude features: documents", + }); + expect(analyzeClaudeCompatibility({}, { mode: "enforce", anthropicBeta: "private-header" })).toEqual({ + decision: "allow", compatible: true, featureCodes: ["unknown_beta"], + }); + }); + + test("mode recognition is exact", () => { + expect(isClaudeCompatibilityMode("shadow")).toBe(true); + expect(isClaudeCompatibilityMode("enforce")).toBe(true); + for (const value of [undefined, null, false, 1, {}, [], "ENFORCE", "enforce ", "invalid"]) { + expect(isClaudeCompatibilityMode(value)).toBe(false); + } + }); +}); diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 558555a43e1..a84e09a8782 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -4,14 +4,17 @@ import { logsFromApiBody } from "../helpers/logs-api"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../../src/config"; +import { loadConfig, saveConfig } from "../../src/config"; +import { readRecentUsageEntries } from "../../src/usage/log"; import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; import type { DesktopProfile } from "../../src/claude/desktop-profile"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; import { clearableDeadline } from "../../src/lib/abort"; import { clearRequestLogsForTests, + addRequestLog, getRequestLogEntries, + hydrateRequestLogsFromDisk, type RequestLogContext, } from "../../src/server/request-log"; import { startServer } from "../../src/server"; @@ -1385,6 +1388,232 @@ test("claudeCode.enabled=false -> 403 permission_error on both routes", async () } }); +test("compatibility is uniform across translated adapters and rejects before inference", async () => { + let sends = 0; + const upstream = Bun.serve({ port: 0, fetch() { sends++; return new Response("unexpected inference", { status: 500 }); } }); + const baseUrl = new URL("/v1", upstream.url).href; + const features: Array> = [ + { messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }, + { messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private-fixture", signature: "opaque-fixture" }] }] }, + { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "opaque-fixture" }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "document" }] }] }] }, + { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "tool_reference", tool_name: "lookup" }] }] }] }, + { tools: [{ type: "tool_search_tool_regex_20251119", name: "tool_search" }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, defer_loading: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, strict: true }] }, + { tools: [{ name: "lookup", input_schema: { type: "object" }, allowed_callers: ["code_execution_20260120"] }] }, + { tools: [{ type: "web_search_20250305", name: "web_search", allowed_domains: ["example.invalid"] }] }, + { output_config: { format: { type: "json_schema", schema: { type: "object" } } } }, + { service_tier: "standard_only" }, + { mcp_servers: [{ type: "url", name: "mcp", url: "https://example.invalid", authorization_token: "private-fixture" }] }, + { tools: [{ type: "mcp_toolset", mcp_server_name: "mcp" }] }, + { tools: [{ type: "code_execution_20260120", name: "code_execution" }] }, + { tools: [{ type: "computer_20250124", name: "computer" }] }, + { context_management: { edits: [] } }, { container: "private-fixture" }, + { inference_geo: "us" }, { user_profile_id: "private-fixture" }, + { future_option: true }, + { messages: [{ role: "user", content: [{ type: "future_block" }] }] }, + ]; + try { + for (const adapter of ["anthropic", "openai-responses", "openai-chat"] as const) { + const config = mockConfig(baseUrl, { compatibility: "enforce" }); + config.providers.mock.adapter = adapter; + saveConfig(config); + const server = startServer(0); + try { + clearRequestLogsForTests(); + for (const [index, feature] of features.entries()) { + const response = await fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", headers: { + "content-type": "application/json", "x-api-key": "placeholder", + "anthropic-beta": Array.from({ length: 50 }, (_, i) => `private-header-${i}`).join(","), + }, + body: JSON.stringify({ model: "mock/test-model", max_tokens: 64, stream: index % 2 === 0, + messages: [{ role: "user", content: "hi" }], ...feature }), + }); + expect(response.status).toBe(400); + const error = await response.json() as { type: string; error: { type: string; message: string } }; + expect(error.type).toBe("error"); + expect(error.error.type).toBe("invalid_request_error"); + expect(error.error.message).not.toContain("private-"); + expect(getRequestLogEntries()).toHaveLength(index + 1); + expect(getRequestLogEntries().at(-1)?.errorCode).toBe("claude_compatibility_unsupported"); + } + expect(sends).toBe(0); + // Count-token success is intentionally independent of Messages admission. + const counted = await fetch(new URL("/v1/messages/count_tokens", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", messages: [{ role: "user", content: [{ type: "document" }] }] }), + }); + expect(counted.status).toBe(200); + } finally { await server.stop(true); } + } + } finally { await upstream.stop(true); } +}); + +test("compatibility unset, shadow persistence and ordinary enforce controls", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + const document = { model: "mock/test-model", max_tokens: 64, stream: true, + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "private-fixture" } }] }] }; + try { + for (const compatibility of [undefined, "shadow", "enforce"] as const) { + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const body = compatibility === "enforce" ? { + ...document, stream: false, thinking: { type: "disabled" }, + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + tools: [{ name: "mcp_lookup", input_schema: { type: "object" }, strict: false, defer_loading: false, input_examples: [{}] }], + messages: [{ role: "user", content: "hi" }], + } : document; + const response = await postMessages(server.url.toString(), body); + expect(response.status).toBe(200); + await response.text(); + const expected = compatibility === "shadow" ? { + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + } : undefined; + expect(getRequestLogEntries()).toHaveLength(1); + const requestId = getRequestLogEntries()[0].requestId; + expect(getRequestLogEntries()[0].claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + const dto = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(dto.find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + clearRequestLogsForTests(); + hydrateRequestLogsFromDisk(); + expect(getRequestLogEntries().find(row => row.requestId === requestId)?.claudeCompatibility).toEqual(expected); + if (compatibility === "shadow") { + addRequestLog({ requestId: "compatibility-boundary", timestamp: Date.now(), model: "test-model", provider: "mock", + status: 200, durationMs: 1, usageStatus: "unreported", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }); + const rows = logsFromApiBody<{ requestId: string; claudeCompatibility?: unknown }>( + await (await fetch(new URL("/api/logs", server.url))).json()); + expect(rows.find(row => row.requestId === "compatibility-boundary")?.claudeCompatibility).toEqual(expected); + } + } finally { await server.stop(true); } + } + expect(captured).toHaveLength(3); + } finally { await upstream.stop(true); } +}); + +test("present invalid compatibility modes return one fixed 503 before translation", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + try { + for (const compatibility of [null, "", "enfroce", false, 1, [], { secret: "private-fixture" }]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ + ...mockConfig(new URL("/v1", upstream.url).href), claudeCode: { compatibility }, + })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 16, messages: [{ role: "user", content: "hi" }], + }); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ type: "error", error: { type: "api_error", message: "Invalid claudeCode.compatibility setting" } }); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].errorCode).toBe("claude_compatibility_configuration"); + if (compatibility === null) { + const malformed = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: { "content-type": "application/json" }, body: "{", + }); + expect(malformed.status).toBe(400); + expect(getRequestLogEntries()).toHaveLength(2); + } + } finally { await server.stop(true); } + } + expect(urls).toEqual([]); + } finally { await upstream.stop(true); } +}); + +test("native passthrough precedes even invalid compatibility mode", async () => { + const received: unknown[] = []; + const upstream = Bun.serve({ port: 0, async fetch(req) { + received.push(await req.json()); + return Response.json({ id: "msg_test", type: "message", role: "assistant", model: "claude-haiku-4-5", + content: [{ type: "text", text: "ok" }], stop_reason: "end_turn", stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }); + } }); + const body = { model: "claude-haiku-4-5", max_tokens: 16, messages: [ + { role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }, + ] }; + try { + for (const compatibility of ["enforce", "invalid"]) { + writeFileSync(join(testDir, "config.json"), JSON.stringify({ ...mockConfig("http://127.0.0.1:1/v1"), + claudeCode: { compatibility, anthropicBaseUrl: upstream.url.origin } })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { method: "POST", + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, body: JSON.stringify(body) }); + expect(response.status).toBe(200); + await response.text(); + } finally { await server.stop(true); } + } + expect(received).toEqual([body, body]); + } finally { await upstream.stop(true); } +}); + +test("compatibility survives management toggles and rejects Desktop source features", async () => { + const { server: upstream, urls } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "enforce" })); + const server = startServer(0); + try { + for (const enabled of [false, true]) { + const toggle = await fetch(new URL("/api/native-integrations/claude", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled }), + }); + expect(toggle.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + } + const settings = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ injectAgents: false }), + }); + expect(settings.status).toBe(200); + expect(loadConfig().claudeCode?.compatibility).toBe("enforce"); + buildDesktop3pRegistry([], [{ provider: "mock", id: "test-model" }], { + version: 1, assignments: { "mock/test-model": { family: "opus", alias: "claude-opus-4-8-20260201" } }, + defaults: { opus: "mock/test-model", fable: null, sonnet: null, haiku: null }, + }); + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "claude-opus-4-8-20260201", max_tokens: 16, + system: [{ type: "text", text: "" }], + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "fixture", signature: "opaque-fixture" }] }, + { role: "user", content: "continue" }], + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain("thinking_replay"); + expect(getRequestLogEntries()).toHaveLength(1); + expect(getRequestLogEntries()[0].surface).toBe("claude-desktop"); + expect(urls).toEqual([]); + } finally { + buildDesktop3pRegistry([], []); + await server.stop(true); + await upstream.stop(true); + } +}); + +test("shadow captures source thinking settings before an effort directive removes them", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(new URL("/v1", upstream.url).href, { compatibility: "shadow" })); + const server = startServer(0); + try { + clearRequestLogsForTests(); + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", max_tokens: 64, stream: true, thinking: { type: "disabled" }, + system: [{ type: "text", text: "\n" }], + messages: [{ role: "user", content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "fixture" } }] }], + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents", "thinking_settings"], reason: "shadow: would reject: documents", + }); + } finally { await server.stop(true); await upstream.stop(true); } +}); + async function postMessages(serverUrl: string, body: Record): Promise { return fetch(new URL("/v1/messages", serverUrl), { method: "POST", diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index 1a3b4e32640..4f15906373b 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -58,6 +58,55 @@ function log(overrides: Partial): RequestLogEntry { } describe("request log metadata", () => { + test("Claude evidence is normalized before direct ring ingress and cannot be mutated afterwards", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-claude-log-")); + process.env.OPENCODEX_HOME = home; + clearRequestLogsForTests(); + try { + const raw = JSON.parse('{"decision":"shadow","featureCodes":["documents","unknown_beta","private-header"],"reason":"private-reason"}'); + addRequestLog(log({ claudeCompatibility: raw })); + raw.featureCodes.length = 0; + raw.reason = "changed"; + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(getRequestLogEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + const finalized: RequestLogEntry[] = []; + addFinalRequestLog("claude-final", 1, { model: "test", provider: "mock", + claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents"],"reason":"private-final"}') }, + 200, { closeReason: "non_stream" }, row => finalized.push(row)); + expect(finalized).toHaveLength(1); + expect(finalized[0].claudeCompatibility).toEqual({ + decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents", + }); + } finally { + clearRequestLogsForTests(); + resetUsageReadCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); + + test("custom hydration normalizes Claude evidence and ignores malformed optional rows", () => { + clearRequestLogsForTests(); + const raw: PersistedUsageEntry[] = [ + { ...log({ requestId: "legacy" }) }, + { ...log({ requestId: "shadow" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":["documents","private-header"],"reason":"private-reason"}') }, + { ...log({ requestId: "malformed" }), claudeCompatibility: JSON.parse('{"decision":"shadow","featureCodes":null,"reason":"private-reason"}') }, + ]; + try { + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(3); + expect(getRequestLogEntries().map(row => row.claudeCompatibility)).toEqual([ + undefined, { decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject: documents" }, undefined, + ]); + raw[1].claudeCompatibility!.featureCodes.length = 0; + expect(getRequestLogEntries()[1]?.claudeCompatibility?.featureCodes).toEqual(["documents"]); + expect(hydrateRequestLogsFromDisk(() => raw)).toBe(0); + expect(JSON.stringify(getRequestLogEntries())).not.toContain("private-"); + } finally { clearRequestLogsForTests(); } + }); + test("upstream credential attribution requires the resolved canonical xAI transport", () => { const attempt = beginRequestAttempt(1, "xai", "grok-test", "openai-chat"); const oauth = { adapter: "openai-chat", authMode: "oauth" as const, baseUrl: "https://cli-chat-proxy.grok.com/v1" }; diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index e3c490535ed..f39c778d2b2 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -7,6 +7,8 @@ import { appendUsageEntry, currentUsageLogRevision, normalizeUsageEntryForTest, + normalizeClaudeCompatibilityUsageLog, + normalizePersistedUsageRow, readRecentUsageEntries, readUsageEntries, readUsageEntriesForManagement, @@ -39,6 +41,45 @@ afterEach(() => { }); describe("usage log", () => { + test("Claude shadow metadata round trips as closed codes and a regenerated reason", () => { + const evidence = normalizeClaudeCompatibilityUsageLog({ + decision: "shadow", featureCodes: ["unknown_beta", "documents", "documents", "private-header", "__proto__"], + reason: "private-reason", extra: "private-payload", + }); + const expected = { decision: "shadow", featureCodes: ["documents", "unknown_beta"], reason: "shadow: would reject: documents" }; + expect(evidence).toEqual(expected); + appendUsageEntry({ requestId: "claude-shadow", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 3, outputTokens: 2 }, + claudeCompatibility: evidence }); + // Later mutation of the caller's evidence cannot rewrite the serialized record. + evidence!.featureCodes.length = 0; + resetUsageReadCacheForTests(); + expect(readUsageEntries()[0]?.claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1)[0]?.claudeCompatibility).toEqual(expected); + expect(readUsageEntries()[0]?.usage).toMatchObject({ inputTokens: 3, outputTokens: 2 }); + expect(readFileSync(usageLogPath(), "utf8")).not.toContain("private-"); + }); + + test("legacy and malformed persisted Claude metadata does not poison readers", () => { + const base = { requestId: "claude-legacy", timestamp: 1, provider: "mock", model: "test-model", + status: 200, durationMs: 1, usageStatus: "unreported" }; + const invalid: unknown[] = [undefined, null, [], "private-value", 1, + { decision: "reject", featureCodes: ["documents"] }, + { decision: "shadow", featureCodes: "documents" }, + { decision: "shadow", featureCodes: [null, {}, "constructor", "private-header"] }, + { decision: "shadow", featureCodes: ["cache_control"], reason: "private-reason" }, + ]; + for (const claudeCompatibility of invalid) { + const row = normalizePersistedUsageRow({ ...base, claudeCompatibility }); + expect(row).toBeDefined(); + expect(row?.claudeCompatibility).toBeUndefined(); + } + writeFileSync(usageLogPath(), invalid.map(claudeCompatibility => JSON.stringify({ ...base, claudeCompatibility })).join("\n") + "\n"); + resetUsageReadCacheForTests(); + expect(readUsageEntries()).toHaveLength(invalid.length); + expect(readUsageEntries().every(row => row.claudeCompatibility === undefined)).toBe(true); + }); + test("round trips only recognized per-attempt xAI credential sources", () => { const attempt = { ordinal: 1, provider: "xai", model: "grok-test", adapter: "openai-chat", status: 200, From 3ff52b36e2e921890dfcf52b357db3ebfa062ce2 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:02:38 +0900 Subject: [PATCH 6/7] test(claude): register compatibility policy coverage --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3d84e77bd27..6b8c5d83fff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -88,6 +88,7 @@ "^(?:chatgpt|generic|state)-" ] }, + "claude-compatibility.test.ts": "claude-integration", "claude-integration": { "match": [] }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dc1bb60a246..6cc854d0836 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -116,6 +116,7 @@ "cl01-claude-outbound-review-regressions.test.ts": "routing", "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", + "claude-compatibility.test.ts": "claude-integration", "claude-529-mapping.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", From 4255bfac615c59602088a9af5045762067d262c2 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:05:12 +0900 Subject: [PATCH 7/7] fix(test): place compatibility test in the explicit layout inventory [skip ci] --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6b8c5d83fff..794ded9ee5f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -88,7 +88,6 @@ "^(?:chatgpt|generic|state)-" ] }, - "claude-compatibility.test.ts": "claude-integration", "claude-integration": { "match": [] }, @@ -291,6 +290,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6cc854d0836..c68c85ddbb1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -116,7 +116,6 @@ "cl01-claude-outbound-review-regressions.test.ts": "routing", "cl01-openai-chat-review-regressions.test.ts": "routing", "cl01-review-regressions.test.ts": "routing", - "claude-compatibility.test.ts": "claude-integration", "claude-529-mapping.test.ts": "claude-integration", "claude-agent-startup-sync.test.ts": "claude-integration", "claude-agents-inject.test.ts": "claude-integration", @@ -126,6 +125,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration",