From cff6a21974f7e104c8ffdb37567fc6617166f0f2 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 3 Sep 2026 02:37:42 +0800 Subject: [PATCH] feat(model-aliases): restore Fast service-tier option - enable Fast for Codex API, OpenAI-compatible, and Codex OAuth sources - apply service_tier=priority as an independent payload override - support Fast-only and reasoning-effort combinations - disable Fast until a supported model source is selected --- src-tauri/src/agents/commands.rs | 9 +- src-tauri/src/core_config/aliases.rs | 98 +++++------ src-tauri/src/tests/agent_configuration.rs | 2 +- src-tauri/src/tests/model_aliases.rs | 113 ++++++++++-- src/i18n/locales/en.ts | 15 +- src/i18n/locales/zh-CN.ts | 15 +- src/pages/ThinkingAliasesPage.tsx | 189 +++++++++++++++++---- src/styles.css | 70 ++++++++ tests/thinkingAliases.test.ts | 44 ++++- 9 files changed, 443 insertions(+), 112 deletions(-) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 23b2337..e32f480 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -437,6 +437,7 @@ pub(crate) async fn create_thinking_alias( source_id: String, alias: String, effort: String, + fast: Option, ) -> Result, String> { let config = gui_config_state.snapshot()?; let source_id = source_id.trim().to_string(); @@ -449,12 +450,15 @@ pub(crate) async fn create_thinking_alias( } else { validate_thinking_alias_effort(&effort)? }; + let fast = fast.unwrap_or(false); 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; let capability = if !effort.is_empty() { AliasSourceCapability::Reasoning + } else if fast { + AliasSourceCapability::Fast } else { AliasSourceCapability::Base }; @@ -467,6 +471,9 @@ pub(crate) async fn create_thinking_alias( .ok_or_else(|| { "原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string() })?; + if fast && !alias_source_supports_fast(&source) { + return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string()); + } if !effort.is_empty() && !source .source @@ -498,7 +505,7 @@ pub(crate) async fn create_thinking_alias( return Err(format!("别名模型 {alias} 已存在")); } - let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort)?; + let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?; put_management_alias_config_changes(&config, &content, &updated).await?; thinking_aliases_from_yaml(&updated) } diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 3f98363..1f262b0 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -922,9 +922,7 @@ pub(crate) fn resolved_oauth_alias_sources( AliasSourceCapability::Reasoning => { sources.retain(|source| !source.source.reasoning_levels.is_empty()) } - AliasSourceCapability::Fast => { - sources.retain(|source| model_supports_fast(&source.source.model)) - } + AliasSourceCapability::Fast => sources.retain(alias_source_supports_fast), AliasSourceCapability::Base => {} } let configured_codex_api_models = sources @@ -949,8 +947,6 @@ pub(crate) fn resolved_oauth_alias_sources( .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 @@ -985,19 +981,13 @@ 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") - } + 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( @@ -1634,7 +1624,11 @@ pub(crate) fn add_model_alias_to_yaml( source: &ResolvedThinkingAliasSource, alias: &str, effort: &str, + fast: bool, ) -> Result { + if fast && !alias_source_supports_fast(source) { + return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string()); + } let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let mut updated = document.get().clone(); @@ -1667,8 +1661,32 @@ pub(crate) fn add_model_alias_to_yaml( } remove_thinking_payload_model(root, alias)?; - if effort.is_empty() { - return render_updated_core_yaml(&mut document, updated); + remove_speed_payload_model(root, alias)?; + if !effort.is_empty() { + let mut params_mapping = serde_norway::Mapping::new(); + insert_thinking_effort_params(&mut params_mapping, &source.source, effort)?; + append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?; + } + if fast { + let mut params_mapping = serde_norway::Mapping::new(); + params_mapping.insert( + yaml_key("service_tier"), + serde_norway::Value::String("priority".to_string()), + ); + append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?; + } + + render_updated_core_yaml(&mut document, updated) +} + +pub(crate) fn append_alias_payload_override( + root: &mut serde_norway::Mapping, + alias: &str, + protocol: &str, + params_mapping: serde_norway::Mapping, +) -> Result<(), String> { + if params_mapping.is_empty() { + return Ok(()); } let payload = root .entry(yaml_key("payload")) @@ -1688,12 +1706,8 @@ pub(crate) fn add_model_alias_to_yaml( ); model_mapping.insert( yaml_key("protocol"), - serde_norway::Value::String(source.source.protocol.clone()), + serde_norway::Value::String(protocol.to_string()), ); - let mut params_mapping = serde_norway::Mapping::new(); - if !effort.is_empty() { - insert_thinking_effort_params(&mut params_mapping, &source.source, effort)?; - } let mut rule_mapping = serde_norway::Mapping::new(); rule_mapping.insert( yaml_key("models"), @@ -1704,8 +1718,7 @@ pub(crate) fn add_model_alias_to_yaml( serde_norway::Value::Mapping(params_mapping), ); override_rules.push(serde_norway::Value::Mapping(rule_mapping)); - - render_updated_core_yaml(&mut document, updated) + Ok(()) } pub(crate) fn add_speed_alias_to_yaml( @@ -1714,9 +1727,7 @@ pub(crate) fn add_speed_alias_to_yaml( alias: &str, ) -> Result { if !alias_source_supports_fast(source) { - return Err( - "Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 的 GPT 系列模型".to_string(), - ); + return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string()); } let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; @@ -1749,41 +1760,12 @@ pub(crate) fn add_speed_alias_to_yaml( } remove_speed_payload_model(root, alias)?; - let payload = root - .entry(yaml_key("payload")) - .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) - .as_mapping_mut() - .ok_or_else(|| "payload 必须是 YAML 映射".to_string())?; - let override_rules = payload - .entry(yaml_key("override")) - .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) - .as_sequence_mut() - .ok_or_else(|| "payload.override 必须是数组".to_string())?; - - let mut model_mapping = serde_norway::Mapping::new(); - model_mapping.insert( - yaml_key("name"), - serde_norway::Value::String(alias.to_string()), - ); - model_mapping.insert( - yaml_key("protocol"), - serde_norway::Value::String(source.source.protocol.clone()), - ); let mut params_mapping = serde_norway::Mapping::new(); params_mapping.insert( yaml_key("service_tier"), serde_norway::Value::String("priority".to_string()), ); - let mut rule_mapping = serde_norway::Mapping::new(); - rule_mapping.insert( - yaml_key("models"), - serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(model_mapping)]), - ); - rule_mapping.insert( - yaml_key("params"), - serde_norway::Value::Mapping(params_mapping), - ); - override_rules.push(serde_norway::Value::Mapping(rule_mapping)); + append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?; render_updated_core_yaml(&mut document, updated) } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 76e5ef5..829ffb8 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -2214,7 +2214,7 @@ fn thinking_alias_prefers_codex_api_key_model_over_same_named_oauth_definition() )); let rendered = - add_model_alias_to_yaml(input, &sources[0], "gpt-5.6-luna-xhigh", "xhigh").unwrap(); + add_model_alias_to_yaml(input, &sources[0], "gpt-5.6-luna-xhigh", "xhigh", false).unwrap(); assert!(rendered.contains("alias: gpt-5.6-luna-xhigh"), "{rendered}"); assert!(!rendered.contains("oauth-model-alias"), "{rendered}"); } diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index 8a80e75..d65fcd8 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -153,7 +153,7 @@ fn thinking_alias_uses_source_native_override_parameters() { for (channel, model, effort, expected) in cases { let source = test_oauth_thinking_source(channel, model); let alias = format!("{model}-{effort}"); - let rendered = add_model_alias_to_yaml("{}\n", &source, &alias, effort).unwrap(); + let rendered = add_model_alias_to_yaml("{}\n", &source, &alias, effort, false).unwrap(); for parameter in expected { assert!( rendered.contains(parameter), @@ -187,7 +187,8 @@ fn configured_claude_and_gemini_models_use_native_overrides() { .iter() .find(|source| source.source.kind == "claude-api") .unwrap(); - let with_claude = add_model_alias_to_yaml(input, claude, "claude-fixed", "high").unwrap(); + let with_claude = + add_model_alias_to_yaml(input, claude, "claude-fixed", "high", false).unwrap(); assert!( with_claude.contains("output_config.effort: high"), "{with_claude}" @@ -204,7 +205,8 @@ fn configured_claude_and_gemini_models_use_native_overrides() { .iter() .find(|source| source.source.kind == "gemini-api") .unwrap(); - let rendered = add_model_alias_to_yaml(&with_claude, gemini, "gemini-fixed", "low").unwrap(); + let rendered = + add_model_alias_to_yaml(&with_claude, gemini, "gemini-fixed", "low", false).unwrap(); assert!( rendered.contains("generationConfig.thinkingConfig.thinkingLevel: low"), "{rendered}" @@ -260,7 +262,7 @@ fn antigravity_alias_uses_its_own_oauth_channel_and_force_mapping() { 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", "").unwrap(); + 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}"); @@ -291,15 +293,20 @@ fn oauth_alias_reader_and_delete_preserve_the_exact_channel() { } #[test] -fn fast_is_only_available_for_gpt_models_from_supported_sources() { +fn fast_is_only_available_for_supported_model_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"); + assert_eq!(sources.len(), 2); + assert!(sources + .iter() + .any(|source| source.source.model == "gpt-test")); + assert!(sources + .iter() + .any(|source| source.source.model == "deepseek-chat")); let deepseek_source = ResolvedThinkingAliasSource { source: ThinkingAliasSource { @@ -317,7 +324,11 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() { model_index: 1, }, }; - assert!(add_speed_alias_to_yaml(input, &deepseek_source, "deepseek-fast").is_err()); + let deepseek_fast = add_speed_alias_to_yaml(input, &deepseek_source, "deepseek-fast").unwrap(); + assert!( + deepseek_fast.contains("service_tier: priority"), + "{deepseek_fast}" + ); let antigravity_source = ResolvedThinkingAliasSource { source: ThinkingAliasSource { @@ -349,7 +360,8 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() { 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"; let source = test_codex_oauth_thinking_source("gpt-5.5"); - let rendered = add_model_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh").unwrap(); + let rendered = + add_model_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh", false).unwrap(); let aliases = thinking_aliases_from_yaml(&rendered).unwrap(); assert!(rendered.contains("# Keep this comment"), "{rendered}"); @@ -370,7 +382,7 @@ fn thinking_alias_adds_fork_and_matching_payload_rule() { #[test] fn model_alias_can_be_created_without_overrides() { let source = test_codex_oauth_thinking_source("gpt-5.5"); - let rendered = add_model_alias_to_yaml("{}\n", &source, "gpt-5.5-alias", "").unwrap(); + let rendered = add_model_alias_to_yaml("{}\n", &source, "gpt-5.5-alias", "", false).unwrap(); assert!(rendered.contains("alias: gpt-5.5-alias"), "{rendered}"); assert!(!rendered.contains("payload:"), "{rendered}"); @@ -396,7 +408,7 @@ fn configured_model_alias_can_be_created_without_overrides() { .iter() .find(|source| source.source.model == "gpt-custom") .unwrap(); - let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-alias", "").unwrap(); + let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-alias", "", false).unwrap(); assert!(rendered.contains("alias: gpt-custom-alias"), "{rendered}"); assert!(!rendered.contains("thinking:"), "{rendered}"); @@ -414,6 +426,57 @@ fn thinking_alias_removal_cleans_legacy_combined_rules() { assert!(!restored.contains("service_tier: priority"), "{restored}"); } +#[test] +fn model_alias_combines_reasoning_and_fast_as_independent_overrides() { + let source = test_codex_oauth_thinking_source("gpt-5.6-sol"); + let alias = "gpt-5.6-sol-high-fast"; + let rendered = add_model_alias_to_yaml("{}\n", &source, alias, "high", true).unwrap(); + + assert!(rendered.contains("reasoning.effort: high"), "{rendered}"); + assert!(rendered.contains("service_tier: priority"), "{rendered}"); + let document: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + let root = document.as_mapping().unwrap(); + let rules = nested_yaml_value(root, &["payload", "override"]) + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + let alias_rules = rules + .iter() + .filter_map(serde_norway::Value::as_mapping) + .filter(|rule| { + yaml_mapping_value(rule, "models") + .and_then(serde_norway::Value::as_sequence) + .is_some_and(|models| { + models + .iter() + .any(|model| thinking_payload_model_matches(model, alias, "codex")) + }) + }) + .collect::>(); + + assert_eq!(alias_rules.len(), 2, "{rendered}"); + assert!(alias_rules.iter().any(|rule| { + yaml_mapping_value(rule, "params") + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|params| { + yaml_mapping_value(params, "reasoning.effort").is_some() + && yaml_mapping_value(params, "service_tier").is_none() + }) + })); + assert!(alias_rules.iter().any(|rule| { + yaml_mapping_value(rule, "params") + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|params| { + yaml_mapping_value(params, "service_tier").is_some() + && yaml_mapping_value(params, "reasoning.effort").is_none() + }) + })); + assert_eq!(thinking_aliases_from_yaml(&rendered).unwrap().len(), 1); + assert_eq!(speed_aliases_from_yaml(&rendered).unwrap().len(), 1); + + let restored = remove_thinking_alias_from_yaml(&rendered, alias).unwrap(); + assert!(!restored.contains(alias), "{restored}"); +} + #[test] fn speed_alias_adds_fast_service_tier_and_removes_only_its_rule() { let input = "payload:\n override:\n - models:\n - name: existing-thinker\n protocol: codex\n params:\n reasoning.effort: xhigh\n"; @@ -517,7 +580,7 @@ fn thinking_alias_rejects_duplicate_client_visible_name() { let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-high\n fork: true\n"; let source = test_codex_oauth_thinking_source("gpt-5.4"); assert!( - add_model_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high") + add_model_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high", false) .unwrap_err() .contains("已存在") ); @@ -532,7 +595,8 @@ fn thinking_alias_supports_openai_compatible_model_entries() { .iter() .find(|source| source.source.model == "deepseek-chat") .unwrap(); - let rendered = add_model_alias_to_yaml(input, source, "deepseek-chat-high", "high").unwrap(); + let rendered = + add_model_alias_to_yaml(input, source, "deepseek-chat-high", "high", false).unwrap(); let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); let root = value.as_mapping().unwrap(); let providers = yaml_mapping_value(root, "openai-compatibility") @@ -576,7 +640,8 @@ fn thinking_alias_supports_codex_api_model_entries() { .iter() .find(|source| source.source.kind == "codex-api") .unwrap(); - let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh").unwrap(); + let rendered = + add_model_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh", false).unwrap(); assert!(rendered.contains("alias: gpt-custom-xhigh"), "{rendered}"); assert!(rendered.contains("protocol: codex"), "{rendered}"); @@ -586,3 +651,23 @@ fn thinking_alias_supports_codex_api_model_entries() { assert_eq!(entries.len(), 1); assert_eq!(entries[0].kind, "codex-api"); } + +#[test] +fn speed_alias_supports_codex_api_model_entries() { + let input = "codex-api-key:\n - api-key: test\n base-url: https://example.com/v1\n models:\n - name: codex-custom\n"; + let available_models = test_agent_models(&["codex-custom"]); + let sources = resolved_speed_alias_sources(input, &[], &available_models).unwrap(); + let source = sources + .iter() + .find(|source| source.source.kind == "codex-api") + .unwrap(); + let rendered = add_speed_alias_to_yaml(input, source, "codex-custom-fast").unwrap(); + + assert!(rendered.contains("alias: codex-custom-fast"), "{rendered}"); + assert!(rendered.contains("protocol: codex"), "{rendered}"); + assert!(rendered.contains("service_tier: priority"), "{rendered}"); + assert!(!rendered.contains("oauth-model-alias"), "{rendered}"); + let entries = speed_aliases_from_yaml(&rendered).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, "codex-api"); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 53533c8..499f6d6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -320,12 +320,14 @@ export const en: Record = { 'aliases.error.emptyAlias': 'The model alias cannot be empty', 'aliases.error.emptyEffort': 'The reasoning effort cannot be empty', 'aliases.error.unsupportedEffort': 'This model source has no available reasoning-effort levels', + 'aliases.error.unsupportedFast': 'Fast is only available for Codex API, OpenAI-compatible, and Codex OAuth model sources', 'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}', + 'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled', 'aliases.createdPlain': 'Created model alias {alias}', - 'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort override will also be removed.', + 'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.', 'aliases.deleted': 'Deleted {alias}', 'aliases.create.title': 'Create Model Alias', - 'aliases.create.description': 'Create a separate model entry and fix its reasoning effort for the selected source', + 'aliases.create.description': 'Create a separate model entry and override its reasoning effort and Fast request tier', 'aliases.originalModel': 'Original Model', 'aliases.loadingModels': 'Loading models', 'aliases.searchModel': 'Search and select an original model', @@ -339,6 +341,10 @@ export const en: Record = { 'aliases.effort.none': 'Not set', 'aliases.effort.noneHint': 'Do not override reasoning effort', 'aliases.effort.unsupported': 'This model source does not report any available reasoning-effort levels.', + 'aliases.fast.title': 'Fast Acceleration', + 'aliases.fast.enabled': 'Enabled; requests use the priority service tier', + 'aliases.fast.disabled': 'Disabled; preserve the client service_tier', + 'aliases.fast.unsupported': 'Fast is only available for Codex API, OpenAI-compatible, and Codex OAuth model sources.', 'aliases.aliasName.title': 'Model Alias', 'aliases.aliasName.description': 'Adds another entry point without renaming the original model', 'aliases.aliasName.autoDescription': 'Generated from the model and selected options; edit it as needed without renaming the original', @@ -350,12 +356,15 @@ export const en: Record = { 'aliases.creating': 'Creating', 'aliases.create': 'Create Alias', 'aliases.createdList.title': 'Created Aliases', - 'aliases.createdList.description': 'Each alias shows its fixed reasoning effort', + 'aliases.createdList.description': 'Each alias shows its fixed reasoning effort and Fast status', 'aliases.loadingConfig': 'Loading configuration', 'aliases.empty.title': 'No model aliases yet', 'aliases.empty.description': 'Select a model and alias option on the left to create one', 'aliases.unboundEffort': 'No effort bound', 'aliases.delete': 'Delete {alias}', + 'speedAliases.created': 'Created {alias} with the Fast request tier fixed', + 'speedAliases.fast.title': 'Fast', + 'speedAliases.aliasName.example': 'For example: {model}-fast', 'config.routing.roundRobin': 'Round Robin', 'config.routing.fillFirst': 'Fill First', 'config.tabs.label': 'Advanced settings sections', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index cdb553e..545335f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -318,12 +318,14 @@ export const zhCN = { 'aliases.error.emptyAlias': '别名模型不能为空', 'aliases.error.emptyEffort': '思考强度不能为空', 'aliases.error.unsupportedEffort': '该模型来源没有可用的思考强度等级', + 'aliases.error.unsupportedFast': 'Fast 仅支持 Codex API、OpenAI 兼容和 Codex OAuth 模型源', 'aliases.created': '已创建 {alias},思考强度固定为 {effort}', + 'aliases.createdCombined': '已创建 {alias},思考强度固定为 {effort},同时启用 Fast', 'aliases.createdPlain': '已创建别名 {alias}', - 'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n对应的思考强度覆写规则也会被移除。', + 'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n对应的思考强度和 Fast 覆写规则也会被移除。', 'aliases.deleted': '已删除 {alias}', 'aliases.create.title': '创建模型别名', - 'aliases.create.description': '为模型创建独立入口,并按模型来源固定思考强度', + 'aliases.create.description': '为模型创建独立入口,并按模型来源覆写思考强度和 Fast 请求层级', 'aliases.originalModel': '原模型', 'aliases.loadingModels': '正在读取模型', 'aliases.searchModel': '搜索并选择原模型', @@ -337,6 +339,10 @@ export const zhCN = { 'aliases.effort.none': '不设置', 'aliases.effort.noneHint': '不覆盖思考强度', 'aliases.effort.unsupported': '这个模型来源没有报告可用的思考强度等级。', + 'aliases.fast.title': 'Fast 加速', + 'aliases.fast.enabled': '已启用,固定使用 priority 请求层级', + 'aliases.fast.disabled': '未启用,保留客户端原始 service_tier', + 'aliases.fast.unsupported': '仅 Codex API、OpenAI 兼容和 Codex OAuth 模型源支持 Fast。', 'aliases.aliasName.title': '别名模型名称', 'aliases.aliasName.description': '由你填写,仅新增入口,不会改名原模型', 'aliases.aliasName.autoDescription': '根据原模型和已选选项自动生成,可按需修改;不会改名原模型', @@ -348,12 +354,15 @@ export const zhCN = { 'aliases.creating': '创建中', 'aliases.create': '创建别名', 'aliases.createdList.title': '已创建别名', - 'aliases.createdList.description': '每个别名会显示已固定的思考强度', + 'aliases.createdList.description': '每个别名会显示已固定的思考强度和 Fast 状态', 'aliases.loadingConfig': '读取配置中', 'aliases.empty.title': '尚未创建模型别名', 'aliases.empty.description': '从左侧选择模型和别名选项即可创建', 'aliases.unboundEffort': '未绑定强度', 'aliases.delete': '删除 {alias}', + 'speedAliases.created': '已创建 {alias},并固定使用 Fast 请求层级', + 'speedAliases.fast.title': 'Fast', + 'speedAliases.aliasName.example': '例如 {model}-fast', 'config.routing.roundRobin': '轮询', 'config.routing.fillFirst': '优先填充', 'config.tabs.label': '高级设置子页面', diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 02c6c30..35e6910 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -15,6 +15,7 @@ import { LoaderCircle, Search, Trash2, + Zap, } from 'lucide-react'; import { getCurrentLocale, translate, useI18n } from '../i18n'; @@ -29,6 +30,19 @@ type ThinkingAliasEntry = { oauthChannel?: string | null; }; +type SpeedAliasEntry = { + sourceModel: string; + alias: string; + serviceTier: string; + provider: string; + kind: string; + oauthChannel?: string | null; +}; + +type AliasListEntry = ThinkingAliasEntry & { + serviceTier: string | null; +}; + type ThinkingAliasSource = { id: string; model: string; @@ -41,6 +55,7 @@ type ThinkingAliasSource = { type ModelAliasSource = ThinkingAliasSource & { supportsReasoning: boolean; + supportsFast: boolean; }; const effortOptions = [ @@ -51,16 +66,49 @@ const effortOptions = [ { value: 'max', label: 'Max', hintKey: 'aliases.effort.max' }, ] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>; +export const combineModelAliasEntries = ( + thinkingEntries: ThinkingAliasEntry[], + speedEntries: SpeedAliasEntry[], +): AliasListEntry[] => { + const entries = new Map(); + const entryKey = ( + entry: Pick, + ) => ( + [entry.oauthChannel ?? '', entry.kind, entry.provider, entry.sourceModel, entry.alias] + .map((value) => value.toLocaleLowerCase()) + .join('\u0000') + ); + + thinkingEntries.forEach((entry) => { + entries.set(entryKey(entry), { ...entry, serviceTier: null }); + }); + speedEntries.forEach((entry) => { + const key = entryKey(entry); + const current = entries.get(key); + entries.set(key, current + ? { ...current, serviceTier: entry.serviceTier } + : { ...entry, effort: null }); + }); + + return [...entries.values()].sort((left, right) => ( + left.provider.localeCompare(right.provider) + || left.alias.localeCompare(right.alias) + )); +}; + 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(); - [...baseSources, ...thinkingSources].forEach((source) => { + [...baseSources, ...speedSources, ...thinkingSources].forEach((source) => { sources.set(source.id, { ...source, supportsReasoning: reasoningSourceIds.has(source.id), + supportsFast: speedSourceIds.has(source.id), }); }); return [...sources.values()]; @@ -69,12 +117,13 @@ export const combineModelAliasSources = ( export const defaultModelAlias = ( model: string | null | undefined, effort: string, + fast: boolean, ) => { const normalizedModel = model?.trim() ?? ''; const normalizedEffort = effort.trim().toLowerCase(); if (!normalizedModel) return ''; - if (!normalizedEffort) return `${normalizedModel}-alias`; - return `${normalizedModel}-${normalizedEffort}`; + if (!normalizedEffort && !fast) return `${normalizedModel}-alias`; + return `${normalizedModel}${normalizedEffort ? `-${normalizedEffort}` : ''}${fast ? '-fast' : ''}`; }; export const uniqueModelAlias = ( @@ -127,10 +176,13 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => ( 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(''); const [effort, setEffort] = useState(''); + const [fastEnabled, setFastEnabled] = useState(false); const [alias, setAlias] = useState(''); const [search, setSearch] = useState(''); const [modelPickerOpen, setModelPickerOpen] = useState(false); @@ -147,14 +199,24 @@ export function ThinkingAliasesPage() { setLoading(true); setError(''); try { - const [nextThinkingEntries, nextBaseSources, nextThinkingSources] = 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); + setSpeedEntries(nextSpeedEntries); setBaseSources(nextBaseSources); setThinkingSources(nextThinkingSources); + setSpeedSources(nextSpeedSources); setSelectedSourceId((current) => ( nextBaseSources.some((source) => source.id === current) ? current @@ -184,8 +246,12 @@ export function ThinkingAliasesPage() { }, [modelPickerOpen]); const sources = useMemo( - () => combineModelAliasSources(baseSources, thinkingSources), - [baseSources, thinkingSources], + () => combineModelAliasSources(baseSources, thinkingSources, speedSources), + [baseSources, speedSources, thinkingSources], + ); + const entries = useMemo( + () => combineModelAliasEntries(thinkingEntries, speedEntries), + [speedEntries, thinkingEntries], ); useEffect(() => { @@ -198,6 +264,13 @@ export function ThinkingAliasesPage() { () => sources.find((source) => source.id === selectedSourceId) ?? null, [selectedSourceId, sources], ); + const fastAvailable = Boolean(selectedSource?.supportsFast); + + useEffect(() => { + if (!fastAvailable) { + setFastEnabled(false); + } + }, [fastAvailable]); const filteredSources = useMemo(() => { const query = search.trim().toLowerCase(); @@ -228,6 +301,7 @@ export function ThinkingAliasesPage() { const chooseSource = (source: ModelAliasSource) => { setSelectedSourceId(source.id); setEffort(''); + setFastEnabled((current) => current && source.supportsFast); setAlias(''); generatedAliasRef.current = ''; setModelPickerOpen(false); @@ -265,7 +339,7 @@ export function ThinkingAliasesPage() { }; const normalizedEffort = effort.trim().toLowerCase(); - const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort); + const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort, fastEnabled); const uniqueDefaultAlias = uniqueModelAlias(defaultAlias, sources.map((source) => source.model)); const availableEfforts = selectedSource?.reasoningLevels ?? []; const visibleEffortOptions = availableEfforts.map((value) => { @@ -295,6 +369,10 @@ export function ThinkingAliasesPage() { setError(t('aliases.error.emptyAlias')); return; } + if (fastEnabled && !selectedSource.supportsFast) { + setError(t('aliases.error.unsupportedFast')); + return; + } if (normalizedEffort && !selectedSource.supportsReasoning) { setError(t('aliases.error.unsupportedEffort')); return; @@ -304,14 +382,32 @@ export function ThinkingAliasesPage() { setError(''); setNotice(''); try { - await invoke('create_thinking_alias', { - sourceId: selectedSource.id, - alias: normalizedAlias, - effort: normalizedEffort, - }); - setNotice(normalizedEffort - ? t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort }) - : t('aliases.createdPlain', { alias: normalizedAlias })); + if (normalizedEffort) { + await invoke('create_thinking_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + effort: normalizedEffort, + fast: fastEnabled, + }); + setNotice(t(fastEnabled ? 'aliases.createdCombined' : 'aliases.created', { + alias: normalizedAlias, + effort: normalizedEffort, + })); + } else if (fastEnabled) { + await invoke('create_speed_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + }); + setNotice(t('speedAliases.created', { alias: normalizedAlias })); + } else { + await invoke('create_thinking_alias', { + sourceId: selectedSource.id, + alias: normalizedAlias, + effort: '', + fast: false, + }); + setNotice(t('aliases.createdPlain', { alias: normalizedAlias })); + } setAlias(''); await load(); } catch (requestError) { @@ -322,7 +418,7 @@ export function ThinkingAliasesPage() { } }; - const deleteAlias = async (entry: ThinkingAliasEntry) => { + const deleteAlias = async (entry: AliasListEntry) => { if (!window.confirm( t('aliases.deleteConfirm', { alias: entry.alias }), )) return; @@ -331,10 +427,17 @@ export function ThinkingAliasesPage() { setError(''); setNotice(''); try { - await invoke('delete_thinking_alias', { - alias: entry.alias, - oauthChannel: entry.oauthChannel, - }); + 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 })); await load(); } catch (requestError) { @@ -392,6 +495,7 @@ export function ThinkingAliasesPage() { ) { setSelectedSourceId(''); setEffort(''); + setFastEnabled(false); setAlias(''); generatedAliasRef.current = ''; } @@ -503,6 +607,31 @@ export function ThinkingAliasesPage() { ) : null} +
+
+ {t('aliases.fast.title')} +
+ + {selectedSource && !selectedSource.supportsFast ? ( + {t('aliases.fast.unsupported')} + ) : null} +
+ - {thinkingEntries.length} + {entries.length}
{loading ? (
{t('aliases.loadingConfig')}
- ) : thinkingEntries.length === 0 ? ( + ) : entries.length === 0 ? (
{t('aliases.empty.title')} {t('aliases.empty.description')}
- ) : thinkingEntries.map((entry) => ( + ) : entries.map((entry) => (
@@ -584,6 +712,9 @@ export function ThinkingAliasesPage() { {entry.effort ? ( {entry.effort} ) : null} + {entry.serviceTier ? ( + {t('speedAliases.fast.title')} + ) : null}