* chore(release): stop publishing legacy Windows update packages

* feat(agents): support OpenCode Desktop across platforms

Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls.

Closes #156

* fix(codex): align model display names and show total session count

- preserve runtime model identity in generated catalogs
- count distinct sessions across database and rollout sources
- update session count labels in all supported languages

* fix(codex): keep config and auth files in sync

- generate auth.json for API key configuration
- preserve and validate OAuth credentials
- include auth.json in transactional updates and rollback
- retain compatibility with legacy Codex configuration states

* fix(cpa-gui): update version to 0.2.59 in Cargo files

* fix(codex): preserve OAuth mode after closing configuration

* feat(config): add TLS management and protocol-aware local URLs

- add TLS controls to advanced settings
- persist certificate and private key paths
- restart the core after TLS changes
- switch WebUI, API and management URLs between HTTP and HTTPS
- synchronize agent configurations and detection with the active protocol

* refactor(config): reorganize network, routing, and TLS settings

- split network settings and session routing into separate tabs
- move TLS configuration into network settings
- preserve TLS restart and HTTPS URL synchronization behavior

* feat(config): add independent network settings and custom listen IP

* fix(navigation): allow access to usage records while core is stopped

* refactor(home): show a single API URL based on the configured listen IP

* feat(config): add file pickers for TLS certificate and private key

* fix(usage): distinguish client cancellations from request failures

- persist usage failure status and response details
- classify HTTP 499 and canceled contexts separately
- exclude canceled requests from failure statistics
- add canceled filtering and failure details to the usage UI
- migrate and backfill existing usage records

* feat(ui): revamp usage records and improve app interactions

- redesign usage records dashboard, filters, charts, and pricing views
- add resizable centered columns with persisted widths
- expose OpenCode desktop launch and restart actions
- suppress the API access enabled success notice

* feat(agents): persist applied configurations across app restarts

- preserve applied agent configs and managed Claude aliases on exit
- keep explicit close actions restoring original configurations
- add Codex restart persistence regression coverage
- bump desktop version to 0.2.61

* fix(model-aliases): support provider-aware OAuth aliases

- map OAuth aliases to their correct provider channels
- support Antigravity aliases with force mapping
- read and delete aliases by their exact OAuth channel
- refresh the runtime model registry after alias changes
- restrict Fast aliases to GPT models from OpenAI-compatible or Codex sources

* docs(readme): add APIMart sponsorship sections

- add English and Chinese sponsor descriptions
- add localized APIMart banner images
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-24 17:16:00 +08:00
1 parent 3d44eba492
commit 68da51bae0
11 files changed
+695 -167

No files matched your search

+8
View File
@@ -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
+8
View File
@@ -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 地址
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+65 -34
View File
@@ -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<Vec<ThinkingAliasSource>, 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<String>,
) -> Result<Vec<ThinkingAliasEntry>, 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<String>,
) -> Result<Vec<SpeedAliasEntry>, 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)
}
+428 -123
View File
@@ -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<CodexModelDefinition>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AliasSourceCapability {
Base,
Reasoning,
Fast,
}
pub(crate) fn oauth_alias_channel(channel: &str) -> Option<OAuthAliasChannel> {
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<std::collections::HashSet<String>, 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<String, String> {
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<Vec<CodexModelDefinition>, String> {
fetch_oauth_channel_model_definitions(config, "codex").await
}
pub(crate) async fn fetch_oauth_channel_model_definitions(
config: &GuiConfigFile,
channel: &str,
) -> Result<Vec<CodexModelDefinition>, 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<OAuthModelDefinitions> {
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<Vec<ResolvedThinkingAliasSource>, 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<Vec<ResolvedThinkingAliasSource>, 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<Vec<ResolvedThinkingAliasSource>, 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<OAuthModelDefinitions> {
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<Vec<ResolvedThinkingAliasSource>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(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::<std::collections::HashSet<_>>();
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<bool, String> {
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)
+7 -1
View File
@@ -893,6 +893,7 @@ struct ThinkingAliasEntry {
effort: Option<String>,
provider: String,
kind: String,
oauth_channel: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
@@ -903,6 +904,7 @@ struct SpeedAliasEntry {
service_tier: String,
provider: String,
kind: String,
oauth_channel: Option<String>,
}
#[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,
+139
View File
@@ -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()),
}]
);
+4 -1
View File
@@ -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,
},
}
}
+21 -7
View File
@@ -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<string, AliasListEntry>();
const entryKey = (entry: Pick<ThinkingAliasEntry, 'kind' | 'provider' | 'sourceModel' | 'alias'>) => (
[entry.kind, entry.provider, entry.sourceModel, entry.alias]
const entryKey = (entry: Pick<ThinkingAliasEntry, 'kind' | 'provider' | 'sourceModel' | 'alias' | 'oauthChannel'>) => (
[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<string, ModelAliasSource>();
[...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<ThinkingAliasEntry[]>([]);
const [speedEntries, setSpeedEntries] = useState<SpeedAliasEntry[]>([]);
const [baseSources, setBaseSources] = useState<ThinkingAliasSource[]>([]);
const [thinkingSources, setThinkingSources] = useState<ThinkingAliasSource[]>([]);
const [speedSources, setSpeedSources] = useState<ThinkingAliasSource[]>([]);
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<ThinkingAliasEntry[]>('get_thinking_aliases'),
invoke<ThinkingAliasSource[]>('get_model_alias_sources'),
invoke<ThinkingAliasSource[]>('get_thinking_alias_sources'),
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<ThinkingAliasSource[]>('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<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
oauthChannel: entry.oauthChannel,
});
} else {
await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
oauthChannel: entry.oauthChannel,
});
}
setNotice(t('aliases.deleted', { alias: entry.alias }));
+15 -1
View File
@@ -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);
});
});