Files
EasyCLIProxyAPI/src-tauri/src/core_config/aliases.rs
T

3167 lines
113 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; 8] = [
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: "devin",
provider: "Devin OAuth",
kind: "devin-oauth",
protocol: "interactions",
supports_reasoning: false,
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"),
"devin" | "cognition" => Some("devin"),
"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, "credentials")?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| format_management_request_error("Failed to read OAuth credential sources", &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 credential source response is missing a files array".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 existing_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label} cannot be empty"));
}
if value.len() > 240 || value.chars().any(char::is_control) {
return Err(format!("Invalid {label} format"));
}
Ok(value.to_string())
}
pub(crate) fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
let value = existing_thinking_alias_model_id(value, label)?;
if value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err(format!("Invalid {label} format; whitespace is not allowed"));
}
Ok(value)
}
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("Reasoning effort cannot be empty".to_string());
}
if effort.chars().all(|character| character.is_ascii_digit()) {
return Err("Fixed reasoning aliases do not support numeric-only budgets. Enter a reasoning level name".to_string());
}
if effort.len() > 64
|| !effort.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return Err("Invalid reasoning effort format. Only letters, numbers, hyphens, underscores, and periods are allowed".to_string());
}
Ok(effort)
}
pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Result<String, String> {
management_v8_yaml_to_legacy_view(&fetch_management_raw_config_yaml(config).await?)
}
pub(crate) async fn fetch_management_raw_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("Failed to read kernel YAML configuration", &error))?;
read_management_text(response).await
}
pub(crate) async fn put_management_config_yaml(
config: &GuiConfigFile,
content: &str,
) -> Result<(), String> {
let current = fetch_management_config_yaml(config).await?;
put_management_legacy_alias_view_changes(config, &current, content).await
}
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, "config/oauth/model-alias")?)
.header("Authorization", management_authorization(config)?)
.json(aliases)
.send()
.await
.map_err(|error| format_management_request_error("Failed to save OAuth model alias", &error))?;
read_management_value(response).await.map(|_| ())
}
pub(crate) const V8_PROVIDER_FAMILIES: [(&str, &str); 8] = [
("gemini-api-key", "gemini"),
("interactions-api-key", "interactions"),
("vertex-api-key", "vertex"),
("codex-api-key", "codex"),
("claude-api-key", "claude"),
("xai-api-key", "xai"),
("meta-api-key", "meta"),
("openai-compatibility", "openai-compatibility"),
];
fn is_v8_shared_provider_field(field: &str) -> bool {
matches!(
field,
"priority"
| "prefix"
| "proxy-url"
| "headers"
| "models"
| "excluded-models"
| "disable-cooling"
| "request-retry"
| "request-scoped-errors"
| "base-url"
)
}
fn normalize_v8_provider_record(
provider: &str,
record: &serde_norway::Mapping,
) -> serde_norway::Mapping {
let mut record = record.clone();
if provider == "openai-compatibility" {
record.remove(yaml_key("test-model"));
record.remove(yaml_key("testModel"));
}
if let Some(models) = record
.get_mut(yaml_key("models"))
.and_then(serde_norway::Value::as_sequence_mut)
{
for model in models {
let Some(thinking) = model
.as_mapping_mut()
.and_then(|model| model.get_mut(yaml_key("thinking")))
.and_then(serde_norway::Value::as_mapping_mut)
else {
continue;
};
for (json, yaml) in [
("zero_allowed", "zero-allowed"),
("dynamic_allowed", "dynamic-allowed"),
] {
if let Some(value) = thinking.remove(yaml_key(json)) {
thinking.entry(yaml_key(yaml)).or_insert(value);
}
}
}
}
record
}
pub(crate) fn flatten_v8_provider_groups(
provider: &str,
groups: &serde_norway::Value,
) -> Result<serde_norway::Value, String> {
let groups = groups
.as_sequence()
.ok_or_else(|| format!("api-keys.{provider} must be an array"))?;
let mut records = Vec::new();
for (group_index, group) in groups.iter().enumerate() {
let group = group.as_mapping().ok_or_else(|| {
format!("api-keys.{provider}[{group_index}] must be an object")
})?;
let keys = yaml_mapping_value(group, "keys")
.and_then(serde_norway::Value::as_sequence)
.ok_or_else(|| {
format!("api-keys.{provider}[{group_index}].keys must be an array")
})?;
if provider == "openai-compatibility" {
let mut record = group.clone();
record.remove(yaml_key("keys"));
if !keys.is_empty() {
record.insert(
yaml_key("api-key-entries"),
serde_norway::Value::Sequence(keys.clone()),
);
}
records.push(serde_norway::Value::Mapping(normalize_v8_provider_record(
provider, &record,
)));
continue;
}
for (key_index, key) in keys.iter().enumerate() {
let key = key.as_mapping().ok_or_else(|| {
format!(
"api-keys.{provider}[{group_index}].keys[{key_index}] must be an object"
)
})?;
let mut record = serde_norway::Mapping::new();
if let Some(name) = yaml_mapping_value(group, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
{
let generated = name
.strip_prefix(&format!("{provider}-"))
.is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
});
if !generated {
record.insert(yaml_key("name"), yaml_key(name));
}
}
for (field, value) in group {
if field
.as_str()
.is_some_and(is_v8_shared_provider_field)
{
record.insert(field.clone(), value.clone());
}
}
for (field, value) in key {
if !value.is_null() {
record.insert(field.clone(), value.clone());
}
}
records.push(serde_norway::Value::Mapping(normalize_v8_provider_record(
provider, &record,
)));
}
}
Ok(serde_norway::Value::Sequence(records))
}
pub(crate) fn group_legacy_provider_records(
provider: &str,
records: &serde_norway::Value,
) -> Result<serde_norway::Value, String> {
let records = records
.as_sequence()
.ok_or_else(|| format!("{provider} provider configuration must be an array"))?;
let mut groups = Vec::new();
for (index, record) in records.iter().enumerate() {
let record = record
.as_mapping()
.ok_or_else(|| format!("{provider} provider entry {index} must be an object"))?;
let record = normalize_v8_provider_record(provider, record);
if provider == "openai-compatibility" {
let mut group = record.clone();
let keys = group
.remove(yaml_key("api-key-entries"))
.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
group.insert(yaml_key("keys"), keys);
groups.push(serde_norway::Value::Mapping(group));
continue;
}
let mut group = serde_norway::Mapping::new();
let name = yaml_mapping_value(&record, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{provider}-{}", index + 1));
group.insert(yaml_key("name"), serde_norway::Value::String(name));
let mut key = serde_norway::Mapping::new();
for (field, value) in &record {
if field.as_str() == Some("name") {
continue;
}
if field
.as_str()
.is_some_and(is_v8_shared_provider_field)
{
group.insert(field.clone(), value.clone());
} else {
key.insert(field.clone(), value.clone());
}
}
group.insert(
yaml_key("keys"),
serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(key)]),
);
groups.push(serde_norway::Value::Mapping(group));
}
Ok(serde_norway::Value::Sequence(groups))
}
pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result<String, String> {
let mut document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
let root = document
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
if yaml_mapping_value(root, "config-version").and_then(serde_norway::Value::as_i64)
!= Some(8)
{
return Ok(content.to_string());
}
let upstreams = yaml_mapping_value(root, "api-keys")
.and_then(serde_norway::Value::as_mapping)
.cloned()
.unwrap_or_default();
let client_keys = yaml_mapping_value(root, "access")
.and_then(serde_norway::Value::as_mapping)
.and_then(|access| yaml_mapping_value(access, "api-keys"))
.cloned();
let oauth_aliases = yaml_mapping_value(root, "oauth")
.and_then(serde_norway::Value::as_mapping)
.and_then(|oauth| yaml_mapping_value(oauth, "model-alias"))
.cloned();
let payload = yaml_mapping_value(root, "requests")
.and_then(serde_norway::Value::as_mapping)
.and_then(|requests| yaml_mapping_value(requests, "payload"))
.cloned();
if let Some(access) = yaml_mapping_value_mut(root, "access")
.and_then(serde_norway::Value::as_mapping_mut)
{
access.remove(yaml_key("api-keys"));
}
if let Some(oauth) = yaml_mapping_value_mut(root, "oauth")
.and_then(serde_norway::Value::as_mapping_mut)
{
oauth.remove(yaml_key("model-alias"));
}
if let Some(requests) = yaml_mapping_value_mut(root, "requests")
.and_then(serde_norway::Value::as_mapping_mut)
{
requests.remove(yaml_key("payload"));
}
root.remove(yaml_key("api-keys"));
// Targeted v8 writes create parents and deletes prune them. Their empty
// shells must not look like unrelated edits to the alias transaction.
for parent in ["access", "oauth", "requests"] {
if yaml_mapping_value(root, parent)
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|mapping| mapping.is_empty())
{
root.remove(yaml_key(parent));
}
}
if let Some(client_keys) = client_keys {
root.insert(yaml_key("api-keys"), client_keys);
}
if let Some(oauth_aliases) = oauth_aliases {
root.insert(yaml_key("oauth-model-alias"), oauth_aliases);
}
if let Some(payload) = payload {
root.insert(yaml_key("payload"), payload);
}
for (legacy, provider) in V8_PROVIDER_FAMILIES {
let groups = yaml_mapping_value(&upstreams, provider)
.cloned()
.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
root.insert(
yaml_key(legacy),
flatten_v8_provider_groups(provider, &groups)?,
);
}
serde_norway::to_string(&document)
.map_err(|error| format!("Failed to serialize kernel YAML configuration: {error}"))
}
pub(crate) async fn put_management_config_value(
config: &GuiConfigFile,
path: &str,
value: &serde_norway::Value,
) -> Result<(), String> {
let value = serde_json::to_value(value)
.map_err(|error| format!("Failed to serialize kernel configuration value: {error}"))?;
let client = management_http_client()?;
let response = client
.put(management_endpoint(config, &format!("config/{path}"))?)
.header("Authorization", management_authorization(config)?)
.json(&value)
.send()
.await
.map_err(|error| {
format_management_request_error("Failed to save kernel configuration", &error)
})?;
read_management_value(response).await.map(|_| ())
}
async fn delete_management_config_value(
config: &GuiConfigFile,
path: &str,
) -> Result<(), String> {
let client = management_http_client()?;
let response = client
.delete(management_endpoint(config, &format!("config/{path}"))?)
.header("Authorization", management_authorization(config)?)
.send()
.await
.map_err(|error| {
format_management_request_error("Failed to delete kernel configuration", &error)
})?;
read_management_value(response).await.map(|_| ())
}
async fn put_management_legacy_alias_view_changes(
config: &GuiConfigFile,
current: &str,
updated: &str,
) -> Result<(), String> {
let parse = |content: &str| {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
document
.as_mapping()
.cloned()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())
};
let mut current = parse(current)?;
let mut updated = parse(updated)?;
let v8 = current.get(yaml_key("config-version")).and_then(serde_norway::Value::as_i64) == Some(8);
// OAuth aliases are written by put_management_oauth_model_aliases so their
// transaction and rollback remain independent from other alias routes.
current.remove(yaml_key("oauth-model-alias"));
updated.remove(yaml_key("oauth-model-alias"));
let current_payload = current.remove(yaml_key("payload"));
let updated_payload = updated.remove(yaml_key("payload"));
let mut provider_changes = Vec::new();
for (legacy, provider) in V8_PROVIDER_FAMILIES {
let before = current.remove(yaml_key(legacy));
let after = updated.remove(yaml_key(legacy));
if before != after {
provider_changes.push((provider, before, after));
}
}
if current != updated {
return Err(
"The alias update changed unsupported kernel configuration; write rejected"
.to_string(),
);
}
let native = if v8 && !provider_changes.is_empty() {
Some(parse(&fetch_management_raw_config_yaml(config).await?)?)
} else { None };
// Resolve every group change before writing payload rules or provider data.
let provider_changes = provider_changes.into_iter().map(|(provider, before, after)| {
let groups = match after {
Some(after) => Some(if let Some(native) = &native {
let groups = nested_yaml_value(native, &["api-keys", provider])
.cloned().unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
update_v8_provider_group_models(provider, &groups, &before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())), &after)?
} else { group_legacy_provider_records(provider, &after)? }),
None => None,
};
Ok::<_, String>((provider, groups))
}).collect::<Result<Vec<_>, _>>()?;
if current_payload != updated_payload {
if let Some(value) = updated_payload.as_ref() {
put_management_config_value(config, "requests/payload", value).await?;
} else {
delete_management_config_value(config, "requests/payload").await?;
}
}
for (provider, groups) in provider_changes {
if let Some(groups) = groups.as_ref() {
put_management_config_value(config, &format!("api-keys/{provider}"), groups).await?;
} else {
delete_management_config_value(config, &format!("api-keys/{provider}")).await?;
}
}
Ok(())
}
#[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!("Failed to parse kernel YAML configuration: {error}"))?;
let root = document
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".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!("Failed to serialize OAuth model alias: {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 alias channel definition is missing".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> {
ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml(
content,
mappings,
models,
oauth_model_definitions,
[
CLAUDE_DESKTOP_OPUS_MODEL_ID,
CLAUDE_DESKTOP_SONNET_MODEL_ID,
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
],
)
}
pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_and_routes_in_yaml(
content: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
oauth_model_definitions: &[OAuthModelDefinitions],
routes: [&str; 3],
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
let sources = root.clone();
if let Some(entries) = &mappings.desktop_models {
validate_claude_desktop_entries(entries)?;
let managed_aliases = managed_claude_desktop_aliases(&sources);
for entry in entries {
if !entry.has_mapping() {
continue;
}
let occupied = configured_model_client_identity(&sources, &entry.alias).is_some()
|| models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(&entry.alias));
let unmanaged_collision = claude_desktop_configured_models(&sources)
.into_iter()
.any(|model| {
configured_model_identity(model).is_some_and(|(_, alias, _)| {
alias.eq_ignore_ascii_case(&entry.alias)
&& !is_managed_claude_model_alias(model, &alias)
})
});
if unmanaged_collision
|| (occupied && !managed_aliases.iter()
.any(|alias| alias.eq_ignore_ascii_case(&entry.alias)))
{
return Err(format!("Alias {} is already used by another model. Choose a different alias", entry.alias));
}
}
for alias in managed_aliases {
if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias)) {
remove_managed_claude_model_alias(root, &alias)?;
}
}
for entry in entries {
if entry.has_mapping() {
ensure_claude_desktop_model_alias(
root,
&sources,
&entry.model,
&entry.alias,
oauth_model_definitions,
)?;
}
}
if *root == sources {
return Ok(content.to_string());
}
return render_updated_core_yaml(&mut document, updated);
}
for alias in managed_claude_desktop_aliases(&sources) {
if !is_claude_desktop_route_id(&alias)
&& ![&mappings.opus, &mappings.sonnet, &mappings.haiku]
.iter()
.any(|model| model.eq_ignore_ascii_case(&alias))
{
remove_managed_claude_model_alias(root, &alias)?;
}
}
for (alias, source_model) in [
(routes[0], mappings.opus.as_str()),
(routes[1], mappings.sonnet.as_str()),
(routes[2], mappings.haiku.as_str()),
] {
if let Some(paired_alias) = paired_claude_desktop_alias(alias) {
remove_managed_claude_model_alias(root, paired_alias)?;
}
if claude_desktop_uses_source_directly(source_model, models) {
if !source_model.eq_ignore_ascii_case(alias) {
remove_managed_claude_model_alias(root, alias)?;
}
continue;
}
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,
)?;
}
}
if *root == sources {
return Ok(content.to_string());
}
render_updated_core_yaml(&mut document, updated)
}
pub(crate) fn validate_claude_desktop_entries(
entries: &[ClaudeDesktopModelMapping],
) -> Result<(), String> {
if entries.is_empty() {
return Err("Add at least one Claude Desktop model".into());
}
let mut ids = HashSet::new();
for entry in entries {
validate_agent_model(entry.source_or_alias())?;
if entry.source_or_alias().chars().any(char::is_whitespace) {
return Err("Source model ID cannot contain whitespace".into());
}
let alias = entry.alias.trim();
if !alias.is_empty() && !alias.eq_ignore_ascii_case(entry.source_or_alias()) && !valid_claude_desktop_alias(alias) {
return Err(format!("Claude Desktop alias {alias} must start with claude-, may contain only lowercase letters, numbers, hyphens, and periods, and cannot contain other model family names such as gpt, grok, gemini, or deepseek"));
}
let id = entry.model_id();
if !ids.insert(id.to_ascii_lowercase()) {
return Err(format!("Duplicate Claude Desktop model ID: {id}"));
}
}
Ok(())
}
pub(crate) fn valid_claude_desktop_alias(alias: &str) -> bool {
claude_desktop_alias_has_valid_syntax(alias) && !claude_desktop_has_other_model_family(alias)
}
fn claude_desktop_alias_has_valid_syntax(alias: &str) -> bool {
alias.len() <= 128
&& alias.strip_prefix("claude-").is_some_and(|rest| {
!rest.is_empty()
&& rest.split(['-', '.']).all(|part| {
!part.is_empty()
&& part.bytes().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
})
})
}
fn claude_desktop_has_other_model_family(name: &str) -> bool {
static OTHER_MODEL_FAMILY: LazyLock<regex::Regex> = LazyLock::new(|| {
let rules: serde_json::Value = serde_json::from_str(include_str!(
"../../../src/services/claudeDesktopModelRules.json"
))
.expect("valid bundled Claude Desktop model rules");
regex::RegexBuilder::new(
rules["otherModelFamilyPattern"]
.as_str()
.expect("Claude Desktop model-family pattern"),
)
.unicode(false)
.build()
.expect("valid Claude Desktop model-family pattern")
});
OTHER_MODEL_FAMILY.is_match(&name.to_ascii_lowercase())
}
fn claude_desktop_configured_models(root: &serde_norway::Mapping) -> Vec<&serde_norway::Value> {
let mut models = Vec::new();
for section in MODEL_ALIAS_CONFIG_SECTIONS {
for provider in yaml_mapping_value(root, section)
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
{
if let Some(entries) = provider.as_mapping()
.and_then(|provider| yaml_mapping_value(provider, "models"))
.and_then(serde_norway::Value::as_sequence)
{
models.extend(entries);
}
}
}
if let Some(channels) = yaml_mapping_value(root, "oauth-model-alias")
.and_then(serde_norway::Value::as_mapping)
{
for entries in channels.values().filter_map(serde_norway::Value::as_sequence) {
models.extend(entries);
}
}
models
}
pub(crate) fn managed_claude_desktop_aliases(root: &serde_norway::Mapping) -> Vec<String> {
claude_desktop_configured_models(root).into_iter().filter_map(|model| {
let (_, alias, _) = configured_model_identity(model)?;
is_managed_claude_model_alias(model, &alias).then_some(alias)
}).collect::<BTreeSet<_>>().into_iter().collect()
}
#[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!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
let mut changed = false;
for alias in [
CLAUDE_DESKTOP_OPUS_MODEL_ID,
CLAUDE_DESKTOP_SONNET_MODEL_ID,
CLAUDE_DESKTOP_HAIKU_MODEL_ID,
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0],
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1],
LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2],
] {
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} must be an array"))?;
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 must be an array"))?;
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} must be an array"))?;
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 must be an array"))?;
let before = models.len();
models.retain(|model| !occupies_claude_client_alias(model, 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 must be a YAML mapping".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} must be an array"))?;
let before = entries.len();
entries.retain(|entry| {
if managed_only {
!is_managed_claude_model_alias(entry, alias)
} else {
!occupies_claude_client_alias(entry, 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)
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0])
{
Some(MANAGED_CLAUDE_OPUS_ALIAS_DISPLAY_NAME)
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_SONNET_MODEL_ID)
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1])
{
Some(MANAGED_CLAUDE_SONNET_ALIAS_DISPLAY_NAME)
} else if alias.eq_ignore_ascii_case(CLAUDE_DESKTOP_HAIKU_MODEL_ID)
|| alias.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2])
{
Some(MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME)
} else if claude_desktop_alias_has_valid_syntax(alias) {
Some("EasyCLIProxyAPI managed Claude Desktop mapping")
} else {
None
}
}
pub(crate) fn claude_desktop_uses_source_directly(
source_model: &str,
models: &[AgentModelOption],
) -> bool {
models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(source_model) && model.is_alias)
|| is_claude_native_model_id(source_model)
}
pub(crate) fn is_claude_native_model_id(name: &str) -> bool {
let name = name.trim();
if name.is_empty() || claude_desktop_has_other_model_family(name) {
return false;
}
let lowered = name.to_ascii_lowercase();
if lowered.starts_with("claude-")
|| lowered.starts_with("anthropic.")
|| lowered.contains("anthropic/")
{
return true;
}
["opus", "sonnet", "haiku", "fable", "mythos"]
.into_iter()
.any(|family| {
lowered == family
|| lowered
.strip_prefix(&format!("{family}-"))
.is_some_and(|rest| {
!rest.is_empty()
&& rest
.chars()
.all(|ch| ch.is_ascii_digit() || ch == '.')
})
})
}
pub(crate) fn is_claude_desktop_route_id(name: &str) -> bool {
claude_desktop_route_pairs()
.iter()
.any(|(current, legacy)| current.eq_ignore_ascii_case(name) || legacy.eq_ignore_ascii_case(name))
}
fn claude_desktop_route_pairs() -> [(&'static str, &'static str); 3] {
[
(CLAUDE_DESKTOP_OPUS_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0]),
(CLAUDE_DESKTOP_SONNET_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]),
(CLAUDE_DESKTOP_HAIKU_MODEL_ID, LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]),
]
}
fn paired_claude_desktop_alias(alias: &str) -> Option<&'static str> {
claude_desktop_route_pairs().into_iter().find_map(|(current, legacy)| {
if alias.eq_ignore_ascii_case(current) {
Some(legacy)
} else if alias.eq_ignore_ascii_case(legacy) {
Some(current)
} else {
None
}
})
}
fn occupies_claude_client_alias(model: &serde_norway::Value, alias: &str) -> bool {
configured_model_identity(model).is_some_and(|(source, client_model, _)| {
client_model.eq_ignore_ascii_case(alias) && !source.eq_ignore_ascii_case(&client_model)
})
}
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| configured_provider_model_is_enabled(provider, alias))
.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, alias)?;
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!(
"Unable to determine the CPA configuration source for model {source_model}; cannot create Claude Desktop alias {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,
},
}
pub(crate) fn configured_provider_model_is_enabled(provider: &serde_norway::Mapping, model: &str) -> bool {
if yaml_mapping_value(provider, "disabled") == Some(&serde_norway::Value::Bool(true)) {
return false;
}
let model = model.trim().to_lowercase();
!yaml_mapping_value(provider, "excluded-models")
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.filter_map(serde_norway::Value::as_str)
.any(|pattern| {
let pattern = pattern.trim().to_lowercase();
if pattern.is_empty() {
return false;
}
let Some((prefix, rest)) = pattern.split_once('*') else {
return pattern == model;
};
let Some(mut remaining) = model.strip_prefix(prefix) else {
return false;
};
let mut parts = rest.rsplitn(2, '*');
let suffix = parts.next().unwrap_or_default();
let Some(middle) = remaining.strip_suffix(suffix) else {
return false;
};
remaining = middle;
if let Some(parts) = parts.next() {
for part in parts.split('*').filter(|part| !part.is_empty()) {
let Some(index) = remaining.find(part) else {
return false;
};
remaining = &remaining[index + part.len()..];
}
}
true
})
}
fn resolve_claude_desktop_alias_source(
root: &serde_norway::Mapping,
source_model: &str,
alias: &str,
) -> Result<Option<ClaudeDesktopAliasSource>, String> {
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} must be an array"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
if !configured_provider_model_is_enabled(provider, alias) {
continue;
}
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models must be an array"))?;
if let Some(model) = models.iter().find_map(|model| {
let (_, client_model, _) = configured_model_identity(model)?;
if !configured_provider_model_is_enabled(provider, &client_model) {
return None;
}
matching_model(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("Model source configuration changed. Refresh and try again")?;
(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 must be a YAML mapping")?;
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} must be an array"))?;
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!("Unsupported Claude managed alias: {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 must be a YAML mapping".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} must be an array"))?;
let display_name = managed_claude_alias_display_name(alias)
.ok_or_else(|| format!("Unsupported Claude managed alias: {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!("routing/model-definitions/{channel}"),
)?)
.header("Authorization", management_authorization(config)?)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| {
format_management_request_error(&format!("Failed to read {channel} OAuth model definition"), &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!("Failed to parse kernel YAML configuration: {error}"))?;
let root = document
.as_mapping()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".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-compatible",
"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} must be an array"))?;
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!("Failed to read model source configuration: {error}"))?
.as_bytes(),
);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models must be an array"))?;
for (model_index, model) in models.iter().enumerate() {
let Some((upstream_model, client_model, display_name)) =
configured_model_identity(model)
else {
continue;
};
// A matching ID may be served by another provider. Check this
// source's exclusions too, including '*' used to disable API keys.
if !configured_provider_model_is_enabled(provider, &client_model)
|| !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!("Failed to parse kernel YAML configuration: {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(|| "Kernel configuration root must be a YAML mapping".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 must be a YAML mapping".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} must be an array"))?;
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-compatible",
"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!("Failed to parse kernel YAML configuration: {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(|| "Kernel configuration root must be a YAML mapping".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 must be a YAML mapping".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} must be an array"))?;
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-compatible",
"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} must be an array"))?;
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 must be an array"))?;
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} must be an array"))?;
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 must be an array"))?;
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!("Forcing a reasoning effort override is not yet supported for {protocol} sources"));
}
},
}
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 supports only OpenAI-compatible API, Codex API, or Codex OAuth model sources".to_string());
}
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("Alias model {alias} already exists"));
}
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 must be a YAML mapping".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 must be an array".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 supports only OpenAI-compatible API, Codex API, or Codex OAuth model sources".to_string());
}
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("Alias model {alias} already exists"));
}
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 must be a YAML mapping".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} must be an array"))?;
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} must be an array"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "Model provider changed. Refresh and try again".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models must be an array"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "Source model changed. Refresh and try again".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "Invalid source model configuration format".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("Source model changed. Refresh and try again".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} must be an array"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "Model provider changed. Refresh and try again".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models must be an array"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "Source model changed. Refresh and try again".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "Invalid source model configuration format".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("Source model changed. Refresh and try again".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!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".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 model {alias} does not exist. Refresh and try again"));
}
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!("Failed to parse kernel YAML configuration: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".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 model {alias} does not exist. Refresh and try again"));
}
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 must be a YAML mapping".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} must be an array"))?;
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} must be an array"))?;
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 must be an array"))?;
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} must be an array"))?;
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 must be an array"))?;
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 must be a YAML mapping")?;
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} must be an array"))?;
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 must be an array"))?;
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!("Failed to validate updated kernel configuration: {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!(
"Updated kernel configuration does not match the expected value (path: {path}); write rejected"
));
}
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(|| "Kernel configuration root must be a YAML mapping".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!("Failed to format kernel YAML configuration: {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("Link is empty".to_string());
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("Only http/https links can be opened".to_string());
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|err| format!("Failed to open browser: {err}"))
}