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"}]}"#,
);
|