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

This commit is contained in:
2026-10-02 04:17:43 +08:00
20 changed files with 346 additions and 35 deletions
+7 -1
View File
@@ -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,