Merge branch 'master' into feat/design_agent_simple
Project CI / Frontend tests (pull_request) Successful in 3m9s
Project CI / Backend tests (pull_request) Successful in 6m31s
Project CI / Repository checks (pull_request) Failing after 1m6s
Project CI / Native shell tests (pull_request) Successful in 19m54s

This commit is contained in:
2026-09-07 17:16:26 +08:00
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",