From 4e1bd4a19600a676324b4a62e586c57129d9a1dc Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Wed, 2 Sep 2026 21:22:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(providers):=20=E3=80=8C=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=20CLI=E3=80=8D=E5=88=87=E6=8D=A2=E8=BA=AB=E4=BB=BD=E6=95=B4?= =?UTF-8?q?=E5=A5=97=E6=9B=BF=E6=8D=A2=E6=8C=87=E7=BA=B9=E5=A4=B4=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E5=AE=98=E6=96=B9=20CLI=20=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=A4=B4=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换 CLI 身份原本走 mergeImportedCustomHeaders 的"导入"语义:同名覆盖、其余追加、 从不删除。三家 CLI 除 User-Agent 外没有同名头,于是 Claude Code → Codex 只换掉 UA,x-app / anthropic-version / X-Stainless-* 等 11 条原样残留,拼出哪家都不像的混合 指纹。 - customHeaders.ts:新增 CLI_IDENTITY_HEADER_FAMILIES(每家静态身份头 + 每会话动态 头名)与 applyCliIdentity():先剥掉其它 CLI 家族名下的全部头,再并入所选 CLI 的整套 静态头;所选 CLI 自家的动态头保留用户手填值,非 CLI 业务头不动;结果带 removedCount。 - 按官方源码对齐一键写入的头集:Claude Code 复刻 claude-code-source 2.1.88(内联 SDK 0.74.0,X-Stainless-Retry-Count 用 SDK 的 Pascal-Case);Codex 补 originator/version (与 UA 同版本);Grok 补 x-grok-client-identifier/version/mode 与 cli-chat-proxy 标记 头。UA 版本号与随附 version 头统一取自 *_CLI_VERSION 常量。 - requestOptions.ts:Claude 链路有 sessionId 时补 X-Claude-Code-Session-Id + x-client-request-id;Codex 链路补 session-id / thread-id / x-client-request-id。 - ProviderModal(View):切换身份走 applyCliIdentity,摘要行新增"移除 N";下拉按当前 供应商排序并标"匹配当前",菜单加宽避免 UA 换行。 - provider_models.rs:模型列表链路 never-forge 用例补入新增身份头名。 - 测试:新增 Claude→Codex 整套替换、离开 Grok 剥 x-grok-*、重选同家保留自家动态 头、x-client-request-id 跨家归属 4 个用例;更新 golden 与 request-options 用例。 Closes #736 --- .../src-tauri/src/services/provider_models.rs | 5 + .../lib/providers/runtime/requestOptions.ts | 15 +- .../test/providers/custom-headers.test.mjs | 167 +++++++++++++++++ .../test/providers/request-options.test.mjs | 24 ++- .../test/providers/transport-golden.test.mjs | 8 + .../settings/provider-models-fetch.test.mjs | 9 +- .../src/i18n/translations/enUSSettings.ts | 2 + .../src/i18n/translations/zhCNSettings.ts | 2 + .../src/lib/providers/customHeaders.ts | 177 ++++++++++++++++-- .../src/pages/settings/ProviderModal.tsx | 23 ++- .../src/pages/settings/ProviderModalView.tsx | 16 +- 11 files changed, 410 insertions(+), 38 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/services/provider_models.rs b/crates/agent-gui/src-tauri/src/services/provider_models.rs index fd4103816..de94e38c4 100644 --- a/crates/agent-gui/src-tauri/src/services/provider_models.rs +++ b/crates/agent-gui/src-tauri/src/services/provider_models.rs @@ -629,6 +629,11 @@ mod tests { "anthropic-dangerous-direct-browser-access", "session_id", "conversation_id", + "session-id", + "thread-id", + "x-client-request-id", + "x-claude-code-session-id", + "x-grok-client-identifier", ] { assert!(!names.iter().any(|name| name == forbidden), "{forbidden}"); } diff --git a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts index b283fea2f..c422a8e11 100644 --- a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts +++ b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts @@ -1,8 +1,12 @@ import type { CacheRetention, SimpleStreamOptions } from "@earendil-works/pi-ai"; import { ANTHROPIC_DEFAULT_REQUEST_HEADERS, + CLAUDE_SESSION_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, CODEX_CONVERSATION_ID_HEADER, + CODEX_OFFICIAL_SESSION_ID_HEADER, CODEX_SESSION_ID_HEADER, + CODEX_THREAD_ID_HEADER, isAnthropicOAuthApiKey, mergeCustomHeaders, } from "@liveagent/ui/lib/providers/customHeaders"; @@ -52,19 +56,26 @@ export function buildProviderRequestHeaders( const authHeaders = buildProviderAuthHeaders(providerId, apiKey); if (providerId === "claude_code") { if (isAnthropicOAuthApiKey(apiKey)) return {}; + const requestSessionId = normalizeSessionId(sessionId); return { ...authHeaders, ...ANTHROPIC_DEFAULT_REQUEST_HEADERS, + // 官方 CLI 每请求都带 X-Claude-Code-Session-Id(client.ts:108)。 + ...(requestSessionId ? { [CLAUDE_SESSION_ID_HEADER]: requestSessionId } : {}), }; } if (providerId === "codex") { // 标准 Chat Completions 是无状态协议,只需 Authorization—— - // session_id/conversation_id 是 Responses(Codex CLI)链路专属头, - // 不得泄漏进 completions 格式的请求。 + // 会话身份头是 Responses(Codex CLI)链路专属,不得泄漏进 completions。 if (requestFormat === "openai-completions") return authHeaders; const requestSessionId = normalizeSessionId(sessionId) ?? createUuid(); return { ...authHeaders, + // 现行 Codex CLI(codex-api responses.rs):session-id / thread-id / + // x-client-request-id。下划线旧名留给既有中转与 LiveAgent 存量链路。 + [CODEX_OFFICIAL_SESSION_ID_HEADER]: requestSessionId, + [CODEX_THREAD_ID_HEADER]: requestSessionId, + [CLIENT_REQUEST_ID_HEADER]: requestSessionId, [CODEX_SESSION_ID_HEADER]: requestSessionId, [CODEX_CONVERSATION_ID_HEADER]: requestSessionId, }; diff --git a/crates/agent-gui/test/providers/custom-headers.test.mjs b/crates/agent-gui/test/providers/custom-headers.test.mjs index 9aaea2b5a..6861672ea 100644 --- a/crates/agent-gui/test/providers/custom-headers.test.mjs +++ b/crates/agent-gui/test/providers/custom-headers.test.mjs @@ -215,6 +215,173 @@ test("does not read @files and leaves current headers unchanged when nothing is assert.deepEqual(current, [{ key: "X-Existing", value: "unchanged" }]); }); +test("buildCliIdentityHeaders(claude_code) writes UA plus the Anthropic fingerprint minus Content-Type", () => { + const headers = customHeaders.buildCliIdentityHeaders("claude_code"); + assert.deepEqual(headers[0], { + key: "User-Agent", + value: customHeaders.CLI_IDENTITY_USER_AGENTS.claude_code, + }); + const map = new Map(headers.map((header) => [header.key, header.value])); + // Content-Type 不写入(发请求侧按 body 决定)。 + assert.equal(map.has("Content-Type"), false); + // 其余 Anthropic 指纹头逐条写入且取值一致。 + for (const [key, value] of Object.entries(customHeaders.ANTHROPIC_DEFAULT_REQUEST_HEADERS)) { + if (key.toLowerCase() === "content-type") continue; + assert.equal(map.get(key), value); + } +}); + +test("buildCliIdentityHeaders(codex) adds static originator + version headers matched to the UA", () => { + const ua = customHeaders.CLI_IDENTITY_USER_AGENTS.codex; + const codexVersion = ua.slice("codex_cli_rs/".length).split(" ")[0]; + assert.deepEqual(customHeaders.buildCliIdentityHeaders("codex"), [ + { key: "User-Agent", value: ua }, + { key: "originator", value: "codex_cli_rs" }, + { key: "version", value: codexVersion }, + ]); +}); + +test("buildCliIdentityHeaders(xai) adds the grok-shell client identity headers matched to the UA", () => { + const ua = customHeaders.CLI_IDENTITY_USER_AGENTS.xai; + const grokVersion = ua.slice("grok-shell/".length).split(" ")[0]; + assert.deepEqual(customHeaders.buildCliIdentityHeaders("xai"), [ + { key: "User-Agent", value: ua }, + { key: "x-grok-client-identifier", value: "grok-shell" }, + { key: "x-grok-client-version", value: grokVersion }, + { key: "x-grok-client-mode", value: "interactive" }, + { key: "X-XAI-Token-Auth", value: "xai-grok-cli" }, + { key: "x-authenticateresponse", value: "authenticate-response" }, + ]); +}); + +test("listCliIdentityProviderIds puts the matching provider first", () => { + assert.deepEqual(customHeaders.listCliIdentityProviderIds(), [ + "claude_code", + "codex", + "xai", + ]); + assert.deepEqual(customHeaders.listCliIdentityProviderIds("xai"), [ + "xai", + "claude_code", + "codex", + ]); + assert.deepEqual(customHeaders.listCliIdentityProviderIds("gemini"), [ + "claude_code", + "codex", + "xai", + ]); +}); + +test("every CLI identity header set is valid, unreserved, and merges without duplicates", () => { + for (const id of customHeaders.CLI_IDENTITY_PROVIDER_IDS) { + const identity = customHeaders.buildCliIdentityHeaders(id); + for (const { key, value } of identity) { + assert.ok(customHeaders.isValidCustomHeaderKey(key), `invalid key: ${key}`); + assert.ok(customHeaders.isValidCustomHeaderValue(value), `invalid value for ${key}`); + assert.equal( + customHeaders.isReservedCustomHeaderKey(key), + false, + `reserved key leaked into identity: ${key}`, + ); + } + const merged = customHeaders.mergeImportedCustomHeaders([], identity); + assert.equal(merged.headers.length, identity.length, `duplicate keys for ${id}`); + } +}); + +test("applyCliIdentity replaces the previous CLI's whole fingerprint instead of layering on top", () => { + const business = [{ key: "X-Relay-Channel", value: "vip" }]; + const claude = customHeaders.applyCliIdentity(business, "claude_code"); + assert.equal(claude.removedCount, 0); + assert.equal(claude.overwrittenCount, 0); + assert.equal( + claude.headers.length, + 1 + customHeaders.buildCliIdentityHeaders("claude_code").length, + ); + + // 用户手填了 Claude 的会话头,然后切到 Codex。 + const withDynamic = [ + ...claude.headers, + { key: customHeaders.CLAUDE_SESSION_ID_HEADER, value: "sess-1" }, + ]; + const codex = customHeaders.applyCliIdentity(withDynamic, "codex"); + const keys = codex.headers.map((header) => header.key.toLowerCase()); + + // Anthropic 家族整套消失,包括手填的会话头。 + assert.ok(!keys.includes("x-app")); + assert.ok(!keys.some((key) => key.startsWith("x-stainless-"))); + assert.ok(!keys.includes("anthropic-version")); + assert.ok(!keys.includes("anthropic-dangerous-direct-browser-access")); + assert.ok(!keys.includes(customHeaders.CLAUDE_SESSION_ID_HEADER.toLowerCase())); + + // 业务头原样保留在原位;UA 就地换成 Codex。 + assert.deepEqual(codex.headers[0], { key: "X-Relay-Channel", value: "vip" }); + const map = new Map(codex.headers.map((header) => [header.key, header.value])); + assert.equal(map.get("User-Agent"), customHeaders.CLI_IDENTITY_USER_AGENTS.codex); + assert.equal(map.get("originator"), "codex_cli_rs"); + + // 结果恰好 = 业务头 + Codex 整套身份头,没有残留。 + assert.equal(codex.headers.length, 1 + customHeaders.buildCliIdentityHeaders("codex").length); + assert.equal(codex.overwrittenCount, 1); + // 只有业务头和共享的 User-Agent 留下,其余都是被剥掉的 Anthropic 头。 + assert.equal(codex.removedCount, withDynamic.length - 2); + // 输入未被改动。 + assert.equal(withDynamic.length, claude.headers.length + 1); +}); + +test("applyCliIdentity strips hand-filled x-grok-* per-turn headers when leaving Grok", () => { + const start = [ + ...customHeaders.buildCliIdentityHeaders("xai"), + { key: "x-grok-conv-id", value: "conv-1" }, + { key: "X-Title", value: "mine" }, + ]; + const claude = customHeaders.applyCliIdentity(start, "claude_code"); + const keys = claude.headers.map((header) => header.key.toLowerCase()); + assert.ok(!keys.some((key) => key.startsWith("x-grok-"))); + assert.ok(!keys.includes("x-xai-token-auth")); + assert.ok(!keys.includes("x-authenticateresponse")); + assert.ok(keys.includes("x-title")); + assert.equal( + claude.headers.length, + 1 + customHeaders.buildCliIdentityHeaders("claude_code").length, + ); + assert.equal(claude.removedCount, start.length - 2); +}); + +test("re-applying the same CLI keeps its own hand-filled per-session headers", () => { + const start = [ + ...customHeaders.buildCliIdentityHeaders("codex"), + { key: customHeaders.CODEX_OFFICIAL_SESSION_ID_HEADER, value: "thread-1" }, + { key: customHeaders.CLIENT_REQUEST_ID_HEADER, value: "req-1" }, + ]; + const again = customHeaders.applyCliIdentity(start, "codex"); + const map = new Map(again.headers.map((header) => [header.key, header.value])); + assert.equal(map.get(customHeaders.CODEX_OFFICIAL_SESSION_ID_HEADER), "thread-1"); + assert.equal(map.get(customHeaders.CLIENT_REQUEST_ID_HEADER), "req-1"); + assert.equal(again.removedCount, 0); + assert.equal(again.overwrittenCount, customHeaders.buildCliIdentityHeaders("codex").length); + assert.equal(again.headers.length, start.length); +}); + +test("x-client-request-id survives a Claude <-> Codex switch but not a switch to Grok", () => { + const start = [ + ...customHeaders.buildCliIdentityHeaders("claude_code"), + { key: customHeaders.CLIENT_REQUEST_ID_HEADER, value: "req-1" }, + ]; + const codex = customHeaders.applyCliIdentity(start, "codex"); + assert.ok( + codex.headers.some( + (header) => header.key === customHeaders.CLIENT_REQUEST_ID_HEADER && header.value === "req-1", + ), + ); + const grok = customHeaders.applyCliIdentity(start, "xai"); + assert.ok( + !grok.headers.some( + (header) => header.key.toLowerCase() === customHeaders.CLIENT_REQUEST_ID_HEADER.toLowerCase(), + ), + ); +}); + test("parsed and saved headers reach runtime merge while CR/LF values are rejected", () => { const parsed = customHeaders.parseCustomHeadersImport( '{"X-Imported":"sentinel"}', diff --git a/crates/agent-gui/test/providers/request-options.test.mjs b/crates/agent-gui/test/providers/request-options.test.mjs index 73e2ab3d5..92d4dc709 100644 --- a/crates/agent-gui/test/providers/request-options.test.mjs +++ b/crates/agent-gui/test/providers/request-options.test.mjs @@ -138,10 +138,11 @@ test("provider request helpers normalize auth, metadata, errors, and model value "anthropic-version": "2023-06-01", "X-Stainless-Runtime": "node", "X-Stainless-Timeout": "600", - "x-stainless-retry-count": "0", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Package-Version": "0.74.0", "X-Stainless-Runtime-Version": "v26.3.0", "anthropic-dangerous-direct-browser-access": "true", + "X-Claude-Code-Session-Id": "conversation-1", }, ); assert.deepEqual( @@ -150,6 +151,9 @@ test("provider request helpers normalize auth, metadata, errors, and model value ); assert.deepEqual(providers.buildProviderRequestHeaders("codex", "secret", "conversation-1"), { Authorization: "Bearer secret", + "session-id": "conversation-1", + "thread-id": "conversation-1", + "x-client-request-id": "conversation-1", session_id: "conversation-1", conversation_id: "conversation-1", }); @@ -158,6 +162,9 @@ test("provider request helpers normalize auth, metadata, errors, and model value providers.buildProviderRequestHeaders("codex", "secret", "conversation-1", "openai-responses"), { Authorization: "Bearer secret", + "session-id": "conversation-1", + "thread-id": "conversation-1", + "x-client-request-id": "conversation-1", session_id: "conversation-1", conversation_id: "conversation-1", }, @@ -185,6 +192,9 @@ test("provider request helpers normalize auth, metadata, errors, and model value const generatedCodexHeaders = providers.buildProviderRequestHeaders("codex", "secret"); assert.match(generatedCodexHeaders.session_id, /^[0-9a-f-]{36}$/i); assert.equal(generatedCodexHeaders.conversation_id, generatedCodexHeaders.session_id); + assert.equal(generatedCodexHeaders["session-id"], generatedCodexHeaders.session_id); + assert.equal(generatedCodexHeaders["thread-id"], generatedCodexHeaders.session_id); + assert.equal(generatedCodexHeaders["x-client-request-id"], generatedCodexHeaders.session_id); assert.equal(providers.toSimpleStreamReasoning("off"), undefined); assert.equal(providers.toSimpleStreamReasoning("high"), "high"); assert.equal(providers.toSimpleStreamReasoning("max"), "max"); @@ -256,17 +266,27 @@ test("provider-specific custom header suggestions include standard model headers assert.ok(anthropicPresets.includes("anthropic-version")); assert.ok(anthropicPresets.includes("X-Stainless-Runtime-Version")); assert.ok(anthropicPresets.includes("anthropic-dangerous-direct-browser-access")); + assert.ok(anthropicPresets.includes("X-Claude-Code-Session-Id")); + assert.ok(anthropicPresets.includes("x-client-request-id")); assert.ok(!anthropicPresets.includes("anthropic-beta")); assert.ok(!anthropicPresets.includes("session_id")); const codexPresets = customHeaderHelpers.getCustomHeaderKeyPresets("codex"); assert.ok(!codexPresets.includes("User-Agent")); + assert.ok(codexPresets.includes("originator")); + assert.ok(codexPresets.includes("version")); + assert.ok(codexPresets.includes("session-id")); + assert.ok(codexPresets.includes("thread-id")); + assert.ok(codexPresets.includes("x-client-request-id")); assert.ok(codexPresets.includes("session_id")); assert.ok(codexPresets.includes("conversation_id")); assert.ok(!codexPresets.includes("anthropic-version")); const xaiPresets = customHeaderHelpers.getCustomHeaderKeyPresets("xai"); assert.ok(!xaiPresets.includes("User-Agent")); + assert.ok(xaiPresets.includes("x-grok-client-identifier")); + assert.ok(xaiPresets.includes("x-grok-conv-id")); + assert.ok(xaiPresets.includes("x-grok-session-id")); assert.ok(!xaiPresets.includes("session_id")); assert.ok(!xaiPresets.includes("anthropic-version")); }); diff --git a/crates/agent-gui/test/providers/transport-golden.test.mjs b/crates/agent-gui/test/providers/transport-golden.test.mjs index d13406f0b..bdec81495 100644 --- a/crates/agent-gui/test/providers/transport-golden.test.mjs +++ b/crates/agent-gui/test/providers/transport-golden.test.mjs @@ -81,6 +81,7 @@ test("golden/transport: anthropic 全量头集(内置默认头 + 自定义头 assert.deepEqual(headers, { "x-api-key": "sk-ant-test", ...ANTHROPIC_DEFAULT_REQUEST_HEADERS, + "X-Claude-Code-Session-Id": SESSION_ID, "X-Relay-Channel": "vip", Cookie: "session=abc", "x-liveagent-upstream-origin": "https://api.anthropic.com", @@ -90,6 +91,7 @@ test("golden/transport: anthropic 全量头集(内置默认头 + 自定义头 // 覆盖包 = 内置默认头 + 自定义头;鉴权头(x-api-key)按排除集绝不进包。 assert.deepEqual(overrides, { ...ANTHROPIC_DEFAULT_REQUEST_HEADERS, + "X-Claude-Code-Session-Id": SESSION_ID, "X-Relay-Channel": "vip", Cookie: "session=abc", }); @@ -108,12 +110,18 @@ test("golden/transport: codex Responses 链路带 session/conversation 头;直 Authorization: "Bearer sk-codex-test", session_id: SESSION_ID, conversation_id: SESSION_ID, + "session-id": SESSION_ID, + "thread-id": SESSION_ID, + "x-client-request-id": SESSION_ID, "x-liveagent-upstream-origin": "https://chatgpt.com", "x-liveagent-proxy-token": "proxy-token", }); assert.deepEqual(overrides, { session_id: SESSION_ID, conversation_id: SESSION_ID, + "session-id": SESSION_ID, + "thread-id": SESSION_ID, + "x-client-request-id": SESSION_ID, }); }); diff --git a/crates/agent-gui/test/settings/provider-models-fetch.test.mjs b/crates/agent-gui/test/settings/provider-models-fetch.test.mjs index b050c407a..2ca9c33bf 100644 --- a/crates/agent-gui/test/settings/provider-models-fetch.test.mjs +++ b/crates/agent-gui/test/settings/provider-models-fetch.test.mjs @@ -139,6 +139,11 @@ test("buildProviderModelsAttempts uses Authorization first and official auth sec "anthropic-dangerous-direct-browser-access", "session_id", "conversation_id", + "session-id", + "thread-id", + "x-client-request-id", + "x-claude-code-session-id", + "x-grok-client-identifier", ]; for (const [type, attempts] of attemptsByProvider) { for (const attempt of attempts) { @@ -461,7 +466,7 @@ test("gateway WebUI forwards proxy and models URL choices to desktop model fetch isFullUrl: true, modelsUrl: "https://catalog.example.com/models?api-version=2026-01", providerId: "provider-codex", - customHeaders: [{ key: "User-Agent", value: "claude-cli/2.1.186 (external, cli)" }], + customHeaders: [{ key: "User-Agent", value: "claude-cli/2.1.88 (external, cli)" }], }, ); assert.deepEqual( @@ -479,7 +484,7 @@ test("gateway WebUI forwards proxy and models URL choices to desktop model fetch is_full_url: true, // WebView 的 fetch() 会静默丢掉 User-Agent,Gateway 路径必须把用户配的头 // 原样交给桌面端去落地,否则改了头也到不了上游。 - custom_headers: [{ key: "User-Agent", value: "claude-cli/2.1.186 (external, cli)" }], + custom_headers: [{ key: "User-Agent", value: "claude-cli/2.1.88 (external, cli)" }], }, ]); } finally { diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 9695ff156..4dd37c973 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -468,6 +468,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.cliIdentity.claude_code": "Claude Code", "settings.cliIdentity.codex": "Codex", "settings.cliIdentity.xai": "Grok", + "settings.cliIdentityRecommended": "Matches provider", "settings.customHeaderImportLabel": "JSON / cURL", "settings.customHeaderImportPlaceholder": "Paste JSON or cURL", "settings.cancelCustomHeaderImport": "Cancel", @@ -481,6 +482,7 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.customHeaderImportError.failed": "Parsing failed. Existing headers were not changed.", "settings.customHeaderImportSummary.imported": "Imported", "settings.customHeaderImportSummary.overwritten": "Overwrote", + "settings.customHeaderImportSummary.removed": "Removed", "settings.customHeaderImportSummary.skipped": "Skipped", "settings.customHeaderImportUnknownItem": "Unknown header item", "settings.customHeaderImportIssue.invalid-item": "invalid format", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index 79999fb39..0d1231ad7 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -448,6 +448,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.cliIdentity.claude_code": "Claude Code", "settings.cliIdentity.codex": "Codex", "settings.cliIdentity.xai": "Grok", + "settings.cliIdentityRecommended": "匹配当前", "settings.customHeaderImportLabel": "JSON / cURL", "settings.customHeaderImportPlaceholder": "粘贴 JSON 或 cURL", "settings.cancelCustomHeaderImport": "取消", @@ -460,6 +461,7 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.customHeaderImportError.failed": "解析失败,未修改现有请求头。", "settings.customHeaderImportSummary.imported": "已导入", "settings.customHeaderImportSummary.overwritten": "覆盖", + "settings.customHeaderImportSummary.removed": "移除", "settings.customHeaderImportSummary.skipped": "跳过", "settings.customHeaderImportUnknownItem": "未知请求头项", "settings.customHeaderImportIssue.invalid-item": "格式无效", diff --git a/crates/agent-ui/src/lib/providers/customHeaders.ts b/crates/agent-ui/src/lib/providers/customHeaders.ts index 33e9acfe5..578278b99 100644 --- a/crates/agent-ui/src/lib/providers/customHeaders.ts +++ b/crates/agent-ui/src/lib/providers/customHeaders.ts @@ -49,12 +49,14 @@ export class CustomHeaderImportError extends Error { } } -// Claude Code CLI 的 SDK 指纹头,取值与 CPA/CLIProxyAPI 的 claudeDeviceProfile -// 默认档(helps/claude_device_profile.go:22-31)逐字对齐,配套的 UA 见 +// Claude Code CLI 的 SDK 指纹头,逐字复刻官方源码 claude-code-source(v2.1.88, +// 其内联的 @anthropic-ai/sdk = 0.74.0)发往 /v1/messages 的固定头;配套的 UA 见 // CLI_IDENTITY_USER_AGENTS.claude_code——版本号必须成套,SDK 版本和 UA 里的 CLI // 版本对不上本身就是破绽。 -// anthropic-beta 不在这里:它按请求内容逐次计算,写死一个值反而失真,因此列进 -// RESERVED_CUSTOM_HEADER_KEYS 由发请求那侧生成。 +// anthropic-beta 不在这里:它按请求内容逐次计算(comma 拼接的 beta 列表),写死一个 +// 值反而失真,因此列进 RESERVED_CUSTOM_HEADER_KEYS 由发请求那侧生成。 +// X-Stainless-OS/Arch/Runtime-Version 本为运行机实测值,这里固定成一台 macOS/arm64 +// 机器的成套取值。 export const ANTHROPIC_DEFAULT_REQUEST_HEADERS = { "x-app": "cli", "Content-Type": "application/json", @@ -64,14 +66,66 @@ export const ANTHROPIC_DEFAULT_REQUEST_HEADERS = { "anthropic-version": "2023-06-01", "X-Stainless-Runtime": "node", "X-Stainless-Timeout": "600", - "x-stainless-retry-count": "0", - "X-Stainless-Package-Version": "0.94.0", + // 官方 SDK client.mjs:468 写的是 Pascal-Case;HTTP 大小写不敏感,但部分反代/ + // 风控按字面比对,必须与 SDK 一致。 + "X-Stainless-Retry-Count": "0", + "X-Stainless-Package-Version": "0.74.0", "X-Stainless-Runtime-Version": "v26.3.0", "anthropic-dangerous-direct-browser-access": "true", } as const; +// Claude Code 每会话头(claude-code-source src/services/api/client.ts:108 / :356)。 +// 取值是运行时 session UUID,一键模拟不写死;请求装配侧有 sessionId 时再填。 +export const CLAUDE_SESSION_ID_HEADER = "X-Claude-Code-Session-Id"; +export const CLIENT_REQUEST_ID_HEADER = "x-client-request-id"; + +// 旧 Responses 链路(LiveAgent 既有 + 部分中转)仍认这两个下划线头。 export const CODEX_SESSION_ID_HEADER = "session_id"; export const CODEX_CONVERSATION_ID_HEADER = "conversation_id"; +// 现行 Codex CLI(codex-rs/codex-api/src/requests/headers.rs + +// endpoint/responses.rs)改发连字符头:session-id / thread-id,并把 thread-id +// 同步到 x-client-request-id。 +export const CODEX_OFFICIAL_SESSION_ID_HEADER = "session-id"; +export const CODEX_THREAD_ID_HEADER = "thread-id"; + +// 各官方 CLI 的版本号:UA 与随附的 version / client-version 头必须同源,二者对不上 +// 本身就是破绽。claude_code / xai 取自用户提供的官方源码(claude-code-source +// package.json = 2.1.88;xai-grok-version/Cargo.toml = 1.0.6);codex 源码树是占位 +// 0.0.0(release 才 bump),无法作真值,沿用当前发行号。 +const CLAUDE_CLI_VERSION = "2.1.88"; +const CODEX_CLI_VERSION = "0.151.0"; +const GROK_CLI_VERSION = "1.0.6"; + +// Codex CLI 每次请求除 UA 外恒发的两个静态身份头:originator(codex-rs +// login/src/auth/default_client.rs default_headers(),值 codex_cli_rs)与 version +// (codex-rs model-provider-info/src/lib.rs,值 = CARGO_PKG_VERSION,与 UA 同版本)。 +export const CODEX_ORIGINATOR_HEADER = "originator"; +export const CODEX_ORIGINATOR_VALUE = "codex_cli_rs"; +export const CODEX_VERSION_HEADER = "version"; + +// grok-shell 除 UA 外恒发的静态客户端身份头(xai-grok-http/src/lib.rs 的 +// process_client_identifier / process_client_mode + xai-grok-shell +// mvp_agent/mod.rs inject_proxy_headers)。client-identifier/version/mode 各端都发; +// X-XAI-Token-Auth / x-authenticateresponse 仅走官方 cli-chat-proxy 时注入,属于 CLI +// 默认(已登录)指纹的一部分,直连 api.x.ai 时服务端忽略未知头、无副作用。 +const GROK_IDENTITY_HEADERS: readonly CustomHeader[] = [ + { key: "x-grok-client-identifier", value: "grok-shell" }, + { key: "x-grok-client-version", value: GROK_CLI_VERSION }, + { key: "x-grok-client-mode", value: "interactive" }, + { key: "X-XAI-Token-Auth", value: "xai-grok-cli" }, + { key: "x-authenticateresponse", value: "authenticate-response" }, +]; + +// grok-shell 每回合头(xai-grok-sampler/src/client.rs GrokRequestHeaders),取值随 +// conv/req/session 变化,一键模拟不写死。 +const GROK_DYNAMIC_HEADER_KEYS = [ + "x-grok-conv-id", + "x-grok-req-id", + "x-grok-session-id", + "x-grok-agent-id", + "x-grok-model-override", + "x-grok-turn-idx", +] as const; const COMMON_CUSTOM_HEADER_KEY_PRESETS = [ "X-Request-ID", @@ -83,16 +137,27 @@ const COMMON_CUSTOM_HEADER_KEY_PRESETS = [ const ANTHROPIC_CUSTOM_HEADER_KEY_PRESETS: readonly string[] = [ ...Object.keys(ANTHROPIC_DEFAULT_REQUEST_HEADERS), + CLAUDE_SESSION_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, ...COMMON_CUSTOM_HEADER_KEY_PRESETS, ]; const CODEX_CUSTOM_HEADER_KEY_PRESETS: readonly string[] = [ + CODEX_ORIGINATOR_HEADER, + CODEX_VERSION_HEADER, + CODEX_OFFICIAL_SESSION_ID_HEADER, + CODEX_THREAD_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, CODEX_SESSION_ID_HEADER, CODEX_CONVERSATION_ID_HEADER, ...COMMON_CUSTOM_HEADER_KEY_PRESETS, ]; -const XAI_CUSTOM_HEADER_KEY_PRESETS: readonly string[] = COMMON_CUSTOM_HEADER_KEY_PRESETS; +const XAI_CUSTOM_HEADER_KEY_PRESETS: readonly string[] = [ + ...GROK_IDENTITY_HEADERS.map((header) => header.key), + ...GROK_DYNAMIC_HEADER_KEYS, + ...COMMON_CUSTOM_HEADER_KEY_PRESETS, +]; const CUSTOM_HEADER_KEY_PRESETS: Record = { claude_code: ANTHROPIC_CUSTOM_HEADER_KEY_PRESETS, @@ -110,14 +175,16 @@ export function isAnthropicOAuthApiKey(apiKey: string | undefined): boolean { return Boolean(apiKey?.includes("sk-ant-oat")); } -// 「模拟 CLI」按钮写入的身份 UA。claude_code 一项与 CPA/CLIProxyAPI 的 -// defaultClaudeFingerprintUserAgent(helps/claude_device_profile.go:23)逐字一致; -// codex / xai 用当前发行版本号。这些值只在用户点按钮时写进自定义请求头, -// 发请求那侧不含任何内置伪装。 +// 「模拟 CLI」按钮写入的身份 UA,逐字复刻各官方 CLI 发往模型 API 的 User-Agent: +// claude-code-source src/utils/http.ts、codex-rs login/src/auth/default_client.rs 的 +// get_codex_user_agent()、grok xai-grok-sampler/src/client.rs。版本号取自上面三个 +// *_CLI_VERSION 常量,保证 UA 与随附的 version / client-version 头同源。os/arch/终端 +// 段本为运行机实测值,这里固定成成套取值(codex 取 WSL Ubuntu;grok 取 linux)。 +// 这些值只在用户点按钮时写进自定义请求头,发请求那侧不含任何内置伪装。 export const CLI_IDENTITY_USER_AGENTS = { - claude_code: "claude-cli/2.1.186 (external, cli)", - codex: "codex_cli_rs/0.151.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal", - xai: "grok-shell/1.0.13 (linux; x86_64)", + claude_code: `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`, + codex: `codex_cli_rs/${CODEX_CLI_VERSION} (Ubuntu 24.4.0; x86_64) WindowsTerminal`, + xai: `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`, } as const; export type CliIdentityProviderId = keyof typeof CLI_IDENTITY_USER_AGENTS; @@ -130,22 +197,92 @@ export function isCliIdentityProviderId(value: string): value is CliIdentityProv return Object.hasOwn(CLI_IDENTITY_USER_AGENTS, value); } -// 一键模拟按钮写入的整套 CLI 身份头。Content-Type 由发请求那一侧按 body 决定, -// 不进用户可编辑列表;session_id/conversation_id 是每会话随机值,冒充成固定串反而 -// 比不带更可疑,留给用户自己填。 +/** 下拉菜单把与当前供应商匹配的身份档排在最前,降低点错家的概率。 */ +export function listCliIdentityProviderIds(preferred?: string): readonly CliIdentityProviderId[] { + if (!preferred || !isCliIdentityProviderId(preferred)) return CLI_IDENTITY_PROVIDER_IDS; + return [preferred, ...CLI_IDENTITY_PROVIDER_IDS.filter((id) => id !== preferred)]; +} + +// 一键模拟按钮写入的整套 CLI 身份头。Content-Type 由发请求那一侧按 body 决定,不进 +// 用户可编辑列表;每会话随机的动态头(Claude 的 X-Claude-Code-Session-Id / +// x-client-request-id;Codex 的 session-id/thread-id;Grok 的 +// x-grok-conv-id/req-id/session-id/turn-idx)冒充成固定串反而比不带更可疑,一律留给 +// 请求装配侧按 sessionId 填,或用户自己填(键名已进各自预设)。 export function buildCliIdentityHeaders(type: CliIdentityProviderId): CustomHeader[] { - const headers: CustomHeader[] = [ - { key: "User-Agent", value: CLI_IDENTITY_USER_AGENTS[type] }, - ]; + const headers: CustomHeader[] = [{ key: "User-Agent", value: CLI_IDENTITY_USER_AGENTS[type] }]; if (type === "claude_code") { + // Anthropic SDK 指纹头整套写入(Content-Type 除外,见上)。 for (const [key, value] of Object.entries(ANTHROPIC_DEFAULT_REQUEST_HEADERS)) { if (key.toLowerCase() === "content-type") continue; headers.push({ key, value }); } + } else if (type === "codex") { + // Codex CLI 每次请求恒发的两个静态身份头:originator + version。 + headers.push({ key: CODEX_ORIGINATOR_HEADER, value: CODEX_ORIGINATOR_VALUE }); + headers.push({ key: CODEX_VERSION_HEADER, value: CODEX_CLI_VERSION }); + } else if (type === "xai") { + // grok-shell 的静态客户端身份头(含 cli-chat-proxy 标记)。 + for (const header of GROK_IDENTITY_HEADERS) { + headers.push({ ...header }); + } } return headers; } +// 每家 CLI 名下的全部头名:一键写入的静态身份头 + 用户可能手填的每会话动态头。 +// 切换身份时按这张表把其它 CLI 的头整套剥掉——只做同名覆盖的话仅换掉 User-Agent, +// 上一家的 x-app / X-Stainless-* / originator / x-grok-* 会留下来,拼出一份哪家都 +// 不像的假指纹。Content-Type 不在此列(按钮不写、也非某家专属)。 +const CLI_IDENTITY_HEADER_FAMILIES: Record = { + claude_code: [ + ...buildCliIdentityHeaders("claude_code").map((header) => header.key), + CLAUDE_SESSION_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, + ], + codex: [ + ...buildCliIdentityHeaders("codex").map((header) => header.key), + CODEX_OFFICIAL_SESSION_ID_HEADER, + CODEX_THREAD_ID_HEADER, + CLIENT_REQUEST_ID_HEADER, + CODEX_SESSION_ID_HEADER, + CODEX_CONVERSATION_ID_HEADER, + ], + xai: [ + ...buildCliIdentityHeaders("xai").map((header) => header.key), + ...GROK_DYNAMIC_HEADER_KEYS, + ], +}; + +export type CliIdentityApplyResult = { + headers: CustomHeader[]; + importedCount: number; + overwrittenCount: number; + removedCount: number; +}; + +/** + * 应用/切换 CLI 身份:先剥掉其它 CLI 家族名下的所有头(含用户手填的动态头),再把 + * 所选 CLI 的整套静态身份头并入(同名覆盖)。所选 CLI 自家的动态头(如 Codex 的 + * session-id)保留用户手填值;不属于任何 CLI 家族的业务头原样不动。 + */ +export function applyCliIdentity( + current: readonly CustomHeader[], + identity: CliIdentityProviderId, +): CliIdentityApplyResult { + const own = new Set(CLI_IDENTITY_HEADER_FAMILIES[identity].map((key) => key.toLowerCase())); + const foreign = new Set(); + for (const id of CLI_IDENTITY_PROVIDER_IDS) { + if (id === identity) continue; + for (const key of CLI_IDENTITY_HEADER_FAMILIES[id]) { + const lower = key.toLowerCase(); + if (!own.has(lower)) foreign.add(lower); + } + } + const kept = current.filter((header) => !foreign.has(header.key.toLowerCase())); + const merged = mergeImportedCustomHeaders(kept, buildCliIdentityHeaders(identity)); + return { ...merged, removedCount: current.length - kept.length }; +} + function findHeaderKey( headers: Record, name: string, diff --git a/crates/agent-ui/src/pages/settings/ProviderModal.tsx b/crates/agent-ui/src/pages/settings/ProviderModal.tsx index fb67c59cf..9e124e645 100644 --- a/crates/agent-ui/src/pages/settings/ProviderModal.tsx +++ b/crates/agent-ui/src/pages/settings/ProviderModal.tsx @@ -15,7 +15,7 @@ import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { useVerticalListReorder } from "@liveagent/ui/components/ui/useVerticalListReorder"; import { useLocale } from "@liveagent/ui/i18n/index"; import { - buildCliIdentityHeaders, + applyCliIdentity, type CliIdentityProviderId, CustomHeaderImportError, type CustomHeaderImportErrorCode, @@ -76,6 +76,8 @@ type HeaderImportErrorCode = CustomHeaderImportErrorCode | "no-valid" | "failed" type HeaderImportSummary = { importedCount: number; overwrittenCount: number; + /** 切换 CLI 身份时剥掉的上一家身份头数量;普通导入不产生。 */ + removedCount?: number; issues: CustomHeaderImportIssue[]; }; @@ -686,18 +688,20 @@ function useProviderModalController({ providerType, initialData, onSave, onClose setHeaderImportError(null); } - // 一键模拟:把选中 CLI 的身份头并入现有列表。走与「导入」同一条合并路径, - // 同名头覆盖、其余保留,用户手工加的业务头不会被这一下清掉。 + // 一键模拟:换成所选 CLI 的整套身份头。先剥掉其它 CLI 家族的残留头,再并入所选 + // CLI 的头——只做同名覆盖会留下上一家的 x-app / X-Stainless-* / originator,拼出 + // 一份假指纹。不属于任何 CLI 家族的业务头原样保留。 function applyCliIdentityHeaders(identity: CliIdentityProviderId) { - const merged = mergeImportedCustomHeaders(customHeaders, buildCliIdentityHeaders(identity)); - setCustomHeaders(merged.headers); + const result = applyCliIdentity(customHeaders, identity); + setCustomHeaders(result.headers); setHeaderSuggest(null); setHeaderValidationSubmitted(false); setHeaderImportOpen(false); setHeaderImportError(null); setHeaderImportSummary({ - importedCount: merged.importedCount, - overwrittenCount: merged.overwrittenCount, + importedCount: result.importedCount, + overwrittenCount: result.overwrittenCount, + removedCount: result.removedCount, issues: [], }); } @@ -936,6 +940,11 @@ function useProviderModalController({ providerType, initialData, onSave, onClose t("settings.customHeaderImportSummary.overwritten") + " " + headerImportSummary.overwrittenCount, + (headerImportSummary.removedCount ?? 0) > 0 + ? t("settings.customHeaderImportSummary.removed") + + " " + + headerImportSummary.removedCount + : null, headerImportSummary.issues.length > 0 ? t("settings.customHeaderImportSummary.skipped") + " " + diff --git a/crates/agent-ui/src/pages/settings/ProviderModalView.tsx b/crates/agent-ui/src/pages/settings/ProviderModalView.tsx index fa105b255..d471fe860 100644 --- a/crates/agent-ui/src/pages/settings/ProviderModalView.tsx +++ b/crates/agent-ui/src/pages/settings/ProviderModalView.tsx @@ -64,8 +64,9 @@ import { resolveModelInputModalities, } from "@liveagent/ui/lib/models/modelCatalog"; import { - CLI_IDENTITY_PROVIDER_IDS, CLI_IDENTITY_USER_AGENTS, + isCliIdentityProviderId, + listCliIdentityProviderIds, } from "@liveagent/ui/lib/providers/customHeaders"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { @@ -1126,20 +1127,25 @@ export function ProviderModalView({ viewModel }: { viewModel: ProviderModalViewM {t("settings.cliIdentityHeaders")} - + {t("settings.cliIdentityHeadersHint")} - {CLI_IDENTITY_PROVIDER_IDS.map((identity) => ( + {listCliIdentityProviderIds(providerType).map((identity) => ( applyCliIdentityHeaders(identity)} > - + {t(`settings.cliIdentity.${identity}`)} - + {isCliIdentityProviderId(providerType) && identity === providerType ? ( + + {t("settings.cliIdentityRecommended")} + + ) : null} + {CLI_IDENTITY_USER_AGENTS[identity].split(" ")[0]}