fix(codex): align client identity metadata (#1778)

Integrated into release/v3.7.5
This commit is contained in:
Raxxoor
2026-04-29 20:46:43 +01:00
committed by GitHub
parent 59fd2b2152
commit 5b0ac4266e
4 changed files with 259 additions and 78 deletions

View File

@@ -1,10 +1,11 @@
const DEFAULT_CODEX_CLIENT_VERSION = "0.125.0";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26100";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
const CODEX_USER_AGENT_OVERRIDE_ENV = "CODEX_USER_AGENT";
const SAFE_HEADER_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
const SAFE_HEADER_VALUE_PATTERN = /^[\x20-\x7E]{1,200}$/;
const SAFE_CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
function getSafeEnvValue(name: string, pattern: RegExp): string | null {
const raw = process.env[name];
@@ -40,3 +41,9 @@ export function getCodexDefaultHeaders(): Record<string, string> {
"User-Agent": getCodexUserAgent(),
};
}
export function normalizeCodexSessionId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return SAFE_CODEX_SESSION_ID_PATTERN.test(normalized) ? normalized : null;
}

View File

@@ -0,0 +1,83 @@
import { createHash, randomUUID } from "node:crypto";
import { normalizeCodexSessionId } from "./codexClient.ts";
const CODEX_INSTALLATION_SALT = "omniroute-codex-installation";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export type CodexClientIdentity = {
sessionId: string;
turnId: string;
windowId: string;
installationId: string;
};
function normalizeUuid(value: unknown): string | null {
return typeof value === "string" && UUID_PATTERN.test(value.trim()) ? value.trim() : null;
}
function uuidFromStableValue(value: string): string {
const hash = createHash("sha256").update(value).digest("hex");
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
}
export function getCodexInstallationId(providerSpecificData?: Record<string, unknown> | null): string {
const explicit = normalizeUuid(providerSpecificData?.codexInstallationId);
if (explicit) return explicit;
const stableSource =
typeof providerSpecificData?.workspaceId === "string" && providerSpecificData.workspaceId.trim()
? providerSpecificData.workspaceId.trim()
: typeof providerSpecificData?.accountId === "string" && providerSpecificData.accountId.trim()
? providerSpecificData.accountId.trim()
: typeof providerSpecificData?.email === "string" && providerSpecificData.email.trim()
? providerSpecificData.email.trim()
: "default";
return uuidFromStableValue(`${CODEX_INSTALLATION_SALT}:${stableSource}`);
}
export function createCodexClientIdentity(
sessionId: string | null,
providerSpecificData?: Record<string, unknown> | null
): CodexClientIdentity | null {
const normalizedSessionId = normalizeCodexSessionId(sessionId);
if (!normalizedSessionId) return null;
return {
sessionId: normalizedSessionId,
turnId: randomUUID(),
windowId: `${normalizedSessionId}:0`,
installationId: getCodexInstallationId(providerSpecificData),
};
}
export function applyCodexClientIdentityHeaders(
headers: Record<string, string>,
identity?: CodexClientIdentity | null
): void {
if (!identity) return;
headers["session_id"] = identity.sessionId;
headers["x-client-request-id"] = identity.sessionId;
headers["x-codex-window-id"] = identity.windowId;
headers["x-codex-turn-metadata"] = JSON.stringify({
session_id: identity.sessionId,
thread_source: "user",
turn_id: identity.turnId,
sandbox: "none",
});
}
export function applyCodexClientMetadata(
body: Record<string, unknown>,
identity?: CodexClientIdentity | null
): void {
if (!identity) return;
const existing =
body.client_metadata && typeof body.client_metadata === "object" && !Array.isArray(body.client_metadata)
? (body.client_metadata as Record<string, unknown>)
: {};
body.client_metadata = {
...existing,
"x-codex-installation-id": identity.installationId,
};
}

View File

@@ -10,7 +10,16 @@ import {
CODEX_DEFAULT_INSTRUCTIONS,
} from "../config/codexInstructions.ts";
import { PROVIDERS } from "../config/constants.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import {
getCodexClientVersion,
getCodexUserAgent,
normalizeCodexSessionId,
} from "../config/codexClient.ts";
import {
applyCodexClientIdentityHeaders,
applyCodexClientMetadata,
createCodexClientIdentity,
} from "../config/codexIdentity.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { getRememberedResponseFunctionCalls } from "../services/responsesToolCallState.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
@@ -254,68 +263,11 @@ export function getCodexUpstreamModel(model: unknown): string {
return splitCodexReasoningSuffix(model).baseModel;
}
function stringifyCodexInstructionContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part.trim();
if (!part || typeof part !== "object") return "";
const record = part as Record<string, unknown>;
if (typeof record.text === "string") return record.text.trim();
if (typeof record.content === "string") return record.content.trim();
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
return "";
}
function hoistSystemMessagesToInstructions(body: Record<string, unknown>): void {
if (!Array.isArray(body.input)) return;
const systemChunks: string[] = [];
const filteredInput = body.input.filter((itemValue) => {
if (!itemValue || typeof itemValue !== "object" || Array.isArray(itemValue)) {
return true;
}
const item = itemValue as Record<string, unknown>;
const role = typeof item.role === "string" ? item.role : "";
const type = typeof item.type === "string" ? item.type : "";
const isSystemMessage = role === "system" && (!type || type === "message");
if (!isSystemMessage) {
return true;
}
const text = stringifyCodexInstructionContent(item.content);
if (text) {
systemChunks.push(text);
}
return false;
});
if (systemChunks.length === 0) return;
const existingInstructions =
typeof body.instructions === "string" ? body.instructions.trim() : "";
body.instructions = existingInstructions
? `${systemChunks.join("\n\n")}\n\n${existingInstructions}`
: systemChunks.join("\n\n");
body.input = filteredInput;
}
/**
* Convert role=system messages in `input` to role=developer.
*
* GPT-5 models support the `developer` role in input, but reject `system`.
* Unlike hoistSystemMessagesToInstructions(), this keeps the content inside
* This keeps the content inside
* the `input` array where it benefits from OpenAI's automatic prompt caching.
*
* OpenAI's prompt caching matches on the serialized prefix of the `input` array
@@ -758,21 +710,37 @@ export class CodexExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
if (!isCodexResponsesWebSocketRequired(input.model, input.credentials)) {
return super.execute(input);
const sessionId = this.getPromptCacheSessionId(
input.credentials,
input.body as Record<string, unknown> | null
);
const identity = createCodexClientIdentity(sessionId, input.credentials?.providerSpecificData ?? null);
const credentials = identity
? {
...input.credentials,
providerSpecificData: {
...(input.credentials?.providerSpecificData || {}),
codexClientIdentity: identity,
},
}
: input.credentials;
const nextInput = { ...input, credentials };
if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) {
return super.execute(nextInput);
}
const url = CODEX_RESPONSES_WS_URL;
const headers = normalizeCodexWsHeaders(this.buildHeaders(input.credentials, true));
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
const headers = normalizeCodexWsHeaders(this.buildHeaders(nextInput.credentials, true));
mergeUpstreamExtraHeaders(headers, nextInput.upstreamExtraHeaders);
const transformedBody = (await this.transformRequest(
input.model,
input.body,
nextInput.model,
nextInput.body,
true,
input.credentials
nextInput.credentials
)) as Record<string, unknown>;
transformedBody.model = getCodexUpstreamModel(transformedBody.model || input.model);
transformedBody.model = getCodexUpstreamModel(transformedBody.model || nextInput.model);
delete transformedBody.stream;
delete transformedBody.stream_options;
@@ -807,7 +775,7 @@ export class CodexExecutor extends BaseExecutor {
let abortHandler: (() => void) | null = null;
const removeAbortListener = () => {
if (!abortHandler) return;
input.signal?.removeEventListener("abort", abortHandler);
nextInput.signal?.removeEventListener("abort", abortHandler);
abortHandler = null;
};
@@ -868,7 +836,7 @@ export class CodexExecutor extends BaseExecutor {
abortHandler = () => {
finishStream({ reason: "client_aborted" });
};
input.signal?.addEventListener("abort", abortHandler, { once: true });
nextInput.signal?.addEventListener("abort", abortHandler, { once: true });
try {
ws = await websocketFn(toWebSocketUrl(url), {
@@ -877,7 +845,7 @@ export class CodexExecutor extends BaseExecutor {
headers,
});
if (closed) return;
if (input.signal?.aborted) {
if (nextInput.signal?.aborted) {
finishStream({ reason: "client_aborted" });
return;
}
@@ -975,6 +943,7 @@ export class CodexExecutor extends BaseExecutor {
if (workspaceId) {
headers["chatgpt-account-id"] = workspaceId;
}
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity;
// Originator header — identifies the client type to the Codex backend.
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
@@ -987,6 +956,7 @@ export class CodexExecutor extends BaseExecutor {
if (cacheSessionId) {
headers["session_id"] = cacheSessionId;
}
applyCodexClientIdentityHeaders(headers, clientIdentity);
return headers;
}
@@ -1003,13 +973,17 @@ export class CodexExecutor extends BaseExecutor {
credentials,
body: Record<string, unknown> | null
): string | null {
const promptCacheKey = normalizeCodexSessionId(body?.prompt_cache_key);
if (promptCacheKey) return promptCacheKey;
// Prefer per-session identifiers from the client request body
const sessionId = body?.session_id ?? body?.conversation_id;
if (typeof sessionId === "string" && sessionId.length > 0) {
return sessionId;
const normalizedSessionId = normalizeCodexSessionId(sessionId);
if (normalizedSessionId) {
return normalizedSessionId;
}
// Fall back to workspaceId (account-wide) — better than nothing
return credentials?.providerSpecificData?.workspaceId || null;
return normalizeCodexSessionId(credentials?.providerSpecificData?.workspaceId) || null;
}
/**
@@ -1198,6 +1172,7 @@ export class CodexExecutor extends BaseExecutor {
body.prompt_cache_key = cacheSessionId;
}
}
applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity);
// Delete session_id and conversation_id from the body.
// These are often injected by OmniRoute's fallback logic for store=true,

View File

@@ -143,7 +143,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for
assert.equal(standardHeaders.Version, "0.125.0");
assert.equal(standardHeaders["Openai-Beta"], "responses=experimental");
assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets");
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26100; x64)");
assert.equal(
standardHeaders["User-Agent"],
"codex-cli/0.125.0 (Windows 10.0.26200; x64)"
);
assert.equal(compactHeaders.Accept, "application/json");
});
@@ -152,13 +155,16 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
await withEnv(
{
CODEX_CLIENT_VERSION: "0.120.0-alpha.3",
CODEX_CLIENT_VERSION: "0.125.0",
CODEX_USER_AGENT: undefined,
},
() => {
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
assert.equal(headers.Version, "0.120.0-alpha.3");
assert.equal(headers["User-Agent"], "codex-cli/0.120.0-alpha.3 (Windows 10.0.26100; x64)");
assert.equal(headers.Version, "0.125.0");
assert.equal(
headers["User-Agent"],
"codex-cli/0.125.0 (Windows 10.0.26200; x64)"
);
}
);
@@ -420,6 +426,34 @@ test("CodexExecutor.transformRequest keeps gpt-5.5 as the model and applies xhig
assert.equal(result.reasoning.effort, "xhigh");
});
test("CodexExecutor.transformRequest merges Codex installation metadata", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.5",
{
model: "gpt-5.5",
input: [],
client_metadata: { existing: "keep" },
},
true,
{
providerSpecificData: {
codexClientIdentity: {
sessionId: "session-1",
turnId: "turn-1",
windowId: "session-1:0",
installationId: "11111111-1111-4111-a111-111111111111",
},
},
}
);
assert.deepEqual(result.client_metadata, {
existing: "keep",
"x-codex-installation-id": "11111111-1111-4111-a111-111111111111",
});
});
test("CodexExecutor.execute falls back to HTTP when websocket transport is unavailable", async () => {
__setCodexWebSocketTransportForTesting(null);
const executor = new CodexExecutor();
@@ -451,6 +485,88 @@ test("CodexExecutor.execute falls back to HTTP when websocket transport is unava
}
});
test("CodexExecutor.execute adds CLI-like session identity headers without changing response flow", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
let capturedBody: Record<string, unknown> | null = null;
let capturedHeaders: Headers | null = null;
globalThis.fetch = async (_url, init) => {
capturedHeaders = new Headers(init?.headers as HeadersInit);
capturedBody = JSON.parse(String(init?.body || "{}"));
return new Response(JSON.stringify({ id: "resp_identity", object: "response" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const result = await executor.execute({
model: "gpt-5.5",
body: {
model: "gpt-5.5",
session_id: "conversation-1",
input: [{ role: "user", content: "hello" }],
},
stream: true,
credentials: {
accessToken: "codex-token",
providerSpecificData: { workspaceId: "workspace-1" },
},
});
assert.equal(result.response.status, 200);
assert.equal(capturedHeaders?.get("session_id"), "conversation-1");
assert.equal(capturedHeaders?.get("x-client-request-id"), "conversation-1");
assert.equal(capturedHeaders?.get("x-codex-window-id"), "conversation-1:0");
const turnMetadata = JSON.parse(capturedHeaders?.get("x-codex-turn-metadata") || "{}");
assert.equal(turnMetadata.session_id, "conversation-1");
assert.equal(turnMetadata.thread_source, "user");
assert.equal(turnMetadata.sandbox, "none");
assert.equal(typeof turnMetadata.turn_id, "string");
assert.equal(capturedBody?.prompt_cache_key, "conversation-1");
assert.equal(
(capturedBody?.client_metadata as Record<string, unknown>)?.["x-codex-installation-id"],
"7f06a8ee-2981-4c81-a4ca-e443b5400a63"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("CodexExecutor.execute skips identity headers for unsafe session ids", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | null = null;
globalThis.fetch = async (_url, init) => {
capturedHeaders = new Headers(init?.headers as HeadersInit);
return new Response(JSON.stringify({ id: "resp_identity", object: "response" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await executor.execute({
model: "gpt-5.5",
body: {
model: "gpt-5.5",
session_id: "bad\r\nheader",
input: [{ role: "user", content: "hello" }],
},
stream: true,
credentials: { accessToken: "codex-token" },
});
assert.equal(capturedHeaders?.get("x-client-request-id"), null);
assert.equal(capturedHeaders?.get("x-codex-window-id"), null);
assert.equal(capturedHeaders?.get("x-codex-turn-metadata"), null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted tool types", () => {
// Regression: PR #1581 đã vô tình xoá nhánh `namespace` + whitelist hosted tools
// trong normalizeCodexTools, khiến MCP tool group (vd. mcp__atlassian__) bị strip