diff --git a/README.md b/README.md index fe350c3..7fc4047 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ quota inspection, usage records, model aliases, and agent client configuration i The application is built with Tauri, React, and Rust. It can carry a matching CLIProxyAPI core archive, making first-time setup and offline installation easier. +## Sponsor + +[![https://go.apimart.ai/gh-easycliproxyapi](./assets/apimart-en.png)](https://go.apimart.ai/gh-easycliproxyapi) + +Thanks to APIMart for sponsoring this project! + +APIMart is a low-cost API platform for AI image & video generation — GPT-Image-2 from $0.006/image, 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, fetch results via polling or callback. Batch tens of thousands of images without timeouts, switch models without changing code. Pay-as-you-go with no monthly fee — [sign up here](https://go.apimart.ai/gh-easycliproxyapi) to get started. + ## Feature Tour ### Home dashboard and local API endpoints diff --git a/README.zh-CN.md b/README.zh-CN.md index 34745c5..103adae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,6 +24,14 @@ EasyCLIProxyAPI 是基于 [CLIProxyAPI](https://github.com/router-for-me/CLIProx 软件基于 Tauri、React 和 Rust 构建,并可携带匹配版本的 CLIProxyAPI 内核压缩包, 让首次安装和离线安装更加方便。 +## 赞助商 + +[![https://go.apimart.ai/gh-easycliproxyapi](./assets/apimart-zh.png)](https://go.apimart.ai/gh-easycliproxyapi) + +感谢 APIMart 对本项目的赞助! + +APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image-2 每张图片低至 0.006 美元,1 美元可生成 160 多张图片。一个异步 API 即可处理图像和视频:提交任务、获取任务 ID,再通过轮询或回调获取结果。无需担心超时,即可批量生成数万张图片;切换模型也无需修改代码。按量付费,无月费——[立即注册](https://go.apimart.ai/gh-easycliproxyapi)即可开始使用。 + ## 功能导览 ### 首页与本地 API 地址 diff --git a/assets/apimart-en.png b/assets/apimart-en.png new file mode 100644 index 0000000..0b8749e Binary files /dev/null and b/assets/apimart-en.png differ diff --git a/assets/apimart-zh.png b/assets/apimart-zh.png new file mode 100644 index 0000000..e6f0148 Binary files /dev/null and b/assets/apimart-zh.png differ diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 54c4c62..a51d24e 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -391,6 +391,26 @@ pub(crate) async fn get_thinking_aliases( thinking_aliases_from_yaml(&content) } +#[tauri::command] +pub(crate) async fn get_model_alias_sources( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + let content = fetch_management_config_yaml(&config).await?; + let available_models = + fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; + let definitions = fetch_oauth_model_definitions(&config).await; + Ok(resolved_oauth_alias_sources( + &content, + &definitions, + &available_models, + AliasSourceCapability::Base, + )? + .into_iter() + .map(|resolved| resolved.source) + .collect()) +} + #[tauri::command] pub(crate) async fn get_thinking_alias_sources( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -399,15 +419,16 @@ pub(crate) async fn get_thinking_alias_sources( let content = fetch_management_config_yaml(&config).await?; let available_models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; - let definitions = fetch_codex_model_definitions(&config) - .await - .unwrap_or_default(); - Ok( - resolved_thinking_alias_sources(&content, &definitions, &available_models)? - .into_iter() - .map(|resolved| resolved.source) - .collect(), - ) + let definitions = fetch_oauth_model_definitions(&config).await; + Ok(resolved_oauth_alias_sources( + &content, + &definitions, + &available_models, + AliasSourceCapability::Reasoning, + )? + .into_iter() + .map(|resolved| resolved.source) + .collect()) } #[tauri::command] @@ -433,14 +454,16 @@ pub(crate) async fn create_thinking_alias( let content = fetch_management_config_yaml(&config).await?; let available_models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; - let definitions = fetch_codex_model_definitions(&config) - .await - .unwrap_or_default(); - let sources = if effort.is_empty() { - resolved_alias_sources(&content, &definitions, &available_models, false)? + let definitions = fetch_oauth_model_definitions(&config).await; + let capability = if !effort.is_empty() { + AliasSourceCapability::Reasoning + } else if fast { + AliasSourceCapability::Fast } else { - resolved_thinking_alias_sources(&content, &definitions, &available_models)? + AliasSourceCapability::Base }; + let sources = + resolved_oauth_alias_sources(&content, &definitions, &available_models, capability)?; let source = sources .iter() .find(|source| source.source.id == source_id) @@ -468,7 +491,7 @@ pub(crate) async fn create_thinking_alias( } let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?; - put_management_config_yaml(&config, &updated).await?; + put_management_alias_config_changes(&config, &content, &updated).await?; thinking_aliases_from_yaml(&updated) } @@ -476,12 +499,14 @@ pub(crate) async fn create_thinking_alias( pub(crate) async fn delete_thinking_alias( gui_config_state: tauri::State<'_, GuiConfigState>, alias: String, + oauth_channel: Option, ) -> Result, String> { let config = gui_config_state.snapshot()?; let alias = validate_thinking_alias_model_id(&alias, "别名模型")?; let content = fetch_management_config_yaml(&config).await?; - let updated = remove_thinking_alias_from_yaml(&content, &alias)?; - put_management_config_yaml(&config, &updated).await?; + let updated = + remove_thinking_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?; + put_management_alias_config_changes(&config, &content, &updated).await?; thinking_aliases_from_yaml(&updated) } @@ -502,15 +527,16 @@ pub(crate) async fn get_speed_alias_sources( let content = fetch_management_config_yaml(&config).await?; let available_models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; - let definitions = fetch_codex_model_definitions(&config) - .await - .unwrap_or_default(); - Ok( - resolved_speed_alias_sources(&content, &definitions, &available_models)? - .into_iter() - .map(|resolved| resolved.source) - .collect(), - ) + let definitions = fetch_oauth_model_definitions(&config).await; + Ok(resolved_oauth_alias_sources( + &content, + &definitions, + &available_models, + AliasSourceCapability::Fast, + )? + .into_iter() + .map(|resolved| resolved.source) + .collect()) } #[tauri::command] @@ -528,10 +554,13 @@ pub(crate) async fn create_speed_alias( let content = fetch_management_config_yaml(&config).await?; let available_models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?; - let definitions = fetch_codex_model_definitions(&config) - .await - .unwrap_or_default(); - let sources = resolved_speed_alias_sources(&content, &definitions, &available_models)?; + let definitions = fetch_oauth_model_definitions(&config).await; + let sources = resolved_oauth_alias_sources( + &content, + &definitions, + &available_models, + AliasSourceCapability::Fast, + )?; let source = sources .iter() .find(|source| source.source.id == source_id) @@ -558,7 +587,7 @@ pub(crate) async fn create_speed_alias( } let updated = add_speed_alias_to_yaml(&content, &source, &alias)?; - put_management_config_yaml(&config, &updated).await?; + put_management_alias_config_changes(&config, &content, &updated).await?; speed_aliases_from_yaml(&updated) } @@ -566,12 +595,14 @@ pub(crate) async fn create_speed_alias( pub(crate) async fn delete_speed_alias( gui_config_state: tauri::State<'_, GuiConfigState>, alias: String, + oauth_channel: Option, ) -> Result, String> { let config = gui_config_state.snapshot()?; let alias = validate_thinking_alias_model_id(&alias, "别名模型")?; let content = fetch_management_config_yaml(&config).await?; - let updated = remove_speed_alias_from_yaml(&content, &alias)?; - put_management_config_yaml(&config, &updated).await?; + let updated = + remove_speed_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?; + put_management_alias_config_changes(&config, &content, &updated).await?; speed_aliases_from_yaml(&updated) } diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 9b73191..5e95583 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -1,5 +1,179 @@ use super::*; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OAuthAliasChannel { + pub(crate) key: &'static str, + pub(crate) provider: &'static str, + pub(crate) kind: &'static str, + pub(crate) protocol: &'static str, + pub(crate) supports_reasoning: bool, + pub(crate) supports_fast: bool, + pub(crate) force_mapping: bool, +} + +pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [ + OAuthAliasChannel { + key: "vertex", + provider: "Vertex OAuth", + kind: "vertex-oauth", + protocol: "gemini", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, + OAuthAliasChannel { + key: "aistudio", + provider: "AI Studio OAuth", + kind: "aistudio-oauth", + protocol: "gemini", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, + OAuthAliasChannel { + key: "antigravity", + provider: "Antigravity OAuth", + kind: "antigravity-oauth", + protocol: "antigravity", + supports_reasoning: false, + supports_fast: false, + force_mapping: true, + }, + OAuthAliasChannel { + key: "claude", + provider: "Claude OAuth", + kind: "claude-oauth", + protocol: "claude", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, + OAuthAliasChannel { + key: "codex", + provider: "Codex OAuth", + kind: "codex-oauth", + protocol: "codex", + supports_reasoning: true, + supports_fast: true, + force_mapping: false, + }, + OAuthAliasChannel { + key: "kimi", + provider: "Kimi OAuth", + kind: "kimi-oauth", + protocol: "openai", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, + OAuthAliasChannel { + key: "xai", + provider: "xAI OAuth", + kind: "xai-oauth", + protocol: "openai", + supports_reasoning: false, + supports_fast: false, + force_mapping: false, + }, +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OAuthModelDefinitions { + pub(crate) channel: OAuthAliasChannel, + pub(crate) models: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AliasSourceCapability { + Base, + Reasoning, + Fast, +} + +pub(crate) fn oauth_alias_channel(channel: &str) -> Option { + OAUTH_ALIAS_CHANNELS + .iter() + .copied() + .find(|candidate| candidate.key.eq_ignore_ascii_case(channel)) +} + +pub(crate) fn oauth_alias_channel_details(channel: &str) -> (String, String, String) { + oauth_alias_channel(channel) + .map(|details| { + ( + details.provider.to_string(), + details.kind.to_string(), + details.protocol.to_string(), + ) + }) + .unwrap_or_else(|| { + let channel = channel.trim().to_ascii_lowercase(); + ( + format!("{channel} OAuth"), + format!("{channel}-oauth"), + channel, + ) + }) +} + +pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str> { + let value = value.trim().to_ascii_lowercase().replace('_', "-"); + match value.as_str() { + "vertex" | "vertex-ai" => Some("vertex"), + "aistudio" | "ai-studio" | "gemini" | "gemini-cli" => Some("aistudio"), + "antigravity" | "anti-gravity" => Some("antigravity"), + "claude" | "anthropic" => Some("claude"), + "codex" => Some("codex"), + "kimi" | "moonshot" => Some("kimi"), + "xai" | "x-ai" | "grok" => Some("xai"), + _ => None, + } +} + +pub(crate) async fn fetch_active_oauth_alias_channels( + config: &GuiConfigFile, +) -> Result, String> { + let client = management_http_client()?; + let response = client + .get(management_endpoint(config, "auth-files")?) + .header("Authorization", management_authorization(config)?) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|error| format!("读取 OAuth 凭据来源失败: {error}"))?; + let payload = read_management_value(response).await?; + let files = payload + .get("files") + .and_then(serde_json::Value::as_array) + .or_else(|| payload.as_array()) + .ok_or_else(|| "OAuth 凭据来源响应缺少 files 数组".to_string())?; + Ok(files + .iter() + .filter(|file| { + !file + .get("disabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && !file + .get("unavailable") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|file| { + ["provider", "type"] + .into_iter() + .find_map(|key| { + file.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .and_then(normalize_oauth_alias_channel) + }) + .map(str::to_string) + .collect()) +} + pub(crate) fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result { let value = value.trim(); if value.is_empty() { @@ -557,25 +731,60 @@ pub(crate) fn append_managed_codex_oauth_model_alias( pub(crate) async fn fetch_codex_model_definitions( config: &GuiConfigFile, +) -> Result, String> { + fetch_oauth_channel_model_definitions(config, "codex").await +} + +pub(crate) async fn fetch_oauth_channel_model_definitions( + config: &GuiConfigFile, + channel: &str, ) -> Result, String> { let client = management_http_client()?; let response = client - .get(management_endpoint(config, "model-definitions/codex")?) + .get(management_endpoint( + config, + &format!("model-definitions/{channel}"), + )?) .header("Authorization", management_authorization(config)?) .header(reqwest::header::ACCEPT, "application/json") .send() .await - .map_err(|error| format!("读取 Codex OAuth 模型定义失败: {error}"))?; + .map_err(|error| format!("读取 {channel} OAuth 模型定义失败: {error}"))?; let payload = read_management_value(response).await?; parse_codex_model_definitions(&payload) } +pub(crate) async fn fetch_oauth_model_definitions( + config: &GuiConfigFile, +) -> Vec { + let active_channels = fetch_active_oauth_alias_channels(config).await.ok(); + let mut definitions = Vec::new(); + for channel in OAUTH_ALIAS_CHANNELS { + if active_channels + .as_ref() + .is_some_and(|active| !active.contains(channel.key)) + { + continue; + } + if let Ok(models) = fetch_oauth_channel_model_definitions(config, channel.key).await { + definitions.push(OAuthModelDefinitions { channel, models }); + } + } + definitions +} + pub(crate) fn resolved_thinking_alias_sources( content: &str, definitions: &[CodexModelDefinition], available_models: &[AgentModelOption], ) -> Result, String> { - resolved_alias_sources(content, definitions, available_models, true) + let definitions = codex_oauth_definition_set(definitions); + resolved_oauth_alias_sources( + content, + &definitions, + available_models, + AliasSourceCapability::Reasoning, + ) } pub(crate) fn resolved_speed_alias_sources( @@ -583,7 +792,13 @@ pub(crate) fn resolved_speed_alias_sources( definitions: &[CodexModelDefinition], available_models: &[AgentModelOption], ) -> Result, String> { - resolved_alias_sources(content, definitions, available_models, false) + let definitions = codex_oauth_definition_set(definitions); + resolved_oauth_alias_sources( + content, + &definitions, + available_models, + AliasSourceCapability::Fast, + ) } pub(crate) fn resolved_alias_sources( @@ -591,6 +806,38 @@ pub(crate) fn resolved_alias_sources( definitions: &[CodexModelDefinition], available_models: &[AgentModelOption], require_reasoning_levels: bool, +) -> Result, String> { + let definitions = codex_oauth_definition_set(definitions); + resolved_oauth_alias_sources( + content, + &definitions, + available_models, + if require_reasoning_levels { + AliasSourceCapability::Reasoning + } else { + AliasSourceCapability::Base + }, + ) +} + +pub(crate) fn codex_oauth_definition_set( + definitions: &[CodexModelDefinition], +) -> Vec { + vec![OAuthModelDefinitions { + channel: OAUTH_ALIAS_CHANNELS + .iter() + .copied() + .find(|channel| channel.key == "codex") + .expect("codex OAuth alias channel must exist"), + models: definitions.to_vec(), + }] +} + +pub(crate) fn resolved_oauth_alias_sources( + content: &str, + definition_sets: &[OAuthModelDefinitions], + available_models: &[AgentModelOption], + capability: AliasSourceCapability, ) -> Result, String> { let document = serde_norway::from_str::(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -621,31 +868,54 @@ pub(crate) fn resolved_alias_sources( available_models, &mut sources, )?; + if capability == AliasSourceCapability::Fast { + sources.retain(|source| model_supports_fast(&source.source.model)); + } let configured_codex_api_models = sources .iter() .filter(|source| source.source.kind == "codex-api") .map(|source| source.source.model.to_ascii_lowercase()) .collect::>(); - sources.extend( - definitions - .iter() - .filter(|definition| { - (!require_reasoning_levels || !definition.reasoning_levels.is_empty()) - && thinking_alias_model_is_available(available_models, &definition.id) - && !configured_codex_api_models.contains(&definition.id.to_ascii_lowercase()) - }) - .map(|definition| ResolvedThinkingAliasSource { - source: ThinkingAliasSource { - id: format!("codex-oauth:{}", definition.id), - model: definition.id.clone(), - display_name: definition.display_name.clone(), - provider: "Codex OAuth".to_string(), - kind: "codex-oauth".to_string(), - protocol: "codex".to_string(), - }, - location: ThinkingAliasSourceLocation::CodexOauth, - }), - ); + for definition_set in definition_sets { + let channel = definition_set.channel; + let supports_capability = match capability { + AliasSourceCapability::Base => true, + AliasSourceCapability::Reasoning => channel.supports_reasoning, + AliasSourceCapability::Fast => channel.supports_fast, + }; + if !supports_capability { + continue; + } + sources.extend( + definition_set + .models + .iter() + .filter(|definition| { + (capability != AliasSourceCapability::Reasoning + || !definition.reasoning_levels.is_empty()) + && (capability != AliasSourceCapability::Fast + || model_supports_fast(&definition.id)) + && thinking_alias_model_is_available(available_models, &definition.id) + && (channel.key != "codex" + || !configured_codex_api_models + .contains(&definition.id.to_ascii_lowercase())) + }) + .map(|definition| ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: format!("{}:{}", channel.kind, definition.id), + model: definition.id.clone(), + display_name: definition.display_name.clone(), + provider: channel.provider.to_string(), + kind: channel.kind.to_string(), + protocol: channel.protocol.to_string(), + }, + location: ThinkingAliasSourceLocation::Oauth { + channel: channel.key, + force_mapping: channel.force_mapping, + }, + }), + ); + } Ok(sources) } @@ -658,6 +928,21 @@ pub(crate) fn thinking_alias_model_is_available( .any(|available| available.name.eq_ignore_ascii_case(model)) } +pub(crate) fn model_supports_fast(model: &str) -> bool { + let model = model.trim().to_ascii_lowercase(); + model == "gpt" || model.starts_with("gpt-") +} + +pub(crate) fn alias_source_supports_fast(source: &ResolvedThinkingAliasSource) -> bool { + model_supports_fast(&source.source.model) + && match &source.location { + ThinkingAliasSourceLocation::Oauth { channel, .. } => *channel == "codex", + ThinkingAliasSourceLocation::ConfigModel { section, .. } => { + matches!(*section, "codex-api-key" | "openai-compatibility") + } + } +} + pub(crate) fn collect_config_thinking_alias_sources( root: &serde_norway::Mapping, section: &'static str, @@ -788,20 +1073,16 @@ pub(crate) fn thinking_aliases_from_value( let oauth_aliases = oauth_aliases .as_mapping() .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - if let Some(codex_aliases) = yaml_mapping_value(oauth_aliases, "codex") { - let codex_aliases = codex_aliases + for (channel, channel_aliases) in oauth_aliases { + let channel = channel.as_str().unwrap_or("unknown"); + let channel_aliases = channel_aliases .as_sequence() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; - for entry in codex_aliases { + .ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?; + let (provider, kind, protocol) = oauth_alias_channel_details(channel); + for entry in channel_aliases { let Some(mapping) = entry.as_mapping() else { continue; }; - if !matches!( - yaml_mapping_value(mapping, "fork"), - Some(serde_norway::Value::Bool(true)) - ) { - continue; - } let Some(source_model) = yaml_mapping_value(mapping, "name") .and_then(serde_norway::Value::as_str) .map(str::trim) @@ -819,9 +1100,10 @@ pub(crate) fn thinking_aliases_from_value( entries.push(ThinkingAliasEntry { source_model: source_model.to_string(), alias: alias.to_string(), - effort: find_thinking_alias_effort(root, alias, "codex"), - provider: "Codex OAuth".to_string(), - kind: "codex-oauth".to_string(), + effort: find_thinking_alias_effort(root, alias, &protocol), + provider: provider.clone(), + kind: kind.clone(), + oauth_channel: Some(channel.to_string()), }); } } @@ -872,20 +1154,16 @@ pub(crate) fn speed_aliases_from_value( let oauth_aliases = oauth_aliases .as_mapping() .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - if let Some(codex_aliases) = yaml_mapping_value(oauth_aliases, "codex") { - let codex_aliases = codex_aliases + for (channel, channel_aliases) in oauth_aliases { + let channel = channel.as_str().unwrap_or("unknown"); + let channel_aliases = channel_aliases .as_sequence() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; - for entry in codex_aliases { + .ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?; + let (provider, kind, protocol) = oauth_alias_channel_details(channel); + for entry in channel_aliases { let Some(mapping) = entry.as_mapping() else { continue; }; - if !matches!( - yaml_mapping_value(mapping, "fork"), - Some(serde_norway::Value::Bool(true)) - ) { - continue; - } let Some(source_model) = yaml_mapping_value(mapping, "name") .and_then(serde_norway::Value::as_str) .map(str::trim) @@ -900,15 +1178,17 @@ pub(crate) fn speed_aliases_from_value( else { continue; }; - let Some(service_tier) = find_speed_alias_service_tier(root, alias, "codex") else { + let Some(service_tier) = find_speed_alias_service_tier(root, alias, &protocol) + else { continue; }; entries.push(SpeedAliasEntry { source_model: source_model.to_string(), alias: alias.to_string(), service_tier, - provider: "Codex OAuth".to_string(), - kind: "codex-oauth".to_string(), + provider: provider.clone(), + kind: kind.clone(), + oauth_channel: Some(channel.to_string()), }); } } @@ -985,6 +1265,7 @@ pub(crate) fn collect_config_thinking_alias_entries( effort, provider: provider_name.clone(), kind: kind.to_string(), + oauth_channel: None, }); } } @@ -1033,6 +1314,7 @@ pub(crate) fn collect_config_speed_alias_entries( service_tier, provider: provider_name.clone(), kind: kind.to_string(), + oauth_channel: None, }); } } @@ -1138,6 +1420,11 @@ pub(crate) fn add_model_alias_to_yaml( effort: &str, fast: bool, ) -> Result { + if fast && !alias_source_supports_fast(source) { + return Err( + "Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 的 GPT 系列模型".to_string(), + ); + } let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let mut updated = document.get().clone(); @@ -1150,9 +1437,10 @@ pub(crate) fn add_model_alias_to_yaml( } match &source.location { - ThinkingAliasSourceLocation::CodexOauth => { - append_codex_oauth_model_alias(root, &source.source.model, alias)?; - } + ThinkingAliasSourceLocation::Oauth { + channel, + force_mapping, + } => append_oauth_model_alias(root, channel, &source.source.model, alias, *force_mapping)?, ThinkingAliasSourceLocation::ConfigModel { section, provider_index, @@ -1241,6 +1529,11 @@ pub(crate) fn add_speed_alias_to_yaml( source: &ResolvedThinkingAliasSource, alias: &str, ) -> Result { + if !alias_source_supports_fast(source) { + return Err( + "Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 的 GPT 系列模型".to_string(), + ); + } let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let mut updated = document.get().clone(); @@ -1253,9 +1546,10 @@ pub(crate) fn add_speed_alias_to_yaml( } match &source.location { - ThinkingAliasSourceLocation::CodexOauth => { - append_codex_oauth_model_alias(root, &source.source.model, alias)?; - } + ThinkingAliasSourceLocation::Oauth { + channel, + force_mapping, + } => append_oauth_model_alias(root, channel, &source.source.model, alias, *force_mapping)?, ThinkingAliasSourceLocation::ConfigModel { section, provider_index, @@ -1310,21 +1604,23 @@ pub(crate) fn add_speed_alias_to_yaml( render_updated_core_yaml(&mut document, updated) } -pub(crate) fn append_codex_oauth_model_alias( +pub(crate) fn append_oauth_model_alias( root: &mut serde_norway::Mapping, + channel: &str, source_model: &str, alias: &str, + force_mapping: bool, ) -> Result<(), String> { let oauth_aliases = root .entry(yaml_key("oauth-model-alias")) .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) .as_mapping_mut() .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - let codex_aliases = oauth_aliases - .entry(yaml_key("codex")) + let channel_aliases = oauth_aliases + .entry(yaml_key(channel)) .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) .as_sequence_mut() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + .ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?; let mut alias_mapping = serde_norway::Mapping::new(); alias_mapping.insert( yaml_key("name"), @@ -1335,7 +1631,10 @@ pub(crate) fn append_codex_oauth_model_alias( serde_norway::Value::String(alias.to_string()), ); alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); - codex_aliases.push(serde_norway::Value::Mapping(alias_mapping)); + if force_mapping { + alias_mapping.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true)); + } + channel_aliases.push(serde_norway::Value::Mapping(alias_mapping)); Ok(()) } @@ -1462,6 +1761,14 @@ pub(crate) fn append_config_speed_alias( pub(crate) fn remove_thinking_alias_from_yaml( content: &str, alias: &str, +) -> Result { + remove_thinking_alias_from_yaml_for_channel(content, alias, None) +} + +pub(crate) fn remove_thinking_alias_from_yaml_for_channel( + content: &str, + alias: &str, + oauth_channel: Option<&str>, ) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -1469,92 +1776,90 @@ pub(crate) fn remove_thinking_alias_from_yaml( let root = updated .as_mapping_mut() .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; - let mut removed = false; - let mut remove_oauth_section = false; - if let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") { - let oauth_aliases = oauth_aliases - .as_mapping_mut() - .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - if let Some(codex_aliases) = yaml_mapping_value_mut(oauth_aliases, "codex") { - let codex_aliases = codex_aliases - .as_sequence_mut() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; - codex_aliases.retain(|entry| { - let matches = entry - .as_mapping() - .and_then(|mapping| yaml_mapping_value(mapping, "alias")) - .and_then(serde_norway::Value::as_str) - .is_some_and(|value| value.trim().eq_ignore_ascii_case(alias)); - if matches { - removed = true; - } - !matches - }); - if codex_aliases.is_empty() { - oauth_aliases.remove(yaml_key("codex")); - } - } - remove_oauth_section = oauth_aliases.is_empty(); + let mut removed = remove_oauth_model_alias(root, alias, oauth_channel)?; + if oauth_channel.is_none() { + removed |= remove_config_thinking_alias(root, "codex-api-key", "codex", alias)?; + removed |= remove_config_thinking_alias(root, "openai-compatibility", "openai", alias)?; } - removed |= remove_config_thinking_alias(root, "codex-api-key", "codex", alias)?; - removed |= remove_config_thinking_alias(root, "openai-compatibility", "openai", alias)?; if !removed { return Err(format!("别名模型 {alias} 不存在,请刷新后重试")); } - if remove_oauth_section { - root.remove(yaml_key("oauth-model-alias")); - } remove_thinking_payload_model(root, alias)?; remove_speed_payload_model(root, alias)?; render_updated_core_yaml(&mut document, updated) } pub(crate) fn remove_speed_alias_from_yaml(content: &str, alias: &str) -> Result { + remove_speed_alias_from_yaml_for_channel(content, alias, None) +} + +pub(crate) fn remove_speed_alias_from_yaml_for_channel( + content: &str, + alias: &str, + oauth_channel: Option<&str>, +) -> Result { let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let mut updated = document.get().clone(); let root = updated .as_mapping_mut() .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; - let mut removed = false; - let mut remove_oauth_section = false; - if let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") { - let oauth_aliases = oauth_aliases - .as_mapping_mut() - .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; - if let Some(codex_aliases) = yaml_mapping_value_mut(oauth_aliases, "codex") { - let codex_aliases = codex_aliases - .as_sequence_mut() - .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; - codex_aliases.retain(|entry| { - let matches = entry - .as_mapping() - .and_then(|mapping| yaml_mapping_value(mapping, "alias")) - .and_then(serde_norway::Value::as_str) - .is_some_and(|value| value.trim().eq_ignore_ascii_case(alias)); - if matches { - removed = true; - } - !matches - }); - if codex_aliases.is_empty() { - oauth_aliases.remove(yaml_key("codex")); - } - } - remove_oauth_section = oauth_aliases.is_empty(); + let mut removed = remove_oauth_model_alias(root, alias, oauth_channel)?; + if oauth_channel.is_none() { + removed |= remove_config_speed_alias(root, "codex-api-key", "codex", alias)?; + removed |= remove_config_speed_alias(root, "openai-compatibility", "openai", alias)?; } - removed |= remove_config_speed_alias(root, "codex-api-key", "codex", alias)?; - removed |= remove_config_speed_alias(root, "openai-compatibility", "openai", alias)?; if !removed { return Err(format!("别名模型 {alias} 不存在,请刷新后重试")); } - if remove_oauth_section { - root.remove(yaml_key("oauth-model-alias")); - } remove_speed_payload_model(root, alias)?; render_updated_core_yaml(&mut document, updated) } +pub(crate) fn remove_oauth_model_alias( + root: &mut serde_norway::Mapping, + alias: &str, + target_channel: Option<&str>, +) -> Result { + let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") else { + return Ok(false); + }; + let oauth_aliases = oauth_aliases + .as_mapping_mut() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + let mut removed = false; + let mut empty_channels = Vec::new(); + for (channel, entries) in oauth_aliases.iter_mut() { + let channel_name = channel.as_str().unwrap_or("unknown"); + if target_channel.is_some_and(|target| !target.eq_ignore_ascii_case(channel_name)) { + continue; + } + let entries = entries + .as_sequence_mut() + .ok_or_else(|| format!("oauth-model-alias.{channel_name} 必须是数组"))?; + entries.retain(|entry| { + let matches = entry + .as_mapping() + .and_then(|mapping| yaml_mapping_value(mapping, "alias")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(alias)); + removed |= matches; + !matches + }); + if entries.is_empty() { + empty_channels.push(channel.clone()); + } + } + for channel in empty_channels { + oauth_aliases.remove(&channel); + } + let remove_section = oauth_aliases.is_empty(); + if remove_section { + root.remove(yaml_key("oauth-model-alias")); + } + Ok(removed) +} + pub(crate) fn configured_model_alias_exists(root: &serde_norway::Mapping, alias: &str) -> bool { let oauth_exists = yaml_mapping_value(root, "oauth-model-alias") .and_then(serde_norway::Value::as_mapping) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 99160ad..a37612a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -893,6 +893,7 @@ struct ThinkingAliasEntry { effort: Option, provider: String, kind: String, + oauth_channel: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -903,6 +904,7 @@ struct SpeedAliasEntry { service_tier: String, provider: String, kind: String, + oauth_channel: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -918,7 +920,10 @@ struct ThinkingAliasSource { #[derive(Clone, Debug, PartialEq, Eq)] enum ThinkingAliasSourceLocation { - CodexOauth, + Oauth { + channel: &'static str, + force_mapping: bool, + }, ConfigModel { section: &'static str, provider_index: usize, @@ -2039,6 +2044,7 @@ fn main() { check_codex_oauth_login, update_codex_model_catalog, get_thinking_aliases, + get_model_alias_sources, get_thinking_alias_sources, create_thinking_alias, delete_thinking_alias, diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index 463960f..6348b6c 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -1,6 +1,142 @@ use super::support::*; use super::*; +fn test_oauth_definition_set(channel: &str, models: &[&str]) -> OAuthModelDefinitions { + OAuthModelDefinitions { + channel: oauth_alias_channel(channel).unwrap(), + models: models + .iter() + .map(|model| CodexModelDefinition { + id: (*model).to_string(), + display_name: None, + description: None, + context_window: None, + reasoning_levels: Vec::new(), + supports_tools: None, + }) + .collect(), + } +} + +#[test] +fn oauth_auth_provider_names_map_to_alias_channels() { + assert_eq!( + normalize_oauth_alias_channel("anti-gravity"), + Some("antigravity") + ); + assert_eq!(normalize_oauth_alias_channel("anthropic"), Some("claude")); + assert_eq!( + normalize_oauth_alias_channel("gemini-cli"), + Some("aistudio") + ); + assert_eq!(normalize_oauth_alias_channel("grok"), Some("xai")); +} + +#[test] +fn antigravity_alias_uses_its_own_oauth_channel_and_force_mapping() { + let available_models = test_agent_models(&["gemini-pro-agent"]); + let definitions = vec![test_oauth_definition_set( + "antigravity", + &["gemini-pro-agent"], + )]; + let sources = resolved_oauth_alias_sources( + "{}\n", + &definitions, + &available_models, + AliasSourceCapability::Base, + ) + .unwrap(); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source.kind, "antigravity-oauth"); + let rendered = + add_model_alias_to_yaml("{}\n", &sources[0], "gemini-3.1-pro-preview", "", false).unwrap(); + + assert!(rendered.contains("antigravity:"), "{rendered}"); + assert!(rendered.contains("name: gemini-pro-agent"), "{rendered}"); + assert!( + rendered.contains("alias: gemini-3.1-pro-preview"), + "{rendered}" + ); + assert!(rendered.contains("force-mapping: true"), "{rendered}"); + assert!(!rendered.contains("codex:"), "{rendered}"); +} + +#[test] +fn oauth_alias_reader_and_delete_preserve_the_exact_channel() { + let input = "oauth-model-alias:\n antigravity:\n - name: gemini-pro-agent\n alias: shared-alias\n force-mapping: true\n codex:\n - name: gpt-5.5\n alias: shared-alias\n fork: true\n"; + let entries = thinking_aliases_from_yaml(input).unwrap(); + + assert_eq!(entries.len(), 2); + assert!(entries.iter().any(|entry| { + entry.oauth_channel.as_deref() == Some("antigravity") && entry.kind == "antigravity-oauth" + })); + let rendered = + remove_thinking_alias_from_yaml_for_channel(input, "shared-alias", Some("antigravity")) + .unwrap(); + + assert!(!rendered.contains("antigravity:"), "{rendered}"); + assert!(rendered.contains("codex:"), "{rendered}"); + assert!(rendered.contains("name: gpt-5.5"), "{rendered}"); +} + +#[test] +fn fast_is_only_available_for_gpt_models_from_supported_sources() { + let input = "openai-compatibility:\n - name: Relay\n base-url: https://example.com/v1\n models:\n - name: gpt-test\n - name: deepseek-chat\n"; + let available_models = test_agent_models(&["gpt-test", "deepseek-chat"]); + let sources = + resolved_oauth_alias_sources(input, &[], &available_models, AliasSourceCapability::Fast) + .unwrap(); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source.model, "gpt-test"); + + let deepseek_source = ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: "test:deepseek-chat".to_string(), + model: "deepseek-chat".to_string(), + display_name: None, + provider: "Relay".to_string(), + kind: "openai-compatible".to_string(), + protocol: "openai".to_string(), + }, + location: ThinkingAliasSourceLocation::ConfigModel { + section: "openai-compatibility", + provider_index: 0, + model_index: 1, + }, + }; + assert!(add_speed_alias_to_yaml(input, &deepseek_source, "deepseek-fast").is_err()); + assert!( + add_model_alias_to_yaml(input, &deepseek_source, "deepseek-fast", "high", true).is_err() + ); + + let antigravity_source = ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: "antigravity-oauth:gpt-test".to_string(), + model: "gpt-test".to_string(), + display_name: None, + provider: "Antigravity OAuth".to_string(), + kind: "antigravity-oauth".to_string(), + protocol: "antigravity".to_string(), + }, + location: ThinkingAliasSourceLocation::Oauth { + channel: "antigravity", + force_mapping: true, + }, + }; + let antigravity_fast_sources = resolved_oauth_alias_sources( + "{}\n", + &[test_oauth_definition_set("antigravity", &["gpt-test"])], + &test_agent_models(&["gpt-test"]), + AliasSourceCapability::Fast, + ) + .unwrap(); + assert!(antigravity_fast_sources.is_empty()); + assert!(add_speed_alias_to_yaml("{}\n", &antigravity_source, "gpt-fast").is_err()); + assert!(add_model_alias_to_yaml("{}\n", &antigravity_source, "gpt-fast", "", true).is_err()); +} + #[test] fn thinking_alias_adds_fork_and_matching_payload_rule() { let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n"; @@ -19,6 +155,7 @@ fn thinking_alias_adds_fork_and_matching_payload_rule() { effort: Some("xhigh".to_string()), provider: "Codex OAuth".to_string(), kind: "codex-oauth".to_string(), + oauth_channel: Some("codex".to_string()), }] ); } @@ -38,6 +175,7 @@ fn model_alias_can_be_created_without_overrides() { effort: None, provider: "Codex OAuth".to_string(), kind: "codex-oauth".to_string(), + oauth_channel: Some("codex".to_string()), }] ); } @@ -99,6 +237,7 @@ fn speed_alias_adds_fast_service_tier_and_removes_only_its_rule() { service_tier: "priority".to_string(), provider: "Codex OAuth".to_string(), kind: "codex-oauth".to_string(), + oauth_channel: Some("codex".to_string()), }] ); diff --git a/src-tauri/src/tests/support.rs b/src-tauri/src/tests/support.rs index be3396d..cb243e4 100644 --- a/src-tauri/src/tests/support.rs +++ b/src-tauri/src/tests/support.rs @@ -43,6 +43,9 @@ pub(super) fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingA kind: "codex-oauth".to_string(), protocol: "codex".to_string(), }, - location: ThinkingAliasSourceLocation::CodexOauth, + location: ThinkingAliasSourceLocation::Oauth { + channel: "codex", + force_mapping: false, + }, } } diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 3714ca1..5876b4d 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -28,6 +28,7 @@ type ThinkingAliasEntry = { effort: string | null; provider: string; kind: string; + oauthChannel?: string | null; }; type SpeedAliasEntry = { @@ -36,6 +37,7 @@ type SpeedAliasEntry = { serviceTier: string; provider: string; kind: string; + oauthChannel?: string | null; }; type AliasListEntry = ThinkingAliasEntry & { @@ -69,8 +71,8 @@ export const combineModelAliasEntries = ( speedEntries: SpeedAliasEntry[], ): AliasListEntry[] => { const entries = new Map(); - const entryKey = (entry: Pick) => ( - [entry.kind, entry.provider, entry.sourceModel, entry.alias] + const entryKey = (entry: Pick) => ( + [entry.oauthChannel ?? '', entry.kind, entry.provider, entry.sourceModel, entry.alias] .map((value) => value.toLocaleLowerCase()) .join('\u0000') ); @@ -93,13 +95,14 @@ export const combineModelAliasEntries = ( }; export const combineModelAliasSources = ( + baseSources: ThinkingAliasSource[], thinkingSources: ThinkingAliasSource[], speedSources: ThinkingAliasSource[], ): ModelAliasSource[] => { const reasoningSourceIds = new Set(thinkingSources.map((source) => source.id)); const speedSourceIds = new Set(speedSources.map((source) => source.id)); const sources = new Map(); - [...speedSources, ...thinkingSources].forEach((source) => { + [...baseSources, ...speedSources, ...thinkingSources].forEach((source) => { sources.set(source.id, { ...source, supportsReasoning: reasoningSourceIds.has(source.id), @@ -145,6 +148,12 @@ export const uniqueModelAlias = ( export const thinkingAliasSourceKindLabel = (kind: string) => { if (kind === 'codex-oauth') return 'Codex OAuth'; + if (kind === 'antigravity-oauth') return 'Antigravity OAuth'; + if (kind === 'claude-oauth') return 'Claude OAuth'; + if (kind === 'aistudio-oauth') return 'AI Studio OAuth'; + if (kind === 'vertex-oauth') return 'Vertex OAuth'; + if (kind === 'kimi-oauth') return 'Kimi OAuth'; + if (kind === 'xai-oauth') return 'xAI OAuth'; if (kind === 'codex-api') return 'Codex API'; if (kind === 'openai-compatible') return translate(getCurrentLocale(), 'aliases.source.openAiCompatible'); return translate(getCurrentLocale(), 'aliases.source.other'); @@ -164,6 +173,7 @@ export function ThinkingAliasesPage() { const { t } = useI18n(); const [thinkingEntries, setThinkingEntries] = useState([]); const [speedEntries, setSpeedEntries] = useState([]); + const [baseSources, setBaseSources] = useState([]); const [thinkingSources, setThinkingSources] = useState([]); const [speedSources, setSpeedSources] = useState([]); const [selectedSourceId, setSelectedSourceId] = useState(''); @@ -186,18 +196,20 @@ export function ThinkingAliasesPage() { setLoading(true); setError(''); try { - const [nextThinkingEntries, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([ + const [nextThinkingEntries, nextBaseSources, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([ invoke('get_thinking_aliases'), + invoke('get_model_alias_sources'), invoke('get_thinking_alias_sources'), invoke('get_speed_aliases'), invoke('get_speed_alias_sources'), ]); setThinkingEntries(nextThinkingEntries); + setBaseSources(nextBaseSources); setThinkingSources(nextThinkingSources); setSpeedEntries(nextSpeedEntries); setSpeedSources(nextSpeedSources); setSelectedSourceId((current) => ( - [...nextThinkingSources, ...nextSpeedSources].some((source) => source.id === current) + nextBaseSources.some((source) => source.id === current) ? current : '' )); @@ -225,8 +237,8 @@ export function ThinkingAliasesPage() { }, [modelPickerOpen]); const sources = useMemo( - () => combineModelAliasSources(thinkingSources, speedSources), - [speedSources, thinkingSources], + () => combineModelAliasSources(baseSources, thinkingSources, speedSources), + [baseSources, speedSources, thinkingSources], ); const entries = useMemo( () => combineModelAliasEntries(thinkingEntries, speedEntries), @@ -408,10 +420,12 @@ export function ThinkingAliasesPage() { if (entry.effort || !entry.serviceTier) { await invoke('delete_thinking_alias', { alias: entry.alias, + oauthChannel: entry.oauthChannel, }); } else { await invoke('delete_speed_alias', { alias: entry.alias, + oauthChannel: entry.oauthChannel, }); } setNotice(t('aliases.deleted', { alias: entry.alias })); diff --git a/tests/thinkingAliases.test.ts b/tests/thinkingAliases.test.ts index 40c190a..9750830 100644 --- a/tests/thinkingAliases.test.ts +++ b/tests/thinkingAliases.test.ts @@ -85,6 +85,18 @@ describe('统一模型别名列表', () => { protocol: 'codex', }; const sources = combineModelAliasSources( + [ + { ...baseSource, id: 'reasoning-and-fast' }, + { ...baseSource, id: 'fast-only', model: 'gpt-speed-only' }, + { + ...baseSource, + id: 'plain-only', + model: 'gemini-pro-agent', + provider: 'Antigravity OAuth', + kind: 'antigravity-oauth', + protocol: 'antigravity', + }, + ], [{ ...baseSource, id: 'reasoning-and-fast' }], [ { ...baseSource, id: 'reasoning-and-fast' }, @@ -92,10 +104,12 @@ describe('统一模型别名列表', () => { ], ); - expect(sources).toHaveLength(2); + expect(sources).toHaveLength(3); expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsReasoning).toBe(true); expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsFast).toBe(true); expect(sources.find((source) => source.id === 'fast-only')?.supportsReasoning).toBe(false); expect(sources.find((source) => source.id === 'fast-only')?.supportsFast).toBe(true); + expect(sources.find((source) => source.id === 'plain-only')?.supportsReasoning).toBe(false); + expect(sources.find((source) => source.id === 'plain-only')?.supportsFast).toBe(false); }); });