修复 AGC 的 cc 路由被本机 ANTHROPIC_* 环境顶掉

- 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 记录本次「官方模型全被发到用户个人中转」的根因、现行口径与排查手段
This commit is contained in:
kdletters
2026-10-02 19:26:44 +08:00
parent fcb3f2d258
commit c5673c5757
2 changed files with 232 additions and 55 deletions
@@ -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<String> {
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<String> {
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<String>,
credential: Option<ClaudeCodeCredential>,
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");
}
}