AGC 支持 Anthropic 独立网关路由与后台协议选项
新增平台 Anthropic 网关路由 /api/llm/anthropic/{*path},复用账号 Router 凭据边界
OpenAI 侧规范路径改为 /api/llm/v1/responses 与 /api/llm/v1/chat/completions,旧路径保留兼容
baseUrl 不再携带路由与版本段,统一由协议自己拼 v1/<op>
cc 执行器改走平台网关,ANTHROPIC_AUTH_TOKEN 复用平台会话,不再直连 Router
后台模型目录 agentMode 增加 anthropic,管理页与客户端契约同步
补齐三条路由埋点与静态段白名单,新增三条回归测试
同步后端架构文档、模型别名文档与排障记忆
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -226,6 +226,7 @@ export function AdminAgcModelsPage({
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
<option value="anthropic">Anthropic 协议</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
|
||||
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option<String> {
|
||||
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('/')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/<op>`。
|
||||
let proxy_base_url = format!("{api_base_url}/api/llm/v1");
|
||||
LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
proxy_base_url,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 <path>`,后来卡满 `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/<op>`(服务端 `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 返回失败终态;界面只显示“执行通道未能建立或已断开”。
|
||||
|
||||
@@ -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 兼容。
|
||||
|
||||
@@ -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/*`。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<AppState> {
|
||||
))
|
||||
.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<AppState> {
|
||||
))
|
||||
.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(
|
||||
|
||||
@@ -602,6 +602,15 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrack
|
||||
Some(route_spec("llm_request", "llm", User, "anonymous"))
|
||||
}
|
||||
("POST", "/api/llm/responses") => 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 [
|
||||
|
||||
@@ -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<AgcModelCatalog, _> = serde_json::from_str(
|
||||
r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user