修复AGC大上下文请求体限制 (#295)
Project CI / Repository checks (push) Successful in 3m12s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / Backend tests (push) Successful in 7m42s
Project CI / Native shell tests (push) Successful in 20m14s
Project CI / Frontend tests (pull_request) Successful in 50m43s
Project CI / Repository checks (pull_request) Successful in 20m21s
Project CI / Backend tests (pull_request) Successful in 6m18s
Project CI / Native shell tests (pull_request) Successful in 18m19s

## 背景与问题

AGC(AI 游戏创作智能体 App)Direct Codex 模式在携带图片工具结果等大上下文调用 LLM 时,`/api/llm/responses` 与 `/api/llm/chat/completions` 会命中 Axum 默认 **2 MiB** 请求体上限,直接被 413 拒绝,大上下文任务无法执行;同时 Codex app-server 的 failed turn 未把上游 413 映射为稳定错误分类,前端只能显示笼统的“其他错误”,无法引导用户处理。

## 改动内容

**1. api-server(server-rs)**
- 新增常量 `LLM_REQUEST_MAX_BODY_BYTES = 32 MiB`,作为两个 LLM 代理路由的正式请求体上限;
- 两个路由显式配置 `DefaultBodyLimit::max(32 MiB)`,避免 Axum 默认 2 MiB 提前拒绝;handler 内保留超限检查,超过 32 MiB 仍返回 `413 PAYLOAD_TOO_LARGE`;
- 补充回归测试:>2 MiB 大上下文请求不再命中默认限制;超过 32 MiB 仍返回 413。

**2. AGC 壳(apps/ai-game-creator-shell)**
- `codex_app_server` 错误映射:上游/连接层 HTTP 413、`PAYLOAD_TOO_LARGE`、`provider request too large` 等统一映射为稳定分类 `codex-app-server-error:request-too-large`,不再落入 `other` 或误判为权限/安全策略错误;
- 前端新增用户可见文案“模型请求体过大,请减少参考图或上下文后重试”,并补充单测断言。

**3. 文档**
- 【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md 补充“2026-09-06 AGC LLM 代理请求体合同”:明确 32 MiB 上限、必须显式配置 `DefaultBodyLimit`、413 映射规则与用户文案。

## 验证

- `cargo test -p api-server llm_routes_accept_large_context_bodies_beyond_axum_default` 通过
- `cargo test -p api-server llm_responses_rejects_bodies_above_explicit_limit` 通过
- `cargo test ... ai-game-creator-shell ... codex_app_server_failed_turn_maps_request_too_large_details` 通过
- `npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts`(27 通过)

全量 workspace 测试与 CI 门禁待推送后由 CI 覆盖。

Reviewed-on: #295
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #295.
This commit is contained in:
2026-09-07 17:05:42 +08:00
committed by 段舒康
parent 964290c641
commit e15e3b455f
6 changed files with 166 additions and 12 deletions
@@ -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::<Vec<_>>()
.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 [
@@ -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': '工作区隔离启动失败,请检查项目目录后重试',
@@ -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 超时',
@@ -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` 或泛化成权限 / 安全策略错误。
+74 -2
View File
@@ -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<Response, Response> {
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 {
@@ -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<AppState> {
)
.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",