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/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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2e2b806deea..794ded9ee5f 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", @@ -289,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/src/chat/outbound.ts b/src/chat/outbound.ts index 812f1b553f8..e69d03f499d 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,125 @@ 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; + }>(); + 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; + 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") { + 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(refusalEntryBytes + Buffer.byteLength(candidate)); + item.id = candidate; + refusalIndexById.set(candidate, index); + } + } + 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; + // Sparse final snapshots may omit type/content but cannot change a known 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; + } + // 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 +366,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 +441,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 +518,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 +544,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 +610,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 +641,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 +653,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 +698,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 +724,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 +746,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 +775,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 +803,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 +854,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 +883,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 +891,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 +943,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 +958,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 +999,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 +1019,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/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/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/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/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/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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a1f0198e44a..c68c85ddbb1 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", @@ -124,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", diff --git a/tests/responses/chat-refusal.test.ts b/tests/responses/chat-refusal.test.ts new file mode 100644 index 00000000000..27f6ff86efd --- /dev/null +++ b/tests/responses/chat-refusal.test.ts @@ -0,0 +1,474 @@ +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)])], + ["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)], + ]; + 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"]); + }); + } + } + } +}); + +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); +}); 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,