fix(aliases): prevent stale edits and preserve payload rules

- Validate configuration revisions and reject ambiguous model mappings
- Preserve raw overrides, conditions, and unchanged settings
- Serialize saves and restore configuration after write failures
- Add regression coverage and remove comments from affected files
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 7c9b8897ed
commit 36d3181475
12 files changed
+903 -195

No files matched your search

+6 -4
View File
@@ -496,12 +496,12 @@ pub(crate) async fn get_thinking_alias_sources(
pub(crate) async fn get_model_alias_edit_source(
gui_config_state: tauri::State<'_, GuiConfigState>,
alias: String,
) -> Result<ThinkingAliasSource, String> {
) -> Result<ModelAliasEditContext, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let definitions = fetch_oauth_model_definitions(&config).await;
Ok(resolve_model_alias_edit_source(&content, &alias, &definitions)?.source)
model_alias_edit_context(&content, &alias, &definitions)
}
#[tauri::command]
@@ -512,6 +512,7 @@ pub(crate) async fn create_thinking_alias(
effort: String,
fast: Option<bool>,
original_alias: Option<String>,
expected_revision: Option<String>,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let source_id = source_id.trim().to_string();
@@ -526,6 +527,9 @@ pub(crate) async fn create_thinking_alias(
};
let fast = fast.unwrap_or(false);
let content = fetch_management_config_yaml(&config).await?;
if original_alias.is_some() {
validate_model_alias_revision(&content, expected_revision.as_deref())?;
}
let available_models =
fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
let definitions = fetch_oauth_model_definitions(&config).await;
@@ -801,8 +805,6 @@ pub(crate) async fn fetch_codex_runtime_models(
Err("本地内核不支持 Codex 模型列表接口".to_string())
}
// Codex client metadata distinguishes the default context from the maximum.
// Do not overwrite either with the generic model-definitions context_length.
pub(crate) async fn fetch_codex_catalog_runtime_models(
config: &GuiConfigFile,
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
+2 -1
View File
@@ -1,13 +1,14 @@
//! Core configuration commands and loss-minimizing YAML editing.
use super::*;
mod alias_edit;
mod alias_save;
mod aliases;
mod commands;
mod settings;
mod yaml;
pub(crate) use alias_edit::*;
pub(crate) use alias_save::*;
pub(crate) use aliases::*;
pub(crate) use commands::*;
pub(crate) use settings::*;
+215 -58
View File
@@ -1,9 +1,70 @@
//! Edit aliases without rebuilding their configuration from a base model.
use super::*;
struct EditableModelAlias {
source: ResolvedThinkingAliasSource,
value: serde_norway::Value,
model_index: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelAliasEditContext {
pub(crate) source: ThinkingAliasSource,
pub(crate) revision: String,
pub(crate) effort: Option<String>,
pub(crate) fast: bool,
}
pub(crate) fn validate_model_alias_revision(
content: &str,
revision: Option<&str>,
) -> Result<(), String> {
if revision != Some(model_alias_config_revision(content)?.as_str()) {
return Err("配置已变化,请关闭编辑器并刷新后重试".to_string());
}
Ok(())
}
fn model_alias_config_revision(content: &str) -> Result<String, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut value = serde_json::to_value(document).map_err(|error| error.to_string())?;
let root = value
.as_object_mut()
.ok_or("内核配置顶层必须是 YAML 映射")?;
if root
.get("oauth-model-alias")
.and_then(serde_json::Value::as_object)
.is_some_and(|aliases| aliases.is_empty())
{
root.remove("oauth-model-alias");
}
value.sort_all_objects();
Ok(sha256_bytes(
serde_json::to_string(&value)
.map_err(|error| error.to_string())?
.as_bytes(),
))
}
pub(crate) fn model_alias_edit_context(
content: &str,
alias: &str,
definitions: &[OAuthModelDefinitions],
) -> Result<ModelAliasEditContext, String> {
let source = resolve_model_alias_edit_source(content, alias, definitions)?.source;
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| error.to_string())?;
let root = document
.as_mapping()
.ok_or("内核配置顶层必须是 YAML 映射")?;
Ok(ModelAliasEditContext {
effort: find_thinking_alias_effort(root, alias, &source.protocol),
fast: find_speed_alias_service_tier(root, alias, &source.protocol).as_deref()
== Some("priority"),
source,
revision: model_alias_config_revision(content)?,
})
}
pub(crate) fn model_alias_edit_source_id(alias: &str) -> String {
@@ -15,6 +76,7 @@ fn editable_model_alias(
alias: &str,
) -> Result<EditableModelAlias, String> {
let mut matches = Vec::new();
let mut matching_names = 0;
for (section, provider, kind, protocol) in [
("codex-api-key", "Codex API", "codex-api", "codex"),
(
@@ -47,7 +109,11 @@ fn editable_model_alias(
else {
continue;
};
if upstream == client || !client.eq_ignore_ascii_case(alias) {
if !client.eq_ignore_ascii_case(alias) {
continue;
}
matching_names += 1;
if upstream == client {
continue;
}
matches.push(EditableModelAlias {
@@ -68,6 +134,7 @@ fn editable_model_alias(
},
},
value: model.clone(),
model_index,
});
}
}
@@ -80,12 +147,16 @@ fn editable_model_alias(
let models = models
.as_sequence()
.ok_or("oauth-model-alias 的通道配置必须是数组")?;
for model in models {
for (model_index, model) in models.iter().enumerate() {
let Some((upstream, client, display_name)) = configured_model_identity(model)
else {
continue;
};
if upstream == client || !client.eq_ignore_ascii_case(alias) {
if !client.eq_ignore_ascii_case(alias) {
continue;
}
matching_names += 1;
if upstream == client {
continue;
}
let channel = channel_name
@@ -109,11 +180,12 @@ fn editable_model_alias(
},
},
value: model.clone(),
model_index,
});
}
}
}
if matches.len() != 1 {
if matching_names != 1 || matches.len() != 1 {
return Err("别名不存在或存在多个同名映射,请刷新并检查配置后重试".to_string());
}
Ok(matches.remove(0))
@@ -140,7 +212,6 @@ pub(crate) fn resolve_model_alias_edit_source(
source.source.reasoning_levels = model.reasoning_levels.clone();
}
}
// An unavailable catalog must not prevent keeping an existing effort setting.
if let Some(effort) = find_thinking_alias_effort(root, alias, &source.source.protocol) {
if !source.source.reasoning_levels.contains(&effort) {
source.source.reasoning_levels.push(effort);
@@ -170,7 +241,6 @@ pub(crate) fn edit_model_alias_in_yaml(
if !alias.eq_ignore_ascii_case(original_alias) && configured_model_alias_exists(root, alias) {
return Err(format!("别名模型 {alias} 已存在"));
}
// Capture the destination before removing anything so array indices cannot shift.
let mut replacement = match &source.location {
ThinkingAliasSourceLocation::ConfigModel {
section,
@@ -227,7 +297,6 @@ pub(crate) fn edit_model_alias_in_yaml(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
// Preserve ordering and metadata for edits within the same provider/channel.
let same_group = match (&original.source.location, &source.location) {
(
ThinkingAliasSourceLocation::ConfigModel {
@@ -247,6 +316,35 @@ pub(crate) fn edit_model_alias_in_yaml(
) => a == b,
_ => false,
};
let payload_edit = AliasPayloadEdit {
source: &source.source,
effort,
fast,
change_effort: find_thinking_alias_effort(
root,
original_alias,
&original.source.source.protocol,
)
.as_deref()
.unwrap_or("")
!= effort,
change_fast: (find_speed_alias_service_tier(
root,
original_alias,
&original.source.source.protocol,
)
.as_deref()
== Some("priority"))
!= fast,
};
if same_group
&& original.value.as_mapping() == Some(&replacement)
&& original.source.source.protocol == source.source.protocol
&& !payload_edit.change_effort
&& !payload_edit.change_fast
{
return Ok(content.to_string());
}
if !same_group {
remove_existing_claude_model_alias(root, original_alias)?;
}
@@ -274,11 +372,7 @@ pub(crate) fn edit_model_alias_in_yaml(
};
if same_group {
let model = models
.iter_mut()
.find(|model| {
configured_model_identity(model)
.is_some_and(|(_, name, _)| name.eq_ignore_ascii_case(original_alias))
})
.get_mut(original.model_index)
.ok_or("原别名已经变化,请刷新后重试")?;
*model = serde_norway::Value::Mapping(replacement);
} else {
@@ -289,32 +383,59 @@ pub(crate) fn edit_model_alias_in_yaml(
original_alias,
&original.source.source.protocol,
alias,
&source.source.protocol,
&payload_edit,
)?;
let mut params = serde_norway::Mapping::new();
if !effort.is_empty() {
insert_thinking_effort_params(&mut params, &source.source, effort)?;
}
if fast {
params.insert(
yaml_key("service_tier"),
serde_norway::Value::String("priority".to_string()),
);
}
append_alias_payload_override(root, alias, &source.source.protocol, params)?;
render_updated_core_yaml(&mut document, updated)
}
const ALIAS_EFFORT_KEYS: &[&str] = &[
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
"thinking.type",
];
struct AliasPayloadEdit<'a> {
source: &'a ThinkingAliasSource,
effort: &'a str,
fast: bool,
change_effort: bool,
change_fast: bool,
}
fn insert_alias_edit_params(
params: &mut serde_norway::Mapping,
replacement: serde_norway::Mapping,
raw: bool,
) -> Result<(), String> {
for (key, value) in replacement {
let value = if raw {
serde_norway::Value::String(
serde_json::to_string(&value).map_err(|error| error.to_string())?,
)
} else {
value
};
params.insert(key, value);
}
Ok(())
}
fn edit_alias_payload(
root: &mut serde_norway::Mapping,
original_alias: &str,
original_protocol: &str,
alias: &str,
protocol: &str,
edit: &AliasPayloadEdit<'_>,
) -> Result<(), String> {
let Some(payload) = yaml_mapping_value_mut(root, "payload") else {
return Ok(());
};
let protocol = &edit.source.protocol;
let mut found_effort = false;
let mut found_fast = false;
let payload = root
.entry(yaml_key("payload"))
.or_insert_with(|| serde_norway::Value::Mapping(Default::default()));
let payload = payload.as_mapping_mut().ok_or("payload 必须是 YAML 映射")?;
for section in [
"default",
@@ -342,25 +463,12 @@ fn edit_alias_payload(
let models = models.as_sequence().ok_or("payload.models 必须是数组")?;
let (mut target, others): (Vec<_>, Vec<_>) =
models.iter().cloned().partition(|model| {
thinking_payload_model_name_matches(model, original_alias)
&& model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "protocol"))
.is_none_or(|value| {
value.as_str().is_some_and(|value| {
value.eq_ignore_ascii_case(original_protocol)
})
})
thinking_payload_model_matches(model, original_alias, original_protocol)
});
if target.is_empty() {
result.push(rule.clone());
continue;
}
if !others.is_empty() {
let mut shared = mapping.clone();
shared.insert(yaml_key("models"), serde_norway::Value::Sequence(others));
result.push(serde_norway::Value::Mapping(shared));
}
for model in &mut target {
let model = model
.as_mapping_mut()
@@ -369,7 +477,10 @@ fn edit_alias_payload(
yaml_key("name"),
serde_norway::Value::String(alias.to_string()),
);
if model.contains_key(yaml_key("protocol")) {
if yaml_mapping_value(model, "protocol")
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
{
model.insert(
yaml_key("protocol"),
serde_norway::Value::String(protocol.to_string()),
@@ -378,28 +489,67 @@ fn edit_alias_payload(
}
let mut edited = mapping.clone();
edited.insert(yaml_key("models"), serde_norway::Value::Sequence(target));
let mut remove_target = false;
if matches!(section, "override" | "override-raw") {
if let Some(params) = yaml_mapping_value_mut(&mut edited, "params") {
let params = params
.as_mapping_mut()
.ok_or("payload.override.params 必须是映射")?;
for key in [
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
"thinking.type",
"service_tier",
] {
params.remove(yaml_key(key));
let has_effort = ALIAS_EFFORT_KEYS
.iter()
.any(|key| params.contains_key(yaml_key(key)));
let has_fast = params.contains_key(yaml_key("service_tier"));
found_effort |= has_effort;
found_fast |= has_fast;
let raw = section == "override-raw";
if has_effort && (edit.change_effort || original_protocol != protocol) {
let effort = if edit.change_effort {
edit.effort.to_string()
} else {
thinking_effort_from_params(params, original_protocol, raw).ok_or(
"此规则的思考配置无法转换,请先在配置中调整后再切换模型协议",
)?
};
for key in ALIAS_EFFORT_KEYS {
params.remove(yaml_key(key));
}
if !effort.is_empty() {
let mut replacement = serde_norway::Mapping::new();
insert_thinking_effort_params(&mut replacement, edit.source, &effort)?;
insert_alias_edit_params(params, replacement, raw)?;
}
}
if params.is_empty() {
continue;
if has_fast && edit.change_fast {
params.remove(yaml_key("service_tier"));
if edit.fast {
let replacement = serde_norway::Mapping::from_iter([(
yaml_key("service_tier"),
yaml_key("priority"),
)]);
insert_alias_edit_params(params, replacement, raw)?;
}
}
remove_target = params.is_empty();
}
}
result.push(serde_norway::Value::Mapping(edited));
let mut unchanged = mapping.clone();
unchanged.insert(yaml_key("models"), edited[yaml_key("models")].clone());
if !remove_target
&& original_alias == alias
&& original_protocol == protocol
&& edited == unchanged
{
result.push(rule.clone());
continue;
}
if !others.is_empty() {
let mut shared = mapping.clone();
shared.insert(yaml_key("models"), serde_norway::Value::Sequence(others));
result.push(serde_norway::Value::Mapping(shared));
}
if !remove_target {
result.push(serde_norway::Value::Mapping(edited));
}
}
*rules = result;
if rules.is_empty() {
@@ -409,5 +559,12 @@ fn edit_alias_payload(
if payload.is_empty() {
root.remove(yaml_key("payload"));
}
Ok(())
let mut params = serde_norway::Mapping::new();
if edit.change_effort && !found_effort && !edit.effort.is_empty() {
insert_thinking_effort_params(&mut params, edit.source, edit.effort)?;
}
if edit.change_fast && !found_fast && edit.fast {
params.insert(yaml_key("service_tier"), yaml_key("priority"));
}
append_alias_payload_override(root, alias, protocol, params)
}
+74
View File
@@ -0,0 +1,74 @@
use super::*;
fn alias_config_is_unchanged(current: &str, latest: &str) -> Result<bool, String> {
let changes = management_alias_config_changes(current, latest)?;
Ok(changes.oauth_model_aliases.is_none() && !changes.update_config_yaml)
}
pub(crate) async fn put_management_alias_config_changes(
config: &GuiConfigFile,
current: &str,
updated: &str,
) -> Result<(), String> {
static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
let _guard = SAVE_LOCK.lock().await;
let changes = management_alias_config_changes(current, updated)?;
if changes.oauth_model_aliases.is_none() && !changes.update_config_yaml {
return Ok(());
}
let latest = fetch_management_config_yaml(config).await?;
if !alias_config_is_unchanged(current, &latest)? {
return Err("配置已变化,请关闭编辑器并刷新后重试".to_string());
}
let result = async {
if let Some(aliases) = changes.oauth_model_aliases.as_ref() {
put_management_oauth_model_aliases(config, aliases).await?;
}
if changes.update_config_yaml {
put_management_config_yaml(config, updated).await?;
}
Ok::<_, String>(())
}
.await;
if let Err(error) = result {
return Err(match restore_management_alias_config(config, current, updated).await {
Ok(()) => format!("保存失败,已恢复原配置,可重试:{error}"),
Err(restore_error) => format!("保存失败:{error};自动恢复失败,配置可能已部分写入,请关闭编辑器并刷新检查:{restore_error}"),
});
}
Ok(())
}
async fn restore_management_alias_config(
config: &GuiConfigFile,
current: &str,
updated: &str,
) -> Result<(), String> {
let latest = fetch_management_config_yaml(config).await?;
let from_current = management_alias_config_changes(current, &latest)?;
let from_updated = management_alias_config_changes(updated, &latest)?;
if (from_current.update_config_yaml && from_updated.update_config_yaml)
|| (from_current.oauth_model_aliases.is_some()
&& from_updated.oauth_model_aliases.is_some())
{
return Err("检测到其他配置修改,未覆盖这些修改".to_string());
}
let mut errors = Vec::new();
if from_current.update_config_yaml {
if let Err(error) = put_management_config_yaml(config, current).await {
errors.push(error);
}
}
if let Some(aliases) = management_alias_config_changes(updated, current)?.oauth_model_aliases {
if let Err(error) = put_management_oauth_model_aliases(config, &aliases).await {
errors.push(error);
}
}
if !errors.is_empty() {
return Err(errors.join(";"));
}
if !alias_config_is_unchanged(current, &fetch_management_config_yaml(config).await?)? {
return Err("恢复后配置与原配置不一致".to_string());
}
Ok(())
}
+81 -111
View File
@@ -290,27 +290,6 @@ pub(crate) fn management_alias_config_changes(
})
}
pub(crate) async fn put_management_alias_config_changes(
config: &GuiConfigFile,
current: &str,
updated: &str,
) -> Result<(), String> {
if updated == current {
return Ok(());
}
let changes = management_alias_config_changes(current, updated)?;
if let Some(oauth_model_aliases) = changes.oauth_model_aliases.as_ref() {
// The dedicated endpoint refreshes CPA's OAuth model registry immediately.
// Writing the same section only through config.yaml updates the file and
// management snapshot, but can leave /v1/models and routing stale.
put_management_oauth_model_aliases(config, oauth_model_aliases).await?;
}
if changes.update_config_yaml {
put_management_config_yaml(config, updated).await?;
}
Ok(())
}
pub(crate) async fn ensure_claude_desktop_model_aliases(
config: &GuiConfigFile,
mappings: &ClaudeDesktopModelMappings,
@@ -876,11 +855,6 @@ pub(crate) fn resolved_oauth_alias_sources(
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
// A configured API-key model must win over a catalog entry with the same
// name. `model-definitions/codex` describes the OAuth channel's capabilities;
// it is not evidence that a model returned by /v1/models is using OAuth.
// Otherwise a Codex API alias would be written to oauth-model-alias, which
// CPA deliberately does not apply to codex-api-key credentials.
let mut sources = Vec::new();
collect_config_thinking_alias_sources(
root,
@@ -1017,6 +991,11 @@ pub(crate) fn collect_config_thinking_alias_sources(
}
let provider_name =
thinking_alias_provider_name(provider, fallback_provider, provider_index);
let provider_revision = sha256_bytes(
serde_norway::to_string(provider)
.map_err(|error| format!("读取模型源配置失败: {error}"))?
.as_bytes(),
);
let Some(models) = yaml_mapping_value(provider, "models") else {
continue;
};
@@ -1040,7 +1019,7 @@ pub(crate) fn collect_config_thinking_alias_sources(
let reasoning_levels = configured_model_reasoning_levels(model, protocol);
sources.push(ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("{section}:{provider_index}:{model_index}"),
id: format!("{section}:{provider_index}:{model_index}:{provider_revision}"),
model: client_model,
display_name,
provider: provider_name.clone(),
@@ -1417,64 +1396,79 @@ pub(crate) fn collect_config_speed_alias_entries(
Ok(())
}
fn alias_override_params<'a>(
root: &'a serde_norway::Mapping,
alias: &'a str,
protocol: &'a str,
) -> impl Iterator<Item = (&'a serde_norway::Mapping, bool)> {
["override-raw", "override"]
.into_iter()
.flat_map(move |section| {
nested_yaml_value(root, &["payload", section])
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.rev()
.filter_map(move |rule| {
let rule = rule.as_mapping()?;
let models = yaml_mapping_value(rule, "models")?.as_sequence()?;
if !models
.iter()
.any(|model| thinking_payload_model_matches(model, alias, protocol))
{
return None;
}
Some((
yaml_mapping_value(rule, "params")?.as_mapping()?,
section == "override-raw",
))
})
})
}
fn alias_payload_string(params: &serde_norway::Mapping, key: &str, raw: bool) -> Option<String> {
let value = yaml_mapping_value(params, key)?.as_str()?;
let decoded;
let value = if raw {
decoded = serde_json::from_str::<serde_json::Value>(value).ok()?;
decoded.as_str()?
} else {
value
};
let value = value.trim().to_ascii_lowercase();
(!value.is_empty()).then_some(value)
}
pub(crate) fn thinking_effort_from_params(
params: &serde_norway::Mapping,
protocol: &str,
raw: bool,
) -> Option<String> {
[
"reasoning.effort",
"reasoning_effort",
"output_config.effort",
"generationConfig.thinkingConfig.thinkingLevel",
"thinking.effort",
]
.into_iter()
.find_map(|key| alias_payload_string(params, key, raw))
.or_else(
|| match alias_payload_string(params, "thinking.type", raw)?.as_str() {
"disabled" => Some("none".to_string()),
"adaptive" if protocol.eq_ignore_ascii_case("claude") => Some("auto".to_string()),
_ => None,
},
)
}
pub(crate) fn find_thinking_alias_effort(
root: &serde_norway::Mapping,
alias: &str,
protocol: &str,
) -> Option<String> {
let rules = nested_yaml_value(root, &["payload", "override"])?.as_sequence()?;
for rule in rules {
let Some(rule) = rule.as_mapping() else {
continue;
};
let effort = yaml_mapping_value(rule, "params")
.and_then(serde_norway::Value::as_mapping)
.and_then(|params| {
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
}
})
})
.filter(|value| !value.is_empty());
let Some(effort) = effort 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(effort);
}
}
None
alias_override_params(root, alias, protocol)
.find_map(|(params, raw)| thinking_effort_from_params(params, protocol, raw))
}
pub(crate) fn find_speed_alias_service_tier(
@@ -1482,33 +1476,8 @@ pub(crate) fn find_speed_alias_service_tier(
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
alias_override_params(root, alias, protocol)
.find_map(|(params, raw)| alias_payload_string(params, "service_tier", raw))
}
pub(crate) fn thinking_payload_model_matches(
@@ -1522,9 +1491,11 @@ pub(crate) fn thinking_payload_model_matches(
let name_matches = yaml_mapping_value(model, "name")
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias));
let protocol_matches = yaml_mapping_value(model, "protocol")
.and_then(serde_norway::Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case(protocol));
let protocol_matches = yaml_mapping_value(model, "protocol").is_none_or(|value| {
value.as_str().is_some_and(|value| {
value.trim().is_empty() || value.trim().eq_ignore_ascii_case(protocol)
})
});
name_matches && protocol_matches
}
@@ -1569,7 +1540,6 @@ pub(crate) fn insert_thinking_effort_params(
);
}
"antigravity-oauth" => {
// Antigravity applies payload rules relative to its `request` object.
insert(
params,
"generationConfig.thinkingConfig.thinkingLevel",
+2
View File
@@ -4,6 +4,8 @@ mod agent_configuration;
mod agent_state;
mod agent_transactions;
mod alias_edit;
mod alias_edit_regressions;
mod alias_save;
mod app_settings;
mod app_update;
mod core_config;
+4 -10
View File
@@ -37,7 +37,7 @@ fn alias_edit_preserves_other_parameters_and_shared_models() {
let before = value(MIXED_PAYLOAD);
let after = value(&updated);
let rules = after["payload"]["override"].as_array().unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules.len(), 2);
assert_eq!(rules[0]["models"].as_array().unwrap().len(), 1);
assert_eq!(rules[0]["models"][0]["name"], "other-alias");
assert_eq!(
@@ -47,11 +47,7 @@ fn alias_edit_preserves_other_parameters_and_shared_models() {
assert_eq!(rules[1]["models"][0]["name"], "my-alias");
assert_eq!(
rules[1]["params"],
serde_json::json!({"temperature": 0.2, "max_output_tokens": 1024})
);
assert_eq!(
rules[2]["params"],
serde_json::json!({"reasoning.effort": "low"})
serde_json::json!({"temperature": 0.2, "max_output_tokens": 1024, "reasoning.effort": "low"})
);
assert_eq!(after["oauth-model-alias"], before["oauth-model-alias"]);
assert!(updated.contains("# Keep this comment"));
@@ -109,7 +105,6 @@ fn alias_edit_keeps_api_metadata_and_order_on_an_unchanged_save() {
let content = "openai-compatibility:\n - name: my-provider\n models:\n - name: gpt-test\n display-name: Base Model\n - name: gpt-test\n alias: my-alias\n display-name: Custom Alias Label\n thinking:\n levels: [low, high]\n custom: {keep: true}\n - name: other\n";
let updated = edit(content, "my-alias", "my-alias", "", false);
assert_eq!(value(&updated), value(content));
// Selecting the base entry for the same upstream must preserve alias metadata too.
let source = resolved_oauth_alias_sources(
content,
&[],
@@ -198,7 +193,7 @@ fn alias_edit_selects_exact_provider_even_with_identical_display_names() {
.unwrap();
let source = sources
.iter()
.find(|source| source.source.id == "openai-compatibility:1:0")
.find(|source| source.source.id.starts_with("openai-compatibility:1:0:"))
.unwrap();
let updated =
edit_model_alias_in_yaml(content, "my-alias", source, "my-alias", "", false).unwrap();
@@ -240,7 +235,7 @@ fn alias_edit_moves_between_api_and_oauth_and_updates_payload_protocol() {
);
assert_eq!(
after["payload"]["override"][0]["params"],
serde_json::json!({"temperature":0.2})
serde_json::json!({"temperature":0.2, "reasoning.effort":"low"})
);
let content = format!("{updated}codex-api-key:\n - models: [{{name: back-to-api}}]\n");
let source = resolved_oauth_alias_sources(
@@ -282,7 +277,6 @@ fn alias_edit_rejects_duplicates_missing_aliases_and_name_collisions() {
" fork: false\n - name: another\n alias: taken",
);
assert!(edit_model_alias_in_yaml(&occupied, "my-alias", &source, "TAKEN", "", false).is_err());
// A real model entry without an alias is not an editable alias.
assert!(resolve_model_alias_edit_source(
"codex-api-key:\n - models: [{name: real-model}]\n",
"real-model",
@@ -0,0 +1,196 @@
use super::support::*;
use super::*;
const BASE: &str =
"oauth-model-alias:\n codex:\n - name: gpt-test\n alias: my-alias\n fork: true\n";
fn json(content: &str) -> serde_json::Value {
serde_json::to_value(serde_norway::from_str::<serde_norway::Value>(content).unwrap()).unwrap()
}
fn unchanged_save(content: &str) -> String {
let context = model_alias_edit_context(content, "my-alias", &[]).unwrap();
let source = resolve_model_alias_edit_source(content, "my-alias", &[]).unwrap();
edit_model_alias_in_yaml(
content,
"my-alias",
&source,
"my-alias",
context.effort.as_deref().unwrap_or(""),
context.fast,
)
.unwrap()
}
#[test]
fn alias_regression_noop_preserves_raw_effort_and_fast() {
let content = format!("{BASE}payload:\n override-raw:\n - models: [{{name: my-alias, protocol: codex}}]\n params: {{reasoning.effort: '\"high\"', service_tier: '\"priority\"'}}\n");
let updated = unchanged_save(&content);
assert_eq!(
json(&updated),
json(&content),
"An unchanged save must preserve raw effort/Fast overrides"
);
}
#[test]
fn alias_regression_noop_preserves_rules_without_protocol() {
let content = format!("{BASE}payload:\n override:\n - models: [{{name: my-alias}}]\n params: {{reasoning.effort: high, service_tier: priority}}\n");
let updated = unchanged_save(&content);
assert_eq!(
json(&updated),
json(&content),
"Omitting protocol is a valid match-all selector, not an unset option"
);
}
#[test]
fn alias_regression_noop_preserves_conditional_effort_and_fast() {
let content = format!("{BASE}payload:\n override:\n - models:\n - name: my-alias\n protocol: codex\n headers: {{X-Client: premium}}\n from-protocol: responses\n params: {{reasoning.effort: high, service_tier: priority}}\n");
let updated = unchanged_save(&content);
assert_eq!(
json(&updated),
json(&content),
"An unchanged save must not broaden a conditional override to every request"
);
}
#[test]
fn alias_regression_rejects_collision_with_real_model() {
let content = "codex-api-key:\n - models:\n - name: my-alias\n - name: gpt-test\n alias: my-alias\n";
assert!(resolve_model_alias_edit_source(content, "my-alias", &[]).is_err());
let unambiguous = content.replace(" - name: my-alias\n", "");
let source = resolve_model_alias_edit_source(&unambiguous, "my-alias", &[]).unwrap();
assert!(edit_model_alias_in_yaml(content, "my-alias", &source, "renamed", "", false).is_err());
}
#[test]
fn alias_regression_source_id_does_not_retarget_after_reordering() {
let content = "openai-compatibility:\n - name: original-provider\n models: [{name: original-model, alias: my-alias}]\n - name: intended-provider\n models: [{name: intended-model}]\n - name: other-provider\n models: [{name: other-model}]\n";
let available = test_agent_models(&["my-alias", "intended-model", "other-model"]);
let chosen =
resolved_oauth_alias_sources(content, &[], &available, AliasSourceCapability::Base)
.unwrap()
.into_iter()
.find(|source| source.source.model == "intended-model")
.unwrap();
let mut changed = json(content);
changed["openai-compatibility"]
.as_array_mut()
.unwrap()
.swap(1, 2);
let changed = serde_norway::to_string(&changed).unwrap();
let resolved =
resolved_oauth_alias_sources(&changed, &[], &available, AliasSourceCapability::Base)
.unwrap()
.into_iter()
.find(|source| source.source.id == chosen.source.id);
assert!(
resolved.is_none(),
"A stale positional selection must be rejected"
);
}
#[test]
fn alias_regression_edit_snapshot_rejects_recreated_alias_and_missing_revision() {
let context = model_alias_edit_context(BASE, "my-alias", &[]).unwrap();
assert!(validate_model_alias_revision(BASE, Some(&context.revision)).is_ok());
let reformatted = serde_norway::to_string(&json(BASE)).unwrap();
assert!(validate_model_alias_revision(&reformatted, Some(&context.revision)).is_ok());
assert!(validate_model_alias_revision(BASE, None).is_err());
let recreated = BASE.replace("gpt-test", "another-upstream");
assert!(validate_model_alias_revision(&recreated, Some(&context.revision)).is_err());
}
#[test]
fn alias_regression_readers_use_raw_and_last_override_values() {
let content = format!("{BASE}payload:\n override:\n - models: [{{name: my-alias, protocol: codex}}]\n params: {{reasoning.effort: high, service_tier: priority}}\n - models: [{{name: my-alias, protocol: codex}}]\n params: {{reasoning.effort: low}}\n override-raw:\n - models: [{{name: my-alias}}]\n params: {{service_tier: '\"flex\"'}}\n");
let context = model_alias_edit_context(&content, "my-alias", &[]).unwrap();
assert_eq!(context.effort.as_deref(), Some("low"));
assert!(!context.fast);
assert_eq!(json(&unchanged_save(&content)), json(&content));
assert_eq!(
thinking_aliases_from_yaml(&content).unwrap()[0]
.effort
.as_deref(),
Some("low")
);
assert_eq!(
speed_aliases_from_yaml(&content).unwrap()[0].service_tier,
"flex"
);
}
#[test]
fn alias_regression_explicit_changes_keep_raw_conditions_and_other_fields() {
let content = format!("{BASE}payload:\n override-raw:\n - models:\n - name: my-alias\n protocol: ''\n headers: {{X-Client: premium}}\n from-protocol: responses\n match: [{{metadata.client: codex}}]\n - name: other-alias\n params: {{reasoning.effort: '\"high\"', service_tier: '\"priority\"', temperature: '0.2'}}\n");
let source = resolve_model_alias_edit_source(&content, "my-alias", &[]).unwrap();
let updated =
edit_model_alias_in_yaml(&content, "my-alias", &source, "renamed", "low", true).unwrap();
let before = json(&content);
let after = json(&updated);
assert!(
after["payload"]["override"].is_null(),
"Existing conditional settings must not produce unconditional rules"
);
let rules = after["payload"]["override-raw"].as_array().unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(
rules[0]["params"],
before["payload"]["override-raw"][0]["params"]
);
assert_eq!(rules[0]["models"][0]["name"], "other-alias");
let mut expected_selector = before["payload"]["override-raw"][0]["models"][0].clone();
expected_selector["name"] = serde_json::json!("renamed");
assert_eq!(rules[1]["models"][0], expected_selector);
assert_eq!(
rules[1]["params"],
serde_json::json!({"reasoning.effort":"\"low\"", "service_tier":"\"priority\"", "temperature":"0.2"})
);
let context = model_alias_edit_context(&updated, "renamed", &[]).unwrap();
assert_eq!(context.effort.as_deref(), Some("low"));
assert!(context.fast);
}
#[test]
fn alias_regression_toggle_fast_preserves_distinct_conditional_efforts() {
let content = format!("{BASE}payload:\n override:\n - models: [{{name: my-alias, protocol: codex, headers: {{X-Client: a}}}}]\n params: {{reasoning.effort: low, service_tier: flex}}\n - models: [{{name: my-alias, protocol: codex, headers: {{X-Client: b}}}}]\n params: {{reasoning.effort: high, service_tier: flex}}\n");
let source = resolve_model_alias_edit_source(&content, "my-alias", &[]).unwrap();
let updated =
edit_model_alias_in_yaml(&content, "my-alias", &source, "my-alias", "high", true).unwrap();
let after = json(&updated);
let rules = after["payload"]["override"].as_array().unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0]["params"]["reasoning.effort"], "low");
assert_eq!(rules[1]["params"]["reasoning.effort"], "high");
for (index, rule) in rules.iter().enumerate() {
assert_eq!(
rule["models"],
json(&content)["payload"]["override"][index]["models"]
);
assert_eq!(rule["params"]["service_tier"], "priority");
}
}
#[test]
fn alias_regression_provider_change_with_same_model_invalidates_selection() {
let content = "openai-compatibility:\n - name: provider\n base-url: https://one.example/v1\n models: [{name: shared-model}]\n";
let models = test_agent_models(&["shared-model"]);
let original =
resolved_oauth_alias_sources(content, &[], &models, AliasSourceCapability::Base).unwrap();
let changed = resolved_oauth_alias_sources(
&content.replace("one.example", "two.example"),
&[],
&models,
AliasSourceCapability::Base,
)
.unwrap();
assert_eq!(original[0].source.model, changed[0].source.model);
assert_ne!(original[0].source.id, changed[0].source.id);
}
#[test]
fn alias_regression_unchanged_save_keeps_comments_and_empty_rules_byte_for_byte() {
let content = format!("# custom config\n{BASE}payload: {{override: []}} # keep placeholder\n");
assert_eq!(unchanged_save(&content), content);
}
+296
View File
@@ -0,0 +1,296 @@
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
const CURRENT: &str = "oauth-model-alias:\n codex:\n - name: gpt-test\n alias: my-alias\n fork: true\npayload:\n override:\n - models: [{name: my-alias, protocol: codex}]\n params: {reasoning.effort: high}\n";
#[derive(Clone, Copy, PartialEq, Eq)]
enum Failure {
None,
YamlBeforeWrite,
YamlAfterWrite,
OauthAfterWrite,
Rollback,
ForeignChange,
}
struct MockCore {
config: GuiConfigFile,
finished: Arc<AtomicBool>,
server: Option<std::thread::JoinHandle<(serde_json::Value, Vec<String>)>>,
}
impl MockCore {
fn new(initial: &str, failure: Failure) -> Self {
assert!(!current_core_tls_settings().unwrap().enabled);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
listener.set_nonblocking(true).unwrap();
let finished = Arc::new(AtomicBool::new(false));
let server_finished = finished.clone();
let mut persisted = yaml_json(initial);
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut requests = Vec::new();
let mut oauth_writes = 0;
let mut yaml_writes = 0;
while !server_finished.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
continue;
}
Err(error) => panic!("mock accept: {error}"),
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut bytes = Vec::new();
let (header_end, content_length) = loop {
let mut buffer = [0; 2048];
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0);
bytes.extend_from_slice(&buffer[..read]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let header = String::from_utf8_lossy(&bytes[..end]);
let length: usize = header
.lines()
.find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().unwrap())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while bytes.len() < header_end + content_length {
let mut buffer = [0; 2048];
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0);
bytes.extend_from_slice(&buffer[..read]);
}
let header = String::from_utf8_lossy(&bytes[..header_end]);
let first = header.lines().next().unwrap();
let mut parts = first.split_whitespace();
let method = parts.next().unwrap();
let path = parts.next().unwrap();
requests.push(format!("{method} {path}"));
let request_body = &bytes[header_end..header_end + content_length];
let mut status = "200 OK";
let mut response = "{\"status\":\"ok\"}".to_string();
if method == "GET" {
assert_eq!(path, "/v0/management/config.yaml");
response = serde_norway::to_string(&persisted).unwrap();
} else if path == "/v0/management/oauth-model-alias" {
oauth_writes += 1;
if failure == Failure::Rollback && oauth_writes == 2 {
status = "500 Internal Server Error";
} else {
persisted["oauth-model-alias"] =
serde_json::from_slice(request_body).unwrap();
if failure == Failure::OauthAfterWrite && oauth_writes == 1 {
status = "500 Internal Server Error";
}
}
} else {
assert_eq!(path, "/v0/management/config.yaml");
yaml_writes += 1;
if yaml_writes == 1
&& matches!(
failure,
Failure::YamlBeforeWrite | Failure::Rollback | Failure::ForeignChange
)
{
status = "500 Internal Server Error";
if failure == Failure::ForeignChange {
persisted["debug"] = serde_json::json!(true);
}
} else {
persisted = yaml_json(std::str::from_utf8(request_body).unwrap());
if yaml_writes == 1 && failure == Failure::YamlAfterWrite {
status = "500 Internal Server Error";
}
}
}
if status.starts_with("500") {
response = "{\"error\":\"write_failed\"}".to_string();
}
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
(persisted, requests)
});
Self {
config: GuiConfigFile {
port,
management_secret_key: "test-only".to_string(),
..Default::default()
},
finished,
server: Some(server),
}
}
fn finish(mut self) -> (serde_json::Value, Vec<String>) {
self.finished.store(true, Ordering::SeqCst);
self.server.take().unwrap().join().unwrap()
}
}
impl Drop for MockCore {
fn drop(&mut self) {
self.finished.store(true, Ordering::SeqCst);
if let Some(server) = self.server.take() {
let _ = server.join();
}
}
}
fn yaml_json(content: &str) -> serde_json::Value {
serde_json::to_value(serde_norway::from_str::<serde_norway::Value>(content).unwrap()).unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_recovers_failures_before_and_after_writes() {
for failure in [
Failure::YamlBeforeWrite,
Failure::YamlAfterWrite,
Failure::OauthAfterWrite,
] {
let core = MockCore::new(CURRENT, failure);
let updated = CURRENT.replace("my-alias", "renamed");
let result = put_management_alias_config_changes(&core.config, CURRENT, &updated).await;
assert!(result.unwrap_err().contains("已恢复原配置"));
let (persisted, requests) = core.finish();
assert_eq!(persisted, yaml_json(CURRENT), "{requests:?}");
assert_eq!(
requests
.iter()
.filter(|request| request.ends_with("oauth-model-alias"))
.count(),
2
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_reports_failed_rollback_without_claiming_restoration() {
let core = MockCore::new(CURRENT, Failure::Rollback);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
let error = result.unwrap_err();
assert!(error.contains("自动恢复失败"));
assert!(error.contains("配置可能已部分写入"));
let (persisted, _) = core.finish();
assert_eq!(
persisted["oauth-model-alias"]["codex"][0]["alias"],
"renamed"
);
assert_eq!(persisted["payload"], yaml_json(CURRENT)["payload"]);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_does_not_rollback_over_external_changes() {
let core = MockCore::new(CURRENT, Failure::ForeignChange);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
assert!(result.unwrap_err().contains("检测到其他配置修改"));
let (persisted, requests) = core.finish();
assert_eq!(persisted["debug"], true);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("PUT"))
.count(),
2
);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_rejects_stale_snapshot_before_any_write() {
let core = MockCore::new(&format!("{CURRENT}debug: true\n"), Failure::None);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
assert!(result.unwrap_err().contains("配置已变化"));
let (_, requests) = core.finish();
assert_eq!(requests, ["GET /v0/management/config.yaml"]);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_succeeds_for_mixed_oauth_only_and_yaml_only_changes() {
for updated in [
CURRENT.replace("my-alias", "renamed"),
CURRENT.replacen("my-alias", "renamed", 1),
CURRENT.replace("high", "low"),
] {
let core = MockCore::new(CURRENT, Failure::None);
put_management_alias_config_changes(&core.config, CURRENT, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&updated));
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_serializes_concurrent_writers_and_rejects_the_stale_one() {
let core = MockCore::new(CURRENT, Failure::None);
let first = CURRENT.replace("my-alias", "first");
let second = CURRENT.replace("my-alias", "second");
let (a, b) = tokio::join!(
put_management_alias_config_changes(&core.config, CURRENT, &first),
put_management_alias_config_changes(&core.config, CURRENT, &second),
);
assert_ne!(a.is_ok(), b.is_ok());
let (persisted, requests) = core.finish();
assert_eq!(
persisted,
yaml_json(if a.is_ok() { &first } else { &second })
);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("PUT"))
.count(),
2
);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_can_retry_after_restoration_reformats_yaml() {
let context = model_alias_edit_context(CURRENT, "my-alias", &[]).unwrap();
let core = MockCore::new(CURRENT, Failure::YamlBeforeWrite);
let updated = CURRENT.replace("my-alias", "renamed");
assert!(
put_management_alias_config_changes(&core.config, CURRENT, &updated)
.await
.unwrap_err()
.contains("已恢复原配置")
);
let restored = fetch_management_config_yaml(&core.config).await.unwrap();
validate_model_alias_revision(&restored, Some(&context.revision)).unwrap();
let source = resolve_model_alias_edit_source(&restored, "my-alias", &[]).unwrap();
let updated =
edit_model_alias_in_yaml(&restored, "my-alias", &source, "renamed", "high", false).unwrap();
put_management_alias_config_changes(&core.config, &restored, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&updated));
}
+15 -3
View File
@@ -62,6 +62,13 @@ type ModelAliasSource = ThinkingAliasSource & {
supportsFast: boolean;
};
type ModelAliasEditContext = {
source: ThinkingAliasSource;
revision: string;
effort: string | null;
fast: boolean;
};
const effortOptions = [
{ value: 'low', label: 'Low', hintKey: 'aliases.effort.low' },
{ value: 'medium', label: 'Medium', hintKey: 'aliases.effort.medium' },
@@ -191,6 +198,7 @@ export function ThinkingAliasesPage() {
const [alias, setAlias] = useState('');
const [editingEntry, setEditingEntry] = useState<AliasListEntry | null>(null);
const [editingSource, setEditingSource] = useState<ThinkingAliasSource | null>(null);
const [editingRevision, setEditingRevision] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [search, setSearch] = useState('');
const [modelPickerOpen, setModelPickerOpen] = useState(false);
@@ -405,6 +413,7 @@ export function ThinkingAliasesPage() {
await invoke('create_thinking_alias', {
sourceId: selectedSource.id, alias: normalizedAlias,
effort: normalizedEffort, fast: fastEnabled, originalAlias: editingEntry.alias,
expectedRevision: editingRevision,
});
setNotice(t('aliases.updated', { alias: normalizedAlias }));
} else if (normalizedEffort) {
@@ -448,6 +457,7 @@ export function ThinkingAliasesPage() {
const resetEditor = () => {
setEditingEntry(null);
setEditingSource(null);
setEditingRevision(null);
setSelectedSourceId('');
setEffort('');
setFastEnabled(false);
@@ -476,12 +486,14 @@ export function ThinkingAliasesPage() {
setError('');
setNotice('');
try {
const source = await invoke<ThinkingAliasSource>('get_model_alias_edit_source', { alias: entry.alias });
const context = await invoke<ModelAliasEditContext>('get_model_alias_edit_source', { alias: entry.alias });
const source = context.source;
setEditingEntry(entry);
setEditingSource(source);
setEditingRevision(context.revision);
setSelectedSourceId(source.id);
setEffort(entry.effort ?? '');
setFastEnabled(entry.serviceTier === 'priority');
setEffort(context.effort ?? '');
setFastEnabled(context.fast);
setAlias(entry.alias);
generatedAliasRef.current = '';
setModelPickerOpen(false);
+6 -2
View File
@@ -9,8 +9,9 @@ localStorage.setItem('easy-cli-proxy-api.locale', 'en');
const calls: { cmd: string; args: Record<string, unknown> }[] = [];
(window as any).fixtureCalls = calls;
(window as any).fixtureFailSave = false;
(window as any).fixtureCurrentEffort = null;
const source = {
id: 'openai-compatibility:0:0', model: 'public-gpt', displayName: null,
id: 'openai-compatibility:0:0:provider-revision', model: 'public-gpt', displayName: null,
provider: 'Provider', kind: 'openai-compatible', protocol: 'openai', reasoningLevels: ['low', 'high'],
};
let entry = {
@@ -24,7 +25,10 @@ mockIPC(async (cmd, rawArgs) => {
if (cmd === 'get_thinking_aliases') return [entry];
if (cmd === 'get_speed_aliases') return [];
if (['get_model_alias_sources', 'get_thinking_alias_sources', 'get_speed_alias_sources'].includes(cmd)) return [source];
if (cmd === 'get_model_alias_edit_source') return { ...source, id: `alias-edit:${args.alias}`, model: 'upstream-gpt' };
if (cmd === 'get_model_alias_edit_source') return {
source: { ...source, id: `alias-edit:${args.alias}`, model: 'upstream-gpt' },
revision: `revision:${args.alias}`, effort: (window as any).fixtureCurrentEffort ?? entry.effort, fast: false,
};
if (cmd === 'create_thinking_alias') {
if ((window as any).fixtureFailSave) throw new Error('Save failed: configuration changed');
entry = { ...entry, alias: args.alias, effort: args.effort };
+6 -6
View File
@@ -1,5 +1,3 @@
// Run Vite on port 1423, then: node tests/model-alias-editor-ui.cjs
// Set PLAYWRIGHT_MODULE to an installed Playwright module path when needed.
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const { mkdirSync } = require('node:fs');
@@ -9,7 +7,6 @@ const { mkdirSync } = require('node:fs');
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(10000);
// External font loading is unrelated to the editor's behavior.
await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/')
? route.continue() : route.abort());
const errors = [];
@@ -21,7 +18,6 @@ const { mkdirSync } = require('node:fs');
await dialog.waitFor();
assert.equal(await dialog.locator('#thinking-model-search').inputValue(), 'upstream-gpt');
assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'my-alias');
// Escape dismisses the model picker before dismissing the editor.
await page.keyboard.press('Escape');
assert.equal(await dialog.count(), 1);
assert.equal(await page.getByRole('listbox').count(), 0);
@@ -48,18 +44,21 @@ const { mkdirSync } = require('node:fs');
const saved = (await writes()).at(-1).args;
assert.equal(saved.sourceId, 'alias-edit:my-alias');
assert.equal(saved.originalAlias, 'my-alias');
assert.equal(saved.expectedRevision, 'revision:my-alias');
assert.equal(saved.alias, 'renamed-alias');
assert.equal(saved.effort, 'low');
await page.getByText('Updated model alias renamed-alias', { exact: true }).waitFor();
// Switch from the original configuration source to a live model source.
await page.evaluate(() => { window.fixtureCurrentEffort = 'high'; });
await page.getByRole('button', { name: 'Edit', exact: true }).click();
assert.match(await dialog.getByRole('button', { name: 'High', exact: true }).getAttribute('class'), /active/);
await dialog.locator('#thinking-model-search').fill('public-gpt');
await page.getByRole('option').click();
assert.equal(await dialog.locator('#thinking-alias-name').inputValue(), 'renamed-alias');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
assert.equal((await writes()).at(-1).args.sourceId, 'openai-compatibility:0:0');
assert.equal((await writes()).at(-1).args.sourceId, 'openai-compatibility:0:0:provider-revision');
assert.equal((await writes()).at(-1).args.expectedRevision, 'revision:renamed-alias');
await page.setViewportSize({ width: 640, height: 600 });
await page.getByRole('button', { name: 'Create Alias', exact: true }).click();
@@ -82,6 +81,7 @@ const { mkdirSync } = require('node:fs');
await dialog.getByRole('button', { name: 'Create Alias', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
assert.equal((await writes()).at(-1).args.originalAlias, undefined);
assert.equal((await writes()).at(-1).args.expectedRevision, undefined);
assert.equal((await writes()).at(-1).args.alias, 'new-alias');
assert.deepEqual(errors, []);
console.log('PASS: remapped source, cancel, focus trap, Escape, inline errors, retry, rename, source switch, create, narrow dialog');