merge latest master
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 2m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m56s
Project CI / Frontend tests (pull_request) Successful in 3m43s
Project CI / Backend tests (pull_request) Failing after 5m12s
Project CI / Repository checks (pull_request) Successful in 3m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Native shell tests (pull_request) Successful in 8m33s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 2m39s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m56s
Project CI / Frontend tests (pull_request) Successful in 3m43s
Project CI / Backend tests (pull_request) Failing after 5m12s
Project CI / Repository checks (pull_request) Successful in 3m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / Native shell tests (pull_request) Successful in 8m33s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
<option value="anthropic">Anthropic 协议</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} 协议`}
|
||||
value={model.protocol ?? 'openai_responses'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
protocol: e.target.value as AdminAgcModel['protocol'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai_responses">OpenAI Responses</option>
|
||||
<option value="openai_chat">OpenAI Chat Completions</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner(
|
||||
model_id: String,
|
||||
is_default: bool,
|
||||
catalog_agent_mode: Option<String>,
|
||||
catalog_protocol: Option<String>,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
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<String>,
|
||||
protocol: Option<String>,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile {
|
||||
selected_model_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_is_default: Option<bool>,
|
||||
/// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_model_protocol: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean>;
|
||||
@@ -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<GameCreatorAppConfigView>('select_game_creator_model', {
|
||||
modelId: id,
|
||||
isDefault: false,
|
||||
agentMode,
|
||||
protocol,
|
||||
}),
|
||||
);
|
||||
if (result.config.selectedModelId !== id)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -282,6 +282,7 @@ export function registerDesignAgentSurfaceTests() {
|
||||
modelId: 'fast',
|
||||
isDefault: false,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
|
||||
@@ -149,6 +149,7 @@ export function registerClientHomeTests() {
|
||||
modelId: 'fast',
|
||||
isDefault: false,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
}),
|
||||
);
|
||||
expect(modelTrigger.textContent).toContain('快速');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 兼容。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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::<Result<Vec<_>, 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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<AgcModelProtocol>,
|
||||
}
|
||||
|
||||
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<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"}]}"#,
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user