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:
1 parent
0d94398dad
commit
d8f73cb067
4 files changed
+1080
-5
No files matched your search
+627
-3
@@ -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");
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user