feat(codex): add customizable model catalog editor

- add per-model context, reasoning, modality, visibility, and parallel tool settings
- persist customizations separately and apply them during catalog generation
- support restoring individual or all models to official fallback templates
- expose full fallback reasoning levels while keeping Fast available
- add localized UI, validation, documentation, and regression tests
This commit is contained in:
lzt404 committed 2026-09-05 18:05:57 +08:00
1 parent c370c17ff0
commit 673a322c00
13 files changed
+1671 -8

No files matched your search

+50
View File
@@ -399,6 +399,56 @@ pub(crate) fn load_codex_model_catalog_override(app: &tauri::AppHandle) -> Resul
Ok(())
}
fn codex_model_customizations_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(codex_model_catalog_override_path(app)?.with_file_name("model-customizations.json"))
}
pub(crate) fn load_codex_model_customizations(app: &tauri::AppHandle) -> Result<(), String> {
codex_catalog::load_customizations(&codex_model_customizations_path(app)?)
}
#[tauri::command]
pub(crate) async fn get_codex_model_catalog_editor(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<codex_catalog::CatalogEditorSnapshot, String> {
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
let config = gui_config_state.snapshot()?;
let runtime_models =
fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?;
codex_catalog::editor_snapshot(&runtime_models)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexCatalogEditorSaveResult {
snapshot: codex_catalog::CatalogEditorSnapshot,
synchronization_error: Option<String>,
}
#[tauri::command]
pub(crate) async fn save_codex_model_catalog_editor(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
request: codex_catalog::CatalogEditorRequest,
) -> Result<CodexCatalogEditorSaveResult, String> {
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
let config = gui_config_state.snapshot()?;
let runtime_models =
fetch_codex_runtime_models(config.port, effective_agent_api_key(&config)).await?;
let snapshot = codex_catalog::save_customizations(
&codex_model_customizations_path(&app)?,
&runtime_models,
request,
)?;
let synchronization_error = prepare_codex_agent_models(&runtime_models)
.and_then(|prepared| sync_prepared_codex_model_catalog(&app, &config, &prepared))
.err();
Ok(CodexCatalogEditorSaveResult {
snapshot,
synchronization_error,
})
}
#[tauri::command]
pub(crate) async fn get_thinking_aliases(
gui_config_state: tauri::State<'_, GuiConfigState>,
+38 -6
View File
@@ -3,6 +3,12 @@ use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::sync::{OnceLock, RwLock};
mod customizations;
pub(crate) use customizations::{
editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest,
CatalogEditorSnapshot,
};
const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json");
const FALLBACK_MODEL_JSON: &str = include_str!("../resources/codex_models/fallback-model.json");
@@ -38,6 +44,7 @@ struct CatalogSources {
struct CatalogState {
sources: CatalogSources,
json: String,
customizations: customizations::ModelCustomizations,
}
impl CatalogState {
@@ -195,7 +202,7 @@ pub(crate) fn prepare_catalog(
let state = catalog_state()?
.read()
.map_err(|_| "Codex 模型目录内存锁已损坏".to_string())?;
prepare_catalog_with_sources(runtime_models, &state.sources)
prepare_catalog_with_customizations(runtime_models, &state.sources, &state.customizations)
}
fn catalog_state() -> Result<&'static RwLock<CatalogState>, String> {
@@ -205,6 +212,7 @@ fn catalog_state() -> Result<&'static RwLock<CatalogState>, String> {
RwLock::new(CatalogState {
sources,
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
})
})
})
@@ -376,9 +384,18 @@ fn validate_required_codex_fields(model: &Map<String, Value>, label: &str) -> Re
Ok(())
}
#[cfg(test)]
fn prepare_catalog_with_sources(
runtime_models: &[CodexRuntimeModel],
sources: &CatalogSources,
) -> Result<PreparedCodexCatalog, String> {
prepare_catalog_with_customizations(runtime_models, sources, &Default::default())
}
fn prepare_catalog_with_customizations(
runtime_models: &[CodexRuntimeModel],
sources: &CatalogSources,
customizations: &customizations::ModelCustomizations,
) -> Result<PreparedCodexCatalog, String> {
if runtime_models.is_empty() {
return Err("CPA 当前没有可写入 Codex 的模型".to_string());
@@ -418,6 +435,10 @@ fn prepare_catalog_with_sources(
return Err("CPA 当前没有有效的 Codex 模型 ID".to_string());
}
for entry in &mut entries {
customizations::apply_customizations(&mut entry.value, customizations)?;
}
for entry in &entries {
let slug = string_value(&entry.value, "slug");
validate_required_codex_fields(&entry.value, &slug)?;
@@ -821,8 +842,11 @@ mod tests {
sources.fallback["input_modalities"],
serde_json::json!(["text", "image"])
);
assert_eq!(sources.fallback["default_reasoning_level"], Value::Null);
assert!(reasoning_efforts(&sources.fallback).is_empty());
assert_eq!(sources.fallback["default_reasoning_level"], "medium");
assert_eq!(
reasoning_efforts(&sources.fallback),
["low", "medium", "high", "xhigh", "max", "ultra"]
);
assert_eq!(sources.fallback["shell_type"], "unified_exec");
assert_eq!(sources.fallback["visibility"], "none");
assert_eq!(sources.fallback["default_reasoning_summary"], "auto");
@@ -897,6 +921,7 @@ mod tests {
let mut state = CatalogState {
sources: current,
json: "revision-two".to_string(),
customizations: Default::default(),
};
let mut older = test_sources();
@@ -1230,7 +1255,7 @@ mod tests {
}
#[test]
fn generated_fallback_uses_official_defaults_with_fast_available() {
fn generated_fallback_uses_official_defaults_with_reasoning_and_fast_available() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let runtime = runtime(serde_json::json!({"models":[{"id":"unknown-model"}]}));
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
@@ -1245,8 +1270,15 @@ mod tests {
"auto_compact_token_limit": null,
"comp_hash": null,
"effective_context_window_percent": 95,
"default_reasoning_level": null,
"supported_reasoning_levels": [],
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "low", "description": "Low reasoning effort"},
{"effort": "medium", "description": "Medium reasoning effort"},
{"effort": "high", "description": "High reasoning effort"},
{"effort": "xhigh", "description": "Extra high reasoning effort"},
{"effort": "max", "description": "Maximum reasoning effort"},
{"effort": "ultra", "description": "Ultra reasoning effort"}
],
"shell_type": "unified_exec",
"input_modalities": ["text", "image"],
"supports_image_detail_original": false,
@@ -0,0 +1,492 @@
use super::*;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::Path;
pub(super) type ModelCustomizations = BTreeMap<String, Map<String, Value>>;
const EDITABLE_FIELDS: [&str; 11] = [
"display_name",
"description",
"context_window",
"max_context_window",
"effective_context_window_percent",
"auto_compact_token_limit",
"default_reasoning_level",
"supported_reasoning_levels",
"input_modalities",
"visibility",
"supports_parallel_tool_calls",
];
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavedCustomizations {
version: u32,
models: ModelCustomizations,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CatalogEditorSnapshot {
revision: String,
models: Vec<CatalogEditorModel>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CatalogEditorModel {
slug: String,
has_official_template: bool,
customized: bool,
configuration: Map<String, Value>,
defaults: Map<String, Value>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CatalogEditorRequest {
revision: String,
models: Vec<CatalogEditorModelRequest>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CatalogEditorModelRequest {
slug: String,
configuration: Map<String, Value>,
}
fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
EDITABLE_FIELDS
.iter()
.map(|field| {
(
field.to_string(),
model.get(*field).cloned().unwrap_or(Value::Null),
)
})
.collect()
}
fn validate_configuration(model: &Map<String, Value>) -> Result<(), String> {
for field in model.keys() {
if !EDITABLE_FIELDS.contains(&field.as_str()) {
return Err(format!("不允许修改模型字段 {field}"));
}
}
for field in [
"context_window",
"max_context_window",
"auto_compact_token_limit",
] {
if let Some(value) = model.get(field) {
if field == "auto_compact_token_limit" && value.is_null() {
continue;
}
if !value
.as_u64()
.is_some_and(|value| (1..=9_007_199_254_740_991).contains(&value))
{
return Err(format!("{field} 必须是有效的正整数"));
}
}
}
if let (Some(context), Some(maximum)) = (
model.get("context_window").and_then(Value::as_u64),
model.get("max_context_window").and_then(Value::as_u64),
) {
if maximum < context {
return Err("最大上下文窗口不能小于上下文窗口".to_string());
}
if model
.get("auto_compact_token_limit")
.and_then(Value::as_u64)
.is_some_and(|limit| limit > context)
{
return Err("自动压缩阈值不能大于上下文窗口".to_string());
}
}
if let Some(value) = model.get("effective_context_window_percent") {
if !value
.as_u64()
.is_some_and(|value| (1..=100).contains(&value))
{
return Err("有效上下文比例必须为 1 到 100 的整数".to_string());
}
}
for field in ["display_name", "description"] {
if let Some(value) = model.get(field) {
if field == "description" && value.is_null() {
continue;
}
if !value.as_str().is_some_and(|text| {
text.len() <= 4_000 && (field == "description" || !text.trim().is_empty())
}) {
return Err(format!("{field} 必须是有效的文本"));
}
}
}
if let Some(value) = model.get("visibility") {
if !matches!(value.as_str(), Some("list" | "hide" | "none")) {
return Err("模型显示状态无效".to_string());
}
}
if let Some(value) = model.get("supports_parallel_tool_calls") {
if !value.is_boolean() {
return Err("并行工具调用必须为布尔值".to_string());
}
}
if let Some(value) = model.get("input_modalities") {
let modalities = value.as_array().ok_or("输入类型必须为数组")?;
let mut seen = HashSet::new();
if modalities.is_empty()
|| modalities.iter().any(|value| {
!matches!(value.as_str(), Some("text" | "image")) || !seen.insert(value.as_str())
})
{
return Err("输入类型仅允许不重复的 text 和 image,且至少选择一项".to_string());
}
}
let default = model.get("default_reasoning_level");
if let Some(value) = default {
if !value.is_null() && !value.as_str().is_some_and(is_allowed_reasoning_level) {
return Err("默认思考等级无效".to_string());
}
}
if let Some(value) = model.get("supported_reasoning_levels") {
let levels = value.as_array().ok_or("思考等级必须为数组")?;
let mut seen = HashSet::new();
for level in levels {
let effort = level
.get("effort")
.and_then(Value::as_str)
.ok_or("思考等级缺少 effort")?;
if !is_allowed_reasoning_level(effort) || !seen.insert(effort) {
return Err(format!("思考等级无效或重复: {effort}"));
}
if !level
.get("description")
.and_then(Value::as_str)
.is_some_and(|text| text.len() <= 4_000)
{
return Err("思考等级说明必须是有效的文本".to_string());
}
}
if default
.and_then(Value::as_str)
.is_some_and(|effort| !seen.contains(effort))
{
return Err("默认思考等级必须包含在可选等级中".to_string());
}
}
Ok(())
}
pub(super) fn apply_customizations(
model: &mut Map<String, Value>,
customizations: &ModelCustomizations,
) -> Result<(), String> {
let slug = string_value(model, "slug");
if let Some(customization) = customizations.get(&normalize_id(&slug)) {
model.extend(customization.clone());
validate_configuration(&editable_configuration(model))
.map_err(|error| format!("模型 {slug} 的自定义配置无效: {error}"))?;
enable_fast_mode(model);
}
Ok(())
}
fn snapshot_for_state(
runtime_models: &[CodexRuntimeModel],
state: &CatalogState,
) -> Result<CatalogEditorSnapshot, String> {
let baseline = if runtime_models.is_empty() {
"{\"models\":[]}".to_string()
} else {
prepare_catalog_with_customizations(runtime_models, &state.sources, &Default::default())?
.json
};
let root: Value = serde_json::from_str(&baseline).map_err(|error| error.to_string())?;
let mut models = Vec::new();
for value in root["models"]
.as_array()
.ok_or("模型目录缺少 models 数组")?
{
let mut model = value.as_object().cloned().ok_or("模型目录条目必须为对象")?;
let slug = string_value(&model, "slug");
let key = normalize_id(&slug);
let defaults = editable_configuration(&model);
apply_customizations(&mut model, &state.customizations)?;
models.push(CatalogEditorModel {
slug,
has_official_template: state.sources.templates.contains_key(&key),
customized: state.customizations.contains_key(&key),
configuration: editable_configuration(&model),
defaults,
});
}
let mut digest = Sha256::new();
digest.update(baseline.as_bytes());
digest.update(serde_json::to_vec(&state.customizations).map_err(|error| error.to_string())?);
Ok(CatalogEditorSnapshot {
revision: format!("{:x}", digest.finalize()),
models,
})
}
pub(crate) fn editor_snapshot(
runtime_models: &[CodexRuntimeModel],
) -> Result<CatalogEditorSnapshot, String> {
let state = catalog_state()?
.read()
.map_err(|_| "Codex 模型目录内存锁已损坏")?;
snapshot_for_state(runtime_models, &state)
}
fn customizations_from_request(
snapshot: &CatalogEditorSnapshot,
request: CatalogEditorRequest,
) -> Result<ModelCustomizations, String> {
if request.revision != snapshot.revision {
return Err("CODEX_MODEL_CATALOG_CHANGED".to_string());
}
if request.models.len() != snapshot.models.len() {
return Err("模型列表已变化,请重新加载后编辑".to_string());
}
let mut seen = HashSet::new();
let mut customizations = BTreeMap::new();
for requested in request.models {
let key = normalize_id(&requested.slug);
let current = snapshot
.models
.iter()
.find(|model| normalize_id(&model.slug) == key)
.ok_or_else(|| format!("模型 {} 已不在当前列表中", requested.slug))?;
if !seen.insert(key.clone()) {
return Err(format!("模型 {} 重复", requested.slug));
}
if requested.configuration.len() != EDITABLE_FIELDS.len() {
return Err(format!("模型 {} 的配置字段不完整", requested.slug));
}
validate_configuration(&requested.configuration)
.map_err(|error| format!("模型 {}: {error}", requested.slug))?;
let mut changes: Map<String, Value> = requested
.configuration
.iter()
.filter(|(field, value)| current.defaults.get(*field) != Some(*value))
.map(|(field, value)| (field.clone(), value.clone()))
.collect();
for group in [
&[
"context_window",
"max_context_window",
"auto_compact_token_limit",
"effective_context_window_percent",
][..],
&["default_reasoning_level", "supported_reasoning_levels"][..],
] {
if group.iter().any(|field| changes.contains_key(*field)) {
for field in group {
changes.insert(field.to_string(), requested.configuration[*field].clone());
}
}
}
if !changes.is_empty() {
customizations.insert(key, changes);
}
}
Ok(customizations)
}
fn decode_customizations(content: &[u8]) -> Result<ModelCustomizations, String> {
if content.len() > crate::MAX_CODEX_MODEL_CATALOG_BYTES {
return Err("Codex 自定义模型配置超过大小限制".to_string());
}
let saved: SavedCustomizations =
serde_json::from_slice(content).map_err(|error| error.to_string())?;
if saved.version != 1 {
return Err("不支持的 Codex 自定义模型配置版本".to_string());
}
let mut normalized = BTreeMap::new();
for (slug, model) in saved.models {
let key = normalize_id(&slug);
if key.is_empty() || normalized.contains_key(&key) {
return Err("自定义模型 ID 为空或重复".to_string());
}
validate_configuration(&model)?;
normalized.insert(key, model);
}
Ok(normalized)
}
pub(crate) fn load_customizations(path: &Path) -> Result<(), String> {
let content = match std::fs::read(path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 Codex 自定义模型配置失败: {error}")),
};
let customizations = decode_customizations(&content)?;
let mut state = catalog_state()?
.write()
.map_err(|_| "Codex 模型目录内存锁已损坏")?;
state.customizations = customizations;
Ok(())
}
fn save_for_state(
path: &Path,
runtime_models: &[CodexRuntimeModel],
request: CatalogEditorRequest,
state: &mut CatalogState,
) -> Result<CatalogEditorSnapshot, String> {
let snapshot = snapshot_for_state(runtime_models, state)?;
let customizations = customizations_from_request(&snapshot, request)?;
let saved = SavedCustomizations {
version: 1,
models: customizations,
};
let content = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
if content.len() > crate::MAX_CODEX_MODEL_CATALOG_BYTES {
return Err("Codex 自定义模型配置超过大小限制".to_string());
}
crate::write_bytes_atomically(path, &content)?;
state.customizations = saved.models;
snapshot_for_state(runtime_models, state)
}
pub(crate) fn save_customizations(
path: &Path,
runtime_models: &[CodexRuntimeModel],
request: CatalogEditorRequest,
) -> Result<CatalogEditorSnapshot, String> {
let mut state = catalog_state()?
.write()
.map_err(|_| "Codex 模型目录内存锁已损坏")?;
save_for_state(path, runtime_models, request, &mut state)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn runtime_model(slug: &str) -> CodexRuntimeModel {
CodexRuntimeModel {
slug: slug.to_string(),
display_name: None,
description: None,
context_window: None,
max_context_window: None,
input_modalities: None,
default_reasoning_level: None,
hidden: false,
}
}
fn temporary_path() -> std::path::PathBuf {
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"cpa-codex-model-customizations-{}-{}.json",
std::process::id(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn customizations_persist_apply_and_restore_to_the_current_template() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let mut state = CatalogState {
sources,
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
};
let runtime_models = vec![runtime_model("third-party-model")];
let snapshot = snapshot_for_state(&runtime_models, &state).unwrap();
let mut configuration = snapshot.models[0].configuration.clone();
configuration.insert("context_window".to_string(), serde_json::json!(131_072));
configuration.insert("max_context_window".to_string(), serde_json::json!(262_144));
let path = temporary_path();
let saved = save_for_state(
&path,
&runtime_models,
CatalogEditorRequest {
revision: snapshot.revision,
models: vec![CatalogEditorModelRequest {
slug: "third-party-model".to_string(),
configuration,
}],
},
&mut state,
)
.unwrap();
assert!(saved.models[0].customized);
assert_eq!(saved.models[0].configuration["context_window"], 131_072);
assert_eq!(saved.models[0].configuration["max_context_window"], 262_144);
let generated = prepare_catalog_with_customizations(
&runtime_models,
&state.sources,
&state.customizations,
)
.unwrap();
let generated: Value = serde_json::from_str(&generated.json).unwrap();
assert_eq!(generated["models"][0]["context_window"], 131_072);
assert_eq!(generated["models"][0]["max_context_window"], 262_144);
let loaded = decode_customizations(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(loaded, state.customizations);
let restored = save_for_state(
&path,
&runtime_models,
CatalogEditorRequest {
revision: saved.revision,
models: vec![CatalogEditorModelRequest {
slug: "third-party-model".to_string(),
configuration: saved.models[0].defaults.clone(),
}],
},
&mut state,
)
.unwrap();
assert!(!restored.models[0].customized);
assert!(state.customizations.is_empty());
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(persisted["models"], serde_json::json!({}));
let _ = std::fs::remove_file(path);
}
#[test]
fn stale_editor_revision_is_rejected_without_changing_state() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
let mut state = CatalogState {
sources,
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
};
let runtime_models = vec![runtime_model("third-party-model")];
let snapshot = snapshot_for_state(&runtime_models, &state).unwrap();
let path = temporary_path();
let error = save_for_state(
&path,
&runtime_models,
CatalogEditorRequest {
revision: "stale".to_string(),
models: vec![CatalogEditorModelRequest {
slug: "third-party-model".to_string(),
configuration: snapshot.models[0].configuration.clone(),
}],
},
&mut state,
)
.unwrap_err();
assert_eq!(error, "CODEX_MODEL_CATALOG_CHANGED");
assert!(state.customizations.is_empty());
assert!(!path.exists());
}
}
+5
View File
@@ -2354,6 +2354,9 @@ fn main() {
eprintln!("加载 Codex 模型目录更新文件失败,将使用内置目录: {error}");
}
let catalog_update_app = app.handle().clone();
if let Err(error) = load_codex_model_customizations(app.handle()) {
eprintln!("加载 Codex 自定义模型配置失败: {error}");
}
tauri::async_runtime::spawn(async move {
if let Err(error) = update_codex_model_catalog_inner(&catalog_update_app).await {
eprintln!("后台更新 Codex 模型目录失败,继续使用当前目录: {error}");
@@ -2484,6 +2487,8 @@ fn main() {
uninstall_pi_provider,
check_codex_oauth_login,
update_codex_model_catalog,
get_codex_model_catalog_editor,
save_codex_model_catalog_editor,
get_thinking_aliases,
get_model_alias_sources,
get_thinking_alias_sources,