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
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ headers — see [Adapters](/reference/adapters/)). Pool mode overwrites only aut
client identity (for example `originator`, session, or thread headers) when the caller did not send
them.

For account-switch compatibility diagnosis, enabling provider debug (`ocx debug provider on`) adds
one `[ocx:codex:affinity]` line per canonical ChatGPT forward response. The line contains header
presence, coarse size buckets, process-local HMAC equality tags, safe summaries of known top-level
turn fields, and a count of unknown turn fields. It never includes raw credentials, account ids,
attestation values, thread/session ids, turn metadata, or request bodies; the tags intentionally
change after every proxy restart. Use `ocx debug provider logs -f` while
reproducing the two requests, then run `ocx debug provider off`. This capture is observation-only and
does not strip metadata, retry a request, switch accounts, reset a thread, or otherwise affect routing.

**Diagnostics and reauth.** Human `ocx status` prints an OAuth health block (redacted account ids,
no tokens). `ocx doctor` adds an OAuth reliability section with writable-store / single-flight checks
and WARN rows that include a recovery Action. When an OAuth provider account needs reauthentication, run
Expand Down
162 changes: 162 additions & 0 deletions src/codex/affinity-debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Opt-in Codex affinity diagnostics for account-switch compatibility failures.
*
* The provider debug stream may compare values only within the current process. It never emits
* raw header values, credentials, account identifiers, or durable unsalted hashes.
*/
import { createHmac, randomBytes } from "node:crypto";
import { isDebugEnabled } from "../lib/debug-settings";
import { debugProviderDiagnostic } from "../lib/debug";

const MAX_TAGGED_VALUE_BYTES = 16 * 1024;
const RUN_KEY = randomBytes(32);
let nextSequence = 0;

const SAFE_AFFINITY_HEADERS = [
"openai-beta",
"originator",
"session_id",
"session-id",
"thread-id",
"x-client-request-id",
"x-codex-beta-features",
"x-codex-installation-id",
"x-codex-parent-thread-id",
"x-codex-turn-metadata",
"x-codex-turn-state",
"x-codex-window-id",
"x-openai-subagent",
"x-responsesapi-include-timing-metrics",
] as const;

const KNOWN_TURN_FIELDS = [
"forked_from_thread_id",
"parent_thread_id",
"request_kind",
"session_id",
"subagent_kind",
"thread_id",
] as const;

type SizeBucket = "0" | "1-31" | "32-127" | "128-511" | "512-2047" | "2048-16384" | "oversized";

export interface CodexAffinityValueTag {
name: string;
size: SizeBucket;
tag?: string;
}

export interface CodexAffinityJsonSummary {
shape: "absent" | "malformed" | "oversized" | "array" | "scalar" | "object";
knownFields?: Array<CodexAffinityValueTag & { kind: "string" | "number" | "boolean" | "null" | "array" | "object" }>;
unknownFieldCount?: number;
}

export interface CodexAffinityDiagnosticInput {
inboundHeaders: Headers;
outboundHeaders: HeadersInit;
authKind: "main" | "pool" | "main-pool";
accountMode: "direct" | "pool" | undefined;
fixedAccount: boolean;
credentialSubstituted: boolean;
accountGatedModel: boolean;
wireModelNormalized: boolean;
status: number;
}

function sizeBucket(bytes: number): SizeBucket {
if (bytes === 0) return "0";
if (bytes <= 31) return "1-31";
if (bytes <= 127) return "32-127";
if (bytes <= 511) return "128-511";
if (bytes <= 2047) return "512-2047";
if (bytes <= MAX_TAGGED_VALUE_BYTES) return "2048-16384";
return "oversized";
}

function valueTag(name: string, value: string): CodexAffinityValueTag {
const bytes = Buffer.byteLength(value, "utf8");
const size = sizeBucket(bytes);
if (size === "oversized") return { name, size };
const tag = createHmac("sha256", RUN_KEY)
.update(name)
.update("\0")
.update(value)
.digest("hex")
.slice(0, 12);
return { name, size, tag };
}

function headerTags(headers: Headers): CodexAffinityValueTag[] {
const rows: CodexAffinityValueTag[] = [];
for (const name of SAFE_AFFINITY_HEADERS) {
const value = headers.get(name);
if (value !== null) rows.push(valueTag(name, value));
}
return rows;
}

function jsonSummary(headers: Headers, name: "x-codex-turn-metadata" | "x-codex-turn-state"): CodexAffinityJsonSummary {
const raw = headers.get(name);
if (raw === null) return { shape: "absent" };
if (Buffer.byteLength(raw, "utf8") > MAX_TAGGED_VALUE_BYTES) return { shape: "oversized" };
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return { shape: "malformed" };
}
if (Array.isArray(parsed)) return { shape: "array" };
if (!parsed || typeof parsed !== "object") return { shape: "scalar" };
const record = parsed as Record<string, unknown>;
const knownFields: NonNullable<CodexAffinityJsonSummary["knownFields"]> = [];
for (const field of KNOWN_TURN_FIELDS) {
if (!Object.hasOwn(record, field)) continue;
const value = record[field];
if (value === null) {
knownFields.push({ name: field, kind: "null", size: "0" });
} else {
const scalarKind = typeof value;
if (scalarKind === "string" || scalarKind === "number" || scalarKind === "boolean") {
knownFields.push({ ...valueTag(`${name}.${field}`, String(value)), name: field, kind: scalarKind });
continue;
}
knownFields.push({ name: field, kind: Array.isArray(value) ? "array" : "object", size: "0" });
}
}
const known = new Set<string>(KNOWN_TURN_FIELDS);
const unknownFieldCount = Object.keys(record).filter(key => !known.has(key)).length;
return {
shape: "object",
...(knownFields.length > 0 ? { knownFields } : {}),
...(unknownFieldCount > 0 ? { unknownFieldCount } : {}),
};
}

/** Emit one observation-only, privacy-bounded provider-debug record. */
export function captureCodexAffinityDiagnostic(input: CodexAffinityDiagnosticInput): void {
if (!isDebugEnabled()) return;
try {
const outbound = new Headers(input.outboundHeaders);
debugProviderDiagnostic("codex", "affinity", {
sequence: ++nextSequence,
authKind: input.authKind,
accountMode: input.accountMode ?? "unset",
fixedAccount: input.fixedAccount,
credentialSubstituted: input.credentialSubstituted,
accountGatedModel: input.accountGatedModel,
wireModelNormalized: input.wireModelNormalized,
status: input.status,
inbound: headerTags(input.inboundHeaders),
outbound: headerTags(outbound),
inboundTurnMetadata: jsonSummary(input.inboundHeaders, "x-codex-turn-metadata"),
outboundTurnMetadata: jsonSummary(outbound, "x-codex-turn-metadata"),
inboundTurnState: jsonSummary(input.inboundHeaders, "x-codex-turn-state"),
outboundTurnState: jsonSummary(outbound, "x-codex-turn-state"),
});
} catch {
// Diagnostics must never affect request handling.
}
}

export const CODEX_AFFINITY_DEBUG_SAFE_HEADERS = SAFE_AFFINITY_HEADERS;
38 changes: 37 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
resolveCodexModelEntitlements,
} from "../../codex/model-entitlements";
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
import {
computeQuotaCooldown,
formatCodexProviderForLog,
Expand Down Expand Up @@ -521,6 +522,11 @@ interface CodexPoolAccountRetryArgs {
connectMs: number;
passthroughEstimate?: number;
stream: boolean;
onResponse?: (
response: Response,
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>,
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>,
) => void;
}

type CodexPoolAccountRetryResult =
Expand Down Expand Up @@ -761,6 +767,7 @@ async function retryCodexPoolOnAlternateAccount(
route.provider.authMode === "forward",
);
retrySendCount += 1;
args.onResponse?.(upstreamResponse, retryAuthCtx, request);
if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break;
if (!await shouldRetryCodexPoolAccountModel400(
upstreamResponse,
Expand Down Expand Up @@ -1141,7 +1148,7 @@ function unreadableEncryptedAgentTaskResponse(): Response {
}

type ResponsesAuthResolution =
| { ok: true; authCtx: CodexAuthContext; headers: Headers }
| { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean }
| { ok: false; response: Response };

/**
Expand Down Expand Up @@ -1205,6 +1212,7 @@ async function resolveResponsesCodexAuth(
ok: true,
authCtx,
headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }),
substituteMainCredential,
};
} catch (err) {
if (err instanceof CodexAccountCooldownError) {
Expand Down Expand Up @@ -2217,11 +2225,13 @@ async function handleResponsesInner(
pendingHostAdmissionLease = admission.lease;
}

let substituteMainCredential = false;
{
const finalAuth = await resolveResponsesCodexAuth(req, config, route, options);
if (!finalAuth.ok) return finalAuth.response;
authCtx = finalAuth.authCtx;
selectedForwardHeaders = finalAuth.headers;
substituteMainCredential = finalAuth.substituteMainCredential;
}

route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
Expand Down Expand Up @@ -2884,6 +2894,29 @@ async function handleResponsesInner(
}
}

const captureAffinityResponse = (
response: Response,
captureAuthCtx: CodexAuthContext = authCtx,
captureRequest: Awaited<ReturnType<typeof adapter.buildRequest>> = request,
credentialSubstituted = substituteMainCredential
|| captureAuthCtx.kind === "pool"
|| captureAuthCtx.kind === "main-pool",
): void => {
if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
captureCodexAffinityDiagnostic({
inboundHeaders: req.headers,
outboundHeaders: captureRequest.headers,
authKind: captureAuthCtx.kind,
accountMode: route.codexAccountMode,
fixedAccount: isFixedCodexAccount(captureAuthCtx),
credentialSubstituted,
accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
wireModelNormalized: parsed.modelId !== route.modelId,
status: response.status,
});
};
captureAffinityResponse(upstreamResponse);

if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
let poolRetryOutcome: number | undefined;
if (await shouldRetryCodexPoolAccountModel400(
Expand Down Expand Up @@ -2912,6 +2945,9 @@ async function handleResponsesInner(
connectMs,
passthroughEstimate,
stream: parsed.stream,
onResponse: (response, retryAuthCtx, retryRequest) => {
captureAffinityResponse(response, retryAuthCtx, retryRequest, true);
},
});
if (retry.kind === "transport") {
authCtx = retry.authCtx;
Expand Down
21 changes: 21 additions & 0 deletions structure/08_openai-provider-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,27 @@ preserving a stale one would block every later migration.
usage, model visibility, subagent state, and injection state retain the selected virtual id.
- Compact preserves provider/selected identity but sends the base model without a reasoning object.

## Process-local affinity diagnostics

Provider debug capture includes one `[ocx:codex:affinity]` record for each canonical ChatGPT
forward response before account-model retry selection. The record compares only an explicit safe
header-name allowlist. Values are represented by size buckets and 12-character HMAC equality tags
under a random process-local key; raw credentials, account ids, attestation values, thread/session
ids, turn metadata, and request bodies never enter the record. Known top-level turn-metadata fields
use the same process-local tags, while unknown fields contribute only a count. Oversized values are
classified without hashing. The diagnostic is observational: it cannot strip headers, retry,
switch accounts, reset threads, or mutate affinity.

```text
[Decision Log]
- 목적과 의도: Identify which combined Codex affinity values survive a Plus-to-K12 credential substitution without collecting private thread or account data.
- 기존 구현 및 제약 조건: Pool auth intentionally copies the curated caller metadata and replaces only authorization plus chatgpt-account-id. Individual header probes did not reproduce the workspace denial, while raw captures would expose account-bound identifiers.
- 검토한 주요 대안: Delete all affinity metadata; log raw values; persist ordinary hashes; perform automatic header-ablation retries; or emit process-local keyed equality evidence only when provider debug is enabled.
- 선택한 방식: Emit bounded pre-stream diagnostics with a random per-process HMAC key, a fixed non-credential header allowlist, known turn-field summaries, and no request mutation.
- 다른 대안 대신 이 방식을 선택한 이유: Equality across two requests in one run is enough to narrow the incompatible combination; process-local HMACs prevent durable correlation and make offline guessing useless, while observation-only capture cannot change production semantics.
- 장점, 단점 및 영향: Maintainers can compare a Plus success and exact-K12 denial safely. Tags cannot be compared across restarts, and the diagnostic does not itself identify an upstream policy rule or fix the rejection.
```

## Account identity and store concurrency

Pool mode needs stable public names and a store that survives concurrent refresh:
Expand Down
Loading
Loading