mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
fix(codex): align client identity metadata (#1778)
Integrated into release/v3.7.5
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
83
open-sse/config/codexIdentity.ts
Normal file
83
open-sse/config/codexIdentity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user