Files
EasyCLIProxyAPI/src-tauri/src/core_config/aliases.rs
T
lzt404 6b5d88cf9b fix: preserve legacy Claude alias sources during reconfiguration
Resolve model sources before replacing role aliases and support legacy
API and OAuth configurations that retain only aliased model entries.

Add regression coverage for source priority, disabled providers,
cross-role updates, and transaction rollback.
2026-09-11 16:25:14 +08:00

2485 lines
87 KiB
Rust

use super::*;
use std::collections::BTreeSet;
#[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: true,
supports_fast: false,
force_mapping: false,
},
OAuthAliasChannel {
key: "aistudio",
provider: "AI Studio OAuth",
kind: "aistudio-oauth",
protocol: "gemini",
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
OAuthAliasChannel {
key: "antigravity",
provider: "Antigravity OAuth",
kind: "antigravity-oauth",
protocol: "antigravity",
supports_reasoning: true,
supports_fast: false,
force_mapping: true,
},
OAuthAliasChannel {
key: "claude",
provider: "Claude OAuth",
kind: "claude-oauth",
protocol: "claude",
supports_reasoning: true,
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: true,
supports_fast: false,
force_mapping: false,
},
OAuthAliasChannel {
key: "xai",
provider: "xAI OAuth",
kind: "xai-oauth",
protocol: "codex",
supports_reasoning: true,
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_management_request_error("读取 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() {
return Err(format!("{label}不能为空"));
}
if value.len() > 240
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err(format!("{label}格式无效,不能包含空白字符"));
}
Ok(value.to_string())
}
pub(crate) fn validate_thinking_alias_effort(value: &str) -> Result<String, String> {
let effort = value.trim().to_ascii_lowercase();
if effort.is_empty() {
return Err("思考强度不能为空".to_string());
}
if effort.chars().all(|character| character.is_ascii_digit()) {
return Err("固定思考别名不支持纯数字预算,请输入思考等级名称".to_string());
}
if effort.len() > 64
|| !effort.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return Err("思考强度格式无效,仅支持字母、数字、短横线、下划线和点".to_string());
}
Ok(effort)
}
pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Result<String, String> {
let client = management_http_client()?;
let response = client
.get(management_endpoint(config, "config.yaml")?)
.header("Authorization", management_authorization(config)?)
.header(
reqwest::header::ACCEPT,
"application/yaml,text/yaml,text/plain",
)
.send()
.await
.map_err(|error| format_management_request_error("读取内核 YAML 配置失败", &error))?;
read_management_text(response).await
}
pub(crate) async fn put_management_config_yaml(
config: &GuiConfigFile,
content: &str,
) -> Result<(), String> {
let client = management_http_client()?;
let response = client
.put(management_endpoint(config, "config.yaml")?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::CONTENT_TYPE, "application/yaml")
.body(content.to_string())
.send()
.await
.map_err(|error| format_management_request_error("保存内核 YAML 配置失败", &error))?;
read_management_value(response).await.map(|_| ())
}
pub(crate) async fn put_management_oauth_model_aliases(
config: &GuiConfigFile,
aliases: &serde_json::Value,
) -> Result<(), String> {
let client = management_http_client()?;
let response = client
.put(management_endpoint(config, "oauth-model-alias")?)
.header("Authorization", management_authorization(config)?)
.json(aliases)
.send()
.await
.map_err(|error| format_management_request_error("保存 OAuth 模型别名失败", &error))?;
read_management_value(response).await.map(|_| ())
}
#[derive(Debug, PartialEq)]
pub(crate) struct ManagementAliasConfigChanges {
pub(crate) oauth_model_aliases: Option<serde_json::Value>,
pub(crate) update_config_yaml: bool,
}
pub(crate) fn management_alias_config_changes(
current: &str,
updated: &str,
) -> Result<ManagementAliasConfigChanges, String> {
let split = |content: &str| {
let mut document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let oauth_aliases = root
.remove(yaml_key("oauth-model-alias"))
.unwrap_or_else(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()));
Ok::<_, String>((document, oauth_aliases))
};
let (current_without_oauth, current_oauth) = split(current)?;
let (updated_without_oauth, updated_oauth) = split(updated)?;
let oauth_model_aliases = if current_oauth == updated_oauth {
None
} else {
Some(
serde_json::to_value(updated_oauth)
.map_err(|error| format!("序列化 OAuth 模型别名失败: {error}"))?,
)
};
Ok(ManagementAliasConfigChanges {
oauth_model_aliases,
update_config_yaml: current_without_oauth != updated_without_oauth,
})
}
pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
) -> Result<String, String> {
ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(content, mappings, models, &[])
}
pub(crate) fn ensure_claude_desktop_model_aliases_with_codex_oauth_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
codex_oauth_models: &[CodexModelDefinition],
) -> Result<String, String> {
let codex_channel = oauth_alias_channel("codex")
.ok_or_else(|| "缺少 Codex OAuth 别名 channel 定义".to_string())?;
let definitions = [OAuthModelDefinitions {
channel: codex_channel,
models: codex_oauth_models.to_vec(),
}];
ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
content,
mappings,
models,
&definitions,
)
}
pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
oauth_model_definitions: &[OAuthModelDefinitions],
) -> 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())?;
// All roles resolve against the same input. Replacing one old role must not erase
// the only source record needed to configure another role.
let sources = root.clone();
for (alias, source_model) in [
(CLAUDE_DESKTOP_OPUS_MODEL_ID, mappings.opus.as_str()),
(CLAUDE_DESKTOP_SONNET_MODEL_ID, mappings.sonnet.as_str()),
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, mappings.haiku.as_str()),
] {
let direct_alias = models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias);
if direct_alias {
if !source_model.eq_ignore_ascii_case(alias) {
remove_managed_claude_model_alias(root, alias)?;
}
} else {
ensure_claude_desktop_model_alias(
root,
&sources,
source_model,
alias,
oauth_model_definitions,
)?;
}
}
render_updated_core_yaml(&mut document, updated)
}
#[cfg(test)]
pub(crate) fn remove_managed_claude_model_aliases_in_yaml(content: &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 changed = false;
for alias in [
CLAUDE_DESKTOP_OPUS_MODEL_ID,
CLAUDE_DESKTOP_SONNET_MODEL_ID,
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
] {
changed |= remove_managed_claude_model_alias(root, alias)?;
}
if !changed {
return Ok(content.to_string());
}
render_updated_core_yaml(&mut document, updated)
}
pub(crate) fn remove_managed_claude_model_alias(
root: &mut serde_norway::Mapping,
alias: &str,
) -> Result<bool, String> {
let mut changed = false;
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) = yaml_mapping_value_mut(root, section) else {
continue;
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let before = models.len();
models.retain(|model| !is_managed_claude_model_alias(model, alias));
changed |= models.len() != before;
}
}
changed |= remove_oauth_claude_model_alias(root, alias, true)?;
Ok(changed)
}
pub(crate) fn remove_existing_claude_model_alias(
root: &mut serde_norway::Mapping,
alias: &str,
) -> Result<bool, String> {
let mut changed = false;
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) = yaml_mapping_value_mut(root, section) else {
continue;
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let before = models.len();
models.retain(|model| {
configured_model_identity(model)
.is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias))
});
changed |= models.len() != before;
}
}
changed |= remove_oauth_claude_model_alias(root, alias, false)?;
Ok(changed)
}
pub(crate) fn remove_oauth_claude_model_alias(
root: &mut serde_norway::Mapping,
alias: &str,
managed_only: bool,
) -> 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 changed = false;
let mut empty_channels = Vec::new();
for (channel, entries) in oauth_aliases.iter_mut() {
let channel_name = channel.as_str().unwrap_or("unknown");
let entries = entries
.as_sequence_mut()
.ok_or_else(|| format!("oauth-model-alias.{channel_name} 必须是数组"))?;
let before = entries.len();
entries.retain(|entry| {
if managed_only {
!is_managed_claude_model_alias(entry, alias)
} else {
configured_model_identity(entry)
.is_none_or(|(_, client_model, _)| !client_model.eq_ignore_ascii_case(alias))
}
});
if entries.len() != before {
changed = true;
if entries.is_empty() {
empty_channels.push(channel.clone());
}
}
}
for channel in empty_channels {
oauth_aliases.remove(&channel);
}
let remove_oauth_section = changed && oauth_aliases.is_empty();
if remove_oauth_section {
root.remove(yaml_key("oauth-model-alias"));
}
Ok(changed)
}
pub(crate) fn managed_claude_alias_display_name(alias: &str) -> Option<&'static str> {
if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_OPUS_MODEL_ID) {
Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME)
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID) {
Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME)
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID) {
Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME)
} else {
None
}
}
pub(crate) fn is_managed_claude_model_alias(model: &serde_norway::Value, alias: &str) -> bool {
let Some(expected_display_name) = managed_claude_alias_display_name(alias) else {
return false;
};
let Some((_, client_model, display_name)) = configured_model_identity(model) else {
return false;
};
client_model.eq_ignore_ascii_case(alias)
&& display_name
.as_deref()
.is_some_and(|value| value == expected_display_name)
}
pub(crate) fn configured_managed_claude_alias_matches(
root: &serde_norway::Mapping,
alias: &str,
source_model: &str,
) -> bool {
let matches = |model: &serde_norway::Value| {
is_managed_claude_model_alias(model, alias)
&& configured_model_identity(model)
.is_some_and(|(source, _, _)| source.eq_ignore_ascii_case(source_model))
};
let configured_provider_alias = MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| {
yaml_mapping_value(root, section)
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.filter_map(serde_norway::Value::as_mapping)
.filter(|provider| {
yaml_mapping_value(provider, "disabled") != Some(&serde_norway::Value::Bool(true))
})
.filter_map(|provider| yaml_mapping_value(provider, "models"))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.any(matches)
});
configured_provider_alias
|| yaml_mapping_value(root, "oauth-model-alias")
.and_then(serde_norway::Value::as_mapping)
.into_iter()
.flat_map(|channels| channels.values())
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.any(matches)
}
pub(crate) fn ensure_claude_desktop_model_alias(
root: &mut serde_norway::Mapping,
sources: &serde_norway::Mapping,
source_model: &str,
alias: &str,
oauth_model_definitions: &[OAuthModelDefinitions],
) -> Result<(), String> {
if configured_managed_claude_alias_matches(root, alias, source_model) {
return Ok(());
}
let source = resolve_claude_desktop_alias_source(sources, source_model)?;
if let Some(source) = source {
remove_existing_claude_model_alias(root, alias)?;
append_claude_desktop_model_alias(root, source, alias)?;
return Ok(());
}
if let Some(definition) = oauth_model_definitions.iter().find(|definition| {
definition
.models
.iter()
.any(|model| model.id.eq_ignore_ascii_case(source_model))
}) {
remove_existing_claude_model_alias(root, alias)?;
append_managed_oauth_model_alias(
root,
definition.channel.key,
source_model,
alias,
definition.channel.force_mapping,
)?;
return Ok(());
}
Err(format!(
"无法确定模型 {source_model} 的 CPA 配置来源,无法创建 Claude Desktop 别名 {alias}"
))
}
pub(crate) fn configured_model_client_identity(
root: &serde_norway::Mapping,
client_model: &str,
) -> Option<(String, String)> {
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) =
yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for provider in providers {
let Some(provider) = provider.as_mapping() else {
continue;
};
let Some(models) =
yaml_mapping_value(provider, "models").and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for model in models {
let Some((source, configured_client_model, _)) = configured_model_identity(model)
else {
continue;
};
if configured_client_model.eq_ignore_ascii_case(client_model) {
return Some((source, configured_client_model));
}
}
}
}
if let Some(oauth_aliases) =
yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping)
{
for entries in oauth_aliases
.values()
.filter_map(serde_norway::Value::as_sequence)
{
for model in entries {
let Some((source, configured_client_model, _)) = configured_model_identity(model)
else {
continue;
};
if configured_client_model.eq_ignore_ascii_case(client_model) {
return Some((source, configured_client_model));
}
}
}
}
None
}
enum ClaudeDesktopAliasSource {
Provider {
section: &'static str,
provider_index: usize,
model: serde_norway::Mapping,
},
OAuth {
channel: String,
model: serde_norway::Mapping,
},
}
fn resolve_claude_desktop_alias_source(
root: &serde_norway::Mapping,
source_model: &str,
) -> Result<Option<ClaudeDesktopAliasSource>, String> {
// Prefer an exact client-visible model. Legacy configurations may only retain
// an aliased entry, whose upstream name still identifies its configured source.
for upstream in [false, true] {
let matching_model = |model: &serde_norway::Value| {
let (name, client_model, _) = configured_model_identity(model)?;
let matches = if upstream { &name } else { &client_model };
if !matches.eq_ignore_ascii_case(source_model) {
return None;
}
let mut model = model.as_mapping().cloned().unwrap_or_default();
model.insert(yaml_key("name"), serde_norway::Value::String(name));
Some(model)
};
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) = yaml_mapping_value(root, section) else {
continue;
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) {
continue;
}
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
if let Some(model) = models.iter().find_map(matching_model) {
return Ok(Some(ClaudeDesktopAliasSource::Provider {
section,
provider_index,
model,
}));
}
}
}
if let Some(channels) =
yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping)
{
for (channel, models) in channels {
let (Some(channel), Some(models)) = (channel.as_str(), models.as_sequence()) else {
continue;
};
if let Some(model) = models.iter().find_map(matching_model) {
return Ok(Some(ClaudeDesktopAliasSource::OAuth {
channel: channel.to_string(),
model,
}));
}
}
}
}
Ok(None)
}
fn append_claude_desktop_model_alias(
root: &mut serde_norway::Mapping,
source: ClaudeDesktopAliasSource,
alias: &str,
) -> Result<(), String> {
let (models, mut model) = match source {
ClaudeDesktopAliasSource::Provider {
section,
provider_index,
model,
} => {
let models = yaml_mapping_value_mut(root, section)
.and_then(serde_norway::Value::as_sequence_mut)
.and_then(|providers| providers.get_mut(provider_index))
.and_then(serde_norway::Value::as_mapping_mut)
.and_then(|provider| yaml_mapping_value_mut(provider, "models"))
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or("模型来源配置已变化,请刷新后重试")?;
(models, model)
}
ClaudeDesktopAliasSource::OAuth { channel, mut model } => {
let aliases = root
.entry(yaml_key("oauth-model-alias"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or("oauth-model-alias 必须是 YAML 映射")?;
let models = aliases
.entry(yaml_key(&channel))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?;
model.insert(yaml_key("fork"), serde_norway::Value::Bool(true));
if oauth_alias_channel(&channel).is_some_and(|channel| channel.force_mapping) {
model.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true));
}
(models, model)
}
};
model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
let display_name = managed_claude_alias_display_name(alias)
.ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?;
model.insert(
yaml_key("display-name"),
serde_norway::Value::String(display_name.to_string()),
);
models.push(serde_norway::Value::Mapping(model));
Ok(())
}
pub(crate) fn append_managed_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 channel_aliases = oauth_aliases
.entry(yaml_key(channel))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?;
let display_name = managed_claude_alias_display_name(alias)
.ok_or_else(|| format!("不支持的 Claude 托管别名: {alias}"))?;
let mut alias_mapping = serde_norway::Mapping::new();
alias_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(source_model.to_string()),
);
alias_mapping.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true));
if force_mapping {
alias_mapping.insert(yaml_key("force-mapping"), serde_norway::Value::Bool(true));
}
alias_mapping.insert(
yaml_key("display-name"),
serde_norway::Value::String(display_name.to_string()),
);
channel_aliases.push(serde_norway::Value::Mapping(alias_mapping));
Ok(())
}
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,
&format!("model-definitions/{channel}"),
)?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| {
format_management_request_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
}
#[cfg(test)]
pub(crate) fn resolved_thinking_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
let definitions = codex_oauth_definition_set(definitions);
resolved_oauth_alias_sources(
content,
&definitions,
available_models,
AliasSourceCapability::Reasoning,
)
}
#[cfg(test)]
pub(crate) fn resolved_speed_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
let definitions = codex_oauth_definition_set(definitions);
resolved_oauth_alias_sources(
content,
&definitions,
available_models,
AliasSourceCapability::Fast,
)
}
#[cfg(test)]
pub(crate) fn resolved_alias_sources(
content: &str,
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
},
)
}
#[cfg(test)]
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}"))?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut sources = Vec::new();
collect_config_thinking_alias_sources(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
available_models,
&mut sources,
)?;
collect_config_thinking_alias_sources(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
available_models,
&mut sources,
)?;
collect_config_thinking_alias_sources(
root,
"claude-api-key",
"Claude API",
"claude-api",
"claude",
available_models,
&mut sources,
)?;
collect_config_thinking_alias_sources(
root,
"gemini-api-key",
"Gemini API",
"gemini-api",
"gemini",
available_models,
&mut sources,
)?;
match capability {
AliasSourceCapability::Reasoning => {
sources.retain(|source| !source.source.reasoning_levels.is_empty())
}
AliasSourceCapability::Fast => sources.retain(alias_source_supports_fast),
AliasSourceCapability::Base => {}
}
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<_>>();
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())
&& 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(),
reasoning_levels: definition.reasoning_levels.clone(),
},
location: ThinkingAliasSourceLocation::Oauth {
channel: channel.key,
force_mapping: channel.force_mapping,
},
}),
);
}
Ok(sources)
}
pub(crate) fn thinking_alias_model_is_available(
available_models: &[AgentModelOption],
model: &str,
) -> bool {
available_models
.iter()
.any(|available| available.name.eq_ignore_ascii_case(model))
}
pub(crate) fn alias_source_supports_fast(source: &ResolvedThinkingAliasSource) -> bool {
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,
fallback_provider: &str,
kind: &str,
protocol: &str,
available_models: &[AgentModelOption],
sources: &mut Vec<ResolvedThinkingAliasSource>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
if matches!(
yaml_mapping_value(provider, "disabled"),
Some(serde_norway::Value::Bool(true))
) {
continue;
}
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let provider_revision = sha256_bytes(
serde_norway::to_string(provider)
.map_err(|error| format!("读取模型源配置失败: {error}"))?
.as_bytes(),
);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for (model_index, model) in models.iter().enumerate() {
let Some((upstream_model, client_model, display_name)) =
configured_model_identity(model)
else {
continue;
};
if !thinking_alias_model_is_available(available_models, &client_model) {
continue;
}
if client_model != upstream_model
&& find_thinking_alias_effort(root, &client_model, protocol).is_some()
{
continue;
}
let reasoning_levels = configured_model_reasoning_levels(model, protocol);
sources.push(ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("{section}:{provider_index}:{model_index}:{provider_revision}"),
model: client_model,
display_name,
provider: provider_name.clone(),
kind: kind.to_string(),
protocol: protocol.to_string(),
reasoning_levels,
},
location: ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
},
});
}
}
Ok(())
}
pub(crate) fn configured_model_reasoning_levels(
model: &serde_norway::Value,
protocol: &str,
) -> Vec<String> {
let mut levels = model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "thinking"))
.and_then(serde_norway::Value::as_mapping)
.and_then(|thinking| yaml_mapping_value(thinking, "levels"))
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.filter_map(serde_norway::Value::as_str)
.map(str::trim)
.filter(|level| !level.is_empty())
.map(str::to_ascii_lowercase)
.fold(Vec::new(), |mut result, level| {
if !result.contains(&level) {
result.push(level);
}
result
});
if levels.is_empty() && matches!(protocol, "codex" | "openai") {
levels = ["low", "medium", "high", "xhigh", "max"]
.into_iter()
.map(str::to_string)
.collect();
}
levels
}
pub(crate) fn thinking_alias_provider_name(
provider: &serde_norway::Mapping,
fallback: &str,
index: usize,
) -> String {
yaml_mapping_value(provider, "name")
.or_else(|| yaml_mapping_value(provider, "base-url"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{fallback} {}", index + 1))
}
pub(crate) fn configured_model_identity(
model: &serde_norway::Value,
) -> Option<(String, String, Option<String>)> {
if let Some(name) = model
.as_str()
.map(str::trim)
.filter(|name| !name.is_empty())
{
return Some((name.to_string(), name.to_string(), None));
}
let model = model.as_mapping()?;
let name = yaml_mapping_value(model, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())?;
let alias = yaml_mapping_value(model, "alias")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|alias| !alias.is_empty());
let display_name = yaml_mapping_value(model, "display-name")
.or_else(|| yaml_mapping_value(model, "display_name"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
Some((
name.to_string(),
alias.unwrap_or(name).to_string(),
display_name,
))
}
pub(crate) fn thinking_aliases_from_yaml(content: &str) -> Result<Vec<ThinkingAliasEntry>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
thinking_aliases_from_value(&document)
}
pub(crate) fn thinking_aliases_from_value(
document: &serde_norway::Value,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut entries = Vec::new();
if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
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(|| 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;
};
let Some(source_model) = yaml_mapping_value(mapping, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let Some(alias) = yaml_mapping_value(mapping, "alias")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
entries.push(ThinkingAliasEntry {
source_model: source_model.to_string(),
alias: alias.to_string(),
effort: find_thinking_alias_effort(root, alias, &protocol),
provider: provider.clone(),
kind: kind.clone(),
oauth_channel: Some(channel.to_string()),
});
}
}
}
collect_config_thinking_alias_entries(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
&mut entries,
)?;
collect_config_thinking_alias_entries(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
&mut entries,
)?;
collect_config_thinking_alias_entries(
root,
"claude-api-key",
"Claude API",
"claude-api",
"claude",
&mut entries,
)?;
collect_config_thinking_alias_entries(
root,
"gemini-api-key",
"Gemini API",
"gemini-api",
"gemini",
&mut entries,
)?;
entries.sort_by(|left, right| {
left.provider
.to_ascii_lowercase()
.cmp(&right.provider.to_ascii_lowercase())
.then_with(|| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
})
});
Ok(entries)
}
pub(crate) fn speed_aliases_from_yaml(content: &str) -> Result<Vec<SpeedAliasEntry>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
speed_aliases_from_value(&document)
}
pub(crate) fn speed_aliases_from_value(
document: &serde_norway::Value,
) -> Result<Vec<SpeedAliasEntry>, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut entries = Vec::new();
if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
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(|| 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;
};
let Some(source_model) = yaml_mapping_value(mapping, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let Some(alias) = yaml_mapping_value(mapping, "alias")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
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: provider.clone(),
kind: kind.clone(),
oauth_channel: Some(channel.to_string()),
});
}
}
}
collect_config_speed_alias_entries(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
&mut entries,
)?;
collect_config_speed_alias_entries(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
&mut entries,
)?;
entries.sort_by(|left, right| {
left.provider
.to_ascii_lowercase()
.cmp(&right.provider.to_ascii_lowercase())
.then_with(|| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
})
});
Ok(entries)
}
pub(crate) fn collect_config_thinking_alias_entries(
root: &serde_norway::Mapping,
section: &str,
fallback_provider: &str,
kind: &str,
protocol: &str,
entries: &mut Vec<ThinkingAliasEntry>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for model in models {
let Some((source_model, alias, _)) = configured_model_identity(model) else {
continue;
};
if source_model == alias {
continue;
}
let effort = find_thinking_alias_effort(root, &alias, protocol);
if effort.is_none() && find_speed_alias_service_tier(root, &alias, protocol).is_some() {
continue;
}
entries.push(ThinkingAliasEntry {
source_model,
alias,
effort,
provider: provider_name.clone(),
kind: kind.to_string(),
oauth_channel: None,
});
}
}
Ok(())
}
pub(crate) fn collect_config_speed_alias_entries(
root: &serde_norway::Mapping,
section: &str,
fallback_provider: &str,
kind: &str,
protocol: &str,
entries: &mut Vec<SpeedAliasEntry>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for model in models {
let Some((source_model, alias, _)) = configured_model_identity(model) else {
continue;
};
if source_model == alias {
continue;
}
let Some(service_tier) = find_speed_alias_service_tier(root, &alias, protocol) else {
continue;
};
entries.push(SpeedAliasEntry {
source_model,
alias,
service_tier,
provider: provider_name.clone(),
kind: kind.to_string(),
oauth_channel: None,
});
}
}
Ok(())
}
fn alias_override_params<'a>(
root: &'a serde_norway::Mapping,
alias: &'a str,
protocol: &'a str,
) -> impl Iterator<Item = (&'a serde_norway::Mapping, bool)> {
["override-raw", "override"]
.into_iter()
.flat_map(move |section| {
nested_yaml_value(root, &["payload", section])
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.rev()
.filter_map(move |rule| {
let rule = rule.as_mapping()?;
let models = yaml_mapping_value(rule, "models")?.as_sequence()?;
if !models
.iter()
.any(|model| thinking_payload_model_matches(model, alias, protocol))
{
return None;
}
Some((
yaml_mapping_value(rule, "params")?.as_mapping()?,
section == "override-raw",
))
})
})
}
fn alias_payload_string(params: &serde_norway::Mapping, key: &str, raw: bool) -> Option<String> {
let value = yaml_mapping_value(params, key)?.as_str()?;
let decoded;
let value = if raw {
decoded = serde_json::from_str::<serde_json::Value>(value).ok()?;
decoded.as_str()?
} else {
value
};
let value = value.trim().to_ascii_lowercase();
(!value.is_empty()).then_some(value)
}
pub(crate) fn thinking_effort_from_params(
params: &serde_norway::Mapping,
protocol: &str,
raw: bool,
) -> Option<String> {
[
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
]
.into_iter()
.find_map(|key| alias_payload_string(params, key, raw))
.or_else(
|| match alias_payload_string(params, "thinking.type", raw)?.as_str() {
"disabled" => Some("none".to_string()),
"adaptive" if protocol.eq_ignore_ascii_case("claude") => Some("auto".to_string()),
_ => None,
},
)
}
pub(crate) fn find_thinking_alias_effort(
root: &serde_norway::Mapping,
alias: &str,
protocol: &str,
) -> Option<String> {
alias_override_params(root, alias, protocol)
.find_map(|(params, raw)| thinking_effort_from_params(params, protocol, raw))
}
pub(crate) fn find_speed_alias_service_tier(
root: &serde_norway::Mapping,
alias: &str,
protocol: &str,
) -> Option<String> {
alias_override_params(root, alias, protocol)
.find_map(|(params, raw)| alias_payload_string(params, "service_tier", raw))
}
pub(crate) fn thinking_payload_model_matches(
model: &serde_norway::Value,
alias: &str,
protocol: &str,
) -> bool {
let Some(model) = model.as_mapping() else {
return false;
};
let name_matches = yaml_mapping_value(model, "name")
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias));
let protocol_matches = yaml_mapping_value(model, "protocol").is_none_or(|value| {
value.as_str().is_some_and(|value| {
value.trim().is_empty() || value.trim().eq_ignore_ascii_case(protocol)
})
});
name_matches && protocol_matches
}
pub(crate) fn thinking_payload_model_name_matches(
model: &serde_norway::Value,
alias: &str,
) -> bool {
model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "name"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias))
}
pub(crate) fn insert_thinking_effort_params(
params: &mut serde_norway::Mapping,
source: &ThinkingAliasSource,
effort: &str,
) -> Result<(), String> {
let insert = |params: &mut serde_norway::Mapping, key: &str, value: &str| {
params.insert(
yaml_key(key),
serde_norway::Value::String(value.to_string()),
);
};
match source.kind.as_str() {
"claude-oauth" | "claude-api" => {
if effort.eq_ignore_ascii_case("none") {
insert(params, "thinking.type", "disabled");
} else {
insert(params, "thinking.type", "adaptive");
if !effort.eq_ignore_ascii_case("auto") {
insert(params, "output_config.effort", effort);
}
}
}
"aistudio-oauth" | "vertex-oauth" | "gemini-api" => {
insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
effort,
);
}
"antigravity-oauth" => {
insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
effort,
);
}
"kimi-oauth" => {
if effort.eq_ignore_ascii_case("none") {
insert(params, "thinking.type", "disabled");
} else {
insert(params, "thinking.type", "enabled");
insert(params, "thinking.effort", effort);
}
}
"codex-oauth" | "codex-api" | "xai-oauth" => {
insert(params, "reasoning.effort", effort);
}
"openai-compatible" => {
insert(params, "reasoning_effort", effort);
if source.model.to_ascii_lowercase().starts_with("deepseek") {
insert(params, "thinking.type", "enabled");
}
}
_ => match source.protocol.as_str() {
"codex" => insert(params, "reasoning.effort", effort),
"openai" => insert(params, "reasoning_effort", effort),
"claude" => {
if effort.eq_ignore_ascii_case("none") {
insert(params, "thinking.type", "disabled");
} else {
insert(params, "thinking.type", "adaptive");
if !effort.eq_ignore_ascii_case("auto") {
insert(params, "output_config.effort", effort);
}
}
}
"gemini" | "antigravity" => insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
effort,
),
protocol => {
return Err(format!("暂不支持为 {protocol} 来源强制覆写思考强度"));
}
},
}
Ok(())
}
pub(crate) fn add_model_alias_to_yaml(
content: &str,
source: &ResolvedThinkingAliasSource,
alias: &str,
effort: &str,
fast: bool,
) -> Result<String, String> {
if fast && !alias_source_supports_fast(source) {
return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string());
}
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
match &source.location {
ThinkingAliasSourceLocation::Oauth {
channel,
force_mapping,
} => append_oauth_model_alias(root, channel, &source.source.model, alias, *force_mapping)?,
ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
} => append_config_thinking_alias(
root,
section,
*provider_index,
*model_index,
&source.source.model,
alias,
effort,
)?,
}
let scope = AliasPayloadScope::for_protocol(&source.source.protocol);
remove_alias_payload_options(root, alias, &scope)?;
if !effort.is_empty() {
let mut params_mapping = serde_norway::Mapping::new();
insert_thinking_effort_params(&mut params_mapping, &source.source, effort)?;
append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?;
}
if fast {
let mut params_mapping = serde_norway::Mapping::new();
params_mapping.insert(
yaml_key("service_tier"),
serde_norway::Value::String("priority".to_string()),
);
append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?;
}
render_updated_core_yaml(&mut document, updated)
}
pub(crate) fn append_alias_payload_override(
root: &mut serde_norway::Mapping,
alias: &str,
protocol: &str,
params_mapping: serde_norway::Mapping,
) -> Result<(), String> {
if params_mapping.is_empty() {
return Ok(());
}
let payload = root
.entry(yaml_key("payload"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "payload 必须是 YAML 映射".to_string())?;
let override_rules = payload
.entry(yaml_key("override"))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "payload.override 必须是数组".to_string())?;
let mut model_mapping = serde_norway::Mapping::new();
model_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(alias.to_string()),
);
model_mapping.insert(
yaml_key("protocol"),
serde_norway::Value::String(protocol.to_string()),
);
let mut rule_mapping = serde_norway::Mapping::new();
rule_mapping.insert(
yaml_key("models"),
serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(model_mapping)]),
);
rule_mapping.insert(
yaml_key("params"),
serde_norway::Value::Mapping(params_mapping),
);
override_rules.push(serde_norway::Value::Mapping(rule_mapping));
Ok(())
}
pub(crate) fn add_speed_alias_to_yaml(
content: &str,
source: &ResolvedThinkingAliasSource,
alias: &str,
) -> Result<String, String> {
if !alias_source_supports_fast(source) {
return Err("Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 模型源".to_string());
}
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
match &source.location {
ThinkingAliasSourceLocation::Oauth {
channel,
force_mapping,
} => append_oauth_model_alias(root, channel, &source.source.model, alias, *force_mapping)?,
ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
} => append_config_speed_alias(
root,
section,
*provider_index,
*model_index,
&source.source.model,
alias,
)?,
}
remove_alias_payload_options(
root,
alias,
&AliasPayloadScope::for_protocol(&source.source.protocol),
)?;
let mut params_mapping = serde_norway::Mapping::new();
params_mapping.insert(
yaml_key("service_tier"),
serde_norway::Value::String("priority".to_string()),
);
append_alias_payload_override(root, alias, &source.source.protocol, params_mapping)?;
render_updated_core_yaml(&mut document, updated)
}
pub(crate) fn append_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 channel_aliases = oauth_aliases
.entry(yaml_key(channel))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| format!("oauth-model-alias.{channel} 必须是数组"))?;
let mut alias_mapping = serde_norway::Mapping::new();
alias_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(source_model.to_string()),
);
alias_mapping.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true));
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(())
}
pub(crate) fn append_config_thinking_alias(
root: &mut serde_norway::Mapping,
section: &str,
provider_index: usize,
model_index: usize,
expected_model: &str,
alias: &str,
effort: &str,
) -> Result<(), String> {
let providers = yaml_mapping_value_mut(root, section)
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section} 必须是数组"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "模型提供商已经变化,请刷新后重试".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "原模型已经变化,请刷新后重试".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "原模型配置格式无效".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("原模型已经变化,请刷新后重试".to_string());
}
let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| {
let mut mapping = serde_norway::Mapping::new();
if let Some(name) = source.as_str() {
mapping.insert(
yaml_key("name"),
serde_norway::Value::String(name.to_string()),
);
}
mapping
});
alias_model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
if !effort.is_empty() {
if let Some(display_name) = yaml_mapping_value(&alias_model, "display-name")
.and_then(serde_norway::Value::as_str)
.map(str::to_string)
{
alias_model.insert(
yaml_key("display-name"),
serde_norway::Value::String(format!("{display_name} ({effort})")),
);
}
}
models.push(serde_norway::Value::Mapping(alias_model));
Ok(())
}
pub(crate) fn append_config_speed_alias(
root: &mut serde_norway::Mapping,
section: &str,
provider_index: usize,
model_index: usize,
expected_model: &str,
alias: &str,
) -> Result<(), String> {
let providers = yaml_mapping_value_mut(root, section)
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section} 必须是数组"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "模型提供商已经变化,请刷新后重试".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "原模型已经变化,请刷新后重试".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "原模型配置格式无效".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("原模型已经变化,请刷新后重试".to_string());
}
let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| {
let mut mapping = serde_norway::Mapping::new();
if let Some(name) = source.as_str() {
mapping.insert(
yaml_key("name"),
serde_norway::Value::String(name.to_string()),
);
}
mapping
});
alias_model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
if let Some(display_name) = yaml_mapping_value(&alias_model, "display-name")
.and_then(serde_norway::Value::as_str)
.map(str::to_string)
{
alias_model.insert(
yaml_key("display-name"),
serde_norway::Value::String(format!("{display_name} (Fast)")),
);
}
models.push(serde_norway::Value::Mapping(alias_model));
Ok(())
}
#[cfg(test)]
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}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut removed = remove_oauth_model_alias(root, alias, oauth_channel)?;
if oauth_channel.is_none() {
removed |= remove_config_model_alias(root, "codex-api-key", alias)?;
removed |= remove_config_model_alias(root, "openai-compatibility", alias)?;
removed |= remove_config_model_alias(root, "claude-api-key", alias)?;
removed |= remove_config_model_alias(root, "gemini-api-key", alias)?;
}
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel);
remove_alias_payload_options(root, alias, &scope)?;
render_updated_core_yaml(&mut document, updated)
}
#[cfg(test)]
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 = 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)?;
}
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel);
remove_alias_payload_options(root, alias, &scope)?;
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)
.is_some_and(|channels| {
channels.values().any(|entries| {
entries.as_sequence().is_some_and(|entries| {
entries.iter().any(|entry| {
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))
})
})
})
});
oauth_exists
|| MODEL_ALIAS_CONFIG_SECTIONS
.into_iter()
.filter_map(|section| yaml_mapping_value(root, section))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(serde_norway::Value::as_mapping)
.filter_map(|provider| yaml_mapping_value(provider, "models"))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(|model| configured_model_identity(model).map(|(_, alias, _)| alias))
.any(|value| value.eq_ignore_ascii_case(alias))
}
pub(crate) fn remove_config_model_alias(
root: &mut serde_norway::Mapping,
section: &str,
alias: &str,
) -> Result<bool, String> {
let Some(providers) = yaml_mapping_value_mut(root, section) else {
return Ok(false);
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
let mut removed = false;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
models.retain(|model| {
let matches = configured_model_identity(model)
.map(|(source, model_alias, _)| {
source != model_alias && model_alias.eq_ignore_ascii_case(alias)
})
.unwrap_or(false);
removed |= matches;
!matches
});
}
Ok(removed)
}
pub(crate) fn remove_config_speed_alias(
root: &mut serde_norway::Mapping,
section: &str,
protocol: &str,
alias: &str,
) -> Result<bool, String> {
if find_speed_alias_service_tier(root, alias, protocol).is_none() {
return Ok(false);
}
let Some(providers) = yaml_mapping_value_mut(root, section) else {
return Ok(false);
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
let mut removed = false;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
models.retain(|model| {
let matches = configured_model_identity(model)
.map(|(source, model_alias, _)| {
source != model_alias && model_alias.eq_ignore_ascii_case(alias)
})
.unwrap_or(false);
removed |= matches;
!matches
});
}
Ok(removed)
}
struct AliasPayloadScope {
protocol: Option<String>,
preserved_protocols: BTreeSet<String>,
}
impl AliasPayloadScope {
fn for_protocol(protocol: &str) -> Self {
Self {
protocol: Some(protocol.to_string()),
preserved_protocols: BTreeSet::new(),
}
}
fn after_removal(root: &serde_norway::Mapping, alias: &str, channel: Option<&str>) -> Self {
let mut preserved_protocols = BTreeSet::new();
if let Some(channels) =
yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping)
{
for (channel, entries) in channels {
let matches = entries.as_sequence().is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.as_mapping()
.and_then(|entry| yaml_mapping_value(entry, "alias"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias))
})
});
if matches {
preserved_protocols.insert(
oauth_alias_channel_details(channel.as_str().unwrap_or_default()).2,
);
}
}
}
for (section, protocol) in [
("codex-api-key", "codex"),
("openai-compatibility", "openai"),
("claude-api-key", "claude"),
("gemini-api-key", "gemini"),
] {
let matches = yaml_mapping_value(root, section)
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.filter_map(serde_norway::Value::as_mapping)
.filter_map(|provider| yaml_mapping_value(provider, "models"))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(configured_model_identity)
.any(|(_, name, _)| name.eq_ignore_ascii_case(alias));
if matches {
preserved_protocols.insert(protocol.to_string());
}
}
Self {
protocol: channel.map(|channel| oauth_alias_channel_details(channel).2),
preserved_protocols,
}
}
fn matches(&self, model: &serde_norway::Value, alias: &str) -> bool {
if !thinking_payload_model_name_matches(model, alias) {
return false;
}
let protocol = model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "protocol"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|protocol| !protocol.is_empty());
match protocol {
Some(protocol) => {
self.protocol
.as_ref()
.is_none_or(|target| target.eq_ignore_ascii_case(protocol))
&& !self
.preserved_protocols
.iter()
.any(|existing| existing.eq_ignore_ascii_case(protocol))
}
None => self.preserved_protocols.is_empty(),
}
}
}
fn remove_alias_payload_options(
root: &mut serde_norway::Mapping,
alias: &str,
scope: &AliasPayloadScope,
) -> Result<(), String> {
let Some(payload) = yaml_mapping_value_mut(root, "payload") else {
return Ok(());
};
let payload = payload.as_mapping_mut().ok_or("payload 必须是 YAML 映射")?;
for section in ["override", "override-raw"] {
let Some(rules) = yaml_mapping_value_mut(payload, section) else {
continue;
};
let rules = rules
.as_sequence_mut()
.ok_or_else(|| format!("payload.{section} 必须是数组"))?;
let mut next = Vec::with_capacity(rules.len());
for rule in rules.iter() {
let Some(mapping) = rule.as_mapping() else {
next.push(rule.clone());
continue;
};
let Some(params) =
yaml_mapping_value(mapping, "params").and_then(serde_norway::Value::as_mapping)
else {
next.push(rule.clone());
continue;
};
let mut retained_params = params.clone();
for key in ALIAS_EFFORT_KEYS.iter().copied().chain(["service_tier"]) {
retained_params.remove(yaml_key(key));
}
if retained_params == *params {
next.push(rule.clone());
continue;
}
let Some(models) = yaml_mapping_value(mapping, "models") else {
next.push(rule.clone());
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("payload.{section}.models 必须是数组"))?;
let (target, others): (Vec<_>, Vec<_>) = models
.iter()
.cloned()
.partition(|model| scope.matches(model, alias));
if target.is_empty() {
next.push(rule.clone());
continue;
}
if !others.is_empty() {
let mut shared = mapping.clone();
shared.insert(yaml_key("models"), serde_norway::Value::Sequence(others));
next.push(serde_norway::Value::Mapping(shared));
}
if !retained_params.is_empty() {
let mut retained = mapping.clone();
retained.insert(yaml_key("models"), serde_norway::Value::Sequence(target));
retained.insert(
yaml_key("params"),
serde_norway::Value::Mapping(retained_params),
);
next.push(serde_norway::Value::Mapping(retained));
}
}
*rules = next;
if rules.is_empty() {
payload.remove(yaml_key(section));
}
}
if payload.is_empty() {
root.remove(yaml_key("payload"));
}
Ok(())
}
pub(crate) fn render_updated_core_yaml(
document: &mut yaml_serde_edit::YamlValue,
updated: serde_norway::Value,
) -> Result<String, String> {
document.set(updated);
let rendered = expand_top_level_flow_style_collections(&document.get_string(), document.get())?;
let rendered = indent_indentationless_yaml_sequences(&rendered);
let validated = serde_norway::from_str::<serde_norway::Value>(&rendered)
.map_err(|error| format!("验证更新后的内核配置失败: {error}"))?;
if &validated != document.get() {
let path = first_yaml_mismatch_path(document.get(), &validated, &mut Vec::new())
.unwrap_or_else(|| "<unknown>".to_string());
return Err(format!(
"更新后的内核配置与预期值不一致(路径: {path}),已拒绝写入"
));
}
Ok(rendered)
}
pub(crate) fn expand_top_level_flow_style_collections(
content: &str,
document: &serde_norway::Value,
) -> Result<String, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut rendered = content.to_string();
for (key, value) in root {
let Some(key) = key.as_str() else {
continue;
};
let is_non_empty_collection = match value {
serde_norway::Value::Mapping(mapping) => !mapping.is_empty(),
serde_norway::Value::Sequence(sequence) => !sequence.is_empty(),
_ => false,
};
if !is_non_empty_collection || !top_level_yaml_entry_uses_flow_style(&rendered, key) {
continue;
}
let mut wrapper = serde_norway::Mapping::new();
wrapper.insert(yaml_key(key), value.clone());
let block = serde_norway::to_string(&serde_norway::Value::Mapping(wrapper))
.map_err(|error| format!("格式化内核 YAML 配置失败: {error}"))?;
rendered = replace_top_level_yaml_block(&rendered, key, &block);
}
Ok(rendered)
}
pub(crate) fn top_level_yaml_entry_uses_flow_style(content: &str, key: &str) -> bool {
let prefix = format!("{key}:");
yaml_line_ranges(content).into_iter().any(|range| {
let line = yaml_line_content(content, range);
if line.chars().next().is_some_and(char::is_whitespace) {
return false;
}
line.strip_prefix(&prefix)
.map(str::trim_start)
.is_some_and(|value| value.starts_with('[') || value.starts_with('{'))
})
}
pub(crate) fn indent_indentationless_yaml_sequences(content: &str) -> String {
let mut lines = content
.split_inclusive('\n')
.map(str::to_string)
.collect::<Vec<_>>();
if !content.is_empty() && !content.ends_with('\n') && lines.is_empty() {
lines.push(content.to_string());
}
loop {
let mut range_to_indent = None;
for key_index in 0..lines.len() {
let key_line = lines[key_index].trim_end_matches(['\r', '\n']);
let key_trimmed = key_line.trim_start_matches(' ');
if key_trimmed.is_empty()
|| key_trimmed.starts_with('#')
|| key_trimmed.starts_with('-')
|| !key_trimmed.ends_with(':')
{
continue;
}
let parent_indent = key_line.len() - key_trimmed.len();
let Some(first_item) = ((key_index + 1)..lines.len()).find(|index| {
let line = lines[*index].trim_end_matches(['\r', '\n']).trim();
!line.is_empty() && !line.starts_with('#')
}) else {
continue;
};
let first_line = lines[first_item].trim_end_matches(['\r', '\n']);
let first_trimmed = first_line.trim_start_matches(' ');
let first_indent = first_line.len() - first_trimmed.len();
if first_indent != parent_indent
|| !is_indentationless_yaml_sequence_item(first_trimmed)
{
continue;
}
let mut end = first_item;
while end < lines.len() {
let line = lines[end].trim_end_matches(['\r', '\n']);
let trimmed = line.trim_start_matches(' ');
if !trimmed.is_empty() {
let indent = line.len() - trimmed.len();
if indent < parent_indent
|| (indent == parent_indent
&& !is_indentationless_yaml_sequence_item(trimmed))
{
break;
}
}
end += 1;
}
range_to_indent = Some((first_item, end));
break;
}
let Some((start, end)) = range_to_indent else {
break;
};
for line in &mut lines[start..end] {
if !line.trim().is_empty() {
line.insert_str(0, " ");
}
}
}
lines.concat()
}
pub(crate) fn truncate_for_error(value: &str) -> String {
const LIMIT: usize = 240;
let trimmed = value.trim();
if trimmed.chars().count() <= LIMIT {
return trimmed.to_string();
}
let shortened: String = trimmed.chars().take(LIMIT).collect();
format!("{shortened}…")
}
pub(crate) fn open_external_url_inner(app: &tauri::AppHandle, url: &str) -> Result<(), String> {
let url = url.trim();
if url.is_empty() {
return Err("链接为空".to_string());
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("只允许打开 http/https 链接".to_string());
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|err| format!("打开浏览器失败: {err}"))
}