From 1aac66d159a35af8579154d034c9977a4b55df43 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 6 Sep 2026 20:47:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=A4=A7=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=AF=B7=E6=B1=82=E4=BD=93=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为LLM Responses与Chat路由显式启用32MiB请求体限制 将Codex上游413映射为request-too-large稳定分类 补充大请求体与错误文案回归测试和架构文档 --- .../src-tauri/src/agent/codex_app_server.rs | 59 ++++++++++++++ .../src/features/agent-runtime/model.ts | 2 + .../tests/agentRuntimeModel.test.ts | 7 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 ++ server-rs/crates/api-server/src/llm/mod.rs | 76 ++++++++++++++++++- .../crates/api-server/src/modules/platform.rs | 29 ++++--- 6 files changed, 166 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index a20098cc1..8c98b5009 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error( ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), + Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), Some(status_code) => platform_llm::LlmError::Upstream { status_code, message: "Codex app-server 连接上游失败".to_string(), @@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure( || detail.contains("http 403") } +fn game_creator_codex_app_server_error_detail_indicates_request_too_large( + error: &serde_json::Value, +) -> bool { + let Some(error) = error.as_object() else { + return false; + }; + let detail = ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .collect::>() + .join(" ") + .to_ascii_lowercase(); + if detail.is_empty() { + return false; + } + detail.contains("413 payload too large") + || detail.contains("http 413") + || detail.contains("status 413") + || detail.contains("payload_too_large") + || detail.contains("payload too large") + || detail.contains("request too large") + || detail.contains("provider request too large") +} + fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points( error: &serde_json::Value, ) -> bool { @@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error( message: "泥点余额不足".to_string(), }; } + if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { + return game_creator_codex_app_server_error_kind("request-too-large"); + } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { return game_creator_codex_app_server_error_kind("unauthorized"); } @@ -4787,6 +4815,12 @@ mod tests { "codex-app-server-error:unauthorized".to_string(), ), ), + ( + serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:request-too-large".to_string(), + ), + ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), platform_llm::LlmError::Upstream { @@ -4847,6 +4881,31 @@ mod tests { } } + #[test] + fn codex_app_server_failed_turn_maps_request_too_large_details() { + for detail in [ + "HTTP 413 Payload Too Large", + "status 413", + "PAYLOAD_TOO_LARGE", + "provider request too large", + ] { + let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ + "status": "failed", + "error": { + "message": detail, + "additionalDetails": "private upstream diagnostics", + "codexErrorInfo": "other" + } + })); + assert_eq!( + error, + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:request-too-large".to_string(), + ) + ); + } + } + #[test] fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() { for detail in [ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index e7dbcb0ea..d8df6bc98 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -1951,6 +1951,7 @@ export function projectRuntimeVisibleError( 'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务', 'usage-limit-exceeded': '智能创作用量已达上限,请检查账户额度后重试', unauthorized: '智能服务鉴权失败,请重新登录后重试', + 'request-too-large': '模型请求体过大,请减少参考图或上下文后重试', 'bad-request': '智能创作请求无效,请稍后重试', 'cyber-policy': '智能创作安全策略拒绝了本次请求,请调整任务内容', 'sandbox-error': '智能创作隔离环境启动失败,请重试或检查本机环境', @@ -1972,6 +1973,7 @@ export function projectRuntimeVisibleError( 'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务', 'usage-limit-exceeded': '用量已达上限,请检查账户额度后重试', unauthorized: '鉴权失败,请重新登录后重试', + 'request-too-large': '模型请求体过大,请减少参考图或上下文后重试', 'bad-request': '请求无效,请稍后重试', 'cyber-policy': '安全策略拒绝了本次请求,请调整任务内容', 'sandbox-error': '工作区隔离启动失败,请检查项目目录后重试', diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index e40690510..6772d6555 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -695,6 +695,13 @@ describe('Agent Runtime Provider 状态投影', () => { true, ), ).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试'); + expect( + projectRuntimeVisibleError( + 'codex-app-server-error:request-too-large', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 模型请求体过大,请减少参考图或上下文后重试'); expect( projectRuntimeVisibleError( 'codex-app-server-terminal-unknown: 等待 turn/completed 超时', diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 59dec2452..3473bffd4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1282,3 +1282,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 配置文件新增 `schemaVersion: \"game-creator-config.v2\"`。新默认配置开启 `stream` 与受控联网;无版本旧配置在启动时补写 v2,旧 `codex_app_server` 路由仅在省略 `webSearchEnabled` 时按历史默认补为开启,显式 `false` 保留;Provider / Anthropic 路由未提供搜索覆盖时保持关闭,避免继承 DirectProject 默认。主配置按完整配置迁移;本地覆盖只补 schema 版本,不凭不完整 overlay 推断或写入 `agentMode` / 搜索布尔值。 - `/llm-status`、开发单 Agent 状态和项目 Agent 状态卡对 Codex 模式显示“流式开启 / 关闭”“受控联网开启 / 关闭”“Codex 原生 web_search 关闭”,不显示 API Key、URL、请求头、绝对路径或 Provider 原始错误正文。 - BDD 验收场景与测试映射:DirectProject 默认工具目录包含 `agc_web_search` 且原生搜索仍 disabled;未审核字段、越界数量和非公开 URL 在桥接端失败关闭;旧无版本配置迁移为 v2 且按路由得到正确默认;状态卡显示三态安全摘要。对应 Rust `configuration`、`direct_tools_mcp`、`direct_tool_bridge`、`codex_app_server` 定向测试及前端状态格式化 / AppSurface 测试。 + +## 2026-09-06 AGC LLM 代理请求体合同 + +- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。 +- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 6170d04ad..e9b526f2a 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -28,6 +28,8 @@ use crate::{ platform_errors::map_llm_error, request_context::RequestContext, state::AppState, }; +pub(crate) const LLM_REQUEST_MAX_BODY_BYTES: usize = 32 * 1024 * 1024; + pub(crate) mod icon_specs; #[cfg(test)] @@ -222,8 +224,7 @@ pub async fn proxy_llm_responses( headers: HeaderMap, body: Bytes, ) -> Result { - const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; - if body.len() > MAX_REQUEST_BYTES { + if body.len() > LLM_REQUEST_MAX_BODY_BYTES { return Err(llm_error_response( &request_context, AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) @@ -1187,6 +1188,77 @@ mod tests { ); } + #[tokio::test] + async fn llm_routes_accept_large_context_bodies_beyond_axum_default() { + let large_input = "x".repeat(2 * 1024 * 1024 + 1024); + let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; + install_test_provisioned_router_credential( + &user_id, + "http://127.0.0.1:1".to_string(), + "fixture-key", + ); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(json!({ "input": large_input }).to_string())) + .expect("request should build"), + ) + .await + .expect("response should not hit the default body limit"); + assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/chat/completions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "messages": [ + { "role": "user", "content": large_input } + ] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("response should not hit the default body limit"); + assert_ne!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn llm_responses_rejects_bodies_above_explicit_limit() { + let (state, user_id) = seed_authenticated_state(AppConfig::default()).await; + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/responses") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(vec![b'x'; LLM_REQUEST_MAX_BODY_BYTES + 1])) + .expect("request should build"), + ) + .await + .expect("oversized response should be returned"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + #[tokio::test] async fn llm_chat_completions_streams_sse_payload() { let server_url = spawn_mock_server(vec![MockResponse { diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 43492f64c..d457b795e 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -1,11 +1,16 @@ use axum::{ - Router, middleware, + Router, + extract::DefaultBodyLimit, + middleware, routing::{get, post}, }; use crate::{ auth::require_bearer_auth, - llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses}, + llm::{ + LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions, + proxy_llm_responses, + }, state::AppState, volcengine_speech::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, @@ -24,17 +29,21 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/llm/chat/completions", - post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(proxy_llm_chat_completions) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) .route( "/api/llm/responses", - post(proxy_llm_responses).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(proxy_llm_responses) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) .route( "/api/speech/volcengine/config",