diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 429ce7c5a..79acaa55c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1075,7 +1075,7 @@ export interface AdminRechargeRefundActionResponse { export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } -export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic'; export interface AdminAgcModel { id: string; diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index c64efc67d..cab805cf7 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -226,6 +226,7 @@ export function AdminAgcModelsPage({ > + diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 641ace7bb..36e7a7c60 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) { fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option { std::env::var("ANTHROPIC_BASE_URL") .ok() - .or_else(|| llm.map(|value| value.base_url.trim_end_matches('/').to_string())) + .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!( + "{}/api/llm/anthropic", + session.api_base_url.trim_end_matches('/') + )); + } + } + Some(llm.base_url.trim_end_matches('/').to_string()) + }) .map(|value| { value .trim_end_matches('/') diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 0b0cbb827..d81e102aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2351,7 +2351,7 @@ impl CodexAppServerConnection { ) })?; effective_llm.base_url = - format!("{}/api/llm", session.api_base_url.trim_end_matches('/')); + format!("{}/api/llm/v1", session.api_base_url.trim_end_matches('/')); effective_llm.api_key.clear(); // Only the platform catalog identifier reaches Codex. api-server // validates it and resolves the actual upstream model. @@ -2619,7 +2619,7 @@ impl CodexAppServerConnection { .. } => ( Some(( - format!("{}/api/llm", api_base_url.trim_end_matches('/')), + format!("{}/api/llm/v1", api_base_url.trim_end_matches('/')), access_token.clone(), )), true, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d77fc38b5..c2ce080b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1098,7 +1098,8 @@ fn select_game_creator_model_inner( if let Some(agent_mode) = catalog_agent_mode { config.agent_mode = match agent_mode.trim() { "codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(), - "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), + // `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。 + "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), value => return Err(format!("模型 Agent 模式无效:{value}")), }; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 8a16b71a8..6eb153861 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -222,7 +222,8 @@ fn build_game_creator_official_platform_llm_config( if api_base_url.is_empty() { return Err("登录态缺少 API Server 地址".to_string()); } - let proxy_base_url = format!("{api_base_url}/api/llm"); + // baseUrl 不带路由与版本段,OpenAI 协议由 platform-llm 自己拼 `v1/`。 + let proxy_base_url = format!("{api_base_url}/api/llm/v1"); LlmConfig::new( LlmProvider::OpenAiCompatible, proxy_base_url, diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 2f4de6483..2221e5cfd 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -3,7 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri'; export type ClientLlmModel = { displayName: string; id: string; - agentMode: 'codex' | 'cc'; + agentMode: 'codex' | 'cc' | 'anthropic'; }; export type ClientLlmModelCatalog = { diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 817cf6a64..065e1da58 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6191,6 +6191,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。 - **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。 +## 2026-10-01 cc/Anthropic 必须走平台网关,客户端不得直连 Router + +- **现象**:目录里把 `claude-opus-5-5` 标成 `agentMode=cc` 后,选它发消息必然失败:先是 Claude CLI 自己打印 `Not logged in · Please run `,后来卡满 `requestTimeoutMs=180000`。 +- **根因**:`claude_code_cli.rs` 用配置里残留的 `llm.baseUrl`(`https://router.genarrative.world/v1`)当 `ANTHROPIC_BASE_URL`,再把平台会话 token 塞成 `ANTHROPIC_AUTH_TOKEN`。平台凭据边界是「客户端只出示平台 access token,账号的 Router key 由 api-server 解析、绝不下发」(见 `api-server/src/llm/mod.rs`)。实测 `POST https://router.genarrative.world/v1/messages` 用平台 token 返回 `401 Invalid token (new_api_error)`。 +- **现行口径**:baseUrl 不带路由与版本段,统一由协议自己拼 `v1/`(服务端 `router_protocol_url` 会先把历史凭据末尾的 `/v1` 归一化掉)。平台侧 Anthropic 是独立路由 `/api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 设成 `{apiBaseUrl}/api/llm/anthropic`;OpenAI 侧是 `/api/llm/v1/responses` 与 `/api/llm/v1/chat/completions`,旧的无 `v1` 路径保留为已发布客户端的兼容别名。 +- **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。 +- **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。 + ## 2026-10-01 AGC 官方 Claude 模型缺少账号令牌会被误报为通道断开 - **现象**:模型目录把回合路由到 `cc`,本地 `game-creator.config.json` 的 `llm.apiKey` 为空时,Claude Agent SDK 返回失败终态;界面只显示“执行通道未能建立或已断开”。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index 2ce2cb439..347575fbf 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -55,7 +55,8 @@ ## 模型绑定 Agent 执行模式 -- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。 +- 后台 AGC 模型目录每项新增 `agentMode`,接受 `codex`、`cc` 与 `anthropic`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。`anthropic` 是 2026-10-01 新增的显式 Anthropic Messages 协议值,`cc` 保留为同一 Claude 执行器的历史别名(两者都映射到 `claude_code_cli`),新目录项应直接写 `anthropic`。 +- 2026-10-01 补充:`cc`/`anthropic` 一律经平台 LLM 网关的独立 Anthropic 路由 `POST /api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。 - `codex` 仍表示现有 AGC Codex app-server 执行链路;`cc` 表示 AGC 客户端启动随包的 Claude Agent SDK sidecar,模型的 `modelId` 原样作为 Claude Agent SDK 的模型标识,不把 Claude 协议伪装成 OpenAI Responses。sidecar 随 AGC 安装包携带 SDK 及匹配平台的 Claude Code runtime,用户不需要预先安装 `claude` 命令。 - `/api/llm/models` 的启用模型摘要返回 `agentMode`,后台完整目录、管理 DTO 和客户端目录保持同一绑定快照。客户端在选择模型时同时持久化模型 ID 与执行模式;默认模型变化、模型被停用或目录刷新回退时一并更新执行模式。 - 客户端设置保存不得把后台模型绑定覆盖回 Codex。旧客户端配置缺少执行模式时继续按 Codex 运行;旧后台响应缺少 `agentMode` 时客户端按 Codex 兼容。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index adb9499c7..11c8d95c7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -78,6 +78,7 @@ npm run check:server-rs-ddd - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 +- 2026-10-01 路由约定:LLM 网关 baseUrl 不带路由与版本段,路径由协议自己拼。OpenAI 侧是 `POST /api/llm/v1/responses` 与 `POST /api/llm/v1/chat/completions`;Anthropic 是独立路由 `POST /api/llm/anthropic/{*path}`(Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages`,网关不自己补 `v1`)。旧的无 `v1` 路径 `/api/llm/responses`、`/api/llm/chat/completions` 保留为已发布客户端的兼容别名。三条路径共用同一凭据边界:客户端只出示平台 access token,账号 Router key 由 api-server 解析且不下发,模型名由目录解析后替换。 - 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。 - 编辑器与素材生成:`/api/editor/projects*`、`/api/editor/assets*`、`/api/editor/showcase/*`、`/api/editor/*/generations`、`/api/editor/images/*`、`/api/editor/icon-spritesheets/*`、`/api/editor/ui-designs/*`,以及编辑器 Agent 会话路由。通用任务与素材支撑另保留 `/api/ai/tasks*`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*` 和 `/api/assets/hyper3d/*`。 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 439ddbd7e..3ff4dc75c 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -365,7 +365,7 @@ pub async fn proxy_llm_responses( .with_message(format!("创建 LLM Router 请求客户端失败:{error}")), ) })?; - let upstream_url = format!("{}/responses", base_url.trim_end_matches('/')); + let upstream_url = router_protocol_url(&base_url, "responses"); let mut request = client .post(upstream_url) .bearer_auth(api_key) @@ -470,6 +470,227 @@ pub async fn proxy_llm_responses( ) } +/// Proxies the Anthropic Messages protocol for the LLM Router. +/// +/// Credential boundary is identical to [`proxy_llm_responses`]: the caller only +/// presents the platform access token, and the account-owned Router credential +/// is resolved inside api-server and never returned to the client. The Claude +/// Agent SDK appends `/v1/messages` to `ANTHROPIC_BASE_URL`, so this route is +/// registered at that exact path and only rewrites `model`. +pub async fn proxy_llm_messages( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + headers: HeaderMap, + body: Bytes, +) -> Result { + if body.len() > LLM_REQUEST_MAX_BODY_BYTES { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_message("LLM Messages 请求体超过大小限制"), + )); + } + + if let Err(error) = + ensure_llm_router_user_can_start_conversation(&state, authenticated.claims().user_id()) + .await + { + return Err(llm_error_response(&request_context, error)); + } + + let mut payload = serde_json::from_slice::(&body).map_err(|_| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("LLM Messages 请求体必须是合法 JSON"), + ) + })?; + let object = payload.as_object_mut().ok_or_else(|| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("LLM Messages 请求体必须是 JSON 对象"), + ) + })?; + let requested_model = object + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + // Same boundary as Responses: provider/credential controls never reach the + // Router, even when an older desktop build still sends them. + for field in [ + "apiKey", + "api_key", + "baseUrl", + "base_url", + "provider", + "apiKind", + "api_kind", + "agentLlm", + "agent_llm", + "agentMode", + "agent_mode", + ] { + object.remove(field); + } + let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + .await + .map_err(|error| llm_error_response(&request_context, error))?; + // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补 + // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识; + // 目录外取值仍由 `resolve_requested` 拒绝。 + let requested_model = requested_model.as_deref(); + let selected_model = catalog + .resolve_requested(requested_model) + .map_err(|message| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), + ) + })? + .to_string(); + object.insert("model".to_string(), Value::String(selected_model)); + + let (base_url, api_key, key_id) = + resolve_llm_router_credentials(&state, authenticated.claims().user_id()) + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error), + ) + })?; + + prepare_llm_router_billing(&state, authenticated.claims().user_id()) + .await + .map_err(|error| llm_error_response(&request_context, error))?; + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .read_timeout(std::time::Duration::from_secs(180)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("创建 LLM Router 请求客户端失败:{error}")), + ) + })?; + let upstream_url = router_protocol_url(&base_url, "messages"); + // The Router speaks Anthropic Messages on this path; accept both credential + // headers because the SDK uses `x-api-key` while the gateway also allows + // `Authorization: Bearer`. + let mut request = client + .post(upstream_url) + .header("x-api-key", api_key.clone()) + .bearer_auth(api_key) + .header("content-type", "application/json"); + for name in ["anthropic-version", "anthropic-beta", "accept"] { + if let Some(value) = headers.get(name) { + request = request.header(name, value); + } + } + let upstream = request + .body(serde_json::to_vec(&payload).map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("序列化 LLM Messages 请求失败:{error}")), + ) + })?) + .send() + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("LLM Router 暂时不可用:{error}")), + ) + })?; + + let status = upstream.status(); + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + if let Err(error) = crate::external_api_keys::revoke_llm_router_account( + &state, + authenticated.claims().user_id(), + &key_id, + ) + .await + { + tracing::warn!( + request_id = request_context.request_id(), + user_id = %authenticated.claims().user_id(), + key_id = %key_id, + error = %error, + "LLM Router 返回确定鉴权失败,但本地账号 Key 失效标记未完成" + ); + } + } + let upstream_headers = upstream.headers().clone(); + let is_stream = payload + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + if !is_stream { + let body = upstream.bytes().await.map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取 LLM Router 响应失败:{error}")), + ) + })?; + if status.is_success() { + if let Err(error) = + settle_llm_router_usage(&state, authenticated.claims().user_id()).await + { + tracing::error!( + request_id = request_context.request_id(), + user_id = %authenticated.claims().user_id(), + error = %error, + "LLM Router Messages 成功但累计额度同步未完成" + ); + } + } + return build_upstream_response( + status, + &upstream_headers, + Body::from(body), + &request_context, + ); + } + + if !status.is_success() { + let stream = upstream.bytes_stream().map(|chunk| { + chunk.map_err(|error| { + std::io::Error::other(format!("LLM Router 响应流读取失败:{error}")) + }) + }); + return build_upstream_response( + status, + &upstream_headers, + Body::from_stream(stream), + &request_context, + ); + } + + let stream = stream_messages_with_billing( + upstream.bytes_stream(), + state.clone(), + authenticated.claims().user_id().to_string(), + request_context.request_id().to_string(), + ); + build_upstream_response( + status, + &upstream_headers, + Body::from_stream(stream), + &request_context, + ) +} + fn build_upstream_response( status: StatusCode, upstream_headers: &HeaderMap, @@ -666,6 +887,17 @@ fn is_responses_done_sse_event(event: &str) -> bool { .any(|line| matches!(line.trim(), "data: [DONE]" | "[DONE]")) } +/// Anthropic Messages ends a stream with `message_stop` and has no `[DONE]` +/// sentinel, so the terminal event is the only settle point. +fn is_messages_terminal_sse_event(event: &str) -> bool { + event.lines().any(|line| { + let line = line.trim(); + line == "event: message_stop" + || line.contains("\"type\":\"message_stop\"") + || line.contains("\"type\": \"message_stop\"") + }) +} + fn finalize_llm_router_terminal_event( terminal_event: &mut Option, billing_result: Option<&Result<(), AppError>>, @@ -816,6 +1048,77 @@ fn stream_responses_with_billing( } } +/// Passes an Anthropic Messages SSE stream through unchanged and settles the +/// account quota once the upstream signals `message_stop`. +/// +/// The bytes are forwarded verbatim so the Claude Agent SDK keeps parsing the +/// same event shapes it received before the platform gateway was introduced. +fn stream_messages_with_billing( + mut upstream: impl futures_util::Stream> + Unpin, + state: AppState, + owner_user_id: String, + request_id: String, +) -> impl futures_util::Stream> { + async_stream::stream! { + let mut pending = String::new(); + let mut utf8_pending = Vec::new(); + let mut billing_result = None; + + while let Some(chunk) = upstream.next().await { + match chunk { + Ok(bytes) => { + append_utf8_chunk(&mut pending, &mut utf8_pending, bytes.as_ref()); + pending = pending.replace("\r\n", "\n"); + while let Some(separator) = pending.find("\n\n") { + let raw_event = pending[..separator + 2].to_string(); + let event = pending[..separator].to_string(); + pending.drain(..separator + 2); + if is_messages_terminal_sse_event(&event) && billing_result.is_none() { + billing_result = Some(settle_llm_router_usage( + &state, + owner_user_id.as_str(), + ).await); + if let Some(Err(error)) = billing_result.as_ref() { + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %error, + "LLM Router 流式响应已完成但累计额度同步未完成" + ); + } + } + yield Ok(Bytes::from(raw_event)); + } + } + Err(error) => { + if billing_result.is_none() { + if let Err(billing_error) = settle_llm_router_usage( + &state, + owner_user_id.as_str(), + ) + .await + { + tracing::error!( + request_id = %request_id, + user_id = %owner_user_id, + error = %billing_error, + "LLM Router 流式响应中断且累计额度同步未完成" + ); + } + } + yield Err(std::io::Error::other(format!( + "LLM Router 响应流读取失败:{error}" + ))); + return; + } + } + } + if !pending.is_empty() { + yield Ok(Bytes::from(pending)); + } + } +} + fn append_utf8_chunk(pending: &mut String, carry: &mut Vec, bytes: &[u8]) { carry.extend_from_slice(bytes); loop { @@ -1082,6 +1385,21 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage { LlmMessage::new(role, message.content) } +/// Composes the Router endpoint for one protocol operation. +/// +/// The account credential stored for the AGC official route is a bare gateway +/// base: it must not carry the version segment or the operation path, so the +/// protocol composes `v1/` itself. Historical rows still store a +/// trailing `/v1`, so it is normalised away first instead of being duplicated. +fn router_protocol_url(base_url: &str, operation: &str) -> String { + let base = base_url.trim().trim_end_matches('/'); + let base = base + .strip_suffix("/v1") + .unwrap_or(base) + .trim_end_matches('/'); + format!("{base}/v1/{operation}") +} + fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -1387,7 +1705,8 @@ mod tests { .expect("captured request lock") .clone() .expect("mock server should capture upstream request"); - assert!(upstream_request.starts_with("POST /responses HTTP/1.1")); + // baseUrl 不带路由与版本段,Responses 由协议自己拼 `v1/responses`。 + assert!(upstream_request.starts_with("POST /v1/responses HTTP/1.1")); assert!( upstream_request .lines() @@ -1657,6 +1976,145 @@ mod tests { assert!(carry.is_empty()); } + #[test] + fn router_protocol_url_composes_v1_without_duplicating_it() { + // baseUrl 不带路由与版本段,协议自己拼。 + assert_eq!( + router_protocol_url("https://router.example", "messages"), + "https://router.example/v1/messages" + ); + assert_eq!( + router_protocol_url("https://router.example", "responses"), + "https://router.example/v1/responses" + ); + // 历史凭据里带着 `/v1`,归一化掉而不是拼成 `/v1/v1/...`。 + assert_eq!( + router_protocol_url("https://router.example/v1", "messages"), + "https://router.example/v1/messages" + ); + assert_eq!( + router_protocol_url("https://router.example/v1/", "chat/completions"), + "https://router.example/v1/chat/completions" + ); + } + + #[tokio::test] + async fn llm_v1_responses_route_reaches_the_router_v1_path() { + let (server_url, captured) = spawn_capturing_mock_server(MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_v1_01","status":"completed","output":[]}"#.to_string(), + extra_headers: Vec::new(), + }); + let (state, user_id) = seed_authenticated_state(AppConfig { + llm_router_base_url: server_url.clone(), + ..AppConfig::default() + }) + .await; + install_test_provisioned_router_credential(&user_id, server_url, "router-key"); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/v1/responses") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1") + .body(Body::from( + json!({ "input": [{ "role": "user", "content": "ping" }] }).to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let captured_request = captured + .lock() + .expect("captured request lock should not poison") + .clone() + .expect("upstream request should be captured"); + assert!( + captured_request + .to_ascii_lowercase() + .starts_with("post /v1/responses"), + "canonical v1 route must reach the Router v1 path: {captured_request}" + ); + } + + #[tokio::test] + async fn llm_anthropic_messages_uses_account_router_credential() { + let (server_url, captured) = spawn_capturing_mock_server(MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"msg_api_server_01","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}]}"#.to_string(), + extra_headers: Vec::new(), + }); + let (state, user_id) = seed_authenticated_state(AppConfig { + llm_router_base_url: server_url.clone(), + ..AppConfig::default() + }) + .await; + install_test_provisioned_router_credential(&user_id, server_url, "router-key"); + let token = issue_access_token(&state, &user_id); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/llm/anthropic/v1/messages") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("anthropic-version", "2023-06-01") + .header("x-genarrative-client", "agc") + .body(Body::from( + json!({ + "max_tokens": 16, + "messages": [{ "role": "user", "content": "ping" }] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + assert!( + String::from_utf8_lossy(&body).contains("msg_api_server_01"), + "anthropic payload should pass through" + ); + + let captured_request = captured + .lock() + .expect("captured request lock should not poison") + .clone() + .expect("upstream request should be captured"); + let head = captured_request.to_ascii_lowercase(); + assert!( + head.starts_with("post /v1/messages"), + "upstream must receive the Anthropic path: {captured_request}" + ); + assert!( + head.contains("x-api-key: router-key"), + "upstream must receive the account Router key: {captured_request}" + ); + assert!( + !head.contains(&token.to_ascii_lowercase()), + "platform access token must never reach the Router" + ); + } + fn spawn_mock_server(responses: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index d457b795e..4cfbda348 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -9,7 +9,7 @@ use crate::{ auth::require_bearer_auth, llm::{ LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions, - proxy_llm_responses, + proxy_llm_messages, proxy_llm_responses, }, state::AppState, volcengine_speech::{ @@ -36,6 +36,15 @@ pub fn router(state: AppState) -> Router { )) .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) + .route( + "/api/llm/v1/chat/completions", + 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) @@ -45,6 +54,27 @@ pub fn router(state: AppState) -> Router { )) .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), ) + .route( + "/api/llm/v1/responses", + post(proxy_llm_responses) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)), + ) + // Anthropic 客户端(Claude Agent SDK / Claude Code CLI)固定请求 + // `{ANTHROPIC_BASE_URL}/v1/messages`,所以这里只声明 Anthropic 自己的 + // 路由前缀,尾段交给上游协议本身,网关不自己发明版本段。 + .route( + "/api/llm/anthropic/{*path}", + post(proxy_llm_messages) + .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", get(get_volcengine_speech_config).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 9a02ec3ea..ee48803b9 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -602,6 +602,15 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec("llm_request", "llm", User, "anonymous")), + ("POST", "/api/llm/v1/responses") => { + Some(route_spec("llm_request", "llm", User, "anonymous")) + } + ("POST", "/api/llm/v1/chat/completions") => { + Some(route_spec("llm_request", "llm", User, "anonymous")) + } + ("POST", "/api/llm/anthropic/v1/messages") => { + Some(route_spec("llm_request", "llm", User, "anonymous")) + } ("GET", "/api/speech/volcengine/config") => Some(route_spec( "speech_config_view", "speech", @@ -946,6 +955,7 @@ fn is_known_static_route_segment(segment: &str) -> bool { matches!( segment, "ai" | "analytics" + | "anthropic" | "api" | "api-keys" | "asr" @@ -988,11 +998,13 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "library" | "llm" | "login" + | "messages" | "optimizations" | "login-options" | "logout" | "logout-all" | "me" + | "responses" | "metric" | "objects" | "orders" @@ -1616,6 +1628,35 @@ mod tests { ); } + #[test] + fn llm_gateway_protocol_paths_keep_their_tracking_specs() { + // 协议路径段必须保持静态,否则归一化会把 Anthropic 路由打成 + // `/api/llm/{id}/v1/{id}`,埋点永远匹配不上。 + assert_eq!( + normalize_route_path("/api/llm/anthropic/v1/messages"), + "/api/llm/anthropic/v1/messages" + ); + assert_eq!( + normalize_route_path("/api/llm/v1/responses"), + "/api/llm/v1/responses" + ); + assert_eq!( + normalize_route_path("/api/llm/v1/chat/completions"), + "/api/llm/v1/chat/completions" + ); + + for path in [ + "/api/llm/v1/responses", + "/api/llm/v1/chat/completions", + "/api/llm/anthropic/v1/messages", + ] { + let spec = resolve_route_tracking_spec(&Method::POST, path) + .unwrap_or_else(|| panic!("{path} should keep the llm_request spec")); + assert_eq!(spec.event_key, "llm_request"); + assert_eq!(spec.module_key, "llm"); + } + } + #[test] fn active_runtime_settings_routes_keep_tracking_specs() { for (method, event_key) in [ diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 16cb030a8..e4ff1c5fb 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -17,6 +17,9 @@ pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; pub enum AgcAgentMode { Codex, Cc, + /// 显式 Anthropic Messages 协议。`cc` 是同一执行器的历史别名, + /// 新目录项应直接写 `anthropic`。 + Anthropic, } impl Default for AgcAgentMode { @@ -30,6 +33,7 @@ impl AgcAgentMode { match self { Self::Codex => "codex", Self::Cc => "cc", + Self::Anthropic => "anthropic", } } @@ -37,7 +41,8 @@ impl AgcAgentMode { match value { "codex" => Ok(Self::Codex), "cc" => Ok(Self::Cc), - _ => Err("Agent 模式只允许 codex 或 cc".into()), + "anthropic" => Ok(Self::Anthropic), + _ => Err("Agent 模式只允许 codex、cc 或 anthropic".into()), } } } @@ -340,13 +345,21 @@ mod tests { } #[test] - fn catalog_agent_mode_accepts_codex_and_cc_only() { + fn catalog_agent_mode_accepts_codex_cc_and_anthropic_only() { let mut catalog = AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); catalog.models[0].agent_mode = AgcAgentMode::Cc; assert!(catalog.validate().is_ok()); assert_eq!(catalog.models[0].agent_mode.as_str(), "cc"); + catalog.models[0].agent_mode = AgcAgentMode::Anthropic; + assert!(catalog.validate().is_ok()); + assert_eq!(catalog.models[0].agent_mode.as_str(), "anthropic"); + assert_eq!( + AgcAgentMode::parse("anthropic").unwrap(), + AgcAgentMode::Anthropic + ); + let invalid: Result = serde_json::from_str( r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#, );