统一 Claude Code 与 Codex 错误分类
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m46s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m5s
Project CI / Frontend tests (push) Successful in 4m36s
Project CI / Backend tests (push) Failing after 5m40s
Project CI / Repository checks (push) Failing after 4m32s
Project CI / AI game creator shell web tests (push) Successful in 2m54s
Project CI / Native shell tests (push) Successful in 8m37s

保留 Claude Code 的上游 HTTP 状态、超时、空响应和无效 JSON 语义

补充状态摘要、重试建议、定向回归测试与排障记录
This commit is contained in:
kdletters
2026-10-03 17:57:41 +08:00
parent 0b03d563c2
commit 8aee5db84e
5 changed files with 136 additions and 11 deletions
@@ -43,9 +43,9 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
};
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
pub(crate) use codex_app_server::turn_error::*;
@@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
})
}
/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。
///
/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
if let Some(status_code) = claude_code_failure_status_code(&detail) {
return platform_llm::LlmError::Upstream {
status_code,
message: detail,
};
}
if detail.contains("Claude Agent SDK sidecar 回合超时") {
return platform_llm::LlmError::Timeout { attempts: 1 };
}
if detail.contains("Claude Code 缺少最终回复") {
return platform_llm::LlmError::EmptyResponse;
}
if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON")
{
return platform_llm::LlmError::Deserialize(detail);
}
platform_llm::LlmError::Transport(detail)
}
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
["Request rejected (", "HTTP "].iter().find_map(|marker| {
let tail = detail.split_once(marker)?.1;
let digits = tail
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 3 {
return None;
}
let status_code = digits.parse::<u16>().ok()?;
(100..=599).contains(&status_code).then_some(status_code)
})
}
fn parse_claude_code_result(
stdout: &[u8],
request: &LlmRunRequest,
@@ -758,7 +798,7 @@ fn parse_claude_code_result(
let detail = claude_result_error_detail(&value)
.map(|detail| format!(":{detail}"))
.unwrap_or_default();
return Err(platform_llm::LlmError::Transport(format!(
return Err(claude_code_failure_to_llm_error(format!(
"Claude Code 返回失败终态{detail}"
)));
}
@@ -1156,6 +1196,29 @@ mod tests {
assert!(error.to_string().contains("Authentication failed"));
}
#[test]
fn claude_failure_reuses_codex_error_categories() {
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:API Error: Request rejected (429)".into()
),
platform_llm::LlmError::Upstream {
status_code: 429,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into()
),
platform_llm::LlmError::Timeout { attempts: 1 }
));
assert!(matches!(
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
platform_llm::LlmError::EmptyResponse
));
}
#[test]
fn parses_direct_stream_result_and_final_text() {
let mut observed = Vec::new();
@@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer,
)
.await
.map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail)));
.map_err(|detail| {
TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail))
});
}
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
TurnError::EnvironmentNotReady(EnvironmentNotReady {
@@ -304,7 +304,7 @@ impl ModelCallKind {
native,
} => match native {
Some(native) => native.is_retryable(),
None => *status_code >= 500,
None => matches!(*status_code, 401 | 408 | 429 | 500..=599),
},
Self::PaidCreditsInsufficient => false,
Self::EmptyResponse => false,
@@ -322,9 +322,16 @@ impl ModelCallKind {
}
Self::EmptyResponse => Some("模型未返回内容"),
Self::PayloadInvalid { .. } => Some("模型回执无法解析"),
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
Self::RequestRejected { native } => {
native.as_ref().and_then(NativeKind::public_summary)
}
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::public_summary)
.or_else(|| upstream_status_summary(*status_code)),
}
}
@@ -341,13 +348,40 @@ impl ModelCallKind {
Self::EmptyResponse | Self::PayloadInvalid { .. } => {
Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断")
}
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
native.as_ref().and_then(NativeKind::recovery_hint)
}
Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint),
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::recovery_hint)
.or_else(|| upstream_status_recovery_hint(*status_code)),
}
}
}
fn upstream_status_summary(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "上游服务拒绝认证(HTTP 401)",
403 => "上游服务拒绝访问(HTTP 403)",
408 => "上游请求超时(HTTP 408)",
429 => "上游服务返回 HTTP 429",
500..=599 => "上游服务暂时不可用",
_ => return None,
})
}
fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断",
403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断",
408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断",
429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断",
500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断",
_ => return None,
})
}
// ══════════════════════════════════════════════════════════════════════════════════════════
// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列
// ══════════════════════════════════════════════════════════════════════════════════════════
@@ -1327,6 +1361,31 @@ mod tests {
assert!(!projected.is_model_repairable());
}
#[test]
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
status_code: 429,
message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(),
});
assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429"));
assert_eq!(
rate_limited.recovery_hint(),
Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断")
);
assert!(rate_limited.is_retryable());
assert!(!rate_limited.is_model_repairable());
let unauthorized = TurnError::from_model_call(&LlmError::Upstream {
status_code: 401,
message: "Claude Code 返回失败终态:HTTP 401".into(),
});
assert_eq!(
unauthorized.public_summary(),
Some("上游服务拒绝认证(HTTP 401)")
);
assert!(unauthorized.is_retryable());
}
/// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。
#[test]
fn terminal_native_kinds_are_not_fed_back_to_the_model() {