3167 lines
113 KiB
Rust
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, ¤t, 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}"))
|
|
}
|