修复 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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user