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:
1 parent
7c9b8897ed
commit
36d3181475
12 files changed
+903
-195
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user