From c5673c5757b4dc97252e0f1b4521cdac71bbee04 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:26:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=9A=84=20cc=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=A2=AB=E6=9C=AC=E6=9C=BA=20ANTHROPIC=5F*?= =?UTF-8?q?=20=E7=8E=AF=E5=A2=83=E9=A1=B6=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude_code_cli.rs 用 claude_code_route 统一决定 cc 的 baseUrl 与凭据:平台会话 > 自定义端点 > 配置 key,本机 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY 完全不再参与 - 未登录且没配 key 时由 game_creator_claude_code_cli_route_error 明确报「cc 模式需要先登录陶泥儿账号」,不再静默回落到用户个人中转 - 不再把 ANTHROPIC_API_KEY、ANTHROPIC_AUTH_TOKEN 放进 sidecar 的 copy_env 白名单,凭据只由解析出的路由下发 - 新增 agent.direct_codex.claude_route 诊断行(source / host / credential),只写主机名,不带路径与凭据 - 新增 claude_code_cli 路由单测:平台优先、自定义端点用 x-api-key、未登录不回落本机环境、baseUrl /v1 归一化、主机名诊断 - pitfalls.md 记录本次「官方模型全被发到用户个人中转」的根因、现行口径与排查手段 --- .../src-tauri/src/agent/claude_code_cli.rs | 278 ++++++++++++++---- docs/project-memory/shared-memory/pitfalls.md | 9 + 2 files changed, 232 insertions(+), 55 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index f0310c4fa..276af0ae5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -370,10 +370,10 @@ pub(crate) fn game_creator_claude_code_cli_route_error( if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS { return Some("cc 模式需要有效的 Claude Code 模型标识".to_string()); } - if llm.api_key.trim().is_empty() - && !llm.custom_enabled - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() + // 官方模型只经平台网关,凭据就是平台会话;没有会话就没有凭据。这里不再退回本机 + // `ANTHROPIC_*` 环境——否则用户终端里的个人中转会静默顶掉平台路由(2026-10-02 事故)。 + if !llm.custom_enabled + && llm.api_key.trim().is_empty() && crate::platform_session::current_platform_session().is_none() { return Some("cc 模式需要先登录陶泥儿账号".to_string()); @@ -387,31 +387,109 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) { } } -fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option { - std::env::var("ANTHROPIC_BASE_URL") - .ok() - .or_else(|| { - let llm = llm?; - // Official models never talk to the Router directly: the account - // Router credential lives server-side, so the Claude sidecar goes - // through the platform gateway and appends `/v1/messages` itself. - if !llm.custom_enabled { - if let Some(session) = crate::platform_session::current_platform_session() { - return Some(format!( +/// baseUrl 不带路由与版本段,协议路径由客户端协议自己拼(Claude Agent SDK 固定 +/// 请求 `{ANTHROPIC_BASE_URL}/v1/messages`),历史凭据末尾的 `/v1` 在这里归一化掉。 +fn normalize_anthropic_base_url(value: &str) -> Option { + let normalized = value + .trim() + .trim_end_matches('/') + .trim_end_matches("/v1") + .trim_end_matches('/'); + (!normalized.is_empty()).then(|| normalized.to_string()) +} + +/// cc 的 Anthropic 路由来源,只用于诊断日志,不参与协议判断。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ClaudeCodeRouteSource { + Platform, + Custom, + Config, +} + +/// Anthropic 凭据有两种下发方式:`x-api-key`(`ANTHROPIC_API_KEY`)与 +/// `Authorization: Bearer`(`ANTHROPIC_AUTH_TOKEN`)。Router 的 `/v1/messages` +/// 只认前者,用 Bearer 会挂住不返回,所以这里必须区分,不能只留一个字符串。 +#[derive(Clone, Debug, Eq, PartialEq)] +enum ClaudeCodeCredential { + ApiKey(String), + Bearer(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ClaudeCodeRoute { + base_url: Option, + credential: Option, + source: ClaudeCodeRouteSource, +} + +/// cc 的路由与凭据只由 AGC 决定,本机 `ANTHROPIC_*` 一律不参与。 +/// +/// 2026-10-02 实测事故:用户终端里给 Claude Code 配了个人中转(`ANTHROPIC_BASE_URL` +/// + `ANTHROPIC_AUTH_TOKEN`),AGC 进程继承后,官方模型的回合全都发到那个个人中转上 +/// ——平台网关、模型目录解析、账号 Router key 全部绕过,表现是「能回但极慢」和 +/// 「卡满 requestTimeoutMs 超时」,后台怎么改都无效。个人中转要走 AGC 自己的 +/// `customEnabled` + baseUrl + apiKey,不再从进程环境隐式继承。 +fn claude_code_route( + llm: Option<&GameCreatorLlmConfig>, + session: Option<&crate::platform_session::PlatformSessionSnapshot>, +) -> ClaudeCodeRoute { + if let Some(llm) = llm { + // 官方模型一律走平台网关:客户端只出示平台 access token,账号 Router key + // 与模型名解析都留在 api-server,客户端侧不落 Router 凭据。 + if !llm.custom_enabled { + if let Some(session) = session { + return ClaudeCodeRoute { + base_url: Some(format!( "{}/api/llm/anthropic", session.api_base_url.trim_end_matches('/') - )); - } + )), + credential: Some(ClaudeCodeCredential::Bearer(session.access_token.clone())), + source: ClaudeCodeRouteSource::Platform, + }; } - Some(llm.base_url.trim_end_matches('/').to_string()) - }) - .map(|value| { - value - .trim_end_matches('/') - .trim_end_matches("/v1") - .to_string() - }) - .filter(|value| !value.is_empty()) + } + // 自定义目录(含用户自己填的 Router 地址)以 AGC 配置为准,同样不吃本机环境。 + if llm.custom_enabled || !llm.api_key.trim().is_empty() { + return ClaudeCodeRoute { + base_url: normalize_anthropic_base_url(&llm.base_url), + credential: (!llm.api_key.trim().is_empty()).then(|| { + if llm.custom_enabled { + ClaudeCodeCredential::ApiKey(llm.api_key.trim().to_string()) + } else { + ClaudeCodeCredential::Bearer(llm.api_key.trim().to_string()) + } + }), + source: if llm.custom_enabled { + ClaudeCodeRouteSource::Custom + } else { + ClaudeCodeRouteSource::Config + }, + }; + } + return ClaudeCodeRoute { + base_url: normalize_anthropic_base_url(&llm.base_url), + credential: None, + source: ClaudeCodeRouteSource::Config, + }; + } + ClaudeCodeRoute { + base_url: None, + credential: None, + source: ClaudeCodeRouteSource::Config, + } +} + +/// 诊断只写主机名,不写完整地址与凭据。 +fn claude_route_host(base_url: &str) -> String { + let without_scheme = base_url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(base_url); + without_scheme + .split(['/', '?']) + .next() + .unwrap_or_default() + .to_string() } fn configure_claude_code_environment( @@ -438,8 +516,6 @@ fn configure_claude_code_environment( "WINDIR", "ComSpec", "PATHEXT", - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", ] { copy_env(command, name); } @@ -450,36 +526,37 @@ fn configure_claude_code_environment( .env("CI", "1") .env("NO_COLOR", "1") .env("TERM", "dumb"); - if let Some(base_url) = claude_base_url(llm) { + let session = crate::platform_session::current_platform_session(); + let route = claude_code_route(llm, session.as_ref()); + app_log!( + "agent.direct_codex.claude_route source={:?} host={} credential={}", + route.source, + route + .base_url + .as_deref() + .map(claude_route_host) + .unwrap_or_default(), + match route.credential { + Some(ClaudeCodeCredential::ApiKey(_)) => "x-api-key", + Some(ClaudeCodeCredential::Bearer(_)) => "bearer", + None => "none", + } + ); + if let Some(base_url) = route.base_url { command.env("ANTHROPIC_BASE_URL", base_url); } + // 凭据只从当前路由下发,不继承本机环境:Claude SDK 只从子进程环境读 Anthropic + // 认证,桥接平台会话令牌时也不能落盘。 + match route.credential { + Some(ClaudeCodeCredential::ApiKey(api_key)) => { + command.env("ANTHROPIC_API_KEY", api_key); + } + Some(ClaudeCodeCredential::Bearer(token)) => { + command.env("ANTHROPIC_AUTH_TOKEN", token); + } + None => {} + } if let Some(llm) = llm { - if !llm.api_key.trim().is_empty() - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() - { - if llm.custom_enabled { - // 自定义 Claude 兼容端点用 Anthropic 规范的 `x-api-key`:Claude Code 只在 - // `ANTHROPIC_API_KEY` 下发这个头(`ANTHROPIC_AUTH_TOKEN` 会发 Bearer,实测 - // Router 的 `/v1/messages` 对 Bearer 会挂住不返回)。 - command.env("ANTHROPIC_API_KEY", llm.api_key.trim()); - } else { - command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim()); - } - } - // Official AGC model entries use the account session rather than a - // user-supplied API key. The Claude SDK only reads Anthropic auth - // from its child-process environment, so bridge the already-held - // platform token without persisting or exposing it in argv/logs. - if llm.api_key.trim().is_empty() - && !llm.custom_enabled - && std::env::var_os("ANTHROPIC_API_KEY").is_none() - && std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none() - { - if let Some(session) = crate::platform_session::current_platform_session() { - command.env("ANTHROPIC_AUTH_TOKEN", session.access_token); - } - } if !llm.model.trim().is_empty() { command.env("ANTHROPIC_MODEL", llm.model.trim()); } @@ -977,4 +1054,95 @@ mod tests { .expect_err("failed terminal must be rejected"); assert!(error.contains("Authentication failed")); } + + fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot { + crate::platform_session::PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "platform-token".to_string(), + api_base_url: "https://api.example".to_string(), + identity_generation: 1, + revision: 1, + } + } + + #[test] + fn anthropic_base_url_normalization_drops_version_segment() { + assert_eq!( + normalize_anthropic_base_url("https://router.example/v1/").as_deref(), + Some("https://router.example") + ); + assert_eq!( + normalize_anthropic_base_url(" https://router.example/ ").as_deref(), + Some("https://router.example") + ); + assert_eq!(normalize_anthropic_base_url(" "), None); + } + + #[test] + fn official_model_routes_through_platform_gateway_even_with_personal_anthropic_env() { + let llm = GameCreatorLlmConfig { + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + // 用户终端里配着个人中转(ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)时,官方 + // 模型仍必须走平台网关:路由函数的入参里根本没有本机环境,这是结构上的保证。 + let route = claude_code_route(Some(&llm), Some(&test_platform_session())); + assert_eq!(route.source, ClaudeCodeRouteSource::Platform); + assert_eq!( + route.base_url.as_deref(), + Some("https://api.example/api/llm/anthropic") + ); + assert_eq!( + route.credential, + Some(ClaudeCodeCredential::Bearer("platform-token".to_string())) + ); + } + + #[test] + fn custom_endpoint_keeps_agc_config_and_sends_x_api_key() { + let llm = GameCreatorLlmConfig { + custom_enabled: true, + base_url: "https://router.example/v1".to_string(), + api_key: "router-key".to_string(), + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + let route = claude_code_route(Some(&llm), Some(&test_platform_session())); + assert_eq!(route.source, ClaudeCodeRouteSource::Custom); + assert_eq!(route.base_url.as_deref(), Some("https://router.example")); + assert_eq!( + route.credential, + Some(ClaudeCodeCredential::ApiKey("router-key".to_string())) + ); + } + + #[test] + fn official_model_without_platform_session_never_falls_back_to_personal_anthropic_env() { + let llm = GameCreatorLlmConfig { + model: "claude-opus-5-5".to_string(), + ..Default::default() + }; + let route = claude_code_route(Some(&llm), None); + assert_eq!(route.source, ClaudeCodeRouteSource::Config); + // 没登录就没有凭据,路由也不会去读 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN。 + assert_eq!(route.credential, None); + assert!(!route + .base_url + .as_deref() + .unwrap_or_default() + .contains("personal.example")); + assert_eq!( + game_creator_claude_code_cli_route_error(&llm).as_deref(), + Some("cc 模式需要先登录陶泥儿账号") + ); + } + + #[test] + fn route_host_diagnostic_never_carries_path_or_credentials() { + assert_eq!( + claude_route_host("https://api.example/api/llm/anthropic"), + "api.example" + ); + assert_eq!(claude_route_host("http://127.0.0.1:8084"), "127.0.0.1:8084"); + } } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 901bff371..b4115d4ea 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6257,3 +6257,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理**:夹具补 `None` 协议参数与 `selected_model_protocol: None`,不改任何断言口径。 - **验证**:`cargo test --features=… --bin … tests::configuration::` 45 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`apps/ai-game-creator-shell/src-tauri/src/main.rs`。 + +## 2026-10-02 AGC 的 cc 路由被本机 `ANTHROPIC_*` 环境顶掉,官方模型全发到用户个人中转 + +- **现象**:用户终端里给 Claude Code 配了自己的中转(`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`)。AGC 选 `claude-opus-5-5` 发消息时好时坏:偶尔两分半回一句话,多数回合一路静默到 `requestTimeoutMs`(180s)超时,日志只有 `claude-sidecar-start` 加 `claude_sidecar_idle idleSeconds=30/60/…`。同一时间平台网关、模型目录、账号 Router 分组怎么改都没反应。 +- **根因**:`claude_code_cli.rs` 先把 AGC 进程继承来的 `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` 原样复制给 sidecar,再让它们优先于平台会话(`claude_base_url` 里 `std::env::var("ANTHROPIC_BASE_URL")` 排在 `.or_else` 最前,凭据有 `if … is_none()` 守卫因此永远不覆盖)。实测:直接跑 sidecar 探针,`claude.exe`(PID 43256)的两条 443 连接落在 `198.18.2.60` = 用户个人的 `yunyi.rdzhvip.com`,完全没碰 `dev.genarrative.world` / Router;同一个 token 对 `POST https://yunyi.rdzhvip.com/claude/v1/messages` 45 秒不返回(Bearer 与 `x-api-key` 都一样)。用户 `~/.claude/settings.json` 里的 env 反而无关——sidecar 用 `settingSources: []` 关掉了设置文件来源,事故来源是**进程环境**。 +- **现行口径**:cc 的路由与凭据只由 AGC 决定,本机 `ANTHROPIC_*` **完全不参与**(`claude_code_route` 的入参里没有进程环境)。官方模型 + 平台会话 → `{apiBaseUrl}/api/llm/anthropic` + `Bearer <平台 access token>`;`customEnabled` 或配了 `llm.apiKey` → AGC 配置的 baseUrl(自定义端点用 `x-api-key`,其余保持 Bearer);没登录也没配 key 就报「cc 模式需要先登录陶泥儿账号」,不再退回本机环境。`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 也不再进 `copy_env` 白名单,只由解析出的路由下发。个人中转要走 AGC 的 `customEnabled` + baseUrl + apiKey,不做隐式继承。 +- **排查手段**:sidecar 每次启动都写一行 `agent.direct_codex.claude_route source=<…> host=<…> credential=`(只写主机名,不含路径与凭据)。这一行是判断"回合到底发到哪个网关"的唯一低成本证据。 +- **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 10 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化与主机名诊断。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`、[`【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`](../../technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)。