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
5 changes: 5 additions & 0 deletions crates/agent-gui/src-tauri/src/services/provider_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Expand Down
15 changes: 13 additions & 2 deletions crates/agent-gui/src/lib/providers/runtime/requestOptions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
};
Expand Down
167 changes: 167 additions & 0 deletions crates/agent-gui/test/providers/custom-headers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}',
Expand Down
24 changes: 22 additions & 2 deletions crates/agent-gui/test/providers/request-options.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
});
Expand All @@ -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",
},
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"));
});
Expand Down
8 changes: 8 additions & 0 deletions crates/agent-gui/test/providers/transport-golden.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
});
Expand All @@ -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,
});
});

Expand Down
9 changes: 7 additions & 2 deletions crates/agent-gui/test/settings/provider-models-fetch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-ui/src/i18n/translations/enUSSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-ui/src/i18n/translations/zhCNSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "取消",
Expand All @@ -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": "格式无效",
Expand Down
Loading
Loading