feat(aliases): add Fast service-tier alias management

Add a dedicated speed alias page that creates model aliases with
service_tier set to priority, plus YAML persistence and validation.
This commit is contained in:
lzt404 committed 2026-08-01 02:25:29 +08:00
1 parent 0d94398dad
commit d8f73cb067
4 files changed
+1080 -5

No files matched your search

+627 -3
View File
@@ -673,6 +673,16 @@ struct ThinkingAliasEntry {
kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SpeedAliasEntry {
source_model: String,
alias: String,
service_tier: String,
provider: String,
kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ThinkingAliasSource {
@@ -1841,6 +1851,96 @@ async fn delete_thinking_alias(
thinking_aliases_from_yaml(&updated)
}
#[tauri::command]
async fn get_speed_aliases(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<SpeedAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let content = fetch_management_config_yaml(&config).await?;
speed_aliases_from_yaml(&content)
}
#[tauri::command]
async fn get_speed_alias_sources(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<ThinkingAliasSource>, String> {
let config = gui_config_state.snapshot()?;
let content = fetch_management_config_yaml(&config).await?;
let available_models =
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
let definitions = fetch_codex_model_definitions(&config)
.await
.unwrap_or_default();
Ok(
resolved_speed_alias_sources(&content, &definitions, &available_models)?
.into_iter()
.map(|resolved| resolved.source)
.collect(),
)
}
#[tauri::command]
async fn create_speed_alias(
gui_config_state: tauri::State<'_, GuiConfigState>,
source_id: String,
alias: String,
) -> Result<Vec<SpeedAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let source_id = source_id.trim().to_string();
if source_id.is_empty() {
return Err("请先选择原模型".to_string());
}
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let available_models =
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
let definitions = fetch_codex_model_definitions(&config)
.await
.unwrap_or_default();
let sources = resolved_speed_alias_sources(&content, &definitions, &available_models)?;
let source = sources
.iter()
.find(|source| source.source.id == source_id)
.cloned()
.ok_or_else(|| {
"原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重试".to_string()
})?;
if source.source.model.eq_ignore_ascii_case(&alias) {
return Err("别名模型不能和原模型相同".to_string());
}
if available_models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(&alias))
{
return Err(format!("{alias} 已经是实际模型 ID,不能再作为别名"));
}
let document = serde_norway::from_str::<serde_norway::Value>(&content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, &alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
let updated = add_speed_alias_to_yaml(&content, &source, &alias)?;
put_management_config_yaml(&config, &updated).await?;
speed_aliases_from_yaml(&updated)
}
#[tauri::command]
async fn delete_speed_alias(
gui_config_state: tauri::State<'_, GuiConfigState>,
alias: String,
) -> Result<Vec<SpeedAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let updated = remove_speed_alias_from_yaml(&content, &alias)?;
put_management_config_yaml(&config, &updated).await?;
speed_aliases_from_yaml(&updated)
}
async fn fetch_agent_models(port: u16, api_key: &str) -> Result<Vec<AgentModelOption>, String> {
if port == 0 {
return Err("内核端口无效".to_string());
@@ -9926,6 +10026,23 @@ fn resolved_thinking_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
resolved_alias_sources(content, definitions, available_models, true)
}
fn resolved_speed_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
resolved_alias_sources(content, definitions, available_models, false)
}
fn resolved_alias_sources(
content: &str,
definitions: &[CodexModelDefinition],
available_models: &[AgentModelOption],
require_reasoning_levels: bool,
) -> Result<Vec<ResolvedThinkingAliasSource>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
@@ -9965,7 +10082,7 @@ fn resolved_thinking_alias_sources(
definitions
.iter()
.filter(|definition| {
!definition.reasoning_levels.is_empty()
(!require_reasoning_levels || !definition.reasoning_levels.is_empty())
&& thinking_alias_model_is_available(available_models, &definition.id)
&& !configured_codex_api_models.contains(&definition.id.to_ascii_lowercase())
})
@@ -10187,6 +10304,93 @@ fn thinking_aliases_from_value(
Ok(entries)
}
fn speed_aliases_from_yaml(content: &str) -> Result<Vec<SpeedAliasEntry>, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
speed_aliases_from_value(&document)
}
fn speed_aliases_from_value(
document: &serde_norway::Value,
) -> Result<Vec<SpeedAliasEntry>, String> {
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut entries = Vec::new();
if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
if let Some(codex_aliases) = yaml_mapping_value(oauth_aliases, "codex") {
let codex_aliases = codex_aliases
.as_sequence()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
for entry in codex_aliases {
let Some(mapping) = entry.as_mapping() else {
continue;
};
if !matches!(
yaml_mapping_value(mapping, "fork"),
Some(serde_norway::Value::Bool(true))
) {
continue;
}
let Some(source_model) = yaml_mapping_value(mapping, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.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, "codex") else {
continue;
};
entries.push(SpeedAliasEntry {
source_model: source_model.to_string(),
alias: alias.to_string(),
service_tier,
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
});
}
}
}
collect_config_speed_alias_entries(
root,
"codex-api-key",
"Codex API",
"codex-api",
"codex",
&mut entries,
)?;
collect_config_speed_alias_entries(
root,
"openai-compatibility",
"OpenAI 兼容",
"openai-compatible",
"openai",
&mut entries,
)?;
entries.sort_by(|left, right| {
left.provider
.to_ascii_lowercase()
.cmp(&right.provider.to_ascii_lowercase())
.then_with(|| {
left.alias
.to_ascii_lowercase()
.cmp(&right.alias.to_ascii_lowercase())
})
});
Ok(entries)
}
fn collect_config_thinking_alias_entries(
root: &serde_norway::Mapping,
section: &str,
@@ -10235,6 +10439,54 @@ fn collect_config_thinking_alias_entries(
Ok(())
}
fn collect_config_speed_alias_entries(
root: &serde_norway::Mapping,
section: &str,
fallback_provider: &str,
kind: &str,
protocol: &str,
entries: &mut Vec<SpeedAliasEntry>,
) -> Result<(), String> {
let Some(providers) = yaml_mapping_value(root, section) else {
return Ok(());
};
let providers = providers
.as_sequence()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for (provider_index, provider) in providers.iter().enumerate() {
let Some(provider) = provider.as_mapping() else {
continue;
};
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
let models = models
.as_sequence()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
for model in models {
let Some((source_model, alias, _)) = configured_model_identity(model) else {
continue;
};
if source_model == alias {
continue;
}
let Some(service_tier) = find_speed_alias_service_tier(root, &alias, protocol) else {
continue;
};
entries.push(SpeedAliasEntry {
source_model,
alias,
service_tier,
provider: provider_name.clone(),
kind: kind.to_string(),
});
}
}
Ok(())
}
fn find_thinking_alias_effort(
root: &serde_norway::Mapping,
alias: &str,
@@ -10276,6 +10528,40 @@ fn find_thinking_alias_effort(
None
}
fn find_speed_alias_service_tier(
root: &serde_norway::Mapping,
alias: &str,
protocol: &str,
) -> Option<String> {
let rules = nested_yaml_value(root, &["payload", "override"])?.as_sequence()?;
for rule in rules {
let Some(rule) = rule.as_mapping() else {
continue;
};
let service_tier = yaml_mapping_value(rule, "params")
.and_then(serde_norway::Value::as_mapping)
.and_then(|params| yaml_mapping_value(params, "service_tier"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(service_tier) = service_tier else {
continue;
};
let Some(models) =
yaml_mapping_value(rule, "models").and_then(serde_norway::Value::as_sequence)
else {
continue;
};
if models
.iter()
.any(|model| thinking_payload_model_matches(model, alias, protocol))
{
return Some(service_tier.to_ascii_lowercase());
}
}
None
}
fn thinking_payload_model_matches(
model: &serde_norway::Value,
alias: &str,
@@ -10312,7 +10598,7 @@ fn add_thinking_alias_to_yaml(
match &source.location {
ThinkingAliasSourceLocation::CodexOauth => {
append_codex_oauth_thinking_alias(root, &source.source.model, alias)?;
append_codex_oauth_model_alias(root, &source.source.model, alias)?;
}
ThinkingAliasSourceLocation::ConfigModel {
section,
@@ -10385,7 +10671,81 @@ fn add_thinking_alias_to_yaml(
render_updated_core_yaml(&mut document, updated)
}
fn append_codex_oauth_thinking_alias(
fn add_speed_alias_to_yaml(
content: &str,
source: &ResolvedThinkingAliasSource,
alias: &str,
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if configured_model_alias_exists(root, alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
match &source.location {
ThinkingAliasSourceLocation::CodexOauth => {
append_codex_oauth_model_alias(root, &source.source.model, alias)?;
}
ThinkingAliasSourceLocation::ConfigModel {
section,
provider_index,
model_index,
} => append_config_speed_alias(
root,
section,
*provider_index,
*model_index,
&source.source.model,
alias,
)?,
}
remove_speed_payload_model(root, alias)?;
let payload = root
.entry(yaml_key("payload"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "payload 必须是 YAML 映射".to_string())?;
let override_rules = payload
.entry(yaml_key("override"))
.or_insert_with(|| serde_norway::Value::Sequence(Vec::new()))
.as_sequence_mut()
.ok_or_else(|| "payload.override 必须是数组".to_string())?;
let mut model_mapping = serde_norway::Mapping::new();
model_mapping.insert(
yaml_key("name"),
serde_norway::Value::String(alias.to_string()),
);
model_mapping.insert(
yaml_key("protocol"),
serde_norway::Value::String(source.source.protocol.clone()),
);
let mut params_mapping = serde_norway::Mapping::new();
params_mapping.insert(
yaml_key("service_tier"),
serde_norway::Value::String("priority".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));
render_updated_core_yaml(&mut document, updated)
}
fn append_codex_oauth_model_alias(
root: &mut serde_norway::Mapping,
source_model: &str,
alias: &str,
@@ -10478,6 +10838,60 @@ fn append_config_thinking_alias(
Ok(())
}
fn append_config_speed_alias(
root: &mut serde_norway::Mapping,
section: &str,
provider_index: usize,
model_index: usize,
expected_model: &str,
alias: &str,
) -> Result<(), String> {
let providers = yaml_mapping_value_mut(root, section)
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section} 必须是数组"))?;
let provider = providers
.get_mut(provider_index)
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or_else(|| "模型提供商已经变化,请刷新后重试".to_string())?;
let models = yaml_mapping_value_mut(provider, "models")
.and_then(serde_norway::Value::as_sequence_mut)
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let source = models
.get(model_index)
.cloned()
.ok_or_else(|| "原模型已经变化,请刷新后重试".to_string())?;
let (_, current_model, _) =
configured_model_identity(&source).ok_or_else(|| "原模型配置格式无效".to_string())?;
if !current_model.eq_ignore_ascii_case(expected_model) {
return Err("原模型已经变化,请刷新后重试".to_string());
}
let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| {
let mut mapping = serde_norway::Mapping::new();
if let Some(name) = source.as_str() {
mapping.insert(
yaml_key("name"),
serde_norway::Value::String(name.to_string()),
);
}
mapping
});
alias_model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
if let Some(display_name) = yaml_mapping_value(&alias_model, "display-name")
.and_then(serde_norway::Value::as_str)
.map(str::to_string)
{
alias_model.insert(
yaml_key("display-name"),
serde_norway::Value::String(format!("{display_name} (Fast)")),
);
}
models.push(serde_norway::Value::Mapping(alias_model));
Ok(())
}
fn remove_thinking_alias_from_yaml(content: &str, alias: &str) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
@@ -10524,6 +10938,52 @@ fn remove_thinking_alias_from_yaml(content: &str, alias: &str) -> Result<String,
render_updated_core_yaml(&mut document, updated)
}
fn remove_speed_alias_from_yaml(content: &str, alias: &str) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut removed = false;
let mut remove_oauth_section = false;
if let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") {
let oauth_aliases = oauth_aliases
.as_mapping_mut()
.ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?;
if let Some(codex_aliases) = yaml_mapping_value_mut(oauth_aliases, "codex") {
let codex_aliases = codex_aliases
.as_sequence_mut()
.ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?;
codex_aliases.retain(|entry| {
let matches = entry
.as_mapping()
.and_then(|mapping| yaml_mapping_value(mapping, "alias"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case(alias));
if matches {
removed = true;
}
!matches
});
if codex_aliases.is_empty() {
oauth_aliases.remove(yaml_key("codex"));
}
}
remove_oauth_section = oauth_aliases.is_empty();
}
removed |= remove_config_speed_alias(root, "codex-api-key", "codex", alias)?;
removed |= remove_config_speed_alias(root, "openai-compatibility", "openai", alias)?;
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
if remove_oauth_section {
root.remove(yaml_key("oauth-model-alias"));
}
remove_speed_payload_model(root, alias)?;
render_updated_core_yaml(&mut document, updated)
}
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)
@@ -10593,6 +11053,45 @@ fn remove_config_thinking_alias(
Ok(removed)
}
fn remove_config_speed_alias(
root: &mut serde_norway::Mapping,
section: &str,
protocol: &str,
alias: &str,
) -> Result<bool, String> {
if find_speed_alias_service_tier(root, alias, protocol).is_none() {
return Ok(false);
}
let Some(providers) = yaml_mapping_value_mut(root, section) else {
return Ok(false);
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
let mut removed = false;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
models.retain(|model| {
let matches = configured_model_identity(model)
.map(|(source, model_alias, _)| {
source != model_alias && model_alias.eq_ignore_ascii_case(alias)
})
.unwrap_or(false);
removed |= matches;
!matches
});
}
Ok(removed)
}
fn remove_thinking_payload_model(
root: &mut serde_norway::Mapping,
alias: &str,
@@ -10649,6 +11148,56 @@ fn remove_thinking_payload_model(
Ok(())
}
fn remove_speed_payload_model(root: &mut serde_norway::Mapping, alias: &str) -> Result<(), String> {
let mut remove_payload_section = false;
if let Some(payload) = yaml_mapping_value_mut(root, "payload") {
let payload = payload
.as_mapping_mut()
.ok_or_else(|| "payload 必须是 YAML 映射".to_string())?;
if let Some(override_rules) = yaml_mapping_value_mut(payload, "override") {
let override_rules = override_rules
.as_sequence_mut()
.ok_or_else(|| "payload.override 必须是数组".to_string())?;
let mut next_rules = Vec::with_capacity(override_rules.len());
for mut rule in std::mem::take(override_rules) {
let mut removed_from_rule = false;
let mut models_empty = false;
if let Some(rule_mapping) = rule.as_mapping_mut() {
let has_speed = yaml_mapping_value(rule_mapping, "params")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|params| yaml_mapping_value(params, "service_tier").is_some());
if has_speed {
if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") {
let models = models
.as_sequence_mut()
.ok_or_else(|| "payload.override.models 必须是数组".to_string())?;
let before = models.len();
models.retain(|model| {
!thinking_payload_model_matches(model, alias, "codex")
&& !thinking_payload_model_matches(model, alias, "openai")
});
removed_from_rule = models.len() != before;
models_empty = models.is_empty();
}
}
}
if !(removed_from_rule && models_empty) {
next_rules.push(rule);
}
}
*override_rules = next_rules;
if override_rules.is_empty() {
payload.remove(yaml_key("override"));
}
}
remove_payload_section = payload.is_empty();
}
if remove_payload_section {
root.remove(yaml_key("payload"));
}
Ok(())
}
fn render_updated_core_yaml(
document: &mut yaml_serde_edit::YamlValue,
updated: serde_norway::Value,
@@ -14154,6 +14703,10 @@ fn main() {
get_thinking_alias_sources,
create_thinking_alias,
delete_thinking_alias,
get_speed_aliases,
get_speed_alias_sources,
create_speed_alias,
delete_speed_alias,
apply_agent_config,
reset_agent_config_to_default,
clear_codex_config,
@@ -15520,6 +16073,77 @@ mod tests {
);
}
#[test]
fn speed_alias_adds_fast_service_tier_and_removes_only_its_rule() {
let input = "payload:\n override:\n - models:\n - name: existing-thinker\n protocol: codex\n params:\n reasoning.effort: xhigh\n";
let source = test_codex_oauth_thinking_source("gpt-5.6-sol");
let rendered = add_speed_alias_to_yaml(input, &source, "gpt-5.6-sol-fast").unwrap();
assert!(rendered.contains("alias: gpt-5.6-sol-fast"), "{rendered}");
assert!(rendered.contains("service_tier: priority"), "{rendered}");
assert!(!rendered.contains("reasoning.effort: fast"), "{rendered}");
assert_eq!(
speed_aliases_from_yaml(&rendered).unwrap(),
vec![SpeedAliasEntry {
source_model: "gpt-5.6-sol".to_string(),
alias: "gpt-5.6-sol-fast".to_string(),
service_tier: "priority".to_string(),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
}]
);
let restored = remove_speed_alias_from_yaml(&rendered, "gpt-5.6-sol-fast").unwrap();
assert!(!restored.contains("gpt-5.6-sol-fast"), "{restored}");
assert!(restored.contains("reasoning.effort: xhigh"), "{restored}");
}
#[test]
fn speed_alias_supports_openai_compatible_model_entries() {
let input = "openai-compatibility:\n - name: Relay\n base-url: https://example.com/v1\n api-key-entries:\n - api-key: test\n models:\n - name: gpt-5.6-terra\n display-name: Terra\n";
let available_models = test_agent_models(&["gpt-5.6-terra"]);
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
let source = sources
.iter()
.find(|source| source.source.model == "gpt-5.6-terra")
.unwrap();
let rendered = add_speed_alias_to_yaml(input, source, "gpt-5.6-terra-fast").unwrap();
assert!(rendered.contains("alias: gpt-5.6-terra-fast"), "{rendered}");
assert!(
rendered.contains("display-name: Terra (Fast)"),
"{rendered}"
);
assert!(rendered.contains("protocol: openai"), "{rendered}");
assert!(rendered.contains("service_tier: priority"), "{rendered}");
assert_eq!(speed_aliases_from_yaml(&rendered).unwrap().len(), 1);
let restored = remove_speed_alias_from_yaml(&rendered, "gpt-5.6-terra-fast").unwrap();
assert!(!restored.contains("gpt-5.6-terra-fast"), "{restored}");
}
#[test]
fn speed_alias_sources_include_codex_models_without_reasoning_levels() {
let definitions = vec![CodexModelDefinition {
id: "gpt-speed-only".to_string(),
display_name: None,
description: None,
context_window: None,
reasoning_levels: Vec::new(),
supports_tools: None,
}];
let available_models = test_agent_models(&["gpt-speed-only"]);
assert!(
resolved_thinking_alias_sources("{}", &definitions, &available_models)
.unwrap()
.is_empty()
);
let sources = resolved_speed_alias_sources("{}", &definitions, &available_models).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].source.model, "gpt-speed-only");
}
#[test]
fn thinking_alias_effort_accepts_provider_defined_levels() {
assert_eq!(validate_thinking_alias_effort(" AUTO ").unwrap(), "auto");
+30
View File
@@ -16,6 +16,7 @@ export const zhCN = {
'app.nav.kernel': '内核',
'app.nav.config': '高级设置',
'app.nav.thinkingAliases': '模型别名',
'app.nav.speedAliases': '速度别名',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接入',
'app.nav.authFiles': '认证文件',
@@ -242,6 +243,20 @@ export const zhCN = {
'aliases.empty.description': '从左侧选择模型和强度即可创建',
'aliases.unboundEffort': '未绑定强度',
'aliases.delete': '删除 {alias}',
'speedAliases.created': '已创建 {alias},速度层级固定为 Fast',
'speedAliases.deleted': '已删除 {alias}',
'speedAliases.deleteConfirm': '确定删除速度别名模型“{alias}”吗?\n将同时移除对应的 Fast 覆盖规则。',
'speedAliases.create.title': '创建速度别名',
'speedAliases.create.description': '为别名模型固定启用 Fast 请求层级',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例如 {model}-fast',
'speedAliases.creating': '创建中',
'speedAliases.create': '创建 Fast 别名',
'speedAliases.createdList.title': '已创建速度别名',
'speedAliases.createdList.description': '每个别名都保留原模型、来源和固定 Fast 层级',
'speedAliases.empty.title': '尚未创建速度别名',
'speedAliases.empty.description': '从左侧选择模型即可创建 Fast 别名',
'speedAliases.delete': '删除 {alias}',
'config.routing.roundRobin': '轮询',
'config.routing.fillFirst': '优先填充',
'config.tabs.label': '高级设置子页面',
@@ -864,6 +879,7 @@ export const en: Record<MessageKey, string> = {
'app.nav.kernel': 'Core',
'app.nav.config': 'Advanced Settings',
'app.nav.thinkingAliases': 'Model Aliases',
'app.nav.speedAliases': 'Speed Aliases',
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API Access',
'app.nav.authFiles': 'Auth Files',
@@ -1090,6 +1106,20 @@ export const en: Record<MessageKey, string> = {
'aliases.empty.description': 'Select a model and effort on the left to create one',
'aliases.unboundEffort': 'No effort bound',
'aliases.delete': 'Delete {alias}',
'speedAliases.created': 'Created {alias} with the Fast service tier fixed',
'speedAliases.deleted': 'Deleted {alias}',
'speedAliases.deleteConfirm': 'Delete the speed alias “{alias}”?\nIts Fast service-tier override will also be removed.',
'speedAliases.create.title': 'Create Speed Alias',
'speedAliases.create.description': 'Create an alias that always enables the Fast request tier',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': 'For example: {model}-fast',
'speedAliases.creating': 'Creating',
'speedAliases.create': 'Create Fast Alias',
'speedAliases.createdList.title': 'Created Speed Aliases',
'speedAliases.createdList.description': 'Each alias retains its source model, provider, and fixed Fast tier',
'speedAliases.empty.title': 'No speed aliases yet',
'speedAliases.empty.description': 'Select a model on the left to create a Fast alias',
'speedAliases.delete': 'Delete {alias}',
'config.routing.roundRobin': 'Round Robin',
'config.routing.fillFirst': 'Fill First',
'config.tabs.label': 'Advanced settings sections',
+24 -2
View File
@@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import { useI18n } from '../i18n';
import { SpeedAliasesPage } from './SpeedAliasesPage';
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
type CoreConfigSettings = {
@@ -46,7 +47,7 @@ type ConfigAction =
| 'network'
| null;
type NoticeTone = 'success' | 'error';
type ConfigSubpage = 'general' | 'network' | 'aliases';
type ConfigSubpage = 'general' | 'network' | 'aliases' | 'speedAliases';
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
@@ -400,6 +401,18 @@ export function ConfigPanelPage() {
>
{t('app.nav.thinkingAliases')}
</button>
<button
type="button"
id="config-subpage-tab-speed-aliases"
role="tab"
className={activeSubpage === 'speedAliases' ? 'active' : ''}
aria-selected={activeSubpage === 'speedAliases'}
aria-controls="config-subpage-panel-speed-aliases"
tabIndex={activeSubpage === 'speedAliases' ? 0 : -1}
onClick={() => setActiveSubpage('speedAliases')}
>
{t('app.nav.speedAliases')}
</button>
</div>
{activeSubpage === 'general' ? (
@@ -710,7 +723,7 @@ export function ConfigPanelPage() {
</div>
</section>
</div>
) : (
) : activeSubpage === 'aliases' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-aliases"
@@ -719,6 +732,15 @@ export function ConfigPanelPage() {
>
<ThinkingAliasesPage />
</div>
) : (
<div
className="config-subpage-panel"
id="config-subpage-panel-speed-aliases"
role="tabpanel"
aria-labelledby="config-subpage-tab-speed-aliases"
>
<SpeedAliasesPage />
</div>
)}
{addDialogOpen ? (
+399
View File
@@ -0,0 +1,399 @@
import {
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
ArrowRight,
Check,
GitFork,
LoaderCircle,
Search,
Trash2,
Zap,
} from 'lucide-react';
import { useI18n } from '../i18n';
import { thinkingAliasSourceKindLabel } from './ThinkingAliasesPage';
type SpeedAliasEntry = {
sourceModel: string;
alias: string;
serviceTier: string;
provider: string;
kind: string;
};
type SpeedAliasSource = {
id: string;
model: string;
displayName: string | null;
provider: string;
kind: string;
protocol: string;
};
export function SpeedAliasesPage() {
const { t } = useI18n();
const [entries, setEntries] = useState<SpeedAliasEntry[]>([]);
const [sources, setSources] = useState<SpeedAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [alias, setAlias] = useState('');
const [search, setSearch] = useState('');
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
const modelPickerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [busyAlias, setBusyAlias] = useState('');
const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [nextEntries, nextSources] = await Promise.all([
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<SpeedAliasSource[]>('get_speed_alias_sources'),
]);
setEntries(nextEntries);
setSources(nextSources);
setSelectedSourceId((current) => (
nextSources.some((source) => source.id === current) ? current : ''
));
} catch (requestError) {
setError(String(requestError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!modelPickerOpen) return undefined;
const closeOnOutsideClick = (event: PointerEvent) => {
if (!modelPickerRef.current?.contains(event.target as Node)) {
setModelPickerOpen(false);
setSearch('');
}
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [modelPickerOpen]);
const selectedSource = useMemo(
() => sources.find((source) => source.id === selectedSourceId) ?? null,
[selectedSourceId, sources],
);
const filteredSources = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return sources;
return sources.filter((source) => {
const haystack = [
source.model,
source.displayName ?? '',
source.provider,
thinkingAliasSourceKindLabel(source.kind),
].join(' ').toLowerCase();
return haystack.includes(query);
});
}, [search, sources]);
useEffect(() => {
setActiveSourceIndex(0);
}, [search, sources]);
const chooseSource = (source: SpeedAliasSource) => {
setSelectedSourceId(source.id);
setModelPickerOpen(false);
setSearch('');
};
const handleModelSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
setModelPickerOpen(false);
setSearch('');
event.currentTarget.blur();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!modelPickerOpen) {
setModelPickerOpen(true);
return;
}
if (!filteredSources.length) return;
const direction = event.key === 'ArrowDown' ? 1 : -1;
setActiveSourceIndex((current) => (
(current + direction + filteredSources.length) % filteredSources.length
));
return;
}
if (event.key === 'Enter' && modelPickerOpen && filteredSources[activeSourceIndex]) {
event.preventDefault();
chooseSource(filteredSources[activeSourceIndex]);
}
};
const createAlias = async () => {
if (!selectedSource) {
setError(t('aliases.error.selectModel'));
return;
}
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
setError(t('aliases.error.emptyAlias'));
return;
}
setBusyAlias(normalizedAlias);
setBusyAction('create');
setError('');
setNotice('');
try {
const nextEntries = await invoke<SpeedAliasEntry[]>('create_speed_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
});
setEntries(nextEntries);
setNotice(t('speedAliases.created', { alias: normalizedAlias }));
setAlias('');
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
const deleteAlias = async (entry: SpeedAliasEntry) => {
if (!window.confirm(t('speedAliases.deleteConfirm', { alias: entry.alias }))) return;
setBusyAlias(entry.alias);
setBusyAction('delete');
setError('');
setNotice('');
try {
const nextEntries = await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
});
setEntries(nextEntries);
setNotice(t('speedAliases.deleted', { alias: entry.alias }));
} catch (requestError) {
setError(String(requestError));
} finally {
setBusyAlias('');
setBusyAction('');
}
};
return (
<section className="page management-page thinking-alias-page">
<div className="thinking-alias-feedback" aria-live="polite">
{error ? <div className="management-alert error">{error}</div> : null}
{!error && notice ? <div className="management-alert success">{notice}</div> : null}
</div>
<div className="thinking-alias-workbench">
<section className="panel thinking-alias-editor-panel">
<div className="thinking-alias-panel-heading">
<span><Zap size={18} /></span>
<div>
<h2>{t('speedAliases.create.title')}</h2>
<p>{t('speedAliases.create.description')}</p>
</div>
</div>
<div className="thinking-alias-field thinking-model-field">
<label htmlFor="speed-model-search">{t('aliases.originalModel')}</label>
<div className="thinking-model-picker" ref={modelPickerRef}>
<div className="thinking-model-search">
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
<input
id="speed-model-search"
role="combobox"
aria-autocomplete="list"
aria-expanded={modelPickerOpen}
aria-controls="speed-model-options"
aria-activedescendant={modelPickerOpen && filteredSources[activeSourceIndex]
? `speed-model-option-${activeSourceIndex}`
: undefined}
value={modelPickerOpen ? search : selectedSource?.model ?? ''}
onFocus={(event) => {
setSearch(selectedSource?.model ?? '');
setModelPickerOpen(true);
event.currentTarget.select();
}}
onChange={(event) => {
setSearch(event.currentTarget.value);
setModelPickerOpen(true);
}}
onKeyDown={handleModelSearchKeyDown}
placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')}
autoComplete="off"
spellCheck={false}
disabled={loading}
/>
{!modelPickerOpen && selectedSource ? (
<span className="thinking-source-kind">
{thinkingAliasSourceKindLabel(selectedSource.kind)}
</span>
) : null}
</div>
{modelPickerOpen ? (
<div
className="thinking-model-list"
id="speed-model-options"
role="listbox"
aria-label={t('aliases.availableModels')}
>
{filteredSources.length === 0 ? (
<div className="thinking-model-empty">
{sources.length ? t('aliases.noMatch') : t('aliases.noModels')}
</div>
) : filteredSources.map((source, index) => {
const selected = source.id === selectedSourceId;
return (
<button
type="button"
role="option"
aria-selected={selected}
id={`speed-model-option-${index}`}
className={`${selected ? 'selected ' : ''}${index === activeSourceIndex ? 'active' : ''}`.trim()}
key={source.id}
onMouseEnter={() => setActiveSourceIndex(index)}
onClick={() => chooseSource(source)}
disabled={Boolean(busyAlias)}
>
<span className="thinking-model-option-copy">
<span>
<strong title={source.model}>{source.model}</strong>
{source.displayName && source.displayName !== source.model
? <small>{source.displayName}</small>
: null}
</span>
<span className="thinking-model-source">
<em>{thinkingAliasSourceKindLabel(source.kind)}</em>
<small title={source.provider}>{source.provider}</small>
</span>
</span>
{selected ? <Check size={15} /> : null}
</button>
);
})}
</div>
) : null}
</div>
{selectedSource ? (
<div className="thinking-model-selection">
<span>{thinkingAliasSourceKindLabel(selectedSource.kind)}</span>
<strong title={selectedSource.provider}>{selectedSource.provider}</strong>
</div>
) : (
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
)}
</div>
<div className="thinking-alias-preview">
<Zap size={18} />
<div>
<span>{t('speedAliases.fast.title')}</span>
<code>service_tier = priority</code>
</div>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
</div>
<input
id="speed-alias-name"
className="thinking-alias-input"
value={alias}
onChange={(event) => setAlias(event.currentTarget.value)}
placeholder={selectedSource
? t('speedAliases.aliasName.example', { model: selectedSource.model })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
<GitFork size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>service_tier = priority</code>
</div>
</div>
<button
type="button"
className="primary-button thinking-alias-create"
onClick={() => void createAlias()}
disabled={loading || Boolean(busyAlias) || !selectedSource || !alias.trim()}
>
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <Zap size={16} />}
{busyAction === 'create' ? t('speedAliases.creating') : t('speedAliases.create')}
</button>
</section>
<section className="panel thinking-alias-list-panel">
<div className="thinking-alias-list-heading">
<div>
<h2>{t('speedAliases.createdList.title')}</h2>
<span>{t('speedAliases.createdList.description')}</span>
</div>
<strong>{entries.length}</strong>
</div>
<div className="thinking-alias-list">
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('aliases.loadingConfig')}</div>
) : entries.length === 0 ? (
<div className="management-empty">
<Zap size={25} />
<strong>{t('speedAliases.empty.title')}</strong>
<span>{t('speedAliases.empty.description')}</span>
</div>
) : entries.map((entry) => (
<article className="thinking-alias-row" key={`${entry.kind}:${entry.provider}:${entry.alias}`}>
<div className="thinking-alias-route">
<div className="thinking-alias-route-source">
<span title={entry.sourceModel}>{entry.sourceModel}</span>
<small>
<em>{thinkingAliasSourceKindLabel(entry.kind)}</em>
<span title={entry.provider}>{entry.provider}</span>
</small>
</div>
<ArrowRight size={14} />
<strong title={entry.alias}>{entry.alias}</strong>
</div>
<span className="thinking-effort-badge">{entry.serviceTier}</span>
<button
type="button"
className="icon-button danger"
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={t('speedAliases.delete', { alias: entry.alias })}
>
{busyAction === 'delete' && busyAlias === entry.alias
? <LoaderCircle size={15} className="spin" />
: <Trash2 size={15} />}
</button>
</article>
))}
</div>
</section>
</div>
</section>
);
}