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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { OcxConfig } from "../types";
import { readJsonRequestBody } from "./request-decompress";
import {
addFinalRequestLog,
httpStatusForTerminalStatus,
httpStatusForRequestLogTerminal,
recordFirstOutput,
type RequestLogContext,
type RequestLogEntry,
Expand Down Expand Up @@ -216,7 +216,7 @@ async function handleChatCompletionsWithBudget(
inboundWire: "chat",
translatorBudget,
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }),
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
});

Expand Down
4 changes: 2 additions & 2 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { evidenceFromBody } from "../routing/request-evidence";
import { resolveWireProtocolOverride } from "./adapter-resolve";
import type { OcxConfig } from "../types";
import { readJsonRequestBody } from "./request-decompress";
import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
import { responseWithDeferredRequestLog } from "./relay";
import { handleResponses } from "./responses";
Expand Down Expand Up @@ -738,7 +738,7 @@ async function handleClaudeMessagesWithBudget(
stripClaudeMainAuthForNoncanonicalForward: true,
translatorBudget,
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }),
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
});
const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
Expand Down
3 changes: 1 addition & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ import {
addFinalRequestLog,
hydrateRequestLogsFromDisk,
httpStatusForRequestLogTerminal,
httpStatusForTerminalStatus,
inspectResponseLogSsePayload,
nextRequestLogId,
recordFirstOutput,
Expand Down Expand Up @@ -1185,7 +1184,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
abortSignal: req.signal,
onFirstOutput: () => recordFirstOutput(logCtx, start),
onNativePassthroughTerminal: status => {
finalizeNativePassthroughLog(httpStatusForTerminalStatus(status), {
finalizeNativePassthroughLog(httpStatusForRequestLogTerminal(status, logCtx), {
terminalStatus: status,
closeReason: "terminal",
});
Expand Down
42 changes: 38 additions & 4 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
import type { ResponsesTerminalStatus } from "../bridge";
import {
classifyError,
CYBER_POLICY_ERROR_CODE,
httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError,
isClientClosedMessage,
isCyberPolicyCode,
} from "../lib/errors";
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
import { readCodexCatalogPath } from "../codex/catalog";
Expand Down Expand Up @@ -94,6 +96,8 @@ export interface RequestLogContext {
upstreamError?: string;
/** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
terminalHttpStatus?: number;
/** Recognized structured terminal code whose exact identity must survive status mapping. */
terminalErrorCode?: typeof CYBER_POLICY_ERROR_CODE;
/** Structured reason from `response.incomplete`; internal-only input to log classification. */
terminalIncompleteReason?: string;
affinity?: "reused" | "new_bind" | "rebound" | "cleared";
Expand Down Expand Up @@ -446,12 +450,26 @@ export function recordAdapterReasoning(
}
}

export function requestLogErrorCode(status: number, upstreamError?: string): string | undefined {
export function requestLogErrorCode(
status: number,
upstreamError?: string,
terminalErrorCode?: string,
): string | undefined {
if (status >= 200 && status < 400) return undefined;
// A structured terminal code is authoritative even when the provider message is localized,
// generic, or absent. Only preserve the one narrowly recognized policy code here: broadly
// forwarding arbitrary upstream codes would change unrelated request-log taxonomy.
if (isCyberPolicyCode(terminalErrorCode)) return CYBER_POLICY_ERROR_CODE;
const classifiedCode = upstreamError?.trim()
? classifyError(status, "upstream_error", upstreamError).code
: undefined;
// Defense in depth: mid-stream web-search aborts used to land as 502 with this message.
if (status === 499 || (upstreamError?.trim() && classifyError(status, "upstream_error", upstreamError).code === "client_closed_request")) {
if (status === 499 || classifiedCode === "client_closed_request") {
return "client_closed_request";
}
// Keep the high-confidence message fallback for runtimes/providers that stripped the
// structured code before emitting response.failed.
if (classifiedCode === CYBER_POLICY_ERROR_CODE) return CYBER_POLICY_ERROR_CODE;
if (status === 400 || status === 409) return "invalid_request_error";
if (status === 401) return "invalid_api_key";
if (status === 403) {
Expand Down Expand Up @@ -719,9 +737,17 @@ function captureTerminalHttpStatus(
if (json.type !== "response.failed") return;
const error = json.response?.error;
if (!error || typeof error !== "object") return;
const terminalCode = error.code === null || typeof error.code === "string"
? error.code
: undefined;
if (isCyberPolicyCode(terminalCode)) {
logCtx.terminalErrorCode = CYBER_POLICY_ERROR_CODE;
} else {
delete logCtx.terminalErrorCode;
}
logCtx.terminalHttpStatus = httpStatusFromTerminalError({
type: typeof error.type === "string" ? error.type : undefined,
code: error.code === null || typeof error.code === "string" ? error.code : undefined,
code: terminalCode,
message: typeof error.message === "string" ? error.message : undefined,
});
}
Expand Down Expand Up @@ -777,7 +803,11 @@ export function addFinalRequestLog(
const effectiveStatus = status >= 500 && logCtx.upstreamError && isClientClosedMessage(logCtx.upstreamError)
? 499
: status;
const errorCode = requestLogErrorCode(effectiveStatus, logCtx.upstreamError);
const errorCode = requestLogErrorCode(
effectiveStatus,
logCtx.upstreamError,
logCtx.terminalErrorCode,
);
// A response.failed whose classified status is 499 is still a client cancel, not an upstream
// terminal failure — keep /api/logs closeReason aligned with that.
const closeReason = effectiveStatus === 499
Expand All @@ -790,6 +820,10 @@ export function addFinalRequestLog(
Date.now() - (logCtx.activeAttemptStartedAt ?? start),
logCtx.usage,
);
// The final row and its active physical attempt describe the same terminal. Preserve the
// semantic code on both so detailed attempt telemetry cannot regress to a generic status code.
if (errorCode) logCtx.activeAttempt.errorCode = errorCode;
else delete logCtx.activeAttempt.errorCode;
}
const existing = finalizedUsage(
logCtx.providerAdapter ?? logCtx.provider,
Expand Down
1 change: 1 addition & 0 deletions src/server/responses/policy-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
delete logCtx.usageFromBridge;
delete logCtx.upstreamError;
delete logCtx.terminalHttpStatus;
delete logCtx.terminalErrorCode;
delete logCtx.terminalIncompleteReason;
}

Expand Down
4 changes: 4 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ Translated response request-log tracking and the heartbeat relay also reuse
`createSseInspector`. This keeps every client-facing SSE observation path on
the same byte-bounded, discard-and-resynchronize frame policy and ensures the
request-log, first-output, and terminal observers share one payload parse.
The inspector records a structured `response.failed` status before invoking the
terminal observer. Native Responses, Chat Completions, Claude Messages, and WebSocket
request logs must therefore finalize through the context-aware terminal mapper; recognized
`cyber_policy` terminals stay `400 / cyber_policy` rather than collapsing to a generic 502.

## Standalone Search and exact account selectors

Expand Down
74 changes: 74 additions & 0 deletions tests/chat-completions-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,80 @@ test("POST /v1/chat/completions finalizes native passthrough request logs", asyn
}
});

test("POST /v1/chat/completions logs native cyber terminals as 400 cyber_policy", async () => {
const { clearRequestLogsForTests, getRequestLogEntries } = await import("../src/server/request-log");
clearRequestLogsForTests();
const upstream = Bun.serve({
port: 0,
fetch() {
return new Response([
"event: response.failed",
`data: ${JSON.stringify({
type: "response.failed",
response: {
status: "failed",
error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" },
},
})}`,
"",
"",
].join("\n"), { headers: { "Content-Type": "text/event-stream" } });
},
});
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const url = new URL(requestUrl);
const prefix = "/backend-api/codex";
if (url.hostname === "chatgpt.com" && url.pathname.startsWith(prefix)) {
return originalFetch(new URL(`${url.pathname.slice(prefix.length)}${url.search}`, upstream.url), init);
}
return originalFetch(input, init);
}) as typeof fetch;
saveConfig({
port: 0,
defaultProvider: "openai",
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
codexAccountMode: "direct",
},
},
} as OcxConfig);
const server = startServer(0);
try {
const response = await fetch(new URL("/v1/chat/completions", server.url), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: ["Bear" + "er", "caller-direct-token"].join(" "),
},
body: JSON.stringify({
model: "gpt-test",
stream: true,
messages: [{ role: "user", content: "hi" }],
}),
});
expect(response.status).toBe(200);
expect(await response.text()).toContain("blocked");
const entry = getRequestLogEntries().findLast(e => e.inboundProtocol === "chat");
expect(entry).toMatchObject({
status: 400,
errorCode: "cyber_policy",
terminalStatus: "failed",
closeReason: "terminal",
upstreamError: "blocked",
});
} finally {
await server.stop(true);
await upstream.stop(true);
globalThis.fetch = originalFetch;
clearRequestLogsForTests();
}
});


test("responsesSseToChatCompletionsSse reconciles done-frame final arguments (last-write-wins)", async () => {
const { responsesSseToChatCompletionsSse, collectChatCompletion } = budgetedChatOutbound(await import("../src/chat/outbound"));
Expand Down
66 changes: 65 additions & 1 deletion tests/claude-messages-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { join } from "node:path";
import { saveConfig } from "../src/config";
import { createAnthropicAdapter } from "../src/adapters/anthropic";
import { clearableDeadline } from "../src/lib/abort";
import type { RequestLogContext } from "../src/server/request-log";
import {
clearRequestLogsForTests,
getRequestLogEntries,
type RequestLogContext,
} from "../src/server/request-log";
import { startServer } from "../src/server";
import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspection";
import {
Expand Down Expand Up @@ -687,6 +691,66 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi
}
});

test("native openai-responses Claude route logs cyber terminals as 400 cyber_policy", async () => {
clearRequestLogsForTests();
const upstream = Bun.serve({
port: 0,
fetch() {
return new Response([
"event: response.failed",
`data: ${JSON.stringify({
type: "response.failed",
response: {
status: "failed",
error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" },
},
})}`,
"",
"",
].join("\n"), { headers: { "Content-Type": "text/event-stream" } });
},
});
saveConfig({
port: 0,
defaultProvider: "native",
providers: {
native: {
adapter: "openai-responses",
baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`,
authMode: "forward",
allowPrivateNetwork: true,
},
},
} as OcxConfig);
const server = startServer(0);
try {
const response = await fetch(new URL("/v1/messages", server.url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "native/gpt-test",
max_tokens: 128,
stream: true,
messages: [{ role: "user", content: "hi" }],
}),
});
expect(response.status).toBe(200);
expect(await response.text()).toContain("blocked");
const entry = getRequestLogEntries().findLast(e => e.surface === "claude");
expect(entry).toMatchObject({
status: 400,
errorCode: "cyber_policy",
terminalStatus: "failed",
closeReason: "terminal",
upstreamError: "blocked",
});
} finally {
await server.stop(true);
await upstream.stop(true);
clearRequestLogsForTests();
}
});

test("custom forward openai-responses route never receives the main ChatGPT credential", async () => {
writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({
tokens: { access_token: "main-secret-must-not-leave", account_id: "main-account-must-not-leave" },
Expand Down
38 changes: 38 additions & 0 deletions tests/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,11 @@ describe("request log metadata", () => {
expect(requestLogErrorCode(429)).toBe("rate_limit_exceeded");
expect(requestLogErrorCode(499)).toBe("client_closed_request");
expect(requestLogErrorCode(502, "client closed request during web-search")).toBe("client_closed_request");
expect(requestLogErrorCode(400, "blocked", "cyber_policy")).toBe("cyber_policy");
expect(requestLogErrorCode(
502,
"This content was flagged for possible cybersecurity risk. To get authorized for security work, join the Trusted Access for Cyber program.",
)).toBe("cyber_policy");
expect(requestLogErrorCode(503)).toBe("server_is_overloaded");
expect(requestLogErrorCode(502)).toBe("upstream_server_error");
expect(requestLogErrorCode(404)).toBe("http_404");
Expand Down Expand Up @@ -849,6 +854,39 @@ describe("request log metadata", () => {
});
});

test("deferred SSE logging preserves structured cyber_policy status and code", async () => {
const entries: RequestLogEntry[] = [];
const failedPayload = JSON.stringify({
type: "response.failed",
response: {
status: "failed",
error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" },
},
});
const response = responseWithDeferredRequestLog(
new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`data: ${failedPayload}\n\n`));
controller.close();
},
}), { status: 200, headers: { "content-type": "text/event-stream" } }),
"ocx-test-cyber-policy",
Date.now(),
{ model: "gpt-5.6-sol", provider: "openai" },
entry => entries.push(entry),
);

await response.text();
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
terminalStatus: "failed",
upstreamError: "blocked",
status: 400,
errorCode: "cyber_policy",
closeReason: "terminal",
});
});

test("deferred SSE logging maps web-search client closes to 499 client_cancel", async () => {
const entries: RequestLogEntry[] = [];
const message = "client closed request during web-search";
Expand Down
Loading
Loading