Dev (#172)
* 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:
1 parent
3d44eba492
commit
68da51bae0
11 files changed
+695
-167
No files matched your search
@@ -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)
|
||||
|
||||
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
|
||||
|
||||
@@ -24,6 +24,14 @@ EasyCLIProxyAPI 是基于 [CLIProxyAPI](https://github.com/router-for-me/CLIProx
|
||||
软件基于 Tauri、React 和 Rust 构建,并可携带匹配版本的 CLIProxyAPI 内核压缩包,
|
||||
让首次安装和离线安装更加方便。
|
||||
|
||||
## 赞助商
|
||||
|
||||
[](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 |
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
}]
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user