diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c3a9e7256..1f7ffeec2 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse { export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } -export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic'; +export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcModelProtocol = + | 'openai_responses' + | 'openai_chat' + | 'anthropic'; export interface AdminAgcModel { id: string; @@ -1083,6 +1087,8 @@ export interface AdminAgcModel { modelId: string; enabled: boolean; agentMode: AdminAgcAgentMode; + /** 上游线协议,与执行器正交。 */ + protocol?: AdminAgcModelProtocol; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index 77b9262bb..612d21826 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -104,3 +104,49 @@ test('new models default to Codex and save the selected Agent mode', async () => }); }); }); + +test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'claude-opus-5-5', + enabled: true, + agentMode: 'cc' as const, + protocol: 'anthropic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('claude-opus-5-5'); + + const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement; + const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement; + expect(agentMode.value).toBe('cc'); + expect(protocol.value).toBe('anthropic'); + // 执行器下拉里不再混入协议取值。 + expect( + Array.from(agentMode.options).map((option) => option.value), + ).toEqual(['codex', 'cc']); + + fireEvent.change(agentMode, { target: { value: 'codex' } }); + fireEvent.change(protocol, { target: { value: 'openai_chat' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ + agentMode: 'codex', + protocol: 'openai_chat', + }); + }); +}); diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index cab805cf7..b009aa222 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -117,6 +117,7 @@ export function AdminAgcModelsPage({ { key: 'alias', label: '别名' }, { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, + { key: 'protocol', label: '协议' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -157,6 +158,7 @@ export function AdminAgcModelsPage({ modelId: '', enabled: true, agentMode: 'codex', + protocol: 'openai_responses', }, ], }, @@ -226,7 +228,22 @@ export function AdminAgcModelsPage({ > - + + + + 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 d81e102aa..3abf9ef84 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 @@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command( ) } +/// Codex 的 `wire_api` 取自所选模型的线协议。 +/// +/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定 +/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。 +fn game_creator_codex_provider_wire_api() -> &'static str { + #[cfg(test)] + { + "responses" + } + #[cfg(not(test))] + { + match crate::config::load_game_creator_app_config() + .ok() + .and_then(|config| config.selected_model_protocol) + .as_deref() + { + Some("openai_chat") => "chat", + _ => "responses", + } + } +} + fn configure_game_creator_codex_app_server_command_for_mode( command: &mut tokio::process::Command, llm: &GameCreatorLlmConfig, @@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode( let provider_token = provider_proxy .map(CodexProviderProxy::downstream_bearer_token) .unwrap_or(legacy_api_key); + // 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions, + // 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。 + let wire_api = game_creator_codex_provider_wire_api(); command .arg("-c") .arg(format!( @@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode( .arg("-c") .arg(format!( "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}", - quoted_toml_string("responses")? + quoted_toml_string(wire_api)? )) .env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token); } 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 9be6df9bb..565de0461 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner( model_id: String, is_default: bool, catalog_agent_mode: Option, + catalog_protocol: Option, ) -> Result { let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() @@ -1098,13 +1099,34 @@ 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` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。 - "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), + "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), value => return Err(format!("模型 Agent 模式无效:{value}")), }; } + // 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。 + let protocol = match catalog_protocol.as_deref().map(str::trim) { + Some("openai_responses") => "openai_responses", + Some("openai_chat") => "openai_chat", + Some("anthropic") => "anthropic", + Some("") | None => { + if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI { + "anthropic" + } else { + "openai_responses" + } + } + Some(value) => return Err(format!("模型协议无效:{value}")), + }; + let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI; + if claude != (protocol == "anthropic") { + return Err(format!( + "执行器与协议组合无效:{} 不支持 {protocol}", + config.agent_mode + )); + } config.selected_model_id = model_id; config.selected_model_is_default = is_default; + config.selected_model_protocol = Some(protocol.to_string()); persist_game_creator_app_config(config, overlays, true) } @@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model( model_id: String, is_default: bool, agent_mode: Option, + protocol: Option, ) -> Result { - select_game_creator_model_inner(model_id, is_default, agent_mode) + select_game_creator_model_inner(model_id, is_default, agent_mode, protocol) } fn persist_game_creator_app_config( 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 6eb153861..2eaf16112 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4037,6 +4037,9 @@ fn merge_game_creator_config_content( if let Some(selected_model_is_default) = file_config.selected_model_is_default { config.selected_model_is_default = selected_model_is_default; } + if let Some(selected_model_protocol) = file_config.selected_model_protocol { + config.selected_model_protocol = Some(selected_model_protocol); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 0944e4f9f..7e89cb64f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String { "codex".to_string() } +fn default_model_protocol() -> String { + "openai_responses".to_string() +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct WireModelSummary { @@ -23,6 +27,9 @@ struct WireModelSummary { display_name: String, #[serde(default = "default_codex_agent_mode")] agent_mode: String, + /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 + #[serde(default = "default_model_protocol")] + protocol: String, } #[derive(Debug, Deserialize)] @@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { id: model.id, display_name: model.display_name, agent_mode: model.agent_mode, + protocol: model.protocol, }) .collect(), revision: catalog.revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ea1b8dc56..befa993db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile { selected_model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] selected_model_is_default: Option, + /// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。 + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_model_protocol: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -873,6 +876,9 @@ struct GameCreatorAppConfig { selected_model_id: String, #[serde(default)] selected_model_is_default: bool, + /// 所选模型的上游线协议;缺省按执行器推导。 + #[serde(default)] + selected_model_protocol: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1384,6 +1390,7 @@ impl Default for GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig::default(), selected_model_id: String::new(), selected_model_is_default: false, + selected_model_protocol: None, } } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index b7b44234b..2bc9df75c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -19,6 +19,22 @@ import { refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +/** + * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 + */ +function resolveModelProtocol( + model: { agentMode?: 'codex' | 'cc'; protocol?: string } | undefined, +): 'openai_responses' | 'openai_chat' | 'anthropic' { + if ( + model?.protocol === 'openai_responses' || + model?.protocol === 'openai_chat' || + model?.protocol === 'anthropic' + ) { + return model.protocol; + } + return model?.agentMode === 'cc' ? 'anthropic' : 'openai_responses'; +} + export type ConversationModelSelectHandle = { /** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */ ensureUsable: () => Promise; @@ -174,6 +190,9 @@ export function ConversationModelSelect({ agentMode: catalog.models.find((model) => model.id === next)?.agentMode ?? 'codex', + protocol: resolveModelProtocol( + catalog.models.find((model) => model.id === next), + ), }), ); } catch { @@ -341,11 +360,15 @@ export function ConversationModelSelect({ if (!invoke) throw new Error('Native host unavailable'); const agentMode = models.find((model) => model.id === id)?.agentMode ?? 'codex'; + const protocol = resolveModelProtocol( + models.find((model) => model.id === id), + ); const result = await queueConfigWrite(() => invoke('select_game_creator_model', { modelId: id, isDefault: false, agentMode, + protocol, }), ); if (result.config.selectedModelId !== id) diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 2221e5cfd..5c1bff3cc 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -3,7 +3,10 @@ import { resolveTauriInvoke } from '../app/tauri'; export type ClientLlmModel = { displayName: string; id: string; - agentMode: 'codex' | 'cc' | 'anthropic'; + /** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */ + agentMode: 'codex' | 'cc'; + /** 上游线协议;服务端未下发时按执行器的默认协议解释。 */ + protocol?: 'openai_responses' | 'openai_chat' | 'anthropic'; }; export type ClientLlmModelCatalog = { diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 15ec5f80d..d96c09714 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -282,6 +282,7 @@ export function registerDesignAgentSurfaceTests() { modelId: 'fast', isDefault: false, agentMode: 'codex', + protocol: 'openai_responses', }); expect( screen.getByRole('button', { name: '对话模型' }).textContent, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 912be89a7..c0c9a5327 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -149,6 +149,7 @@ export function registerClientHomeTests() { modelId: 'fast', isDefault: false, agentMode: 'codex', + protocol: 'openai_responses', }), ); expect(modelTrigger.textContent).toContain('快速'); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 9208b832b..c2a5c252f 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -130,6 +130,7 @@ test('custom mode only shows checked endpoint model IDs and never requests the p modelId: 'vendor/fast:latest', isDefault: false, agentMode: 'codex', + protocol: 'openai_responses', }); }); @@ -290,6 +291,7 @@ test('only displays aliases and persists selection through the native command', modelId: 'fast', isDefault: false, agentMode: 'cc', + protocol: 'anthropic', }), ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); @@ -320,6 +322,7 @@ test('falls back to the default model when the saved selection was removed', asy modelId: 'quality', isDefault: true, agentMode: 'codex', + protocol: 'openai_responses', }); expect( screen.getByRole('button', { name: '对话模型' }).textContent, @@ -529,6 +532,7 @@ test('pre-send validation falls back when the selected model is disabled', async modelId: 'quality', isDefault: true, agentMode: 'codex', + protocol: 'openai_responses', }); }); @@ -554,6 +558,7 @@ test('follows the new server default when the saved selection was the default', modelId: 'fast', isDefault: true, agentMode: 'cc', + protocol: 'anthropic', }); expect( screen.getByRole('button', { name: '对话模型' }).textContent, diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index 347575fbf..d14ae6087 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -55,8 +55,10 @@ ## 模型绑定 Agent 执行模式 -- 后台 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`。 +- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。 +- 2026-10-01 修正(原先误把协议塞进 `agentMode`):**执行器与线协议是两个正交维度**。目录新增独立字段 `protocol`,取值 `openai_responses` / `openai_chat` / `anthropic`;`agentMode` 只表示客户端启动哪个执行器。合法组合是封闭的——`codex` 只能用 `openai_responses` 或 `openai_chat`,`cc` 只能用 `anthropic`,其余组合在后台保存与目录加载时都失败关闭。存量目录缺少 `protocol` 时按执行器推导(codex → `openai_responses`,cc → `anthropic`);历史误写的 `agentMode: "anthropic"` 读取时归一到 `cc`。 +- 选路由协议决定:`openai_responses` → `/api/llm/v1/responses`,`openai_chat` → `/api/llm/v1/chat/completions`,`anthropic` → 独立路由 `/api/llm/anthropic/{*path}`。客户端把所选模型的协议与执行器一起持久化(`agentMode` + `selectedModelProtocol`),Codex 的 `wire_api` 按协议在 `responses` / `chat` 间切换。 +- 2026-10-01 补充:`cc` 一律经平台 LLM 网关的独立 Anthropic 路由,客户端 `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 fb31bb682..94f2a7a51 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -536,7 +536,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。 - `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录。 - 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug,`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。 -- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。 +- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态、默认项,以及**执行器 `agentMode` 与线协议 `protocol` 两个正交维度**:`agentMode` 只取 `codex` / `cc`,`protocol` 只取 `openai_responses` / `openai_chat` / `anthropic`,合法组合封闭(codex ⇒ responses/chat,cc ⇒ anthropic),非法组合在保存与加载两侧都失败关闭;存量目录缺 `protocol` 时按执行器推导。客户端 `GET /api/llm/models` 返回启用项的稳定标识、别名、`agentMode`、解析后的 `protocol` 和目录 `revision`(供条件刷新,不暴露实际模型名)。 - Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。 ### `error_report` diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index 391e32f5f..4875fec63 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -9,6 +9,7 @@ use axum::{ }; use module_runtime::{ AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelCatalog, + AgcModelProtocol, }; use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog}; use spacetime_client::SpacetimeClientError; @@ -235,12 +236,21 @@ pub async fn admin_save_agc_models( let agent_mode = AgcAgentMode::parse(&m.agent_mode).map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) })?; + let protocol = m + .protocol + .as_deref() + .map(AgcModelProtocol::parse) + .transpose() + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) + })?; Ok(module_runtime::AgcModel { id: m.id, alias: m.alias, model_id: m.model_id, enabled: m.enabled, agent_mode, + protocol, }) }) .collect::, AppError>>()?; @@ -277,12 +287,17 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { models: catalog .models .into_iter() - .map(|m| AdminAgcModel { - id: m.id, - alias: m.alias, - model_id: m.model_id, - enabled: m.enabled, - agent_mode: m.agent_mode.as_str().to_string(), + .map(|m| { + // 回读给出解析后的具体协议,后台不会看到空值。 + let protocol = m.resolved_protocol().as_str().to_string(); + AdminAgcModel { + id: m.id, + alias: m.alias, + model_id: m.model_id, + enabled: m.enabled, + agent_mode: m.agent_mode.as_str().to_string(), + protocol: Some(protocol), + } }) .collect(), } diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 3ff4dc75c..b97a8da5d 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -198,11 +198,15 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe .models .into_iter() .filter(|model| model.enabled) - .map(|model| LlmModelSummary { - id: model.id, - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias, - agent_mode: model.agent_mode.as_str().to_string(), + .map(|model| { + let protocol = model.resolved_protocol().as_str().to_string(); + LlmModelSummary { + id: model.id, + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias, + agent_mode: model.agent_mode.as_str().to_string(), + protocol, + } }) .collect(), revision: catalog.revision, diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index e4ff1c5fb..a6d406afc 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -16,10 +16,10 @@ pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; #[serde(rename_all = "lowercase")] pub enum AgcAgentMode { Codex, + /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; + /// 协议本身由 `AgcModel.protocol` 表达。 + #[serde(alias = "anthropic")] Cc, - /// 显式 Anthropic Messages 协议。`cc` 是同一执行器的历史别名, - /// 新目录项应直接写 `anthropic`。 - Anthropic, } impl Default for AgcAgentMode { @@ -33,7 +33,6 @@ impl AgcAgentMode { match self { Self::Codex => "codex", Self::Cc => "cc", - Self::Anthropic => "anthropic", } } @@ -41,8 +40,68 @@ impl AgcAgentMode { match value { "codex" => Ok(Self::Codex), "cc" => Ok(Self::Cc), + _ => Err("Agent 模式只允许 codex 或 cc".into()), + } + } + + /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: + /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 + /// Anthropic Messages。 + pub const fn supports(self, protocol: AgcModelProtocol) -> bool { + match self { + Self::Codex => matches!( + protocol, + AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat + ), + Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), + } + } + + /// 存量目录缺少协议字段时按执行器的默认线协议解释。 + pub const fn default_protocol(self) -> AgcModelProtocol { + match self { + Self::Codex => AgcModelProtocol::OpenAiResponses, + Self::Cc => AgcModelProtocol::Anthropic, + } + } +} + +/// 上游线协议,与执行器正交。 +/// +/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 +/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 +/// `/api/llm/anthropic/{*path}`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AgcModelProtocol { + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "anthropic")] + Anthropic, +} + +impl Default for AgcModelProtocol { + fn default() -> Self { + Self::OpenAiResponses + } +} + +impl AgcModelProtocol { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAiResponses => "openai_responses", + Self::OpenAiChat => "openai_chat", + Self::Anthropic => "anthropic", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "openai_responses" => Ok(Self::OpenAiResponses), + "openai_chat" => Ok(Self::OpenAiChat), "anthropic" => Ok(Self::Anthropic), - _ => Err("Agent 模式只允许 codex、cc 或 anthropic".into()), + _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), } } } @@ -57,6 +116,18 @@ pub struct AgcModel { /// 绑定 AGC 客户端实际启动的 Agent,存量目录缺失时兼容为 Codex。 #[serde(default)] pub agent_mode: AgcAgentMode, + /// 上游线协议。存量目录缺失时按 `agent_mode` 的默认协议解释; + /// 保存时必须与 `agent_mode` 组成合法组合。 + #[serde(default)] + pub protocol: Option, +} + +impl AgcModel { + /// 显式协议优先,缺失时回退到执行器的默认协议。 + pub fn resolved_protocol(&self) -> AgcModelProtocol { + self.protocol + .unwrap_or_else(|| self.agent_mode.default_protocol()) + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -97,6 +168,7 @@ impl AgcModelCatalog { model_id, enabled: true, agent_mode: AgcAgentMode::default(), + protocol: None, }); } let default_model_id = entries @@ -147,6 +219,14 @@ impl AgcModelCatalog { { return Err("实际模型名不能为空,长度不能超过 200 字节".into()); } + let protocol = model.resolved_protocol(); + if !model.agent_mode.supports(protocol) { + return Err(format!( + "Agent 模式 {} 不支持协议 {}:Codex 只能用 openai_responses / openai_chat,Claude Code 只能用 anthropic", + model.agent_mode.as_str(), + protocol.as_str() + )); + } } self.resolve(&self.default_model_id).map(|_| ()) } @@ -235,6 +315,7 @@ mod tests { model_id: model_id.into(), enabled: true, agent_mode: AgcAgentMode::default(), + protocol: None, } } @@ -345,20 +426,13 @@ mod tests { } #[test] - fn catalog_agent_mode_accepts_codex_cc_and_anthropic_only() { + fn catalog_agent_mode_rejects_values_outside_codex_and_cc() { 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 - ); + assert!(AgcAgentMode::parse("anthropic").is_err()); 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"}]}"#, @@ -366,6 +440,47 @@ mod tests { assert!(invalid.is_err()); } + #[test] + fn protocol_is_orthogonal_to_agent_mode_and_pairs_are_closed() { + // 协议缺省按执行器推导:codex -> openai_responses,cc -> anthropic。 + let mut catalog = + AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); + assert_eq!( + catalog.models[0].resolved_protocol(), + AgcModelProtocol::OpenAiResponses + ); + catalog.models[0].agent_mode = AgcAgentMode::Cc; + assert_eq!( + catalog.models[0].resolved_protocol(), + AgcModelProtocol::Anthropic + ); + assert_eq!( + AgcModelProtocol::parse("anthropic").unwrap().as_str(), + "anthropic" + ); + + // codex 支持两种 OpenAI 线协议。 + catalog.models[0].agent_mode = AgcAgentMode::Codex; + catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat); + assert!(catalog.validate().is_ok()); + catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiResponses); + assert!(catalog.validate().is_ok()); + + // 非法组合必须失败关闭:codex + anthropic。 + catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic); + let error = catalog + .validate() + .expect_err("codex must reject the anthropic protocol"); + assert!(error.contains("不支持协议"), "{error}"); + + // cc 只支持 anthropic。 + catalog.models[0].agent_mode = AgcAgentMode::Cc; + catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat); + assert!(catalog.validate().is_err()); + catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic); + assert!(catalog.validate().is_ok()); + } + #[test] fn catalog_rejects_duplicate_aliases_and_ids() { let mut catalog = diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 0309a831d..ad9d7af64 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1663,6 +1663,10 @@ pub struct AdminAgcModel { /// 旧后台请求缺失时按 Codex 兼容。 #[serde(default = "default_codex_agent_mode")] pub agent_mode: String, + /// 上游线协议,与 `agent_mode` 正交。缺省时按 `agent_mode` 的默认协议解释, + /// 服务端回读时始终给出解析后的具体值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index 0b958f23f..a02708cf7 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -65,7 +65,10 @@ mod tests { pub struct LlmModelSummary { pub display_name: String, pub id: String, + /// 执行器:`codex` 或 `cc`。 pub agent_mode: String, + /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。 + pub protocol: String, } #[derive(Debug, serde::Serialize)]