fix(model-alias): apply reasoning effort overrides by model source

- Decouple reasoning effort from the Fast service tier
- Generate native override parameters for each provider
- Validate efforts against the model’s supported levels
- Preserve complete reasoning capabilities on configured models
- Add coverage for multi-provider alias overrides
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent 463c43aae0
commit 8c80ce1151
12 files changed
+552 -589

No files matched your search

+13 -5
View File
@@ -437,7 +437,6 @@ pub(crate) async fn create_thinking_alias(
source_id: String,
alias: String,
effort: String,
fast: Option<bool>,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let source_id = source_id.trim().to_string();
@@ -450,15 +449,12 @@ pub(crate) async fn create_thinking_alias(
} else {
validate_thinking_alias_effort(&effort)?
};
let fast = fast.unwrap_or(false);
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_oauth_model_definitions(&config).await;
let capability = if !effort.is_empty() {
AliasSourceCapability::Reasoning
} else if fast {
AliasSourceCapability::Fast
} else {
AliasSourceCapability::Base
};
@@ -471,6 +467,18 @@ pub(crate) async fn create_thinking_alias(
.ok_or_else(|| {
"原模型已不在内核当前可用模型中,或其配置来源已经变化,请刷新后重新选择".to_string()
})?;
if !effort.is_empty()
&& !source
.source
.reasoning_levels
.iter()
.any(|level| level.eq_ignore_ascii_case(&effort))
{
return Err(format!(
"思考强度 {effort} 不在模型 {} 当前支持的等级中",
source.source.model
));
}
if source.source.model.eq_ignore_ascii_case(&alias) {
return Err("别名模型不能和原模型相同".to_string());
}
@@ -490,7 +498,7 @@ pub(crate) async fn create_thinking_alias(
return Err(format!("别名模型 {alias} 已存在"));
}
let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort, fast)?;
let updated = add_model_alias_to_yaml(&content, &source, &alias, &effort)?;
put_management_alias_config_changes(&config, &content, &updated).await?;
thinking_aliases_from_yaml(&updated)
}
+219 -68
View File
@@ -17,7 +17,7 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
provider: "Vertex OAuth",
kind: "vertex-oauth",
protocol: "gemini",
supports_reasoning: false,
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
@@ -26,7 +26,7 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
provider: "AI Studio OAuth",
kind: "aistudio-oauth",
protocol: "gemini",
supports_reasoning: false,
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
@@ -35,7 +35,7 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
provider: "Antigravity OAuth",
kind: "antigravity-oauth",
protocol: "antigravity",
supports_reasoning: false,
supports_reasoning: true,
supports_fast: false,
force_mapping: true,
},
@@ -44,7 +44,7 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
provider: "Claude OAuth",
kind: "claude-oauth",
protocol: "claude",
supports_reasoning: false,
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
@@ -62,7 +62,7 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
provider: "Kimi OAuth",
kind: "kimi-oauth",
protocol: "openai",
supports_reasoning: false,
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
@@ -70,8 +70,8 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 7] = [
key: "xai",
provider: "xAI OAuth",
kind: "xai-oauth",
protocol: "openai",
supports_reasoning: false,
protocol: "codex",
supports_reasoning: true,
supports_fast: false,
force_mapping: false,
},
@@ -873,8 +873,32 @@ pub(crate) fn resolved_oauth_alias_sources(
available_models,
&mut sources,
)?;
if capability == AliasSourceCapability::Fast {
sources.retain(|source| model_supports_fast(&source.source.model));
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(|source| model_supports_fast(&source.source.model))
}
AliasSourceCapability::Base => {}
}
let configured_codex_api_models = sources
.iter()
@@ -913,6 +937,7 @@ pub(crate) fn resolved_oauth_alias_sources(
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,
@@ -995,6 +1020,7 @@ pub(crate) fn collect_config_thinking_alias_sources(
{
continue;
}
let reasoning_levels = configured_model_reasoning_levels(model, protocol);
sources.push(ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("{section}:{provider_index}:{model_index}"),
@@ -1003,6 +1029,7 @@ pub(crate) fn collect_config_thinking_alias_sources(
provider: provider_name.clone(),
kind: kind.to_string(),
protocol: protocol.to_string(),
reasoning_levels,
},
location: ThinkingAliasSourceLocation::ConfigModel {
section,
@@ -1015,6 +1042,37 @@ pub(crate) fn collect_config_thinking_alias_sources(
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,
@@ -1129,6 +1187,22 @@ pub(crate) fn thinking_aliases_from_value(
"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()
@@ -1339,15 +1413,34 @@ pub(crate) fn find_thinking_alias_effort(
let effort = yaml_mapping_value(rule, "params")
.and_then(serde_norway::Value::as_mapping)
.and_then(|params| {
if protocol.eq_ignore_ascii_case("openai") {
yaml_mapping_value(params, "reasoning_effort")
.or_else(|| yaml_mapping_value(params, "reasoning.effort"))
} else {
yaml_mapping_value(params, "reasoning.effort")
}
let explicit = [
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
]
.into_iter()
.find_map(|key| yaml_mapping_value(params, key))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase);
explicit.or_else(|| {
let thinking_type = yaml_mapping_value(params, "thinking.type")
.and_then(serde_norway::Value::as_str)
.map(str::trim)?;
if thinking_type.eq_ignore_ascii_case("disabled") {
Some("none".to_string())
} else if protocol.eq_ignore_ascii_case("claude")
&& thinking_type.eq_ignore_ascii_case("adaptive")
{
Some("auto".to_string())
} else {
None
}
})
})
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(effort) = effort else {
continue;
@@ -1361,7 +1454,7 @@ pub(crate) fn find_thinking_alias_effort(
.iter()
.any(|model| thinking_payload_model_matches(model, alias, protocol))
{
return Some(effort.to_ascii_lowercase());
return Some(effort);
}
}
None
@@ -1418,18 +1511,103 @@ pub(crate) fn thinking_payload_model_matches(
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" => {
// Antigravity applies payload rules relative to its `request` object.
insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
effort,
);
}
"kimi-oauth" => {
if effort.eq_ignore_ascii_case("none") {
insert(params, "thinking.type", "disabled");
} else {
insert(params, "thinking.type", "enabled");
insert(params, "thinking.effort", effort);
}
}
"codex-oauth" | "codex-api" | "xai-oauth" => {
insert(params, "reasoning.effort", effort);
}
"openai-compatible" => {
insert(params, "reasoning_effort", effort);
if source.model.to_ascii_lowercase().starts_with("deepseek") {
insert(params, "thinking.type", "enabled");
}
}
_ => match source.protocol.as_str() {
"codex" => insert(params, "reasoning.effort", effort),
"openai" => insert(params, "reasoning_effort", effort),
"claude" => {
if effort.eq_ignore_ascii_case("none") {
insert(params, "thinking.type", "disabled");
} else {
insert(params, "thinking.type", "adaptive");
if !effort.eq_ignore_ascii_case("auto") {
insert(params, "output_config.effort", effort);
}
}
}
"gemini" | "antigravity" => insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
effort,
),
protocol => {
return Err(format!("暂不支持为 {protocol} 来源强制覆写思考强度"));
}
},
}
Ok(())
}
pub(crate) fn add_model_alias_to_yaml(
content: &str,
source: &ResolvedThinkingAliasSource,
alias: &str,
effort: &str,
fast: bool,
) -> Result<String, String> {
if fast && !alias_source_supports_fast(source) {
return Err(
"Fast 仅支持 OpenAI 兼容 API、Codex API 或 Codex OAuth 的 GPT 系列模型".to_string(),
);
}
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
@@ -1462,7 +1640,7 @@ pub(crate) fn add_model_alias_to_yaml(
}
remove_thinking_payload_model(root, alias)?;
if effort.is_empty() && !fast {
if effort.is_empty() {
return render_updated_core_yaml(&mut document, updated);
}
let payload = root
@@ -1487,33 +1665,7 @@ pub(crate) fn add_model_alias_to_yaml(
);
let mut params_mapping = serde_norway::Mapping::new();
if !effort.is_empty() {
params_mapping.insert(
yaml_key(if source.source.protocol == "openai" {
"reasoning_effort"
} else {
"reasoning.effort"
}),
serde_norway::Value::String(effort.to_string()),
);
}
if fast {
params_mapping.insert(
yaml_key("service_tier"),
serde_norway::Value::String("priority".to_string()),
);
}
if !effort.is_empty()
&& source.source.protocol == "openai"
&& source
.source
.model
.to_ascii_lowercase()
.starts_with("deepseek")
{
params_mapping.insert(
yaml_key("thinking.type"),
serde_norway::Value::String("enabled".to_string()),
);
insert_thinking_effort_params(&mut params_mapping, &source.source, effort)?;
}
let mut rule_mapping = serde_norway::Mapping::new();
rule_mapping.insert(
@@ -1695,15 +1847,6 @@ pub(crate) fn append_config_thinking_alias(
serde_norway::Value::String(format!("{display_name} ({effort})")),
);
}
let thinking = alias_model
.entry(yaml_key("thinking"))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "模型 thinking 必须是映射".to_string())?;
thinking.insert(
yaml_key("levels"),
serde_norway::Value::Sequence(vec![serde_norway::Value::String(effort.to_string())]),
);
}
models.push(serde_norway::Value::Mapping(alias_model));
Ok(())
@@ -1786,6 +1929,8 @@ pub(crate) fn remove_thinking_alias_from_yaml_for_channel(
if oauth_channel.is_none() {
removed |= remove_config_thinking_alias(root, "codex-api-key", "codex", alias)?;
removed |= remove_config_thinking_alias(root, "openai-compatibility", "openai", alias)?;
removed |= remove_config_thinking_alias(root, "claude-api-key", "claude", alias)?;
removed |= remove_config_thinking_alias(root, "gemini-api-key", "gemini", alias)?;
}
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
@@ -1884,7 +2029,7 @@ pub(crate) fn configured_model_alias_exists(root: &serde_norway::Mapping, alias:
})
});
oauth_exists
|| ["codex-api-key", "openai-compatibility"]
|| MODEL_ALIAS_CONFIG_SECTIONS
.into_iter()
.filter_map(|section| yaml_mapping_value(root, section))
.filter_map(serde_norway::Value::as_sequence)
@@ -1996,8 +2141,16 @@ pub(crate) fn remove_thinking_payload_model(
let has_effort = yaml_mapping_value(rule_mapping, "params")
.and_then(serde_norway::Value::as_mapping)
.is_some_and(|params| {
yaml_mapping_value(params, "reasoning.effort").is_some()
|| yaml_mapping_value(params, "reasoning_effort").is_some()
[
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
]
.into_iter()
.any(|key| yaml_mapping_value(params, key).is_some())
|| yaml_mapping_value(params, "thinking.type").is_some()
});
if has_effort {
if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") {
@@ -2005,10 +2158,8 @@ pub(crate) fn remove_thinking_payload_model(
.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")
});
models
.retain(|model| !thinking_payload_model_name_matches(model, alias));
removed_from_rule = models.len() != before;
models_empty = models.is_empty();
}
+1
View File
@@ -1118,6 +1118,7 @@ struct ThinkingAliasSource {
provider: String,
kind: String,
protocol: String,
reasoning_levels: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -2055,7 +2055,7 @@ fn thinking_alias_prefers_codex_api_key_model_over_same_named_oauth_definition()
));
let rendered =
add_model_alias_to_yaml(input, &sources[0], "gpt-5.6-luna-xhigh", "xhigh", false).unwrap();
add_model_alias_to_yaml(input, &sources[0], "gpt-5.6-luna-xhigh", "xhigh").unwrap();
assert!(rendered.contains("alias: gpt-5.6-luna-xhigh"), "{rendered}");
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
}
+220 -28
View File
@@ -18,6 +18,25 @@ fn test_oauth_definition_set(channel: &str, models: &[&str]) -> OAuthModelDefini
}
}
fn test_oauth_thinking_source(channel: &str, model: &str) -> ResolvedThinkingAliasSource {
let channel = oauth_alias_channel(channel).unwrap();
ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("{}:{model}", channel.kind),
model: model.to_string(),
display_name: None,
provider: channel.provider.to_string(),
kind: channel.kind.to_string(),
protocol: channel.protocol.to_string(),
reasoning_levels: vec!["low".to_string(), "high".to_string()],
},
location: ThinkingAliasSourceLocation::Oauth {
channel: channel.key,
force_mapping: channel.force_mapping,
},
}
}
#[test]
fn oauth_auth_provider_names_map_to_alias_channels() {
assert_eq!(
@@ -32,6 +51,197 @@ fn oauth_auth_provider_names_map_to_alias_channels() {
assert_eq!(normalize_oauth_alias_channel("grok"), Some("xai"));
}
#[test]
fn oauth_reasoning_sources_follow_each_model_definition() {
let definition_sets = [
"vertex",
"aistudio",
"antigravity",
"claude",
"codex",
"kimi",
"xai",
]
.into_iter()
.map(|channel| {
let mut definitions = test_oauth_definition_set(channel, &[&format!("{channel}-model")]);
definitions.models[0].reasoning_levels = vec!["low".to_string(), "high".to_string()];
definitions
})
.collect::<Vec<_>>();
let available = definition_sets
.iter()
.map(|definitions| definitions.models[0].id.as_str())
.collect::<Vec<_>>();
let sources = resolved_oauth_alias_sources(
"{}\n",
&definition_sets,
&test_agent_models(&available),
AliasSourceCapability::Reasoning,
)
.unwrap();
assert_eq!(sources.len(), definition_sets.len());
assert!(sources
.iter()
.all(|source| source.source.reasoning_levels == ["low", "high"]));
assert_eq!(
sources
.iter()
.find(|source| source.source.kind == "xai-oauth")
.unwrap()
.source
.protocol,
"codex"
);
}
#[test]
fn thinking_alias_uses_source_native_override_parameters() {
let cases = [
(
"codex",
"gpt-5.6-sol",
"high",
&["reasoning.effort: high"][..],
),
(
"claude",
"claude-opus-4-6",
"high",
&["thinking.type: adaptive", "output_config.effort: high"][..],
),
(
"claude",
"claude-opus-4-6",
"auto",
&["thinking.type: adaptive"][..],
),
(
"claude",
"claude-opus-4-6",
"none",
&["thinking.type: disabled"][..],
),
(
"aistudio",
"gemini-3.1-pro",
"high",
&["generationConfig.thinkingConfig.thinkingLevel: high"][..],
),
(
"antigravity",
"gemini-3.1-pro",
"high",
&["generationConfig.thinkingConfig.thinkingLevel: high"][..],
),
(
"kimi",
"kimi-k2.5",
"high",
&["thinking.type: enabled", "thinking.effort: high"][..],
),
(
"kimi",
"kimi-k2.5",
"none",
&["thinking.type: disabled"][..],
),
("xai", "grok-4", "high", &["reasoning.effort: high"][..]),
];
for (channel, model, effort, expected) in cases {
let source = test_oauth_thinking_source(channel, model);
let alias = format!("{model}-{effort}");
let rendered = add_model_alias_to_yaml("{}\n", &source, &alias, effort).unwrap();
for parameter in expected {
assert!(
rendered.contains(parameter),
"{channel}: missing {parameter}\n{rendered}"
);
}
assert_eq!(
thinking_aliases_from_yaml(&rendered).unwrap()[0]
.effort
.as_deref(),
Some(effort)
);
let restored = remove_thinking_alias_from_yaml(&rendered, &alias).unwrap();
assert!(!restored.contains(&alias), "{channel}: {restored}");
}
}
#[test]
fn configured_claude_and_gemini_models_use_native_overrides() {
let input = "claude-api-key:\n - api-key: claude-key\n models:\n - name: claude-opus-4-6\n thinking:\n levels: [low, high]\ngemini-api-key:\n - api-key: gemini-key\n models:\n - name: gemini-3.1-pro\n thinking:\n levels: [low, high]\n";
let available_models = test_agent_models(&["claude-opus-4-6", "gemini-3.1-pro"]);
let sources = resolved_oauth_alias_sources(
input,
&[],
&available_models,
AliasSourceCapability::Reasoning,
)
.unwrap();
let claude = sources
.iter()
.find(|source| source.source.kind == "claude-api")
.unwrap();
let with_claude = add_model_alias_to_yaml(input, claude, "claude-fixed", "high").unwrap();
assert!(
with_claude.contains("output_config.effort: high"),
"{with_claude}"
);
let sources = resolved_oauth_alias_sources(
&with_claude,
&[],
&available_models,
AliasSourceCapability::Reasoning,
)
.unwrap();
let gemini = sources
.iter()
.find(|source| source.source.kind == "gemini-api")
.unwrap();
let rendered = add_model_alias_to_yaml(&with_claude, gemini, "gemini-fixed", "low").unwrap();
assert!(
rendered.contains("generationConfig.thinkingConfig.thinkingLevel: low"),
"{rendered}"
);
assert!(thinking_aliases_from_yaml(&rendered)
.unwrap()
.iter()
.any(|entry| entry.alias == "claude-fixed"));
assert!(thinking_aliases_from_yaml(&rendered)
.unwrap()
.iter()
.any(|entry| entry.alias == "gemini-fixed"));
let document: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = document.as_mapping().unwrap();
for (section, alias, protocol) in [
("claude-api-key", "claude-fixed", "claude"),
("gemini-api-key", "gemini-fixed", "gemini"),
] {
let alias_model = yaml_mapping_value(root, section)
.and_then(serde_norway::Value::as_sequence)
.and_then(|providers| providers[0].as_mapping())
.and_then(|provider| yaml_mapping_value(provider, "models"))
.and_then(serde_norway::Value::as_sequence)
.unwrap()
.iter()
.find(|model| {
configured_model_identity(model)
.is_some_and(|(_, model_alias, _)| model_alias == alias)
})
.unwrap();
assert_eq!(
configured_model_reasoning_levels(alias_model, protocol),
["low", "high"]
);
}
}
#[test]
fn antigravity_alias_uses_its_own_oauth_channel_and_force_mapping() {
let available_models = test_agent_models(&["gemini-pro-agent"]);
@@ -50,7 +260,7 @@ fn antigravity_alias_uses_its_own_oauth_channel_and_force_mapping() {
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].source.kind, "antigravity-oauth");
let rendered =
add_model_alias_to_yaml("{}\n", &sources[0], "gemini-3.1-pro-preview", "", false).unwrap();
add_model_alias_to_yaml("{}\n", &sources[0], "gemini-3.1-pro-preview", "").unwrap();
assert!(rendered.contains("antigravity:"), "{rendered}");
assert!(rendered.contains("name: gemini-pro-agent"), "{rendered}");
@@ -99,6 +309,7 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() {
provider: "Relay".to_string(),
kind: "openai-compatible".to_string(),
protocol: "openai".to_string(),
reasoning_levels: vec!["low".to_string(), "medium".to_string(), "high".to_string()],
},
location: ThinkingAliasSourceLocation::ConfigModel {
section: "openai-compatibility",
@@ -107,9 +318,6 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() {
},
};
assert!(add_speed_alias_to_yaml(input, &deepseek_source, "deepseek-fast").is_err());
assert!(
add_model_alias_to_yaml(input, &deepseek_source, "deepseek-fast", "high", true).is_err()
);
let antigravity_source = ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
@@ -119,6 +327,7 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() {
provider: "Antigravity OAuth".to_string(),
kind: "antigravity-oauth".to_string(),
protocol: "antigravity".to_string(),
reasoning_levels: vec!["low".to_string(), "high".to_string()],
},
location: ThinkingAliasSourceLocation::Oauth {
channel: "antigravity",
@@ -134,15 +343,13 @@ fn fast_is_only_available_for_gpt_models_from_supported_sources() {
.unwrap();
assert!(antigravity_fast_sources.is_empty());
assert!(add_speed_alias_to_yaml("{}\n", &antigravity_source, "gpt-fast").is_err());
assert!(add_model_alias_to_yaml("{}\n", &antigravity_source, "gpt-fast", "", true).is_err());
}
#[test]
fn thinking_alias_adds_fork_and_matching_payload_rule() {
let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n";
let source = test_codex_oauth_thinking_source("gpt-5.5");
let rendered =
add_model_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh", false).unwrap();
let rendered = add_model_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh").unwrap();
let aliases = thinking_aliases_from_yaml(&rendered).unwrap();
assert!(rendered.contains("# Keep this comment"), "{rendered}");
@@ -163,7 +370,7 @@ fn thinking_alias_adds_fork_and_matching_payload_rule() {
#[test]
fn model_alias_can_be_created_without_overrides() {
let source = test_codex_oauth_thinking_source("gpt-5.5");
let rendered = add_model_alias_to_yaml("{}\n", &source, "gpt-5.5-alias", "", false).unwrap();
let rendered = add_model_alias_to_yaml("{}\n", &source, "gpt-5.5-alias", "").unwrap();
assert!(rendered.contains("alias: gpt-5.5-alias"), "{rendered}");
assert!(!rendered.contains("payload:"), "{rendered}");
@@ -189,7 +396,7 @@ fn configured_model_alias_can_be_created_without_overrides() {
.iter()
.find(|source| source.source.model == "gpt-custom")
.unwrap();
let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-alias", "", false).unwrap();
let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-alias", "").unwrap();
assert!(rendered.contains("alias: gpt-custom-alias"), "{rendered}");
assert!(!rendered.contains("thinking:"), "{rendered}");
@@ -200,20 +407,7 @@ fn configured_model_alias_can_be_created_without_overrides() {
}
#[test]
fn model_alias_can_apply_reasoning_and_fast_together() {
let source = test_codex_oauth_thinking_source("gpt-5.6-sol");
let rendered =
add_model_alias_to_yaml("{}\n", &source, "gpt-5.6-sol-xhigh-fast", "xhigh", true).unwrap();
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
assert!(rendered.contains("service_tier: priority"), "{rendered}");
assert_eq!(thinking_aliases_from_yaml(&rendered).unwrap().len(), 1);
assert_eq!(speed_aliases_from_yaml(&rendered).unwrap().len(), 1);
let restored = remove_thinking_alias_from_yaml(&rendered, "gpt-5.6-sol-xhigh-fast").unwrap();
assert!(!restored.contains("gpt-5.6-sol-xhigh-fast"), "{restored}");
assert!(!restored.contains("service_tier: priority"), "{restored}");
fn thinking_alias_removal_cleans_legacy_combined_rules() {
let legacy = "oauth-model-alias:\n codex:\n - name: gpt-5.6-sol\n alias: legacy-combined\n fork: true\npayload:\n override:\n - models:\n - name: legacy-combined\n protocol: codex\n params:\n reasoning.effort: high\n - models:\n - name: legacy-combined\n protocol: codex\n params:\n service_tier: priority\n";
let restored = remove_thinking_alias_from_yaml(legacy, "legacy-combined").unwrap();
assert!(!restored.contains("legacy-combined"), "{restored}");
@@ -323,7 +517,7 @@ fn thinking_alias_rejects_duplicate_client_visible_name() {
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-high\n fork: true\n";
let source = test_codex_oauth_thinking_source("gpt-5.4");
assert!(
add_model_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high", false)
add_model_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high")
.unwrap_err()
.contains("已存在")
);
@@ -338,8 +532,7 @@ fn thinking_alias_supports_openai_compatible_model_entries() {
.iter()
.find(|source| source.source.model == "deepseek-chat")
.unwrap();
let rendered =
add_model_alias_to_yaml(input, source, "deepseek-chat-high", "high", false).unwrap();
let rendered = add_model_alias_to_yaml(input, source, "deepseek-chat-high", "high").unwrap();
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = value.as_mapping().unwrap();
let providers = yaml_mapping_value(root, "openai-compatibility")
@@ -383,8 +576,7 @@ fn thinking_alias_supports_codex_api_model_entries() {
.iter()
.find(|source| source.source.kind == "codex-api")
.unwrap();
let rendered =
add_model_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh", false).unwrap();
let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh").unwrap();
assert!(rendered.contains("alias: gpt-custom-xhigh"), "{rendered}");
assert!(rendered.contains("protocol: codex"), "{rendered}");
+4
View File
@@ -42,6 +42,10 @@ pub(super) fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingA
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
protocol: "codex".to_string(),
reasoning_levels: vec!["low", "medium", "high", "xhigh", "max"]
.into_iter()
.map(str::to_string)
.collect(),
},
location: ThinkingAliasSourceLocation::Oauth {
channel: "codex",
+9 -27
View File
@@ -237,15 +237,13 @@ export const jaOverrides = {
'aliases.error.selectModel': '先に元のモデルを選択してください',
'aliases.error.emptyAlias': 'エイリアスモデル名を入力してください',
'aliases.error.emptyEffort': '推論レベルを入力してください',
'aliases.error.emptyOptions': '推論レベルを設定するか、Fast を有効にしてください',
'aliases.error.unsupportedEffort': 'このモデルでは推論レベルを設定できないため、Fast オプションのみ使用できます',
'aliases.error.unsupportedFast': 'このモデルでは Fast を利用できません',
'aliases.error.unsupportedEffort': 'このモデルソースには利用可能な推論レベルがありません',
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
'aliases.createdCombined': '{alias} を作成し、推論レベル {effort} と Fast を有効にしました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルと Fast の上書きルールも削除されます。',
'aliases.createdPlain': 'モデルエイリアス {alias} を作成しました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。',
'aliases.deleted': '{alias} を削除しました',
'aliases.create.title': 'モデルエイリアスを作成',
'aliases.create.description': '推論レベルと Fast 速度階層は個別にも同時にも設定できます',
'aliases.create.description': 'モデルに別の入口を作成し、選択したソースの推論レベルを固定します',
'aliases.originalModel': '元のモデル',
'aliases.loadingModels': 'モデルを読み込んでいます',
'aliases.searchModel': '元のモデルを検索して選択',
@@ -253,22 +251,12 @@ export const jaOverrides = {
'aliases.noMatch': '一致するモデルまたはソースがありません',
'aliases.noModels': '選択可能なモデルがありません',
'aliases.sourceLabel': 'ソース:{source}',
'aliases.sourceHint': '同名モデルは Codex OAuth、Codex API、OpenAI 互換ソースごとに表示されます',
'aliases.sourceHint': '同名モデルは実際の接続ソースごとに分けて表示されます',
'aliases.effort.title': '推論レベル',
'aliases.effort.description': '個別に設定することも、Fast と同時に使用することもできます',
'aliases.effort.description': '選択したモデルソースが対応するレベルのみ表示します',
'aliases.effort.none': '設定しない',
'aliases.effort.noneHint': '推論レベルを上書きせず、ほかの有効なオプションのみ使用します',
'aliases.effort.unsupported': 'このモデルには利用可能な推論レベルがないため、Fast のみ有効にしました。',
'aliases.fast.title': '速度オプション',
'aliases.fast.description': 'Fast リクエスト階層を固定します(service_tier=priority)',
'aliases.fast.enabled': '有効:推論レベルと同時に適用されます',
'aliases.fast.disabled': '無効',
'aliases.customLevel': 'カスタムレベル:{effort}',
'aliases.customLevelHint': 'モデルプロバイダーが対応する別の推論レベルを入力',
'aliases.custom': 'カスタム',
'aliases.customPlaceholder': '例:minimal、auto、ultra、またはベンダー独自レベル',
'aliases.collapseCustom': 'カスタム入力を折りたたむ',
'aliases.customHelp': '対象モデルが実際に対応するレベル名のみ入力してください',
'aliases.effort.noneHint': '推論レベルを上書きしません',
'aliases.effort.unsupported': 'このモデルソースは利用可能な推論レベルを報告していません。',
'aliases.aliasName.title': 'エイリアスモデル名',
'aliases.aliasName.description': '新しい入口のみを追加し、元のモデル名は変更しません',
'aliases.aliasName.autoDescription': 'モデルと選択したオプションから自動生成します。元のモデル名は変更せず編集できます',
@@ -280,18 +268,12 @@ export const jaOverrides = {
'aliases.creating': '作成中',
'aliases.create': 'エイリアスを作成',
'aliases.createdList.title': '作成済みエイリアス',
'aliases.createdList.description': '各エイリアスの推論レベルと Fast 状態をまとめて表示します',
'aliases.createdList.description': '各エイリアスに固定された推論レベルを表示します',
'aliases.loadingConfig': '設定を読み込んでいます',
'aliases.empty.title': 'モデルエイリアスはまだありません',
'aliases.empty.description': '左側でモデルとエイリアスオプションを選択して作成できます',
'aliases.unboundEffort': 'レベル未設定',
'aliases.delete': '{alias} を削除',
'speedAliases.created': '{alias} を作成し、速度階層を Fast に固定しました',
'speedAliases.deleted': '{alias} を削除しました',
'speedAliases.deleteConfirm': '速度エイリアス「{alias}」を削除しますか?\n対応する Fast 上書きルールも削除されます。',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例:{model}-fast',
'speedAliases.delete': '{alias} を削除',
'config.routing.roundRobin': 'ラウンドロビン',
'config.routing.fillFirst': '優先充填',
'config.tabs.label': '詳細設定ページ',
+9 -27
View File
@@ -237,15 +237,13 @@ export const en: Record<MessageKey, string> = {
'aliases.error.selectModel': 'Select an original model first',
'aliases.error.emptyAlias': 'The model alias cannot be empty',
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
'aliases.error.emptyOptions': 'Set a reasoning effort or enable Fast',
'aliases.error.unsupportedEffort': 'This model does not support a reasoning-effort override; only Fast can be used',
'aliases.error.unsupportedFast': 'Fast is not available for this model',
'aliases.error.unsupportedEffort': 'This model source has no available reasoning-effort levels',
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.',
'aliases.createdPlain': 'Created model alias {alias}',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort override will also be removed.',
'aliases.deleted': 'Deleted {alias}',
'aliases.create.title': 'Create Model Alias',
'aliases.create.description': 'Reasoning effort and the Fast service tier can be configured independently or together',
'aliases.create.description': 'Create a separate model entry and fix its reasoning effort for the selected source',
'aliases.originalModel': 'Original Model',
'aliases.loadingModels': 'Loading models',
'aliases.searchModel': 'Search and select an original model',
@@ -253,22 +251,12 @@ export const en: Record<MessageKey, string> = {
'aliases.noMatch': 'No matching models or sources',
'aliases.noModels': 'No models are currently available',
'aliases.sourceLabel': 'Source: {source}',
'aliases.sourceHint': 'Models with the same name are listed separately for Codex OAuth, Codex API, and OpenAI-compatible sources',
'aliases.sourceHint': 'Models with the same name are listed separately for each configured source',
'aliases.effort.title': 'Reasoning Effort',
'aliases.effort.description': 'Configure it independently or use it together with Fast',
'aliases.effort.description': 'Only levels reported by the selected model source are shown',
'aliases.effort.none': 'Not set',
'aliases.effort.noneHint': 'Do not override reasoning effort; use another enabled option only',
'aliases.effort.unsupported': 'This model has no available reasoning efforts, so only Fast has been enabled.',
'aliases.fast.title': 'Speed Option',
'aliases.fast.description': 'Always use the Fast request tier (service_tier=priority)',
'aliases.fast.enabled': 'Enabled; works together with reasoning effort',
'aliases.fast.disabled': 'Disabled',
'aliases.customLevel': 'Custom level: {effort}',
'aliases.customLevelHint': 'Enter another reasoning level supported by the model provider',
'aliases.custom': 'Custom',
'aliases.customPlaceholder': 'For example: minimal, auto, ultra, or a provider-specific level',
'aliases.collapseCustom': 'Collapse custom input',
'aliases.customHelp': 'Enter only a level name supported by the target model',
'aliases.effort.noneHint': 'Do not override reasoning effort',
'aliases.effort.unsupported': 'This model source does not report any available reasoning-effort levels.',
'aliases.aliasName.title': 'Model Alias',
'aliases.aliasName.description': 'Adds another entry point without renaming the original model',
'aliases.aliasName.autoDescription': 'Generated from the model and selected options; edit it as needed without renaming the original',
@@ -280,18 +268,12 @@ export const en: Record<MessageKey, string> = {
'aliases.creating': 'Creating',
'aliases.create': 'Create Alias',
'aliases.createdList.title': 'Created Aliases',
'aliases.createdList.description': 'Each alias shows its reasoning effort and Fast status together',
'aliases.createdList.description': 'Each alias shows its fixed reasoning effort',
'aliases.loadingConfig': 'Loading configuration',
'aliases.empty.title': 'No model aliases yet',
'aliases.empty.description': 'Select a model and alias option 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.fast.title': 'Fast',
'speedAliases.aliasName.example': 'For example: {model}-fast',
'speedAliases.delete': 'Delete {alias}',
'config.routing.roundRobin': 'Round Robin',
'config.routing.fillFirst': 'Fill First',
'config.tabs.label': 'Advanced settings sections',
+9 -27
View File
@@ -235,15 +235,13 @@ export const zhCN = {
'aliases.error.selectModel': '请先选择原模型',
'aliases.error.emptyAlias': '别名模型不能为空',
'aliases.error.emptyEffort': '思考强度不能为空',
'aliases.error.emptyOptions': '请至少设置思考强度或启用 Fast',
'aliases.error.unsupportedEffort': '该模型不支持设置思考强度,只能使用 Fast 选项',
'aliases.error.unsupportedFast': '该模型当前不支持 Fast 选项',
'aliases.error.unsupportedEffort': '该模型来源没有可用的思考强度等级',
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
'aliases.createdCombined': '已创建 {alias},思考强度为 {effort},同时启用 Fast',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度和 Fast 覆盖规则。',
'aliases.createdPlain': '已创建别名 {alias}',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n对应的思考强度覆写规则也会被移除。',
'aliases.deleted': '已删除 {alias}',
'aliases.create.title': '创建模型别名',
'aliases.create.description': '思考强度和 Fast 速度层级可独立设置,也可以同时启用',
'aliases.create.description': '为模型创建独立入口,并按模型来源固定思考强度',
'aliases.originalModel': '原模型',
'aliases.loadingModels': '正在读取模型',
'aliases.searchModel': '搜索并选择原模型',
@@ -251,22 +249,12 @@ export const zhCN = {
'aliases.noMatch': '没有匹配的模型或来源',
'aliases.noModels': '当前没有可选模型',
'aliases.sourceLabel': '来源:{source}',
'aliases.sourceHint': '同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出',
'aliases.sourceHint': '同名模型会按实际接入来源分别列出',
'aliases.effort.title': '思考强度',
'aliases.effort.description': '可单独设置,也可以与 Fast 同时启用',
'aliases.effort.description': '仅显示所选模型来源实际支持的等级',
'aliases.effort.none': '不设置',
'aliases.effort.noneHint': '不覆盖思考强度,仅使用其他已启用的选项',
'aliases.effort.unsupported': '这个模型没有可用的思考强度,已切换为仅启用 Fast。',
'aliases.fast.title': '速度选项',
'aliases.fast.description': '固定使用 Fast 请求层级(service_tier=priority)',
'aliases.fast.enabled': '已启用',
'aliases.fast.disabled': '未启用',
'aliases.customLevel': '自定义等级:{effort}',
'aliases.customLevelHint': '输入模型提供商支持的其他思考等级',
'aliases.custom': '自定义',
'aliases.customPlaceholder': '例如 minimal、auto、ultra 或厂商等级',
'aliases.collapseCustom': '收起自定义输入',
'aliases.customHelp': '仅填写目标模型实际支持的等级名称',
'aliases.effort.noneHint': '不覆盖思考强度',
'aliases.effort.unsupported': '这个模型来源没有报告可用的思考强度等级。',
'aliases.aliasName.title': '别名模型名称',
'aliases.aliasName.description': '由你填写,仅新增入口,不会改名原模型',
'aliases.aliasName.autoDescription': '根据原模型和已选选项自动生成,可按需修改;不会改名原模型',
@@ -278,18 +266,12 @@ export const zhCN = {
'aliases.creating': '创建中',
'aliases.create': '创建别名',
'aliases.createdList.title': '已创建别名',
'aliases.createdList.description': '每个别名会同时显示已设置的思考强度和 Fast 状态',
'aliases.createdList.description': '每个别名会显示已固定的思考强度',
'aliases.loadingConfig': '读取配置中',
'aliases.empty.title': '尚未创建模型别名',
'aliases.empty.description': '从左侧选择模型和别名选项即可创建',
'aliases.unboundEffort': '未绑定强度',
'aliases.delete': '删除 {alias}',
'speedAliases.created': '已创建 {alias},速度层级固定为 Fast',
'speedAliases.deleted': '已删除 {alias}',
'speedAliases.deleteConfirm': '确定删除速度别名模型“{alias}”吗?\n将同时移除对应的 Fast 覆盖规则。',
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例如 {model}-fast',
'speedAliases.delete': '删除 {alias}',
'config.routing.roundRobin': '轮询',
'config.routing.fillFirst': '优先填充',
'config.tabs.label': '高级设置子页面',
+41 -204
View File
@@ -15,8 +15,6 @@ import {
LoaderCircle,
Search,
Trash2,
X,
Zap,
} from 'lucide-react';
import { getCurrentLocale, translate, useI18n } from '../i18n';
@@ -31,19 +29,6 @@ type ThinkingAliasEntry = {
oauthChannel?: string | null;
};
type SpeedAliasEntry = {
sourceModel: string;
alias: string;
serviceTier: string;
provider: string;
kind: string;
oauthChannel?: string | null;
};
type AliasListEntry = ThinkingAliasEntry & {
serviceTier: string | null;
};
type ThinkingAliasSource = {
id: string;
model: string;
@@ -51,11 +36,11 @@ type ThinkingAliasSource = {
provider: string;
kind: string;
protocol: string;
reasoningLevels: string[];
};
type ModelAliasSource = ThinkingAliasSource & {
supportsReasoning: boolean;
supportsFast: boolean;
};
const effortOptions = [
@@ -66,47 +51,16 @@ const effortOptions = [
{ value: 'max', label: 'Max', hintKey: 'aliases.effort.max' },
] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>;
export const combineModelAliasEntries = (
thinkingEntries: ThinkingAliasEntry[],
speedEntries: SpeedAliasEntry[],
): AliasListEntry[] => {
const entries = new Map<string, AliasListEntry>();
const entryKey = (entry: Pick<ThinkingAliasEntry, 'kind' | 'provider' | 'sourceModel' | 'alias' | 'oauthChannel'>) => (
[entry.oauthChannel ?? '', entry.kind, entry.provider, entry.sourceModel, entry.alias]
.map((value) => value.toLocaleLowerCase())
.join('\u0000')
);
thinkingEntries.forEach((entry) => {
entries.set(entryKey(entry), { ...entry, serviceTier: null });
});
speedEntries.forEach((entry) => {
const key = entryKey(entry);
const current = entries.get(key);
entries.set(key, current
? { ...current, serviceTier: entry.serviceTier }
: { ...entry, effort: null, serviceTier: entry.serviceTier });
});
return [...entries.values()].sort((left, right) => (
left.provider.localeCompare(right.provider)
|| left.alias.localeCompare(right.alias)
));
};
export const combineModelAliasSources = (
baseSources: ThinkingAliasSource[],
thinkingSources: ThinkingAliasSource[],
speedSources: ThinkingAliasSource[],
): ModelAliasSource[] => {
const reasoningSourceIds = new Set(thinkingSources.map((source) => source.id));
const speedSourceIds = new Set(speedSources.map((source) => source.id));
const sources = new Map<string, ModelAliasSource>();
[...baseSources, ...speedSources, ...thinkingSources].forEach((source) => {
[...baseSources, ...thinkingSources].forEach((source) => {
sources.set(source.id, {
...source,
supportsReasoning: reasoningSourceIds.has(source.id),
supportsFast: speedSourceIds.has(source.id),
});
});
return [...sources.values()];
@@ -115,13 +69,12 @@ export const combineModelAliasSources = (
export const defaultModelAlias = (
model: string | null | undefined,
effort: string,
fast: boolean,
) => {
const normalizedModel = model?.trim() ?? '';
const normalizedEffort = effort.trim().toLowerCase();
if (!normalizedModel) return '';
if (!normalizedEffort && !fast) return `${normalizedModel}-alias`;
return `${normalizedModel}${normalizedEffort ? `-${normalizedEffort}` : ''}${fast ? '-fast' : ''}`;
if (!normalizedEffort) return `${normalizedModel}-alias`;
return `${normalizedModel}-${normalizedEffort}`;
};
export const uniqueModelAlias = (
@@ -155,6 +108,8 @@ export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'kimi-oauth') return 'Kimi OAuth';
if (kind === 'xai-oauth') return 'xAI OAuth';
if (kind === 'codex-api') return 'Codex API';
if (kind === 'claude-api') return 'Claude API';
if (kind === 'gemini-api') return 'Gemini API';
if (kind === 'openai-compatible') return translate(getCurrentLocale(), 'aliases.source.openAiCompatible');
return translate(getCurrentLocale(), 'aliases.source.other');
};
@@ -172,14 +127,10 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
export function ThinkingAliasesPage() {
const { t } = useI18n();
const [thinkingEntries, setThinkingEntries] = useState<ThinkingAliasEntry[]>([]);
const [speedEntries, setSpeedEntries] = useState<SpeedAliasEntry[]>([]);
const [baseSources, setBaseSources] = useState<ThinkingAliasSource[]>([]);
const [thinkingSources, setThinkingSources] = useState<ThinkingAliasSource[]>([]);
const [speedSources, setSpeedSources] = useState<ThinkingAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [effort, setEffort] = useState('');
const [fastEnabled, setFastEnabled] = useState(false);
const [customEffortOpen, setCustomEffortOpen] = useState(false);
const [alias, setAlias] = useState('');
const [search, setSearch] = useState('');
const [modelPickerOpen, setModelPickerOpen] = useState(false);
@@ -196,18 +147,14 @@ export function ThinkingAliasesPage() {
setLoading(true);
setError('');
try {
const [nextThinkingEntries, nextBaseSources, nextThinkingSources, nextSpeedEntries, nextSpeedSources] = await Promise.all([
const [nextThinkingEntries, nextBaseSources, nextThinkingSources] = await Promise.all([
invoke<ThinkingAliasEntry[]>('get_thinking_aliases'),
invoke<ThinkingAliasSource[]>('get_model_alias_sources'),
invoke<ThinkingAliasSource[]>('get_thinking_alias_sources'),
invoke<SpeedAliasEntry[]>('get_speed_aliases'),
invoke<ThinkingAliasSource[]>('get_speed_alias_sources'),
]);
setThinkingEntries(nextThinkingEntries);
setBaseSources(nextBaseSources);
setThinkingSources(nextThinkingSources);
setSpeedEntries(nextSpeedEntries);
setSpeedSources(nextSpeedSources);
setSelectedSourceId((current) => (
nextBaseSources.some((source) => source.id === current)
? current
@@ -237,12 +184,8 @@ export function ThinkingAliasesPage() {
}, [modelPickerOpen]);
const sources = useMemo(
() => combineModelAliasSources(baseSources, thinkingSources, speedSources),
[baseSources, speedSources, thinkingSources],
);
const entries = useMemo(
() => combineModelAliasEntries(thinkingEntries, speedEntries),
[speedEntries, thinkingEntries],
() => combineModelAliasSources(baseSources, thinkingSources),
[baseSources, thinkingSources],
);
useEffect(() => {
@@ -284,14 +227,7 @@ export function ThinkingAliasesPage() {
const chooseSource = (source: ModelAliasSource) => {
setSelectedSourceId(source.id);
if (!source.supportsReasoning) {
setEffort('');
setFastEnabled(source.supportsFast && (fastEnabled || !source.supportsReasoning));
} else {
setEffort('');
setFastEnabled(source.supportsFast && fastEnabled);
}
setCustomEffortOpen(false);
setEffort('');
setAlias('');
generatedAliasRef.current = '';
setModelPickerOpen(false);
@@ -329,12 +265,15 @@ export function ThinkingAliasesPage() {
};
const normalizedEffort = effort.trim().toLowerCase();
const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort, fastEnabled);
const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort);
const uniqueDefaultAlias = uniqueModelAlias(defaultAlias, sources.map((source) => source.model));
const customEffortSelected = Boolean(
normalizedEffort
&& !effortOptions.some((option) => option.value === normalizedEffort),
);
const availableEfforts = selectedSource?.reasoningLevels ?? [];
const visibleEffortOptions = availableEfforts.map((value) => {
const preset = effortOptions.find((option) => option.value === value);
return preset
? { value: preset.value, label: preset.label, title: t(preset.hintKey) }
: { value, label: value, title: value };
});
useEffect(() => {
setAlias((current) => {
@@ -356,10 +295,6 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyAlias'));
return;
}
if (fastEnabled && !selectedSource.supportsFast) {
setError(t('aliases.error.unsupportedFast'));
return;
}
if (normalizedEffort && !selectedSource.supportsReasoning) {
setError(t('aliases.error.unsupportedEffort'));
return;
@@ -369,35 +304,14 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
if (normalizedEffort) {
await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
fast: fastEnabled,
});
setNotice(t(fastEnabled ? 'aliases.createdCombined' : 'aliases.created', {
alias: normalizedAlias,
effort: normalizedEffort,
}));
} else if (fastEnabled) {
await invoke<SpeedAliasEntry[]>('create_speed_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
});
setNotice(t('speedAliases.created', { alias: normalizedAlias }));
} else {
await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: '',
fast: false,
});
setNotice(t('aliases.created', {
alias: normalizedAlias,
effort: t('aliases.effort.none'),
}));
}
await invoke<ThinkingAliasEntry[]>('create_thinking_alias', {
sourceId: selectedSource.id,
alias: normalizedAlias,
effort: normalizedEffort,
});
setNotice(normalizedEffort
? t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort })
: t('aliases.createdPlain', { alias: normalizedAlias }));
setAlias('');
await load();
} catch (requestError) {
@@ -408,7 +322,7 @@ export function ThinkingAliasesPage() {
}
};
const deleteAlias = async (entry: AliasListEntry) => {
const deleteAlias = async (entry: ThinkingAliasEntry) => {
if (!window.confirm(
t('aliases.deleteConfirm', { alias: entry.alias }),
)) return;
@@ -417,17 +331,10 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
if (entry.effort || !entry.serviceTier) {
await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
oauthChannel: entry.oauthChannel,
});
} else {
await invoke<SpeedAliasEntry[]>('delete_speed_alias', {
alias: entry.alias,
oauthChannel: entry.oauthChannel,
});
}
await invoke<ThinkingAliasEntry[]>('delete_thinking_alias', {
alias: entry.alias,
oauthChannel: entry.oauthChannel,
});
setNotice(t('aliases.deleted', { alias: entry.alias }));
await load();
} catch (requestError) {
@@ -485,8 +392,6 @@ export function ThinkingAliasesPage() {
) {
setSelectedSourceId('');
setEffort('');
setFastEnabled(false);
setCustomEffortOpen(false);
setAlias('');
generatedAliasRef.current = '';
}
@@ -572,92 +477,32 @@ export function ThinkingAliasesPage() {
className={!normalizedEffort ? 'active none' : 'none'}
onClick={() => {
chooseEffort('');
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias)}
title={t('aliases.effort.noneHint')}
>
{t('aliases.effort.none')}
</button>
{effortOptions.map((option) => (
{visibleEffortOptions.map((option) => (
<button
type="button"
className={effort.trim().toLowerCase() === option.value ? 'active' : ''}
key={option.value}
onClick={() => {
chooseEffort(option.value);
setCustomEffortOpen(false);
}}
disabled={Boolean(busyAlias) || Boolean(selectedSource && !selectedSource.supportsReasoning)}
title={t(option.hintKey)}
title={option.title}
>
{option.label}
</button>
))}
<button
type="button"
className={customEffortSelected ? 'active custom' : 'custom'}
onClick={() => {
if (!customEffortSelected) chooseEffort('');
setCustomEffortOpen(true);
}}
disabled={Boolean(busyAlias) || Boolean(selectedSource && !selectedSource.supportsReasoning)}
title={customEffortSelected
? t('aliases.customLevel', { effort: normalizedEffort })
: t('aliases.customLevelHint')}
>
{customEffortSelected ? normalizedEffort : t('aliases.custom')}
</button>
</div>
{customEffortOpen ? <div className="thinking-effort-custom">
<input
id="thinking-effort-custom"
value={customEffortSelected ? effort : ''}
onChange={(event) => chooseEffort(event.currentTarget.value)}
placeholder={t('aliases.customPlaceholder')}
maxLength={64}
spellCheck={false}
autoFocus
disabled={Boolean(busyAlias)}
/>
<button
type="button"
className="icon-button quiet"
onClick={() => setCustomEffortOpen(false)}
title={t('aliases.collapseCustom')}
aria-label={t('aliases.collapseCustom')}
>
<X size={14} />
</button>
<small>{t('aliases.customHelp')}</small>
</div> : null}
{selectedSource && !selectedSource.supportsReasoning ? (
<small className="thinking-model-hint">{t('aliases.effort.unsupported')}</small>
) : null}
</div>
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.fast.title')}</strong>
<span>{t('aliases.fast.description')}</span>
</div>
<label className={`thinking-fast-option${fastEnabled ? ' active' : ''}`}>
<span className="thinking-fast-option-copy">
<span><Zap size={15} /> Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</span>
<span className="switch-control thinking-fast-switch">
<input
type="checkbox"
checked={fastEnabled}
onChange={(event) => setFastEnabled(event.currentTarget.checked)}
disabled={Boolean(busyAlias) || Boolean(selectedSource && !selectedSource.supportsFast)}
aria-label={t('aliases.fast.title')}
/>
<span className="switch-track" />
</span>
</label>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
<div className="thinking-alias-field">
@@ -673,24 +518,19 @@ export function ThinkingAliasesPage() {
placeholder={selectedSource
? normalizedEffort
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort })
: fastEnabled
? t('speedAliases.aliasName.example', { model: selectedSource.model })
: t('aliases.aliasName.example', { model: selectedSource.model, effort: 'alias' })
: t('aliases.aliasName.example', { model: selectedSource.model, effort: 'alias' })
: t('aliases.aliasName.selectFirst')}
disabled={Boolean(busyAlias)}
/>
</div>
<div className="thinking-alias-preview">
{normalizedEffort ? <BrainCircuit size={18} /> : <Zap size={18} />}
<BrainCircuit size={18} />
<div>
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
<code>{[
normalizedEffort
? `${selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = ${normalizedEffort}`
: '',
fastEnabled ? 'service_tier = priority' : '',
].filter(Boolean).join(' · ') || t('aliases.notSet')}</code>
<code>{normalizedEffort
? `${t('aliases.effort.title')} = ${normalizedEffort}`
: t('aliases.notSet')}</code>
</div>
</div>
@@ -713,19 +553,19 @@ export function ThinkingAliasesPage() {
<h2>{t('aliases.createdList.title')}</h2>
<span>{t('aliases.createdList.description')}</span>
</div>
<strong>{entries.length}</strong>
<strong>{thinkingEntries.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 ? (
) : thinkingEntries.length === 0 ? (
<div className="management-empty">
<GitFork size={25} />
<strong>{t('aliases.empty.title')}</strong>
<span>{t('aliases.empty.description')}</span>
</div>
) : entries.map((entry) => (
) : thinkingEntries.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">
@@ -744,9 +584,6 @@ export function ThinkingAliasesPage() {
{entry.effort ? (
<span className="thinking-effort-badge">{entry.effort}</span>
) : null}
{entry.serviceTier ? (
<span className="thinking-effort-badge fast">{t('speedAliases.fast.title')}</span>
) : null}
</div>
<button
type="button"
-117
View File
@@ -4744,122 +4744,11 @@ textarea:focus-visible {
color: var(--theme-554075);
}
.thinking-effort-options button.custom {
overflow: hidden;
border-style: dashed;
text-overflow: ellipsis;
white-space: nowrap;
}
.thinking-effort-options button.custom.active {
border-style: solid;
}
.thinking-effort-options button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.thinking-fast-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 48px;
padding: 8px 10px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 7px;
background: var(--theme-fff);
color: var(--theme-7a746c);
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.thinking-fast-option:hover {
border-color: var(--theme-d7a447);
}
.thinking-fast-option.active {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
.thinking-fast-option-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.thinking-fast-option-copy > span {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--font-size-section);
font-weight: 800;
}
.thinking-fast-option-copy small {
color: var(--theme-9a938a);
font-size: var(--font-size-label);
}
.thinking-fast-option.active .thinking-fast-option-copy small {
color: var(--theme-805a24);
}
.thinking-fast-option:has(input:disabled) {
cursor: not-allowed;
opacity: 0.55;
}
.thinking-fast-switch {
width: 42px;
height: 23px;
}
.thinking-fast-switch input:checked + .switch-track {
border-color: var(--theme-d7a447);
background: var(--theme-d7a447);
}
.thinking-effort-custom {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
align-items: center;
gap: 3px;
}
.thinking-effort-custom input {
width: 100%;
height: 34px;
padding: 0 9px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 6px;
outline: 0;
background: var(--theme-fff);
color: var(--theme-2d2a26);
font-size: var(--font-size-section);
}
.thinking-effort-custom input:focus {
border-color: var(--theme-9d8abd);
box-shadow: 0 0 0 2px rgba(128, 105, 173, 0.12);
}
.thinking-effort-custom .icon-button {
width: 30px;
height: 30px;
}
.thinking-effort-custom small {
grid-column: 1 / -1;
color: var(--theme-918a81);
font-size: var(--font-size-label);
line-height: var(--line-height-body);
}
.thinking-alias-input {
width: 100%;
height: 36px;
@@ -5057,12 +4946,6 @@ textarea:focus-visible {
text-transform: none;
}
.thinking-effort-badge.fast {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
@media (min-width: 1081px) {
.thinking-alias-list-panel .thinking-alias-row {
grid-template-columns: minmax(0, 1fr) auto 34px;
+26 -85
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from 'bun:test';
import {
combineModelAliasEntries,
combineModelAliasSources,
defaultModelAlias,
thinkingAliasSourceKindLabel,
@@ -8,108 +7,50 @@ import {
} from '../src/pages/ThinkingAliasesPage';
describe('模型别名默认名称', () => {
test('按思考强度和 Fast 选项生成可编辑的默认名称', () => {
expect(defaultModelAlias('gpt-5.6-sol', 'XHigh', false)).toBe('gpt-5.6-sol-xhigh');
expect(defaultModelAlias('gpt-5.6-sol', '', true)).toBe('gpt-5.6-sol-fast');
expect(defaultModelAlias('gpt-5.6-sol', 'xhigh', true)).toBe('gpt-5.6-sol-xhigh-fast');
expect(defaultModelAlias('gpt-5.6-sol', '', false)).toBe('gpt-5.6-sol-alias');
test('按思考强度生成可编辑的默认名称', () => {
expect(defaultModelAlias('gpt-5.6-sol', 'XHigh')).toBe('gpt-5.6-sol-xhigh');
expect(defaultModelAlias('gpt-5.6-sol', '')).toBe('gpt-5.6-sol-alias');
});
test('默认别名与现有模型重名时递增数字后缀', () => {
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['gpt-5.6-sol-fast'])).toBe('gpt-5.6-sol-fast-2');
expect(uniqueModelAlias('gpt-5.6-sol-fast', [
'gpt-5.6-sol-fast',
'gpt-5.6-sol-fast-2',
])).toBe('gpt-5.6-sol-fast-3');
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['GPT-5.6-SOL-FAST'])).toBe('gpt-5.6-sol-fast-2');
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['gpt-5.6-sol'])).toBe('gpt-5.6-sol-fast');
expect(uniqueModelAlias('gpt-5.6-sol-high', ['gpt-5.6-sol-high'])).toBe('gpt-5.6-sol-high-2');
expect(uniqueModelAlias('gpt-5.6-sol-high', [
'gpt-5.6-sol-high',
'gpt-5.6-sol-high-2',
])).toBe('gpt-5.6-sol-high-3');
expect(uniqueModelAlias('gpt-5.6-sol-high', ['GPT-5.6-SOL-HIGH'])).toBe('gpt-5.6-sol-high-2');
});
});
describe('思考别名', () => {
describe('思考别名来源', () => {
test('区分同名模型的接入来源', () => {
expect(thinkingAliasSourceKindLabel('codex-oauth')).toBe('Codex OAuth');
expect(thinkingAliasSourceKindLabel('codex-api')).toBe('Codex API');
expect(thinkingAliasSourceKindLabel('claude-api')).toBe('Claude API');
expect(thinkingAliasSourceKindLabel('openai-compatible')).toBe('OpenAI 兼容');
expect(thinkingAliasSourceKindLabel('custom')).toBe('其他来源');
});
});
describe('统一模型别名列表', () => {
test('同时显示思考别名和 Fast 速度别名', () => {
const entries = combineModelAliasEntries(
[{
sourceModel: 'gpt-thinking',
alias: 'gpt-high',
effort: 'high',
provider: 'Provider B',
kind: 'openai-compatible',
}],
[{
sourceModel: 'gpt-fast-source',
alias: 'gpt-fast',
serviceTier: 'priority',
provider: 'Provider A',
kind: 'codex-api',
}],
);
expect(entries.map((entry) => [entry.alias, entry.effort, entry.serviceTier])).toEqual([
['gpt-fast', null, 'priority'],
['gpt-high', 'high', null],
]);
});
test('同一个别名可同时设置思考强度和 Fast', () => {
const identity = {
sourceModel: 'gpt-5.6-sol',
alias: 'gpt-5.6-sol-xhigh-fast',
provider: 'Codex OAuth',
kind: 'codex-oauth',
};
const entries = combineModelAliasEntries(
[{ ...identity, effort: 'xhigh' }],
[{ ...identity, serviceTier: 'priority' }],
);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ effort: 'xhigh', serviceTier: 'priority' });
});
test('速度模型与思考模型合并后保留思考能力标记', () => {
test('仅把内核报告了思考等级的来源标为可覆写', () => {
const baseSource = {
model: 'gpt-5.6-sol',
displayName: 'GPT-5.6 Sol',
provider: 'Codex OAuth',
kind: 'codex-oauth',
model: 'shared-model',
displayName: 'Shared Model',
provider: 'Provider',
kind: 'codex-api',
protocol: 'codex',
reasoningLevels: [] as string[],
};
const sources = combineModelAliasSources(
[
{ ...baseSource, id: 'reasoning-and-fast' },
{ ...baseSource, id: 'fast-only', model: 'gpt-speed-only' },
{
...baseSource,
id: 'plain-only',
model: 'gemini-pro-agent',
provider: 'Antigravity OAuth',
kind: 'antigravity-oauth',
protocol: 'antigravity',
},
],
[{ ...baseSource, id: 'reasoning-and-fast' }],
[
{ ...baseSource, id: 'reasoning-and-fast' },
{ ...baseSource, id: 'fast-only', model: 'gpt-speed-only' },
{ ...baseSource, id: 'reasoning' },
{ ...baseSource, id: 'plain', model: 'plain-model' },
],
[{ ...baseSource, id: 'reasoning', reasoningLevels: ['low', 'high'] }],
);
expect(sources).toHaveLength(3);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsReasoning).toBe(true);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsFast).toBe(true);
expect(sources.find((source) => source.id === 'fast-only')?.supportsReasoning).toBe(false);
expect(sources.find((source) => source.id === 'fast-only')?.supportsFast).toBe(true);
expect(sources.find((source) => source.id === 'plain-only')?.supportsReasoning).toBe(false);
expect(sources.find((source) => source.id === 'plain-only')?.supportsFast).toBe(false);
expect(sources).toHaveLength(2);
expect(sources.find((source) => source.id === 'reasoning')).toMatchObject({
supportsReasoning: true,
reasoningLevels: ['low', 'high'],
});
expect(sources.find((source) => source.id === 'plain')?.supportsReasoning).toBe(false);
});
});