fix(codex): load context defaults from CPA model definitions
- Resolve context limits from core model definitions, aliases, and configuration - Preserve user overrides and display context sources and fallback hints - Refresh defaults from CPA when restoring all model settings - Keep restore-all available without customizations and remove its icon - Allow saving restored defaults even when values remain unchanged - Add regression coverage for context resolution and default restoration
This commit is contained in:
1 parent
ce9944c316
commit
40ba85d6f0
10 files changed
+452
-66
No files matched your search
@@ -414,8 +414,7 @@ pub(crate) async fn get_codex_model_catalog_editor(
|
||||
) -> 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?;
|
||||
let runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
|
||||
codex_catalog::editor_snapshot(&runtime_models)
|
||||
}
|
||||
|
||||
@@ -434,8 +433,7 @@ pub(crate) async fn save_codex_model_catalog_editor(
|
||||
) -> 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 runtime_models = fetch_codex_catalog_runtime_models(&config).await?;
|
||||
let snapshot = codex_catalog::save_customizations(
|
||||
&codex_model_customizations_path(&app)?,
|
||||
&runtime_models,
|
||||
@@ -796,13 +794,60 @@ pub(crate) async fn fetch_codex_runtime_models(
|
||||
Err("本地内核不支持 Codex 模型列表接口".to_string())
|
||||
}
|
||||
|
||||
// The client_version response contains synthesized Codex templates; its context
|
||||
// fields are not the raw core model definitions. Resolve defaults through the
|
||||
// management APIs before generating or editing the Codex catalog.
|
||||
pub(crate) async fn fetch_codex_catalog_runtime_models(
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Vec<codex_catalog::CodexRuntimeModel>, String> {
|
||||
let (runtime, definitions, content) = tokio::join!(
|
||||
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)),
|
||||
fetch_codex_context_definitions(config),
|
||||
fetch_management_config_yaml(config),
|
||||
);
|
||||
let mut runtime = runtime?;
|
||||
let definitions = definitions?;
|
||||
let content = content?;
|
||||
let mut aliases = runtime.iter().map(|model| AgentModelOption {
|
||||
name: model.slug.clone(), alias: None, is_alias: false, context_window: None,
|
||||
}).collect::<Vec<_>>();
|
||||
mark_configured_agent_model_aliases(&mut aliases, &content)?;
|
||||
codex_catalog::merge_context_definitions(&mut runtime, &definitions, &aliases);
|
||||
codex_catalog::apply_configured_context_limits(&mut runtime, &content)?;
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
async fn fetch_codex_context_definitions(
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Vec<CodexModelDefinition>, String> {
|
||||
// API-key sources may expose models from other channels, so do not filter by
|
||||
// active OAuth credentials. Model IDs still come only from the available list.
|
||||
let channels = ["gemini", "vertex", "aistudio", "antigravity", "claude", "codex", "kimi", "xai"];
|
||||
let results = futures_util::future::join_all(channels.iter().map(|channel|
|
||||
fetch_oauth_channel_model_definitions(config, channel)
|
||||
)).await;
|
||||
let mut definitions = Vec::new();
|
||||
let mut successes = 0;
|
||||
let mut first_error = None;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(models) => { successes += 1; definitions.extend(models); }
|
||||
Err(error) => { first_error.get_or_insert(error); }
|
||||
}
|
||||
}
|
||||
if successes == 0 {
|
||||
return Err(format!("读取 CPA 模型上下文定义失败: {}", first_error.unwrap_or_default()));
|
||||
}
|
||||
Ok(definitions)
|
||||
}
|
||||
|
||||
pub(crate) 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 runtime_models = fetch_codex_catalog_runtime_models(config).await?;
|
||||
prepare_codex_agent_models(&runtime_models)
|
||||
} else {
|
||||
let mut models = fetch_agent_models(config.port, api_key).await?;
|
||||
@@ -872,8 +917,7 @@ pub(crate) async fn refresh_applied_codex_model_catalog(
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<bool, String> {
|
||||
let _sync_guard = CODEX_CATALOG_SYNC_LOCK.lock().await;
|
||||
let runtime_models =
|
||||
fetch_codex_runtime_models(config.port, effective_agent_api_key(config)).await?;
|
||||
let runtime_models = fetch_codex_catalog_runtime_models(config).await?;
|
||||
let prepared = prepare_codex_agent_models(&runtime_models)?;
|
||||
sync_prepared_codex_model_catalog(app, config, &prepared)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
mod customizations;
|
||||
mod runtime_context;
|
||||
pub(crate) use customizations::{
|
||||
editor_snapshot, load_customizations, save_customizations, CatalogEditorRequest,
|
||||
CatalogEditorSnapshot,
|
||||
};
|
||||
pub(crate) use runtime_context::{apply_configured_context_limits, merge_context_definitions};
|
||||
|
||||
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");
|
||||
@@ -21,6 +23,7 @@ pub(crate) struct CodexRuntimeModel {
|
||||
description: Option<String>,
|
||||
context_window: Option<u64>,
|
||||
max_context_window: Option<u64>,
|
||||
context_source: &'static str,
|
||||
input_modalities: Option<Vec<String>>,
|
||||
default_reasoning_level: Option<String>,
|
||||
hidden: bool,
|
||||
@@ -138,8 +141,15 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
|
||||
"contextLength",
|
||||
],
|
||||
);
|
||||
let max_context_window =
|
||||
positive_u64_field(value, &["max_context_window", "maxContextWindow"]);
|
||||
let max_context_window = positive_u64_field(
|
||||
value,
|
||||
&[
|
||||
"max_context_window",
|
||||
"maxContextWindow",
|
||||
"max_context_length",
|
||||
"maxContextLength",
|
||||
],
|
||||
);
|
||||
let input_modalities = parse_modalities(value);
|
||||
let default_reasoning_level =
|
||||
optional_string(value, &["default_reasoning_level", "defaultReasoningLevel"])
|
||||
@@ -154,6 +164,11 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
|
||||
description: description.clone(),
|
||||
context_window,
|
||||
max_context_window,
|
||||
context_source: if context_window.or(max_context_window).is_some() {
|
||||
"compatibility"
|
||||
} else {
|
||||
"template"
|
||||
},
|
||||
input_modalities: input_modalities.clone(),
|
||||
default_reasoning_level: default_reasoning_level.clone(),
|
||||
hidden,
|
||||
@@ -414,6 +429,7 @@ fn prepare_catalog_with_customizations(
|
||||
"display_name".to_string(),
|
||||
Value::String(runtime.slug.clone()),
|
||||
);
|
||||
apply_runtime_context_windows(&mut value, runtime);
|
||||
enable_fast_mode(&mut value);
|
||||
entries.push(CatalogEntry {
|
||||
value,
|
||||
@@ -565,6 +581,25 @@ fn normalize_fallback_model(model: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both official and fallback templates take context defaults from the CPA API.
|
||||
// Template values are used only when the API provides no valid context metadata.
|
||||
fn apply_runtime_context_windows(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
if let Some(context_window) = runtime.context_window.or(runtime.max_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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
|
||||
model.insert("slug".to_string(), Value::String(runtime.slug.clone()));
|
||||
model.insert(
|
||||
@@ -579,26 +614,7 @@ fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntime
|
||||
);
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
apply_runtime_context_windows(model, runtime);
|
||||
|
||||
if let Some(modalities) = runtime.input_modalities.as_ref() {
|
||||
model.insert(
|
||||
@@ -1096,7 +1112,8 @@ mod tests {
|
||||
["b", "C"]
|
||||
);
|
||||
assert_eq!(models[0]["base_instructions"], "Known B");
|
||||
assert_eq!(models[0]["context_window"], 300_000);
|
||||
assert_eq!(models[0]["context_window"], 1);
|
||||
assert_eq!(models[0]["max_context_window"], 1);
|
||||
assert_eq!(
|
||||
models[1]["base_instructions"],
|
||||
"You are Codex, a model-neutral coding agent."
|
||||
@@ -1106,6 +1123,54 @@ mod tests {
|
||||
assert!(models[1]["priority"].as_i64().unwrap() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_context_defaults_apply_to_official_and_fallback_models() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
let runtime = runtime(serde_json::json!({"models":[
|
||||
{"slug":"gpt-6-astra","context_window":372000,"max_context_window":872000},
|
||||
{"slug":"gpt-5.6-sol","context_window":921000,"max_context_window":1000000},
|
||||
{"slug":"deepseek-v4-flash","context_length":1000000,"max_context_length":1048576},
|
||||
{"slug":"other-model","context_window":128000}
|
||||
]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let output = output_models(&catalog);
|
||||
for (slug, context, maximum) in [
|
||||
("gpt-6-astra", 372_000, 872_000),
|
||||
("gpt-5.6-sol", 921_000, 1_000_000),
|
||||
("deepseek-v4-flash", 1_000_000, 1_048_576),
|
||||
("other-model", 128_000, 128_000),
|
||||
] {
|
||||
let model = output.iter().find(|model| model["slug"] == slug).unwrap();
|
||||
assert_eq!(model["context_window"], context, "{slug}");
|
||||
assert_eq!(model["max_context_window"], maximum, "{slug}");
|
||||
let option = catalog
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.name == slug)
|
||||
.unwrap();
|
||||
assert_eq!(option.context_window, Some(context));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_only_runtime_context_does_not_inherit_an_unrelated_default() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
for (slug, maximum) in [
|
||||
("gpt-6-astra", 64_000),
|
||||
("gpt-6-astra", 1_000_000),
|
||||
("max-only", 64_000),
|
||||
("max-only", 1_000_000),
|
||||
] {
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"slug": slug, "max_context_window": maximum
|
||||
}]}));
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
assert_eq!(model["context_window"], maximum);
|
||||
assert_eq!(model["max_context_window"], maximum);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_template_preserves_unrelated_capabilities() {
|
||||
let sources = test_sources();
|
||||
@@ -1121,7 +1186,12 @@ mod tests {
|
||||
for (key, expected) in template {
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"slug" | "display_name" | "service_tiers" | "additional_speed_tiers"
|
||||
"slug"
|
||||
| "display_name"
|
||||
| "context_window"
|
||||
| "max_context_window"
|
||||
| "service_tiers"
|
||||
| "additional_speed_tiers"
|
||||
) {
|
||||
assert_eq!(model.get(key), Some(expected), "changed field {key}");
|
||||
}
|
||||
@@ -1129,6 +1199,8 @@ mod tests {
|
||||
assert_eq!(model["slug"], "a");
|
||||
assert_eq!(model["display_name"], "a");
|
||||
assert_eq!(model["nested"]["unknown"], true);
|
||||
assert_eq!(model["context_window"], 1);
|
||||
assert_eq!(model["max_context_window"], 1);
|
||||
assert_eq!(
|
||||
model["service_tiers"],
|
||||
serde_json::json!([{
|
||||
@@ -1182,7 +1254,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_6_astra_generation_uses_official_template_and_optional_fast_mode() {
|
||||
fn gpt_6_astra_uses_api_context_with_official_capabilities_and_fast_mode() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
let runtime = runtime(serde_json::json!({"models":[{
|
||||
"id":"gpt-6-astra",
|
||||
@@ -1193,8 +1265,8 @@ mod tests {
|
||||
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
|
||||
let model = &output_models(&catalog)[0];
|
||||
|
||||
assert_eq!(model["context_window"], 272_000);
|
||||
assert_eq!(model["max_context_window"], 872_000);
|
||||
assert_eq!(model["context_window"], 1_048_576);
|
||||
assert_eq!(model["max_context_window"], 1_048_576);
|
||||
assert_eq!(model["default_reasoning_level"], "low");
|
||||
assert_eq!(
|
||||
reasoning_efforts(model),
|
||||
|
||||
@@ -39,6 +39,7 @@ pub(crate) struct CatalogEditorSnapshot {
|
||||
struct CatalogEditorModel {
|
||||
slug: String,
|
||||
has_official_template: bool,
|
||||
context_source: &'static str,
|
||||
customized: bool,
|
||||
configuration: Map<String, Value>,
|
||||
defaults: Map<String, Value>,
|
||||
@@ -64,7 +65,15 @@ fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
|
||||
.map(|field| {
|
||||
(
|
||||
field.to_string(),
|
||||
model.get(*field).cloned().unwrap_or(Value::Null),
|
||||
model.get(*field).cloned().unwrap_or_else(|| {
|
||||
// Codex defaults this optional field to 95 when it is omitted.
|
||||
// The editor needs the same value to validate and save context changes.
|
||||
if *field == "effective_context_window_percent" {
|
||||
Value::from(95)
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -198,7 +207,7 @@ pub(super) fn apply_customizations(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_for_state(
|
||||
pub(super) fn snapshot_for_state(
|
||||
runtime_models: &[CodexRuntimeModel],
|
||||
state: &CatalogState,
|
||||
) -> Result<CatalogEditorSnapshot, String> {
|
||||
@@ -222,6 +231,10 @@ fn snapshot_for_state(
|
||||
models.push(CatalogEditorModel {
|
||||
slug,
|
||||
has_official_template: state.sources.templates.contains_key(&key),
|
||||
context_source: runtime_models
|
||||
.iter()
|
||||
.find(|runtime| normalize_id(&runtime.slug) == key)
|
||||
.map_or("template", |runtime| runtime.context_source),
|
||||
customized: state.customizations.contains_key(&key),
|
||||
configuration: editable_configuration(&model),
|
||||
defaults,
|
||||
@@ -379,6 +392,7 @@ mod tests {
|
||||
description: None,
|
||||
context_window: None,
|
||||
max_context_window: None,
|
||||
context_source: "template",
|
||||
input_modalities: None,
|
||||
default_reasoning_level: None,
|
||||
hidden: false,
|
||||
@@ -460,6 +474,70 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_context_defaults_follow_api_updates_and_preserve_explicit_overrides() {
|
||||
let mut state = CatalogState {
|
||||
sources: parse_sources(MODEL_CATALOG_JSON).unwrap(),
|
||||
json: MODEL_CATALOG_JSON.to_string(),
|
||||
customizations: Default::default(),
|
||||
};
|
||||
let mut runtime = runtime_model("gpt-6-astra");
|
||||
runtime.context_window = Some(128_000);
|
||||
runtime.max_context_window = Some(256_000);
|
||||
let snapshot = snapshot_for_state(&[runtime.clone()], &state).unwrap();
|
||||
assert_eq!(snapshot.models[0].configuration["context_window"], 128_000);
|
||||
assert_eq!(snapshot.models[0].defaults["max_context_window"], 256_000);
|
||||
assert_eq!(
|
||||
snapshot.models[0].defaults["effective_context_window_percent"],
|
||||
95
|
||||
);
|
||||
let mut configuration = snapshot.models[0].configuration.clone();
|
||||
configuration.insert("context_window".to_string(), serde_json::json!(192_000));
|
||||
state.customizations = customizations_from_request(
|
||||
&snapshot,
|
||||
CatalogEditorRequest {
|
||||
revision: snapshot.revision.clone(),
|
||||
models: vec![CatalogEditorModelRequest {
|
||||
slug: runtime.slug.clone(),
|
||||
configuration,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
runtime.context_window = Some(512_000);
|
||||
runtime.max_context_window = Some(1_000_000);
|
||||
let updated = snapshot_for_state(&[runtime.clone()], &state).unwrap();
|
||||
assert_ne!(updated.revision, snapshot.revision);
|
||||
assert_eq!(updated.models[0].defaults["context_window"], 512_000);
|
||||
assert_eq!(updated.models[0].defaults["max_context_window"], 1_000_000);
|
||||
assert_eq!(updated.models[0].configuration["context_window"], 192_000);
|
||||
assert_eq!(
|
||||
updated.models[0].configuration["max_context_window"],
|
||||
256_000
|
||||
);
|
||||
|
||||
// Restoring defaults drops the saved override and follows the current API response.
|
||||
state.customizations = customizations_from_request(
|
||||
&updated,
|
||||
CatalogEditorRequest {
|
||||
revision: updated.revision.clone(),
|
||||
models: vec![CatalogEditorModelRequest {
|
||||
slug: runtime.slug.clone(),
|
||||
configuration: updated.models[0].defaults.clone(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.customizations.is_empty());
|
||||
let generated =
|
||||
prepare_catalog_with_customizations(&[runtime], &state.sources, &state.customizations)
|
||||
.unwrap();
|
||||
let model: Value = serde_json::from_str(&generated.json).unwrap();
|
||||
assert_eq!(model["models"][0]["context_window"], 512_000);
|
||||
assert_eq!(model["models"][0]["max_context_window"], 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_editor_revision_is_rejected_without_changing_state() {
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn merge_context_definitions(
|
||||
runtime_models: &mut [CodexRuntimeModel],
|
||||
definitions: &[crate::CodexModelDefinition],
|
||||
aliases: &[AgentModelOption],
|
||||
) {
|
||||
for runtime in runtime_models {
|
||||
let source = aliases
|
||||
.iter()
|
||||
.find(|model| model.is_alias && model.name.eq_ignore_ascii_case(&runtime.slug))
|
||||
.and_then(|model| model.alias.as_deref())
|
||||
.unwrap_or(&runtime.slug);
|
||||
// These are core model definitions, not the synthesized Codex client catalog.
|
||||
let context = definitions
|
||||
.iter()
|
||||
.filter(|definition| definition.id.eq_ignore_ascii_case(source))
|
||||
.filter_map(|definition| definition.context_window)
|
||||
.filter(|value| *value > 0)
|
||||
.min();
|
||||
if let Some(context) = context {
|
||||
runtime.context_window = Some(context);
|
||||
runtime.max_context_window =
|
||||
Some(runtime.max_context_window.unwrap_or(context).max(context));
|
||||
runtime.context_source = "definition";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_configured_context_limits(
|
||||
runtime_models: &mut [CodexRuntimeModel],
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let document: serde_norway::Value = serde_norway::from_str(content)
|
||||
.map_err(|error| format!("解析内核模型上下文配置失败: {error}"))?;
|
||||
let root = document
|
||||
.as_mapping()
|
||||
.ok_or("内核配置顶层必须是 YAML 映射")?;
|
||||
let mut limits: HashMap<String, u64> = HashMap::new();
|
||||
for section in crate::MODEL_ALIAS_CONFIG_SECTIONS {
|
||||
let Some(providers) =
|
||||
crate::yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for provider in providers {
|
||||
let Some(models) = provider
|
||||
.as_mapping()
|
||||
.and_then(|provider| crate::yaml_mapping_value(provider, "models"))
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for model in models {
|
||||
let Some((_, public_name, _)) = crate::configured_model_identity(model) else {
|
||||
continue;
|
||||
};
|
||||
let Some(limit) = model
|
||||
.as_mapping()
|
||||
.and_then(|model| crate::yaml_mapping_value(model, "max-context-length"))
|
||||
.and_then(serde_norway::Value::as_u64)
|
||||
.filter(|value| *value > 0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
limits
|
||||
.entry(normalize_id(&public_name))
|
||||
.and_modify(|existing| *existing = (*existing).min(limit))
|
||||
.or_insert(limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
for runtime in runtime_models {
|
||||
if let Some(&limit) = limits.get(&normalize_id(&runtime.slug)) {
|
||||
runtime.context_window = Some(limit);
|
||||
runtime.max_context_window = Some(limit);
|
||||
runtime.context_source = "configuration";
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raw_core_definitions_replace_synthesized_272k_for_models_and_aliases() {
|
||||
let mut runtime = parse_runtime_models(&serde_json::json!({"models":[
|
||||
{"slug":"gpt-5.6-sol","context_window":272000,"max_context_window":872000},
|
||||
{"slug":"gemini-3.8-flash-high","context_window":272000,"max_context_window":272000},
|
||||
{"slug":"fast-alias","context_window":272000,"max_context_window":272000},
|
||||
{"slug":"no-definition","context_window":272000,"max_context_window":272000}
|
||||
]}))
|
||||
.unwrap();
|
||||
let definitions = crate::parse_codex_model_definitions(&serde_json::json!({"models":[
|
||||
{"id":"gpt-5.6-sol","context_length":921000},
|
||||
{"id":"gemini-3.8-flash-high","context_length":1048576}
|
||||
]}))
|
||||
.unwrap();
|
||||
let aliases = vec![AgentModelOption {
|
||||
name: "fast-alias".to_string(),
|
||||
alias: Some("GPT-5.6-SOL".to_string()),
|
||||
is_alias: true,
|
||||
context_window: None,
|
||||
}];
|
||||
merge_context_definitions(&mut runtime, &definitions, &aliases);
|
||||
assert_eq!(runtime[0].context_window, Some(921_000));
|
||||
assert_eq!(runtime[0].max_context_window, Some(921_000));
|
||||
assert_eq!(runtime[1].context_window, Some(1_048_576));
|
||||
assert_eq!(runtime[2].context_window, Some(921_000));
|
||||
assert_eq!(runtime[2].context_source, "definition");
|
||||
assert_eq!(runtime[3].context_source, "compatibility");
|
||||
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
|
||||
let state = CatalogState {
|
||||
sources,
|
||||
json: MODEL_CATALOG_JSON.to_string(),
|
||||
customizations: Default::default(),
|
||||
};
|
||||
let snapshot = customizations::snapshot_for_state(&runtime, &state).unwrap();
|
||||
let snapshot = serde_json::to_value(snapshot).unwrap();
|
||||
let model = snapshot["models"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|model| model["slug"] == "gpt-5.6-sol")
|
||||
.unwrap();
|
||||
assert_eq!(model["configuration"]["context_window"], 921_000);
|
||||
assert_eq!(model["defaults"]["context_window"], 921_000);
|
||||
assert_eq!(model["contextSource"], "definition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_configured_context_limit_takes_precedence_over_static_definitions() {
|
||||
let mut runtime = parse_runtime_models(&serde_json::json!({"models":[
|
||||
{"slug":"custom-alias","context_window":921000,"max_context_window":921000},
|
||||
{"slug":"unmodified","context_window":128000,"max_context_window":128000}
|
||||
]}))
|
||||
.unwrap();
|
||||
apply_configured_context_limits(&mut runtime, "codex-api-key:\n - models:\n - name: upstream-model\n alias: custom-alias\n max-context-length: 64000\n").unwrap();
|
||||
assert_eq!(runtime[0].context_window, Some(64_000));
|
||||
assert_eq!(runtime[0].max_context_window, Some(64_000));
|
||||
assert_eq!(runtime[0].context_source, "configuration");
|
||||
assert_eq!(runtime[1].context_window, Some(128_000));
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -65,7 +65,7 @@ export const jaOverrides = {
|
||||
'config.software.terminal.auto': '自動選択',
|
||||
'agents.catalog.button': 'モデル一覧の設定',
|
||||
'agents.catalog.title': 'Codex モデル一覧',
|
||||
'agents.catalog.subtitle': '利用可能なモデルのパラメーターを編集し、生成される Codex モデル一覧に保存します。',
|
||||
'agents.catalog.subtitle': 'コンテキストの既定値は CPA コア API から取得します。モデルのパラメーターを編集し、Codex モデル一覧に保存できます。',
|
||||
'agents.catalog.search': 'モデル名または ID を検索',
|
||||
'agents.catalog.official': '公式テンプレート',
|
||||
'agents.catalog.fallback': '標準テンプレート',
|
||||
@@ -76,6 +76,11 @@ export const jaOverrides = {
|
||||
'agents.catalog.displayName': '表示名',
|
||||
'agents.catalog.description': 'モデルの説明',
|
||||
'agents.catalog.context': 'コンテキストウィンドウ(トークン)',
|
||||
'agents.catalog.contextSource.definition': 'コンテキストの取得元:CPA コア API のモデル定義。',
|
||||
'agents.catalog.contextSource.configuration': 'コンテキストの取得元:CPA コアでこのモデルに設定された max-context-length。',
|
||||
'agents.catalog.contextSource.compatibility': 'CPA にこのモデル固有のコンテキスト定義がありません。現在の値は互換カタログの値であり、汎用テンプレートの既定値である可能性があります。',
|
||||
'agents.catalog.contextSource.template': 'コアに有効なコンテキスト情報がありません。ソフトウェアのテンプレートの代替値を使用しています。',
|
||||
'agents.catalog.contextSource.customized': 'コンテキストの取得元:このモデルのカスタム設定。',
|
||||
'agents.catalog.maximum': '最大コンテキストウィンドウ(トークン)',
|
||||
'agents.catalog.percent': '有効コンテキスト比率(%)',
|
||||
'agents.catalog.compact': '自動圧縮のしきい値(トークン)',
|
||||
@@ -89,10 +94,10 @@ export const jaOverrides = {
|
||||
'agents.catalog.visible': 'Codex のモデル一覧に表示',
|
||||
'agents.catalog.parallel': '並列ツール呼び出しを許可',
|
||||
'agents.catalog.capabilityHint': 'Fast は常に選択可能です。推論・画像・ツール機能の動作は上流モデルの対応状況によります。',
|
||||
'agents.catalog.resetModel': 'このテンプレートを復元',
|
||||
'agents.catalog.resetAll': 'すべて公式テンプレートに復元',
|
||||
'agents.catalog.resetHint': '専用テンプレートがあるモデルは公式設定に、それ以外はアプリの標準設定に戻ります。保存すると反映されます。',
|
||||
'agents.catalog.restored': '下書きをテンプレートに戻しました。保存すると反映されます。',
|
||||
'agents.catalog.resetModel': 'このモデルの既定値に戻す',
|
||||
'agents.catalog.resetAll': 'すべて既定値に戻す',
|
||||
'agents.catalog.resetHint': 'コンテキストは CPA コア API の値を既定値とし、有効なデータがない場合のみテンプレートの値を使用します。その他の項目はモデルのテンプレートに戻ります。保存後に適用されます。',
|
||||
'agents.catalog.restored': '下書きを既定値に戻しました。保存すると適用されます。',
|
||||
'agents.catalog.saved': '保存しました。管理対象の一覧が同期されます。Codex を再起動して変更を読み込んでください。',
|
||||
'agents.catalog.syncFailed': '設定は保存しましたが、一覧の同期に失敗しました:{error}',
|
||||
'agents.catalog.changed': 'モデル一覧またはテンプレートが更新されました。再読み込みしてから編集してください。',
|
||||
|
||||
+10
-5
@@ -15,7 +15,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.unit.tokens': 'Token',
|
||||
'agents.catalog.button': 'Model list settings',
|
||||
'agents.catalog.title': 'Codex model catalog',
|
||||
'agents.catalog.subtitle': 'Customize parameters for currently available models and save them to the generated Codex catalog.',
|
||||
'agents.catalog.subtitle': 'Default context settings come from the CPA core API. Customize model parameters and save them to the Codex catalog.',
|
||||
'agents.catalog.search': 'Search model names or IDs',
|
||||
'agents.catalog.official': 'Official template',
|
||||
'agents.catalog.fallback': 'Default template',
|
||||
@@ -26,6 +26,11 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.catalog.displayName': 'Display name',
|
||||
'agents.catalog.description': 'Description',
|
||||
'agents.catalog.context': 'Context window (tokens)',
|
||||
'agents.catalog.contextSource.definition': 'Context source: model definitions from the CPA core API.',
|
||||
'agents.catalog.contextSource.configuration': 'Context source: max-context-length configured for this model in the CPA core.',
|
||||
'agents.catalog.contextSource.compatibility': 'CPA provides no independent context definition for this model. The current value comes from its compatibility catalog and may be a generic template default.',
|
||||
'agents.catalog.contextSource.template': 'The core provides no valid context data. The current value is a fallback from the application template.',
|
||||
'agents.catalog.contextSource.customized': 'Context source: your custom settings for this model.',
|
||||
'agents.catalog.maximum': 'Maximum context window (tokens)',
|
||||
'agents.catalog.percent': 'Effective context (%)',
|
||||
'agents.catalog.compact': 'Auto-compaction threshold (tokens)',
|
||||
@@ -39,10 +44,10 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.catalog.visible': 'Show in the Codex model list',
|
||||
'agents.catalog.parallel': 'Allow parallel tool calls',
|
||||
'agents.catalog.capabilityHint': 'Fast is always available. Reasoning, image and tool capabilities depend on the upstream model.',
|
||||
'agents.catalog.resetModel': 'Restore this template',
|
||||
'agents.catalog.resetAll': 'Restore all official templates',
|
||||
'agents.catalog.resetHint': 'Models with dedicated templates return to official settings; other models return to application defaults. Save to apply.',
|
||||
'agents.catalog.restored': 'Templates restored in the draft. Save to apply.',
|
||||
'agents.catalog.resetModel': 'Restore model defaults',
|
||||
'agents.catalog.resetAll': 'Restore all defaults',
|
||||
'agents.catalog.resetHint': 'Context windows default to CPA core API values, with template values used only when valid data is missing. Other parameters revert to the model template. Save to apply.',
|
||||
'agents.catalog.restored': 'Defaults restored in the draft. Save to apply.',
|
||||
'agents.catalog.saved': 'Saved. Managed catalogs are synchronized; restart Codex to load the changes.',
|
||||
'agents.catalog.syncFailed': 'Settings saved, but catalog synchronization failed: {error}',
|
||||
'agents.catalog.changed': 'The model list or templates have changed. Reload before editing again.',
|
||||
|
||||
@@ -14,7 +14,7 @@ export const zhCN = {
|
||||
'usage.unit.tokens': 'Token',
|
||||
'agents.catalog.button': '模型列表设置',
|
||||
'agents.catalog.title': 'Codex 模型列表',
|
||||
'agents.catalog.subtitle': '自定义当前可用模型的参数,保存后用于生成 Codex 模型列表。',
|
||||
'agents.catalog.subtitle': '默认上下文参数读取自 CPA 内核 API;可自定义当前模型参数并保存到 Codex 模型列表。',
|
||||
'agents.catalog.search': '搜索模型名称或 ID',
|
||||
'agents.catalog.official': '官方模板',
|
||||
'agents.catalog.fallback': '默认模板',
|
||||
@@ -25,6 +25,11 @@ export const zhCN = {
|
||||
'agents.catalog.displayName': '显示名称',
|
||||
'agents.catalog.description': '模型说明',
|
||||
'agents.catalog.context': '上下文窗口(tokens)',
|
||||
'agents.catalog.contextSource.definition': '上下文来源:CPA 内核的模型定义 API。',
|
||||
'agents.catalog.contextSource.configuration': '上下文来源:CPA 内核中为此模型配置的 max-context-length。',
|
||||
'agents.catalog.contextSource.compatibility': 'CPA 未提供此模型的独立上下文定义,当前值来自兼容目录,可能是通用模板默认值。',
|
||||
'agents.catalog.contextSource.template': '内核未提供有效的上下文数据,当前使用软件模板的后备值。',
|
||||
'agents.catalog.contextSource.customized': '上下文来源:你为此模型保存或编辑的自定义配置。',
|
||||
'agents.catalog.maximum': '最大上下文窗口(tokens)',
|
||||
'agents.catalog.percent': '有效上下文比例(%)',
|
||||
'agents.catalog.compact': '自动压缩阈值(tokens)',
|
||||
@@ -38,10 +43,10 @@ export const zhCN = {
|
||||
'agents.catalog.visible': '在 Codex 模型列表中显示',
|
||||
'agents.catalog.parallel': '允许并行工具调用',
|
||||
'agents.catalog.capabilityHint': 'Fast 始终可选;思考等级、图片与工具能力是否生效取决于上游模型。',
|
||||
'agents.catalog.resetModel': '恢复此模型模板',
|
||||
'agents.catalog.resetAll': '全部恢复官方模板',
|
||||
'agents.catalog.resetHint': '有专属模板的模型恢复官方配置,其余恢复软件默认配置。恢复操作保存后生效。',
|
||||
'agents.catalog.restored': '模板已恢复到草稿,点击保存后生效。',
|
||||
'agents.catalog.resetModel': '恢复此模型默认值',
|
||||
'agents.catalog.resetAll': '全部恢复默认值',
|
||||
'agents.catalog.resetHint': '上下文窗口默认使用 CPA 内核 API 返回值,仅在缺少有效数据时使用模板值;其他参数恢复模型模板。保存后生效。',
|
||||
'agents.catalog.restored': '默认值已恢复到草稿,点击保存后生效。',
|
||||
'agents.catalog.saved': '已保存。已托管的模型目录会同步更新,重启 Codex 后加载新配置。',
|
||||
'agents.catalog.syncFailed': '设置已保存,但目录同步失败:{error}',
|
||||
'agents.catalog.changed': '模型列表或模板已更新,请重新加载后再编辑。',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-rea
|
||||
import { useI18n } from '../i18n';
|
||||
import {
|
||||
cloneCodexModelConfiguration,
|
||||
codexContextSourceHint,
|
||||
codexReasoningEfforts,
|
||||
sameCodexModelConfiguration,
|
||||
toggleCodexReasoningLevel,
|
||||
@@ -36,23 +37,29 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [defaultsRestored, setDefaultsRestored] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (restoreDefaults = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const next = await invoke<CodexCatalogEditorSnapshot>('get_codex_model_catalog_editor');
|
||||
setSnapshot(next);
|
||||
setModels(cloneModels(next));
|
||||
setModels(cloneModels(next).map((model) => restoreDefaults
|
||||
? { ...model, configuration: cloneCodexModelConfiguration(model.defaults) }
|
||||
: model));
|
||||
setDefaultsRestored(restoreDefaults);
|
||||
setSelectedSlug((current) => next.models.some((model) => model.slug === current)
|
||||
? current
|
||||
: next.models[0]?.slug ?? '');
|
||||
return true;
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,12 +70,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
}, [load]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (defaultsRestored) return true;
|
||||
if (!snapshot || snapshot.models.length !== models.length) return false;
|
||||
return models.some((model) => {
|
||||
const saved = snapshot.models.find((candidate) => candidate.slug === model.slug);
|
||||
return !saved || !sameCodexModelConfiguration(model.configuration, saved.configuration);
|
||||
});
|
||||
}, [models, snapshot]);
|
||||
}, [defaultsRestored, models, snapshot]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
@@ -130,17 +138,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
setNotice(t('agents.catalog.restored'));
|
||||
};
|
||||
|
||||
const restoreAll = () => {
|
||||
setModels((current) => current.map((model) => ({
|
||||
...model,
|
||||
configuration: cloneCodexModelConfiguration(model.defaults),
|
||||
})));
|
||||
setError('');
|
||||
setNotice(t('agents.catalog.restored'));
|
||||
const restoreAll = async () => {
|
||||
if (loading || saving) return;
|
||||
if (await load(true)) setNotice(t('agents.catalog.restored'));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!snapshot || saving) return;
|
||||
if (!snapshot || loading || saving) return;
|
||||
for (const model of models) {
|
||||
const validationKey = validateCodexModelConfiguration(model.configuration);
|
||||
if (validationKey) {
|
||||
@@ -165,6 +169,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
});
|
||||
setSnapshot(result.snapshot);
|
||||
setModels(cloneModels(result.snapshot));
|
||||
setDefaultsRestored(false);
|
||||
setNotice(result.synchronizationError
|
||||
? t('agents.catalog.syncFailed', { error: result.synchronizationError })
|
||||
: t('agents.catalog.saved'));
|
||||
@@ -231,7 +236,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
</aside>
|
||||
|
||||
<main className="codex-catalog-editor">
|
||||
{activeModel ? (
|
||||
{activeModel && !loading ? (
|
||||
<>
|
||||
<div className="codex-catalog-model-heading">
|
||||
<div>
|
||||
@@ -276,6 +281,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.visibility === 'list'} onChange={(event) => updateField('visibility', event.currentTarget.checked ? 'list' : 'hide')} /><span>{t('agents.catalog.visible')}</span></label>
|
||||
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.supports_parallel_tool_calls} onChange={(event) => updateField('supports_parallel_tool_calls', event.currentTarget.checked)} /><span>{t('agents.catalog.parallel')}</span></label>
|
||||
</div>
|
||||
<p className="codex-catalog-hint" role="note">{t(codexContextSourceHint(activeModel))}</p>
|
||||
<p className="codex-catalog-hint">{t('agents.catalog.capabilityHint')}</p>
|
||||
</>
|
||||
) : !loading ? <div className="codex-catalog-state">{t('agents.catalog.empty')}</div> : null}
|
||||
@@ -289,11 +295,11 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
|
||||
{!error && !notice ? <span>{dirty ? t('agents.catalog.unsaved') : t('agents.catalog.saveHint')}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="secondary-button" onClick={restoreAll} disabled={!models.some((model) => !sameCodexModelConfiguration(model.configuration, model.defaults)) || saving}>
|
||||
<RotateCcw size={15} />{t('agents.catalog.resetAll')}
|
||||
<button type="button" className="secondary-button" onClick={() => void restoreAll()} disabled={loading || saving}>
|
||||
{t('agents.catalog.resetAll')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={requestClose} disabled={saving}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || saving}>
|
||||
<button type="button" className="primary-button" onClick={() => void save()} disabled={!snapshot || !dirty || loading || saving}>
|
||||
{saving ? <LoaderCircle size={16} className="spin" /> : null}{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ export type CodexModelConfiguration = {
|
||||
export type CodexCatalogEditorModel = {
|
||||
slug: string;
|
||||
hasOfficialTemplate: boolean;
|
||||
contextSource: "definition" | "configuration" | "compatibility" | "template";
|
||||
customized: boolean;
|
||||
configuration: CodexModelConfiguration;
|
||||
defaults: CodexModelConfiguration;
|
||||
@@ -35,6 +36,14 @@ export type CodexCatalogEditorSnapshot = {
|
||||
models: CodexCatalogEditorModel[];
|
||||
};
|
||||
|
||||
export function codexContextSourceHint(model: CodexCatalogEditorModel): MessageKey {
|
||||
if (model.configuration.context_window !== model.defaults.context_window
|
||||
|| model.configuration.max_context_window !== model.defaults.max_context_window) {
|
||||
return "agents.catalog.contextSource.customized";
|
||||
}
|
||||
return `agents.catalog.contextSource.${model.contextSource ?? "template"}`;
|
||||
}
|
||||
|
||||
export type CodexCatalogEditorSaveResult = {
|
||||
snapshot: CodexCatalogEditorSnapshot;
|
||||
synchronizationError: string | null;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
cloneCodexModelConfiguration,
|
||||
codexContextSourceHint,
|
||||
sameCodexModelConfiguration,
|
||||
toggleCodexReasoningLevel,
|
||||
validateCodexModelConfiguration,
|
||||
type CodexModelConfiguration,
|
||||
type CodexCatalogEditorModel,
|
||||
} from '../src/services/codexModelCatalog';
|
||||
|
||||
const configuration = (): CodexModelConfiguration => ({
|
||||
@@ -26,6 +28,20 @@ const configuration = (): CodexModelConfiguration => ({
|
||||
});
|
||||
|
||||
describe('Codex 模型列表编辑', () => {
|
||||
test("区分内核模型定义与兼容目录的后备上下文", () => {
|
||||
const model: CodexCatalogEditorModel = {
|
||||
slug: "model-a", hasOfficialTemplate: true, customized: false,
|
||||
contextSource: "definition", configuration: configuration(), defaults: configuration(),
|
||||
};
|
||||
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.definition");
|
||||
expect(codexContextSourceHint({ ...model, contextSource: "compatibility" }))
|
||||
.toBe("agents.catalog.contextSource.compatibility");
|
||||
model.configuration.context_window = 64_000;
|
||||
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.customized");
|
||||
model.configuration = cloneCodexModelConfiguration(model.defaults);
|
||||
expect(codexContextSourceHint(model)).toBe("agents.catalog.contextSource.definition");
|
||||
});
|
||||
|
||||
test('克隆配置时隔离数组字段', () => {
|
||||
const original = configuration();
|
||||
const cloned = cloneCodexModelConfiguration(original);
|
||||
|
||||
Reference in new issue
Block a user