feat(codex): add runtime-composed model catalog support
- replace legacy model resources with model-catalog.json - compose Codex catalogs from CPA runtime models - add conservative fallback handling for unknown models - add DeepSeek V4 Flash and Pro templates - align fallback defaults and prompts with Codex - preserve atomic configuration writes and validation
This commit is contained in:
1 parent
054a01f79d
commit
8d8f619720
4 files changed
+2198
-290
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,896 @@
|
||||
use crate::AgentModelOption;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json");
|
||||
|
||||
static SOURCES: OnceLock<Result<CatalogSources, String>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CodexRuntimeModel {
|
||||
pub(crate) slug: String,
|
||||
display_name: Option<String>,
|
||||
description: Option<String>,
|
||||
context_window: Option<u64>,
|
||||
max_context_window: Option<u64>,
|
||||
input_modalities: Option<Vec<String>>,
|
||||
supported_reasoning_levels: Option<Vec<String>>,
|
||||
default_reasoning_level: Option<String>,
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PreparedCodexCatalog {
|
||||
pub(crate) models: Vec<AgentModelOption>,
|
||||
pub(crate) json: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogSources {
|
||||
fallback: Map<String, Value>,
|
||||
templates: HashMap<String, Template>,
|
||||
max_template_priority: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Template {
|
||||
value: Map<String, Value>,
|
||||
order: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogEntry {
|
||||
value: Map<String, Value>,
|
||||
template_order: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_embedded_catalog() -> Result<(), String> {
|
||||
sources().map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
|
||||
let values = payload
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| payload.get("data").and_then(Value::as_array))
|
||||
.or_else(|| payload.as_array())
|
||||
.ok_or_else(|| "Codex 模型列表响应缺少 models 或 data 数组".to_string())?;
|
||||
|
||||
let mut seen = HashSet::with_capacity(values.len());
|
||||
let mut models = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let slug = if let Some(value) = value.as_str() {
|
||||
value.trim()
|
||||
} else {
|
||||
["slug", "id", "name", "model", "value"]
|
||||
.into_iter()
|
||||
.filter_map(|key| value.get(key).and_then(Value::as_str).map(str::trim))
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if slug.is_empty() || !seen.insert(normalize_id(slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let display_name = optional_string(value, &["display_name", "displayName", "alias"]);
|
||||
let description = optional_string(value, &["description"]);
|
||||
let context_window = positive_u64_field(
|
||||
value,
|
||||
&[
|
||||
"context_window",
|
||||
"contextWindow",
|
||||
"context_length",
|
||||
"contextLength",
|
||||
],
|
||||
);
|
||||
let max_context_window =
|
||||
positive_u64_field(value, &["max_context_window", "maxContextWindow"]);
|
||||
let input_modalities = parse_modalities(value);
|
||||
let supported_reasoning_levels = parse_reasoning_levels(value);
|
||||
let default_reasoning_level =
|
||||
optional_string(value, &["default_reasoning_level", "defaultReasoningLevel"])
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.filter(|value| is_allowed_reasoning_level(value));
|
||||
let hidden = optional_string(value, &["visibility"])
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("hide"));
|
||||
|
||||
models.push(CodexRuntimeModel {
|
||||
slug: slug.to_string(),
|
||||
display_name,
|
||||
description,
|
||||
context_window,
|
||||
max_context_window,
|
||||
input_modalities,
|
||||
supported_reasoning_levels,
|
||||
default_reasoning_level,
|
||||
hidden,
|
||||
});
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_catalog(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
) -> Result<PreparedCodexCatalog, String> {
|
||||
prepare_catalog_with_sources(runtime_models, sources()?)
|
||||
}
|
||||
|
||||
fn sources() -> Result<&'static CatalogSources, String> {
|
||||
SOURCES
|
||||
.get_or_init(|| parse_sources(MODEL_CATALOG_JSON))
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)
|
||||
}
|
||||
|
||||
fn parse_sources(catalog_json: &str) -> Result<CatalogSources, String> {
|
||||
let root: Value = serde_json::from_str(catalog_json)
|
||||
.map_err(|error| format!("解析内置 model-catalog.json 失败: {error}"))?;
|
||||
let root = root
|
||||
.as_object()
|
||||
.ok_or_else(|| "内置 model-catalog.json 根节点必须是对象".to_string())?;
|
||||
|
||||
let fallback = root
|
||||
.get("fallback_model")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.ok_or_else(|| "内置 model-catalog.json 缺少 fallback_model 对象".to_string())?;
|
||||
validate_model(&fallback, "fallback_model", false)?;
|
||||
|
||||
let values = root
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|models| !models.is_empty())
|
||||
.ok_or_else(|| "内置 model-catalog.json 必须包含非空 models 数组".to_string())?;
|
||||
let mut templates = HashMap::with_capacity(values.len());
|
||||
let mut max_template_priority = 0;
|
||||
for (order, value) in values.iter().enumerate() {
|
||||
let model = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("内置 model-catalog.json 第 {} 个模型必须是对象", order + 1))?;
|
||||
validate_model(&model, &format!("第 {} 个正式模板", order + 1), true)?;
|
||||
let slug = string_value(&model, "slug");
|
||||
let key = normalize_id(&slug);
|
||||
if templates.contains_key(&key) {
|
||||
return Err(format!(
|
||||
"内置 model-catalog.json 模型 slug 大小写重复: {slug}"
|
||||
));
|
||||
}
|
||||
max_template_priority = max_template_priority.max(priority_value(&model));
|
||||
templates.insert(
|
||||
key,
|
||||
Template {
|
||||
value: model,
|
||||
order,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(CatalogSources {
|
||||
fallback,
|
||||
templates,
|
||||
max_template_priority,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_model(
|
||||
model: &Map<String, Value>,
|
||||
label: &str,
|
||||
require_slug: bool,
|
||||
) -> Result<(), String> {
|
||||
if require_slug && string_value(model, "slug").is_empty() {
|
||||
return Err(format!("内置 model-catalog.json {label} 的 slug 不能为空"));
|
||||
}
|
||||
if string_value(model, "base_instructions").is_empty() {
|
||||
return Err(format!(
|
||||
"内置 model-catalog.json {label} 的 base_instructions 不能为空"
|
||||
));
|
||||
}
|
||||
validate_required_codex_fields(model, label)?;
|
||||
let context_window = positive_u64_value(model.get("context_window"))
|
||||
.ok_or_else(|| format!("内置 model-catalog.json {label} 的 context_window 必须为正数"))?;
|
||||
let max_context_window =
|
||||
positive_u64_value(model.get("max_context_window")).ok_or_else(|| {
|
||||
format!("内置 model-catalog.json {label} 的 max_context_window 必须为正数")
|
||||
})?;
|
||||
if max_context_window < context_window {
|
||||
return Err(format!(
|
||||
"内置 model-catalog.json {label} 的 max_context_window 不能小于 context_window"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_required_codex_fields(model: &Map<String, Value>, label: &str) -> Result<(), String> {
|
||||
for field in ["display_name", "shell_type", "visibility"] {
|
||||
if string_value(model, field).is_empty() {
|
||||
return Err(format!(
|
||||
"Codex 模型 {label} 的必填字段 {field} 必须是非空字符串"
|
||||
));
|
||||
}
|
||||
}
|
||||
for field in ["supported_reasoning_levels", "experimental_supported_tools"] {
|
||||
if !model.get(field).is_some_and(Value::is_array) {
|
||||
return Err(format!("Codex 模型 {label} 的必填字段 {field} 必须是数组"));
|
||||
}
|
||||
}
|
||||
for field in [
|
||||
"supported_in_api",
|
||||
"support_verbosity",
|
||||
"supports_parallel_tool_calls",
|
||||
] {
|
||||
if !model.get(field).is_some_and(Value::is_boolean) {
|
||||
return Err(format!(
|
||||
"Codex 模型 {label} 的必填字段 {field} 必须是布尔值"
|
||||
));
|
||||
}
|
||||
}
|
||||
if !model
|
||||
.get("priority")
|
||||
.is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some())
|
||||
{
|
||||
return Err(format!("Codex 模型 {label} 的必填字段 priority 必须是整数"));
|
||||
}
|
||||
if !model
|
||||
.get("default_reasoning_summary")
|
||||
.is_some_and(Value::is_string)
|
||||
{
|
||||
return Err(format!(
|
||||
"Codex 模型 {label} 的必填字段 default_reasoning_summary 必须是字符串"
|
||||
));
|
||||
}
|
||||
let truncation = model
|
||||
.get("truncation_policy")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| format!("Codex 模型 {label} 缺少 truncation_policy 对象"))?;
|
||||
if string_value(truncation, "mode").is_empty()
|
||||
|| !truncation
|
||||
.get("limit")
|
||||
.is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some())
|
||||
{
|
||||
return Err(format!("Codex 模型 {label} 的 truncation_policy 无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_catalog_with_sources(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
sources: &CatalogSources,
|
||||
) -> Result<PreparedCodexCatalog, String> {
|
||||
if runtime_models.is_empty() {
|
||||
return Err("CPA 当前没有可写入 Codex 的模型".to_string());
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(runtime_models.len());
|
||||
for runtime in runtime_models {
|
||||
let key = normalize_id(&runtime.slug);
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(template) = sources.templates.get(&key) {
|
||||
let mut value = template.value.clone();
|
||||
value.insert("slug".to_string(), Value::String(runtime.slug.clone()));
|
||||
entries.push(CatalogEntry {
|
||||
value,
|
||||
template_order: Some(template.order),
|
||||
});
|
||||
} else {
|
||||
let mut value = sources.fallback.clone();
|
||||
apply_runtime_metadata(&mut value, runtime);
|
||||
disable_fallback_capabilities(&mut value);
|
||||
entries.push(CatalogEntry {
|
||||
value,
|
||||
template_order: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err("CPA 当前没有有效的 Codex 模型 ID".to_string());
|
||||
}
|
||||
|
||||
for entry in &entries {
|
||||
let slug = string_value(&entry.value, "slug");
|
||||
validate_required_codex_fields(&entry.value, &slug)?;
|
||||
}
|
||||
|
||||
let mut fallback_indexes = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.template_order.is_none())
|
||||
.map(|(index, entry)| {
|
||||
(
|
||||
index,
|
||||
string_value(&entry.value, "display_name").to_ascii_lowercase(),
|
||||
string_value(&entry.value, "slug").to_ascii_lowercase(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
fallback_indexes.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2)));
|
||||
for (rank, (index, _, _)) in fallback_indexes.into_iter().enumerate() {
|
||||
entries[index].value.insert(
|
||||
"priority".to_string(),
|
||||
Value::Number((sources.max_template_priority + 100 * (rank as i64 + 1)).into()),
|
||||
);
|
||||
}
|
||||
|
||||
entries.sort_by(
|
||||
|left, right| match (left.template_order, right.template_order) {
|
||||
(Some(left_order), Some(right_order)) => priority_value(&left.value)
|
||||
.cmp(&priority_value(&right.value))
|
||||
.then_with(|| left_order.cmp(&right_order)),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => priority_value(&left.value).cmp(&priority_value(&right.value)),
|
||||
},
|
||||
);
|
||||
|
||||
let models = entries
|
||||
.iter()
|
||||
.map(|entry| AgentModelOption {
|
||||
name: string_value(&entry.value, "slug"),
|
||||
alias: optional_map_string(&entry.value, "display_name").filter(|display| {
|
||||
!display.eq_ignore_ascii_case(&string_value(&entry.value, "slug"))
|
||||
}),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let values = entries
|
||||
.into_iter()
|
||||
.map(|entry| Value::Object(entry.value))
|
||||
.collect::<Vec<_>>();
|
||||
let mut json = serde_json::to_string_pretty(&serde_json::json!({ "models": values }))
|
||||
.map_err(|error| format!("生成 Codex 模型目录失败: {error}"))?;
|
||||
json.push('\n');
|
||||
Ok(PreparedCodexCatalog { models, json })
|
||||
}
|
||||
|
||||
fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
model.insert("slug".to_string(), Value::String(runtime.slug.clone()));
|
||||
model.insert(
|
||||
"display_name".to_string(),
|
||||
Value::String(
|
||||
runtime
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| runtime.slug.clone()),
|
||||
),
|
||||
);
|
||||
model.insert("visibility".to_string(), Value::String("list".to_string()));
|
||||
if let Some(description) = runtime.description.as_ref() {
|
||||
model.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(context_window) = runtime.context_window {
|
||||
let max_context_window = runtime
|
||||
.max_context_window
|
||||
.unwrap_or(context_window)
|
||||
.max(context_window);
|
||||
model.insert(
|
||||
"context_window".to_string(),
|
||||
Value::Number(context_window.into()),
|
||||
);
|
||||
model.insert(
|
||||
"max_context_window".to_string(),
|
||||
Value::Number(max_context_window.into()),
|
||||
);
|
||||
} else if let Some(max_context_window) = runtime.max_context_window {
|
||||
let context_window = positive_u64_value(model.get("context_window")).unwrap_or(128_000);
|
||||
model.insert(
|
||||
"max_context_window".to_string(),
|
||||
Value::Number(max_context_window.max(context_window).into()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(modalities) = runtime.input_modalities.as_ref() {
|
||||
model.insert(
|
||||
"input_modalities".to_string(),
|
||||
Value::Array(modalities.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
model.insert(
|
||||
"supports_image_detail_original".to_string(),
|
||||
Value::Bool(modalities.iter().any(|value| value == "image")),
|
||||
);
|
||||
}
|
||||
|
||||
apply_runtime_reasoning(model, runtime);
|
||||
if runtime.hidden {
|
||||
model.insert("visibility".to_string(), Value::String("hide".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_runtime_reasoning(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
let Some(levels) = runtime.supported_reasoning_levels.as_ref() else {
|
||||
if let Some(default) = runtime.default_reasoning_level.as_ref() {
|
||||
let fallback_levels = reasoning_efforts(model);
|
||||
if fallback_levels.contains(default) {
|
||||
model.insert(
|
||||
"default_reasoning_level".to_string(),
|
||||
Value::String(default.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
let fallback_default = optional_map_string(model, "default_reasoning_level")
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
let default = runtime
|
||||
.default_reasoning_level
|
||||
.as_ref()
|
||||
.filter(|default| levels.contains(default))
|
||||
.cloned()
|
||||
.or_else(|| fallback_default.filter(|default| levels.contains(default)));
|
||||
let Some(default) = default else {
|
||||
return;
|
||||
};
|
||||
model.insert(
|
||||
"supported_reasoning_levels".to_string(),
|
||||
Value::Array(
|
||||
levels
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
serde_json::json!({
|
||||
"effort": effort,
|
||||
"description": reasoning_description(effort),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
model.insert(
|
||||
"default_reasoning_level".to_string(),
|
||||
Value::String(default),
|
||||
);
|
||||
}
|
||||
|
||||
fn disable_fallback_capabilities(model: &mut Map<String, Value>) {
|
||||
model.insert("prefer_websockets".to_string(), Value::Bool(false));
|
||||
model.insert("supports_search_tool".to_string(), Value::Bool(false));
|
||||
model.insert(
|
||||
"web_search_tool_type".to_string(),
|
||||
Value::String("text".to_string()),
|
||||
);
|
||||
model.insert("service_tiers".to_string(), Value::Array(Vec::new()));
|
||||
model.insert(
|
||||
"additional_speed_tiers".to_string(),
|
||||
Value::Array(Vec::new()),
|
||||
);
|
||||
model.insert("default_service_tier".to_string(), Value::Null);
|
||||
model.insert("upgrade".to_string(), Value::Null);
|
||||
model.insert("availability_nux".to_string(), Value::Null);
|
||||
model.remove("minimal_client_version");
|
||||
}
|
||||
|
||||
fn parse_modalities(value: &Value) -> Option<Vec<String>> {
|
||||
let raw = value
|
||||
.get("input_modalities")
|
||||
.or_else(|| value.get("inputModalities"))
|
||||
.or_else(|| value.get("supported_input_modalities"))
|
||||
.and_then(Value::as_array)?;
|
||||
let mut seen = HashSet::new();
|
||||
let modalities = raw
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|value| matches!(value.as_str(), "text" | "image"))
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
(!modalities.is_empty()).then_some(modalities)
|
||||
}
|
||||
|
||||
fn parse_reasoning_levels(value: &Value) -> Option<Vec<String>> {
|
||||
let raw = value
|
||||
.get("supported_reasoning_levels")
|
||||
.or_else(|| value.get("supportedReasoningLevels"))
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("thinking")
|
||||
.and_then(|thinking| thinking.get("levels"))
|
||||
.and_then(Value::as_array)
|
||||
})?;
|
||||
let mut seen = HashSet::new();
|
||||
let levels = raw
|
||||
.iter()
|
||||
.filter_map(|level| {
|
||||
level
|
||||
.as_str()
|
||||
.or_else(|| level.get("effort").and_then(Value::as_str))
|
||||
})
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|level| is_allowed_reasoning_level(level))
|
||||
.filter(|level| seen.insert(level.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
(!levels.is_empty()).then_some(levels)
|
||||
}
|
||||
|
||||
fn reasoning_efforts(model: &Map<String, Value>) -> Vec<String> {
|
||||
model
|
||||
.get("supported_reasoning_levels")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|level| level.get("effort").and_then(Value::as_str))
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn optional_string(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| value.get(*key).and_then(Value::as_str).map(str::trim))
|
||||
.find(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn positive_u64_field(value: &Value, keys: &[&str]) -> Option<u64> {
|
||||
keys.iter()
|
||||
.find_map(|key| positive_u64_value(value.get(*key)))
|
||||
}
|
||||
|
||||
fn positive_u64_value(value: Option<&Value>) -> Option<u64> {
|
||||
value
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str()?.trim().parse().ok())
|
||||
})
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn optional_map_string(model: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
model
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn string_value(model: &Map<String, Value>, key: &str) -> String {
|
||||
optional_map_string(model, key).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn priority_value(model: &Map<String, Value>) -> i64 {
|
||||
model
|
||||
.get("priority")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().map(|value| value as i64))
|
||||
})
|
||||
.unwrap_or(100)
|
||||
}
|
||||
|
||||
fn normalize_id(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_allowed_reasoning_level(level: &str) -> bool {
|
||||
matches!(
|
||||
level,
|
||||
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"
|
||||
)
|
||||
}
|
||||
|
||||
fn reasoning_description(level: &str) -> &'static str {
|
||||
match level {
|
||||
"none" => "No reasoning",
|
||||
"minimal" => "Minimal reasoning",
|
||||
"low" => "Fast responses with lighter reasoning",
|
||||
"medium" => "Balances speed and reasoning depth for everyday tasks",
|
||||
"high" => "Greater reasoning depth for complex problems",
|
||||
"xhigh" => "Extra high reasoning depth for complex problems",
|
||||
"max" => "Maximum available reasoning depth for complex problems",
|
||||
"ultra" => "Highest available reasoning depth",
|
||||
_ => "Model-supported reasoning level",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_sources() -> CatalogSources {
|
||||
parse_sources(
|
||||
r#"{
|
||||
"fallback_model": {
|
||||
"base_instructions": "You are Codex, a model-neutral coding agent.",
|
||||
"display_name": "Compatible Model",
|
||||
"description": "Conservative fallback",
|
||||
"context_window": 128000,
|
||||
"max_context_window": 128000,
|
||||
"input_modalities": ["text"],
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": [
|
||||
{"effort":"low","description":"Low"},
|
||||
{"effort":"medium","description":"Medium"},
|
||||
{"effort":"high","description":"High"}
|
||||
],
|
||||
"shell_type": "shell_command",
|
||||
"supported_in_api": true,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
"truncation_policy": {"mode":"tokens","limit":10000},
|
||||
"prefer_websockets": false,
|
||||
"supports_search_tool": false,
|
||||
"supports_parallel_tool_calls": true,
|
||||
"experimental_supported_tools": [],
|
||||
"service_tiers": [],
|
||||
"additional_speed_tiers": [],
|
||||
"visibility": "list",
|
||||
"priority": 100
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"slug":"A",
|
||||
"display_name":"Known A",
|
||||
"base_instructions":"Known A",
|
||||
"context_window":200000,
|
||||
"max_context_window":200000,
|
||||
"priority":10,
|
||||
"supported_reasoning_levels":[],
|
||||
"shell_type":"shell_command",
|
||||
"visibility":"list",
|
||||
"supported_in_api":true,
|
||||
"default_reasoning_summary":"none",
|
||||
"support_verbosity":false,
|
||||
"truncation_policy":{"mode":"tokens","limit":10000},
|
||||
"supports_parallel_tool_calls":true,
|
||||
"experimental_supported_tools":[],
|
||||
"service_tiers":[{"id":"priority"}],
|
||||
"additional_speed_tiers":["fast"],
|
||||
"nested":{"unknown":true}
|
||||
},
|
||||
{
|
||||
"slug":"B",
|
||||
"display_name":"Known B",
|
||||
"base_instructions":"Known B",
|
||||
"context_window":300000,
|
||||
"max_context_window":300000,
|
||||
"priority":20,
|
||||
"supported_reasoning_levels":[],
|
||||
"shell_type":"shell_command",
|
||||
"visibility":"list",
|
||||
"supported_in_api":true,
|
||||
"default_reasoning_summary":"none",
|
||||
"support_verbosity":false,
|
||||
"truncation_policy":{"mode":"tokens","limit":10000},
|
||||
"supports_parallel_tool_calls":true,
|
||||
"experimental_supported_tools":[]
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn runtime(payload: Value) -> Vec<CodexRuntimeModel> {
|
||||
parse_runtime_models(&payload).unwrap()
|
||||
}
|
||||
|
||||
fn output_models(catalog: &PreparedCodexCatalog) -> Vec<Map<String, Value>> {
|
||||
let root = serde_json::from_str::<Value>(&catalog.json).unwrap();
|
||||
assert!(root.get("fallback_model").is_none());
|
||||
root["models"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|value| value.as_object().unwrap().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_catalog_is_valid() {
|
||||
validate_embedded_catalog().unwrap();
|
||||
let sources = sources().unwrap();
|
||||
assert_eq!(sources.templates.len(), 10);
|
||||
let fallback_prompt = string_value(&sources.fallback, "base_instructions");
|
||||
assert!(fallback_prompt.starts_with(
|
||||
"You are a coding agent running in the Codex CLI, a terminal-based coding assistant."
|
||||
));
|
||||
assert!(!fallback_prompt.contains("GPT-5"));
|
||||
assert_eq!(sources.fallback["context_window"], 272_000);
|
||||
assert_eq!(sources.fallback["max_context_window"], 272_000);
|
||||
assert_eq!(
|
||||
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["shell_type"], "default");
|
||||
assert_eq!(sources.fallback["visibility"], "none");
|
||||
assert_eq!(sources.fallback["default_reasoning_summary"], "auto");
|
||||
assert_eq!(sources.fallback["supports_parallel_tool_calls"], false);
|
||||
assert_eq!(sources.fallback["truncation_policy"]["mode"], "bytes");
|
||||
|
||||
let gpt = &sources.templates["gpt-5.6-sol"].value;
|
||||
let gpt_prompt = string_value(gpt, "base_instructions");
|
||||
let gpt_prompt_body = gpt_prompt.split_once("\n\n").unwrap().1;
|
||||
let gpt_messages = gpt["model_messages"].as_object().unwrap();
|
||||
let gpt_message_prompt = gpt_messages["instructions_template"].as_str().unwrap();
|
||||
let gpt_message_prompt_body = gpt_message_prompt.split_once("\n\n").unwrap().1;
|
||||
|
||||
for slug in ["deepseek-v4-flash", "deepseek-v4-pro"] {
|
||||
let model = &sources.templates[slug].value;
|
||||
assert_eq!(model["context_window"], 1_000_000);
|
||||
assert_eq!(model["max_context_window"], 1_000_000);
|
||||
assert_eq!(model["default_reasoning_level"], "high");
|
||||
assert_eq!(reasoning_efforts(model), ["high", "xhigh"]);
|
||||
assert_eq!(model["input_modalities"], serde_json::json!(["text"]));
|
||||
assert_eq!(model["supports_parallel_tool_calls"], false);
|
||||
assert_eq!(model["supports_search_tool"], false);
|
||||
assert_eq!(model["service_tiers"], serde_json::json!([]));
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!([]));
|
||||
|
||||
let prompt = string_value(model, "base_instructions");
|
||||
assert!(
|
||||
prompt.starts_with("You are Codex, a coding agent powered by a DeepSeek model.")
|
||||
);
|
||||
assert_eq!(prompt.split_once("\n\n").unwrap().1, gpt_prompt_body);
|
||||
|
||||
let mut normalized_messages = model["model_messages"].as_object().unwrap().clone();
|
||||
let message_prompt = normalized_messages["instructions_template"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert!(message_prompt
|
||||
.starts_with("You are Codex, a coding agent powered by a DeepSeek model."));
|
||||
assert_eq!(
|
||||
message_prompt.split_once("\n\n").unwrap().1,
|
||||
gpt_message_prompt_body
|
||||
);
|
||||
normalized_messages.insert(
|
||||
"instructions_template".to_string(),
|
||||
Value::String(gpt_message_prompt.to_string()),
|
||||
);
|
||||
assert_eq!(&normalized_messages, gpt_messages);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_parser_prefers_models_and_keeps_first_duplicate() {
|
||||
let models = runtime(serde_json::json!({
|
||||
"models": [
|
||||
{"slug":"Model-A","context_window":"200000"},
|
||||
{"id":"model-a","context_window":1},
|
||||
{"slug":" ","id":"Model-B"},
|
||||
{"id":""}
|
||||
],
|
||||
"data": [{"id":"ignored"}]
|
||||
}));
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].slug, "Model-A");
|
||||
assert_eq!(models[0].context_window, Some(200_000));
|
||||
assert_eq!(models[1].slug, "Model-B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_and_unknown_models_are_composed_from_the_correct_sources() {
|
||||
let runtime = runtime(serde_json::json!({"models":[
|
||||
{"id":"b","display_name":"Runtime B","context_window":1},
|
||||
{"id":"C","display_name":"Runtime C","context_window":200000}
|
||||
]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
|
||||
let models = output_models(&catalog);
|
||||
assert_eq!(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| string_value(model, "slug"))
|
||||
.collect::<Vec<_>>(),
|
||||
["b", "C"]
|
||||
);
|
||||
assert_eq!(models[0]["base_instructions"], "Known B");
|
||||
assert_eq!(models[0]["context_window"], 300_000);
|
||||
assert_eq!(
|
||||
models[1]["base_instructions"],
|
||||
"You are Codex, a model-neutral coding agent."
|
||||
);
|
||||
assert_eq!(models[1]["context_window"], 200_000);
|
||||
assert_eq!(models[1]["max_context_window"], 200_000);
|
||||
assert!(models[1]["priority"].as_i64().unwrap() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_template_is_preserved_except_for_runtime_slug() {
|
||||
let sources = test_sources();
|
||||
let runtime = runtime(serde_json::json!({"data":[{
|
||||
"id":"a",
|
||||
"display_name":"Overwrite",
|
||||
"context_window":1,
|
||||
"service_tiers":[]
|
||||
}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
let template = &sources.templates["a"].value;
|
||||
for (key, expected) in template {
|
||||
if key != "slug" {
|
||||
assert_eq!(model.get(key), Some(expected), "changed field {key}");
|
||||
}
|
||||
}
|
||||
assert_eq!(model["slug"], "a");
|
||||
assert_eq!(model["nested"]["unknown"], true);
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_accepts_only_safe_valid_metadata() {
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"id":"C",
|
||||
"display_name":"Third Party",
|
||||
"description":"Detailed",
|
||||
"context_window":200000,
|
||||
"max_context_window":100000,
|
||||
"input_modalities":["image","IMAGE","audio"],
|
||||
"supported_reasoning_levels":["low", {"effort":"high"}],
|
||||
"default_reasoning_level":"high",
|
||||
"visibility":"hide",
|
||||
"base_instructions":"unsafe",
|
||||
"supports_search_tool":true,
|
||||
"service_tiers":[{"id":"priority"}]
|
||||
}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
assert_eq!(model["display_name"], "Third Party");
|
||||
assert_eq!(model["context_window"], 200_000);
|
||||
assert_eq!(model["max_context_window"], 200_000);
|
||||
assert_eq!(model["input_modalities"], serde_json::json!(["image"]));
|
||||
assert_eq!(model["default_reasoning_level"], "high");
|
||||
assert_eq!(model["visibility"], "hide");
|
||||
assert_eq!(
|
||||
model["base_instructions"],
|
||||
"You are Codex, a model-neutral coding agent."
|
||||
);
|
||||
assert_eq!(model["supports_search_tool"], false);
|
||||
assert_eq!(model["service_tiers"], serde_json::json!([]));
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reasoning_combination_keeps_fallback_and_missing_context_uses_128k() {
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"id":"C",
|
||||
"supported_reasoning_levels":["xhigh"],
|
||||
"default_reasoning_level":"invalid"
|
||||
}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
assert_eq!(model["context_window"], 128_000);
|
||||
assert_eq!(model["max_context_window"], 128_000);
|
||||
assert_eq!(model["display_name"], "C");
|
||||
assert_eq!(model["visibility"], "list");
|
||||
assert_eq!(model["web_search_tool_type"], "text");
|
||||
assert_eq!(model["availability_nux"], Value::Null);
|
||||
assert_eq!(model["upgrade"], Value::Null);
|
||||
assert_eq!(model["default_reasoning_level"], "medium");
|
||||
assert_eq!(reasoning_efforts(model), ["low", "medium", "high"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_catalogs_and_empty_runtime_are_rejected() {
|
||||
let mut invalid_fallback = test_sources().fallback;
|
||||
invalid_fallback.remove("experimental_supported_tools");
|
||||
assert!(validate_model(&invalid_fallback, "fallback_model", false).is_err());
|
||||
assert!(parse_sources(r#"{"fallback_model":{},"models":[]}"#).is_err());
|
||||
assert!(parse_sources(
|
||||
r#"{
|
||||
"fallback_model":{"base_instructions":"x","context_window":2,"max_context_window":1},
|
||||
"models":[{"slug":"A","base_instructions":"A","context_window":1,"max_context_window":1}]
|
||||
}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_sources(
|
||||
r#"{
|
||||
"fallback_model":{"base_instructions":"x","context_window":1,"max_context_window":1},
|
||||
"models":[
|
||||
{"slug":"A","context_window":1,"max_context_window":1},
|
||||
{"slug":"a","context_window":1,"max_context_window":1}
|
||||
]
|
||||
}"#
|
||||
)
|
||||
.is_err());
|
||||
assert!(prepare_catalog_with_sources(&[], &test_sources()).is_err());
|
||||
}
|
||||
}
|
||||
+270
-267
@@ -1,5 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod codex_catalog;
|
||||
mod codex_sessions;
|
||||
mod usage;
|
||||
|
||||
@@ -78,7 +79,6 @@ const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥";
|
||||
const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456";
|
||||
const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
|
||||
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
|
||||
const DEFAULT_CODEX_CONTEXT_WINDOW: u64 = 128_000;
|
||||
const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700";
|
||||
const LEGACY_AGENT_MODIFICATION_STATE_VERSION: u8 = 1;
|
||||
const AGENT_MODIFICATION_STATE_VERSION: u8 = 2;
|
||||
@@ -627,16 +627,6 @@ struct CodexModelDefinition {
|
||||
supports_tools: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct CodexModelCatalogSpec {
|
||||
id: String,
|
||||
display_name: String,
|
||||
description: String,
|
||||
context_window: u64,
|
||||
reasoning_levels: Vec<String>,
|
||||
supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ThinkingAliasEntry {
|
||||
@@ -792,10 +782,15 @@ struct AgentFileUpdate {
|
||||
|
||||
struct AgentConfigurationOptions<'a> {
|
||||
models: &'a [AgentModelOption],
|
||||
codex_models: Option<&'a [CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&'a str>,
|
||||
oauth_configuration: bool,
|
||||
}
|
||||
|
||||
struct PreparedAgentModels {
|
||||
models: Vec<AgentModelOption>,
|
||||
codex_catalog: Option<String>,
|
||||
}
|
||||
|
||||
type FileSnapshot = (PathBuf, Option<Vec<u8>>);
|
||||
#[cfg(test)]
|
||||
type AgentRecordExtension = (AgentModificationRecord, Vec<FileSnapshot>);
|
||||
@@ -1595,9 +1590,11 @@ fn refresh_agent_config_statuses(
|
||||
#[tauri::command]
|
||||
async fn get_agent_models(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
) -> Result<Vec<AgentModelOption>, String> {
|
||||
let client = AgentClient::parse(&client)?;
|
||||
let config = gui_config_state.snapshot()?;
|
||||
fetch_agent_models(config.port, effective_agent_api_key(&config)).await
|
||||
Ok(fetch_prepared_agent_models(client, &config).await?.models)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1741,13 +1738,84 @@ async fn fetch_agent_models(port: u16, api_key: &str) -> Result<Vec<AgentModelOp
|
||||
Err("本机内核不支持模型列表接口".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_codex_runtime_models(
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
|
||||
if port == 0 {
|
||||
return Err("内核端口无效".to_string());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|error| format!("创建 Codex 模型列表客户端失败: {error}"))?;
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let endpoints = [
|
||||
format!("{base_url}/v1/models"),
|
||||
format!("{base_url}/models"),
|
||||
];
|
||||
|
||||
for (index, endpoint) in endpoints.iter().enumerate() {
|
||||
let response = client
|
||||
.get(endpoint)
|
||||
.query(&[("client_version", env!("CARGO_PKG_VERSION"))])
|
||||
.bearer_auth(api_key)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("请求本地 Codex 模型列表失败: {error}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取本地 Codex 模型列表失败: {error}"))?;
|
||||
if status.is_success() {
|
||||
let payload = serde_json::from_str::<serde_json::Value>(&body).map_err(|error| {
|
||||
format!(
|
||||
"解析本地 Codex 模型列表失败: {error}; body={}",
|
||||
truncate_for_error(&body)
|
||||
)
|
||||
})?;
|
||||
return codex_catalog::parse_runtime_models(&payload);
|
||||
}
|
||||
|
||||
let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405);
|
||||
if !can_try_legacy_path {
|
||||
return Err(format_agent_models_error(status.as_u16(), &body));
|
||||
}
|
||||
}
|
||||
|
||||
Err("本地内核不支持 Codex 模型列表接口".to_string())
|
||||
}
|
||||
|
||||
async fn fetch_prepared_agent_models(
|
||||
client: AgentClient,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<PreparedAgentModels, String> {
|
||||
let api_key = effective_agent_api_key(config);
|
||||
if client == AgentClient::Codex {
|
||||
let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?;
|
||||
let catalog = codex_catalog::prepare_catalog(&runtime_models)?;
|
||||
Ok(PreparedAgentModels {
|
||||
models: catalog.models,
|
||||
codex_catalog: Some(catalog.json),
|
||||
})
|
||||
} else {
|
||||
Ok(PreparedAgentModels {
|
||||
models: fetch_agent_models(config.port, api_key).await?,
|
||||
codex_catalog: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn apply_agent_config(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
model: String,
|
||||
models: Vec<AgentModelOption>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let client = AgentClient::parse(&client)?;
|
||||
@@ -1758,12 +1826,8 @@ async fn apply_agent_config(
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let api_key = effective_agent_api_key(&config);
|
||||
validate_agent_can_enable(client, &home, config.port, api_key)?;
|
||||
let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
|
||||
let codex_models = if client == AgentClient::Codex {
|
||||
Some(fetch_codex_model_catalog_specs(&config, &models).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
@@ -1774,8 +1838,8 @@ async fn apply_agent_config(
|
||||
api_key,
|
||||
&model,
|
||||
AgentConfigurationOptions {
|
||||
models: &models,
|
||||
codex_models: codex_models.as_deref(),
|
||||
models: &prepared.models,
|
||||
codex_catalog: prepared.codex_catalog.as_deref(),
|
||||
oauth_configuration,
|
||||
},
|
||||
)
|
||||
@@ -1787,7 +1851,6 @@ async fn reset_agent_config_to_default(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
client: String,
|
||||
model: String,
|
||||
models: Vec<AgentModelOption>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let client = AgentClient::parse(&client)?;
|
||||
@@ -1798,12 +1861,8 @@ async fn reset_agent_config_to_default(
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let api_key = effective_agent_api_key(&config);
|
||||
validate_agent_can_enable(client, &home, config.port, api_key)?;
|
||||
let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
|
||||
let codex_models = if client == AgentClient::Codex {
|
||||
Some(fetch_codex_model_catalog_specs(&config, &models).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
@@ -1813,7 +1872,7 @@ async fn reset_agent_config_to_default(
|
||||
config.port,
|
||||
api_key,
|
||||
&model,
|
||||
codex_models.as_deref(),
|
||||
prepared.codex_catalog.as_deref(),
|
||||
oauth_configuration,
|
||||
)
|
||||
}
|
||||
@@ -1850,14 +1909,9 @@ async fn set_agent_config_enabled(
|
||||
|
||||
if enabled {
|
||||
validate_agent_can_enable(client, &home, port, api_key)?;
|
||||
let available_models = fetch_agent_models(port, api_key).await?;
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model =
|
||||
resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
|
||||
let codex_models = if client == AgentClient::Codex {
|
||||
Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
@@ -1867,8 +1921,8 @@ async fn set_agent_config_enabled(
|
||||
port,
|
||||
api_key,
|
||||
&model,
|
||||
&available_models,
|
||||
codex_models.as_deref(),
|
||||
&prepared.models,
|
||||
prepared.codex_catalog.as_deref(),
|
||||
)
|
||||
} else {
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
@@ -1894,13 +1948,8 @@ async fn update_agent_config(
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let port = config.port;
|
||||
let api_key = effective_agent_api_key(&config);
|
||||
let available_models = fetch_agent_models(port, api_key).await?;
|
||||
let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
|
||||
let codex_models = if client == AgentClient::Codex {
|
||||
Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prepared = fetch_prepared_agent_models(client, &config).await?;
|
||||
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
@@ -1910,8 +1959,8 @@ async fn update_agent_config(
|
||||
port,
|
||||
api_key,
|
||||
&model,
|
||||
&available_models,
|
||||
codex_models.as_deref(),
|
||||
&prepared.models,
|
||||
prepared.codex_catalog.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2105,62 +2154,6 @@ fn parse_agent_model_options(payload: &serde_json::Value) -> Result<Vec<AgentMod
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn fetch_codex_model_catalog_specs(
|
||||
config: &GuiConfigFile,
|
||||
models: &[AgentModelOption],
|
||||
) -> Vec<CodexModelCatalogSpec> {
|
||||
let definitions = match fetch_codex_model_definitions(config).await {
|
||||
Ok(definitions) => definitions,
|
||||
Err(error) => {
|
||||
eprintln!("读取 CPA Codex 模型定义失败,将使用默认模型参数: {error}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
merge_codex_model_catalog_specs(models, &definitions)
|
||||
}
|
||||
|
||||
fn merge_codex_model_catalog_specs(
|
||||
models: &[AgentModelOption],
|
||||
definitions: &[CodexModelDefinition],
|
||||
) -> Vec<CodexModelCatalogSpec> {
|
||||
models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let definition = definitions
|
||||
.iter()
|
||||
.find(|definition| definition.id.eq_ignore_ascii_case(&model.name));
|
||||
let reasoning_levels = definition
|
||||
.map(|definition| definition.reasoning_levels.clone())
|
||||
.filter(|levels| !levels.is_empty())
|
||||
.unwrap_or_else(default_codex_reasoning_levels);
|
||||
CodexModelCatalogSpec {
|
||||
id: model.name.clone(),
|
||||
display_name: definition
|
||||
.and_then(|definition| definition.display_name.clone())
|
||||
.or_else(|| model.alias.clone())
|
||||
.unwrap_or_else(|| model.name.clone()),
|
||||
description: definition
|
||||
.and_then(|definition| definition.description.clone())
|
||||
.unwrap_or_else(|| format!("由 CPA 提供的模型 {}", model.name)),
|
||||
context_window: definition
|
||||
.and_then(|definition| definition.context_window)
|
||||
.unwrap_or(DEFAULT_CODEX_CONTEXT_WINDOW),
|
||||
reasoning_levels,
|
||||
supports_parallel_tool_calls: definition
|
||||
.and_then(|definition| definition.supports_tools)
|
||||
.unwrap_or(true),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_codex_reasoning_levels() -> Vec<String> {
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_codex_model_definitions(
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<Vec<CodexModelDefinition>, String> {
|
||||
@@ -2421,7 +2414,17 @@ fn inspect_agent_config(
|
||||
) -> AgentConfigStatus {
|
||||
let paths = agent_config_paths(client, home);
|
||||
let config_exists = paths.iter().any(|path| path.is_file());
|
||||
let result = inspect_agent_managed_config(client, &paths, port, api_key);
|
||||
let result = inspect_agent_managed_config(client, &paths, port, api_key).and_then(
|
||||
|(configured, model, oauth_configuration)| {
|
||||
if client == AgentClient::Codex && configured {
|
||||
let model = model
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Codex 配置缺少默认模型".to_string())?;
|
||||
validate_codex_catalog_file(&paths[0], model)?;
|
||||
}
|
||||
Ok((configured, model, oauth_configuration))
|
||||
},
|
||||
);
|
||||
let (configured, current_model, oauth_configuration, config_valid, error) = match result {
|
||||
Ok((configured, model, oauth_configuration)) => {
|
||||
(configured, model, oauth_configuration, true, None)
|
||||
@@ -3466,6 +3469,20 @@ fn inspect_codex_agent_config(
|
||||
Ok((configured, model, oauth_configuration))
|
||||
}
|
||||
|
||||
fn validate_codex_catalog_file(config_path: &Path, model: &str) -> Result<(), String> {
|
||||
let catalog_path = config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(CODEX_MODEL_CATALOG_FILE);
|
||||
let catalog = fs::read_to_string(&catalog_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 Codex 模型目录失败 {}: {error}",
|
||||
path_to_string(&catalog_path)
|
||||
)
|
||||
})?;
|
||||
validate_codex_catalog(&catalog, model)
|
||||
}
|
||||
|
||||
fn inspect_opencode_agent_config(
|
||||
path: &Path,
|
||||
port: u16,
|
||||
@@ -3596,7 +3613,7 @@ fn build_agent_updates(
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
) -> Result<Vec<AgentFileUpdate>, String> {
|
||||
build_agent_updates_with_oauth(
|
||||
client,
|
||||
@@ -3606,7 +3623,7 @@ fn build_agent_updates(
|
||||
model,
|
||||
AgentConfigurationOptions {
|
||||
models,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
oauth_configuration: false,
|
||||
},
|
||||
)
|
||||
@@ -3622,7 +3639,7 @@ fn build_agent_updates_with_oauth(
|
||||
) -> Result<Vec<AgentFileUpdate>, String> {
|
||||
let AgentConfigurationOptions {
|
||||
models,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
oauth_configuration,
|
||||
} = options;
|
||||
let paths = agent_config_paths(client, home);
|
||||
@@ -3699,12 +3716,12 @@ fn build_agent_updates_with_oauth(
|
||||
path: paths[0].clone(),
|
||||
after,
|
||||
}];
|
||||
if let Some(models) = codex_models {
|
||||
updates.push(AgentFileUpdate {
|
||||
path: codex_model_catalog_path(home),
|
||||
after: build_codex_model_catalog(models)?,
|
||||
});
|
||||
}
|
||||
let catalog = codex_catalog.ok_or_else(|| "无法生成 Codex 模型目录".to_string())?;
|
||||
validate_codex_catalog(catalog, model)?;
|
||||
updates.push(AgentFileUpdate {
|
||||
path: codex_model_catalog_path(home),
|
||||
after: catalog.to_string(),
|
||||
});
|
||||
Ok(updates)
|
||||
}
|
||||
AgentClient::OpenCode => {
|
||||
@@ -4211,86 +4228,23 @@ fn build_restored_codex_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_codex_model_catalog(models: &[CodexModelCatalogSpec]) -> Result<String, String> {
|
||||
if models.is_empty() {
|
||||
return Err("当前 CPA 没有可写入 Codex 的模型".to_string());
|
||||
}
|
||||
let entries = models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, model)| {
|
||||
let reasoning_levels = if model.reasoning_levels.is_empty() {
|
||||
default_codex_reasoning_levels()
|
||||
} else {
|
||||
model.reasoning_levels.clone()
|
||||
};
|
||||
let default_reasoning_level = if reasoning_levels.iter().any(|level| level == "medium") {
|
||||
"medium".to_string()
|
||||
} else {
|
||||
reasoning_levels
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "medium".to_string())
|
||||
};
|
||||
let supported_reasoning_levels = reasoning_levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
serde_json::json!({
|
||||
"effort": level,
|
||||
"description": codex_reasoning_level_description(level),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::json!({
|
||||
"slug": model.id,
|
||||
"display_name": model.display_name,
|
||||
"description": model.description,
|
||||
"base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.",
|
||||
"default_reasoning_level": default_reasoning_level,
|
||||
"supported_reasoning_levels": supported_reasoning_levels,
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
"priority": index + 1,
|
||||
"include_skills_usage_instructions": true,
|
||||
"supports_reasoning_summaries": true,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
"truncation_policy": {
|
||||
"mode": "bytes",
|
||||
"limit": 10_000,
|
||||
},
|
||||
"supports_parallel_tool_calls": model.supports_parallel_tool_calls,
|
||||
"supports_image_detail_original": false,
|
||||
"context_window": model.context_window,
|
||||
"max_context_window": model.context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text", "image"],
|
||||
"supports_search_tool": false,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut rendered = serde_json::to_string_pretty(&serde_json::json!({ "models": entries }))
|
||||
.map_err(|error| format!("生成 Codex 模型目录失败: {error}"))?;
|
||||
rendered.push('\n');
|
||||
serde_json::from_str::<serde_json::Value>(&rendered)
|
||||
.map_err(|error| format!("验证 Codex 模型目录失败: {error}"))?;
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn codex_reasoning_level_description(level: &str) -> &'static str {
|
||||
match level {
|
||||
"none" => "关闭推理",
|
||||
"minimal" => "最少推理",
|
||||
"low" => "较低推理强度",
|
||||
"medium" => "平衡速度与推理深度",
|
||||
"high" => "较高推理强度",
|
||||
"xhigh" => "超高推理强度",
|
||||
"max" => "最大推理强度",
|
||||
"ultra" => "最高推理与自动任务委派",
|
||||
_ => "模型支持的推理强度",
|
||||
fn validate_codex_catalog(catalog: &str, model: &str) -> Result<(), String> {
|
||||
let root: serde_json::Value = serde_json::from_str(catalog)
|
||||
.map_err(|error| format!("Codex 模型目录格式无效: {error}"))?;
|
||||
let models = root
|
||||
.get("models")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.filter(|models| !models.is_empty())
|
||||
.ok_or_else(|| "Codex 模型目录必须包含非空 models 数组".to_string())?;
|
||||
if !models.iter().any(|entry| {
|
||||
entry
|
||||
.get("slug")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|slug| slug.eq_ignore_ascii_case(model))
|
||||
}) {
|
||||
return Err(format!("默认模型 {model} 不在生成的 Codex 模型目录中"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_opencode_agent_config(
|
||||
@@ -5564,7 +5518,7 @@ fn apply_agent_configuration(
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
apply_agent_configuration_with_oauth(
|
||||
client,
|
||||
@@ -5574,7 +5528,7 @@ fn apply_agent_configuration(
|
||||
model,
|
||||
AgentConfigurationOptions {
|
||||
models,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
oauth_configuration: false,
|
||||
},
|
||||
)
|
||||
@@ -5599,7 +5553,7 @@ fn reset_agent_configuration_to_default(
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
reset_agent_configuration_to_default_with_oauth(
|
||||
client,
|
||||
@@ -5607,7 +5561,7 @@ fn reset_agent_configuration_to_default(
|
||||
port,
|
||||
api_key,
|
||||
model,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -5618,7 +5572,7 @@ fn reset_agent_configuration_to_default_with_oauth(
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let paths = agent_config_paths(client, home);
|
||||
@@ -5633,10 +5587,11 @@ fn reset_agent_configuration_to_default_with_oauth(
|
||||
.map(|(path, after)| AgentFileUpdate { path, after })
|
||||
.collect::<Vec<_>>();
|
||||
if client == AgentClient::Codex {
|
||||
let models = codex_models.ok_or_else(|| "无法生成 Codex 模型目录".to_string())?;
|
||||
let catalog = codex_catalog.ok_or_else(|| "无法生成 Codex 模型目录".to_string())?;
|
||||
validate_codex_catalog(catalog, model)?;
|
||||
updates.push(AgentFileUpdate {
|
||||
path: codex_model_catalog_path(home),
|
||||
after: build_codex_model_catalog(models)?,
|
||||
after: catalog.to_string(),
|
||||
});
|
||||
}
|
||||
commit_agent_configuration(client, home, model, &updates, "default")
|
||||
@@ -5763,7 +5718,7 @@ fn enable_agent_modification(
|
||||
port: u16,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let paths = agent_config_paths(client, home);
|
||||
let state_path = agent_state_path(&paths)?;
|
||||
@@ -5804,7 +5759,7 @@ fn enable_agent_modification(
|
||||
DEFAULT_API_KEY,
|
||||
model,
|
||||
models,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
)?;
|
||||
let update_paths = updates
|
||||
.iter()
|
||||
@@ -5935,7 +5890,7 @@ fn update_agent_modification(
|
||||
port: u16,
|
||||
model: &str,
|
||||
models: &[AgentModelOption],
|
||||
codex_models: Option<&[CodexModelCatalogSpec]>,
|
||||
codex_catalog: Option<&str>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let paths = agent_config_paths(client, home);
|
||||
let state_path = agent_state_path(&paths)?;
|
||||
@@ -5974,7 +5929,7 @@ fn update_agent_modification(
|
||||
DEFAULT_API_KEY,
|
||||
model,
|
||||
models,
|
||||
codex_models,
|
||||
codex_catalog,
|
||||
)?;
|
||||
let (mut next, backup_snapshots) = extend_agent_record_for_updates(&record, &updates)?;
|
||||
next.phase = AGENT_PHASE_APPLYING.to_string();
|
||||
@@ -13314,6 +13269,9 @@ fn main() {
|
||||
|
||||
let app = app
|
||||
.setup(move |app| {
|
||||
if let Err(error) = codex_catalog::validate_embedded_catalog() {
|
||||
eprintln!("Codex 内置模型目录无效: {error}");
|
||||
}
|
||||
if let Err(error) = restore_main_window_size(app.handle()) {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
@@ -13532,8 +13490,12 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn test_codex_models(names: &[&str]) -> Vec<CodexModelCatalogSpec> {
|
||||
merge_codex_model_catalog_specs(&test_agent_models(names), &[])
|
||||
fn test_codex_models(names: &[&str]) -> String {
|
||||
let payload = serde_json::json!({
|
||||
"models": names.iter().map(|name| serde_json::json!({ "id": name })).collect::<Vec<_>>()
|
||||
});
|
||||
let runtime = codex_catalog::parse_runtime_models(&payload).unwrap();
|
||||
codex_catalog::prepare_catalog(&runtime).unwrap().json
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -13736,7 +13698,7 @@ mod tests {
|
||||
DEFAULT_API_KEY,
|
||||
"model-a",
|
||||
&models,
|
||||
(client == AgentClient::Codex).then_some(codex_models.as_slice()),
|
||||
(client == AgentClient::Codex).then_some(codex_models.as_str()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!updates[0].after.trim().is_empty());
|
||||
@@ -14008,6 +13970,107 @@ mod tests {
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_api_and_oauth_modes_write_the_same_catalog() {
|
||||
let home = agent_test_home("codex-auth-mode-catalog");
|
||||
let models = test_agent_models(&["gpt-5.5", "third-party-model"]);
|
||||
let catalog = test_codex_models(&["gpt-5.5", "third-party-model"]);
|
||||
let build = |oauth_configuration| {
|
||||
build_agent_updates_with_oauth(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-5.5",
|
||||
AgentConfigurationOptions {
|
||||
models: &models,
|
||||
codex_catalog: Some(&catalog),
|
||||
oauth_configuration,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let api_updates = build(false);
|
||||
let oauth_updates = build(true);
|
||||
|
||||
assert_eq!(api_updates[1].after, oauth_updates[1].after);
|
||||
assert!(api_updates[0].after.contains("model_catalog_json"));
|
||||
assert!(oauth_updates[0].after.contains("model_catalog_json"));
|
||||
assert!(!api_updates[0].after.contains("service_tier"));
|
||||
assert!(!oauth_updates[0].after.contains("service_tier"));
|
||||
assert!(!api_updates[0].after.contains("requires_openai_auth"));
|
||||
assert!(oauth_updates[0]
|
||||
.after
|
||||
.contains("requires_openai_auth = true"));
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_status_requires_a_valid_catalog_containing_the_default_model() {
|
||||
let home = agent_test_home("codex-catalog-status");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&config_path,
|
||||
build_codex_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317/v1",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-test",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let missing = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY);
|
||||
assert!(!missing.configured);
|
||||
assert!(!missing.config_valid);
|
||||
|
||||
fs::write(&catalog_path, "not json").unwrap();
|
||||
let damaged = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY);
|
||||
assert!(!damaged.configured);
|
||||
assert!(!damaged.config_valid);
|
||||
|
||||
fs::write(&catalog_path, test_codex_models(&["other-model"])).unwrap();
|
||||
let wrong_model = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY);
|
||||
assert!(!wrong_model.configured);
|
||||
assert!(!wrong_model.config_valid);
|
||||
|
||||
fs::write(&catalog_path, test_codex_models(&["gpt-test"])).unwrap();
|
||||
let valid = inspect_agent_config(AgentClient::Codex, &home, 8317, DEFAULT_API_KEY);
|
||||
assert!(valid.configured);
|
||||
assert!(valid.config_valid);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_codex_catalog_does_not_partially_update_existing_files() {
|
||||
let home = agent_test_home("invalid-codex-catalog-transaction");
|
||||
let config_path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
let original_config = "model = \"user-model\"\n";
|
||||
let original_catalog = "{\"models\":[{\"slug\":\"user-model\"}]}\n";
|
||||
fs::write(&config_path, original_config).unwrap();
|
||||
fs::write(&catalog_path, original_catalog).unwrap();
|
||||
|
||||
let result = apply_agent_configuration(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-test",
|
||||
&test_agent_models(&["gpt-test"]),
|
||||
Some("{invalid"),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_to_string(&config_path).unwrap(), original_config);
|
||||
assert_eq!(fs::read_to_string(&catalog_path).unwrap(), original_catalog);
|
||||
assert!(!agent_state_path(&[config_path]).unwrap().exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_config_builds_gateway_profile_and_index() {
|
||||
let models = test_agent_models(&["claude-sonnet-test", "claude-opus-test"]);
|
||||
@@ -14224,66 +14287,6 @@ mod tests {
|
||||
assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_model_catalog_uses_only_current_cpa_models_and_enriches_matches() {
|
||||
let models = vec![
|
||||
AgentModelOption {
|
||||
name: "deepseek-chat".to_string(),
|
||||
alias: None,
|
||||
},
|
||||
AgentModelOption {
|
||||
name: "gpt-5.4".to_string(),
|
||||
alias: None,
|
||||
},
|
||||
];
|
||||
let definitions = parse_codex_model_definitions(&serde_json::json!({
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"display_name": "GPT 5.4",
|
||||
"description": "Stable GPT 5.4",
|
||||
"context_length": 1_050_000,
|
||||
"supported_parameters": ["tools"],
|
||||
"thinking": { "levels": ["low", "medium", "high", "xhigh"] }
|
||||
},
|
||||
{
|
||||
"id": "not-open-in-cpa",
|
||||
"context_length": 999_999
|
||||
}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let specs = merge_codex_model_catalog_specs(&models, &definitions);
|
||||
|
||||
assert_eq!(specs.len(), 2);
|
||||
assert_eq!(specs[0].id, "deepseek-chat");
|
||||
assert_eq!(specs[0].context_window, DEFAULT_CODEX_CONTEXT_WINDOW);
|
||||
assert_eq!(specs[1].id, "gpt-5.4");
|
||||
assert_eq!(specs[1].display_name, "GPT 5.4");
|
||||
assert_eq!(specs[1].context_window, 1_050_000);
|
||||
assert!(!specs.iter().any(|model| model.id == "not-open-in-cpa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_model_catalog_renders_codex_supported_fields() {
|
||||
let rendered = build_codex_model_catalog(&test_codex_models(&["gpt-test"])).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
let model = &value["models"][0];
|
||||
|
||||
assert_eq!(model["slug"], "gpt-test");
|
||||
assert_eq!(model["context_window"], DEFAULT_CODEX_CONTEXT_WINDOW);
|
||||
assert_eq!(model["default_reasoning_level"], "medium");
|
||||
assert_eq!(model["shell_type"], "shell_command");
|
||||
assert_eq!(model["include_skills_usage_instructions"], true);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_model_validation_only_accepts_models_in_current_list() {
|
||||
let models = vec![AgentModelOption {
|
||||
|
||||
+15
-23
@@ -211,17 +211,6 @@ const writeAgentModelSelections = (
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileAgentModelSelections = (
|
||||
current: Partial<Record<AgentClientId, string>>,
|
||||
models: ModelOption[],
|
||||
) => {
|
||||
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
|
||||
const existing = current[agent.id] ?? '';
|
||||
result[agent.id] = resolveAgentModelSelection(models, existing);
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
|
||||
if (definition.icon) {
|
||||
return <img src={definition.icon} alt="" className="provider-logo" />;
|
||||
@@ -511,7 +500,7 @@ export function AgentsPage() {
|
||||
const [closeAppNotice, setCloseAppNotice] = useState('');
|
||||
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
|
||||
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
|
||||
const autoLoadedModelClientRef = useRef<AgentClientId | null>(null);
|
||||
const modelRequestRef = useRef(0);
|
||||
|
||||
const loadStatuses = useCallback(async (forceRefresh = false) => {
|
||||
const command = forceRefresh
|
||||
@@ -521,22 +510,29 @@ export function AgentsPage() {
|
||||
setStatuses(nextStatuses);
|
||||
}, []);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
const loadModels = useCallback(async (client: AgentClientId) => {
|
||||
const requestId = modelRequestRef.current + 1;
|
||||
modelRequestRef.current = requestId;
|
||||
setModelLoading(true);
|
||||
setModelError('');
|
||||
setModels([]);
|
||||
try {
|
||||
const nextModels = await invoke<ModelOption[]>('get_agent_models');
|
||||
const nextModels = await invoke<ModelOption[]>('get_agent_models', { client });
|
||||
if (modelRequestRef.current !== requestId) return;
|
||||
setModels(nextModels);
|
||||
setModelSelectionError('');
|
||||
setModelByClient((current) => {
|
||||
const next = reconcileAgentModelSelections(current, nextModels);
|
||||
const next = {
|
||||
...current,
|
||||
[client]: resolveAgentModelSelection(nextModels, current[client] ?? ''),
|
||||
};
|
||||
writeAgentModelSelections(next);
|
||||
return next;
|
||||
});
|
||||
} catch (requestError) {
|
||||
setModelError(String(requestError));
|
||||
if (modelRequestRef.current === requestId) setModelError(String(requestError));
|
||||
} finally {
|
||||
setModelLoading(false);
|
||||
if (modelRequestRef.current === requestId) setModelLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -561,9 +557,7 @@ export function AgentsPage() {
|
||||
}, [loadStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadedModelClientRef.current === selected) return;
|
||||
autoLoadedModelClientRef.current = selected;
|
||||
void loadModels();
|
||||
void loadModels(selected);
|
||||
}, [loadModels, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -683,7 +677,7 @@ export function AgentsPage() {
|
||||
: '';
|
||||
|
||||
const refreshModels = () => {
|
||||
void loadModels();
|
||||
void loadModels(selected);
|
||||
};
|
||||
|
||||
const reloadStatusesAfterAction = async () => {
|
||||
@@ -733,7 +727,6 @@ export function AgentsPage() {
|
||||
await invoke<AgentConfigActionResult>('apply_agent_config', {
|
||||
client: selected,
|
||||
model,
|
||||
models,
|
||||
oauthConfiguration,
|
||||
});
|
||||
await reloadStatusesAfterAction();
|
||||
@@ -754,7 +747,6 @@ export function AgentsPage() {
|
||||
await invoke<AgentConfigActionResult>('reset_agent_config_to_default', {
|
||||
client: selected,
|
||||
model,
|
||||
models,
|
||||
oauthConfiguration,
|
||||
});
|
||||
setDefaultConfirmOpen(false);
|
||||
|
||||
Reference in new issue
Block a user