mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 18:41:35 +08:00
fix: Codex model catalog WYSIWYG and config consolidation
- Remove mergeCodexDefaultCatalogModelForSave implicit injection (P1) The model mapping table is now the single source of truth; no hidden entries are prepended on save. - Sync first catalog row model into config.toml on save Ensures Codex default request model matches the table's first entry instead of retaining a stale template value. - Remove API Format selector from CodexFormFields (P3) wire_api is always 'responses'; the selector confused users into thinking they were changing the upstream protocol. Only the 'Needs Local Routing' toggle remains. - Add restart hint to model mapping i18n text (P2) model_catalog_json is loaded at Codex startup; users are now informed that a restart is needed after changes. - Unify write_codex_live_with_catalog helper (P4) Replaces three scattered prepare+write call sites in config.rs, provider/live.rs, and proxy.rs with a single entry point. - Clean up useCodexConfigState dead state (P3 follow-up) Remove codexModelName, codexContextWindow, codexAutoCompactLimit and their handlers/effects since no component consumes them after the UI consolidation.
This commit is contained in:
@@ -6,12 +6,15 @@ use crate::config::{
|
|||||||
write_text_file,
|
write_text_file,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use serde_json::Value;
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
use toml_edit::DocumentMut;
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
|
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
|
||||||
|
pub const CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME: &str = "cc-switch-model-catalog.json";
|
||||||
|
const CODEX_MODEL_CATALOG_TEMPLATE_SLUG: &str = "gpt-5.5";
|
||||||
|
|
||||||
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
|
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
|
||||||
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
|
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
|
||||||
@@ -44,6 +47,10 @@ pub fn get_codex_config_path() -> PathBuf {
|
|||||||
get_codex_config_dir().join("config.toml")
|
get_codex_config_dir().join("config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_codex_model_catalog_path() -> PathBuf {
|
||||||
|
get_codex_config_dir().join(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Codex 供应商配置文件路径
|
/// 获取 Codex 供应商配置文件路径
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_codex_provider_paths(
|
pub fn get_codex_provider_paths(
|
||||||
@@ -413,6 +420,264 @@ pub fn write_codex_live_atomic_with_stable_provider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_codex_positive_u64(value: Option<&Value>) -> Option<u64> {
|
||||||
|
match value {
|
||||||
|
Some(Value::Number(n)) => n.as_u64().filter(|v| *v > 0),
|
||||||
|
Some(Value::String(s)) => s.trim().parse::<u64>().ok().filter(|v| *v > 0),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_codex_top_level_u64(config_text: &str, field: &str) -> Option<u64> {
|
||||||
|
let doc = config_text.parse::<toml::Value>().ok()?;
|
||||||
|
doc.get(field)
|
||||||
|
.and_then(|value| value.as_integer())
|
||||||
|
.and_then(|value| u64::try_from(value).ok())
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_catalog_model_entry(
|
||||||
|
template: &Value,
|
||||||
|
model: &str,
|
||||||
|
display_name: &str,
|
||||||
|
context_window: u64,
|
||||||
|
priority: usize,
|
||||||
|
) -> Value {
|
||||||
|
let mut entry = template.clone();
|
||||||
|
let Some(entry_obj) = entry.as_object_mut() else {
|
||||||
|
return json!({});
|
||||||
|
};
|
||||||
|
|
||||||
|
entry_obj.insert("slug".to_string(), json!(model));
|
||||||
|
entry_obj.insert("display_name".to_string(), json!(display_name));
|
||||||
|
entry_obj.insert("description".to_string(), json!(display_name));
|
||||||
|
entry_obj.insert("context_window".to_string(), json!(context_window));
|
||||||
|
entry_obj.insert("max_context_window".to_string(), json!(context_window));
|
||||||
|
entry_obj.insert("priority".to_string(), json!(1000 + priority));
|
||||||
|
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
|
||||||
|
entry_obj.insert("service_tiers".to_string(), json!([]));
|
||||||
|
entry_obj.insert("availability_nux".to_string(), Value::Null);
|
||||||
|
entry_obj.insert("upgrade".to_string(), Value::Null);
|
||||||
|
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct CodexCatalogModelSpec {
|
||||||
|
model: String,
|
||||||
|
display_name: String,
|
||||||
|
context_window: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
|
||||||
|
let Some(models) = settings
|
||||||
|
.get("modelCatalog")
|
||||||
|
.and_then(|catalog| catalog.get("models"))
|
||||||
|
.and_then(|models| models.as_array())
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_context_window =
|
||||||
|
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut specs = Vec::new();
|
||||||
|
|
||||||
|
for model_config in models {
|
||||||
|
let Some(model) = model_config
|
||||||
|
.get("model")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|model| !model.is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !seen.insert(model.to_string()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_name = model_config
|
||||||
|
.get("displayName")
|
||||||
|
.or_else(|| model_config.get("display_name"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or(model);
|
||||||
|
let context_window = parse_codex_positive_u64(
|
||||||
|
model_config
|
||||||
|
.get("contextWindow")
|
||||||
|
.or_else(|| model_config.get("context_window")),
|
||||||
|
)
|
||||||
|
.unwrap_or(default_context_window);
|
||||||
|
|
||||||
|
specs.push(CodexCatalogModelSpec {
|
||||||
|
model: model.to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
context_window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
specs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_codex_model_template(catalog: &Value) -> Option<Value> {
|
||||||
|
catalog
|
||||||
|
.get("models")
|
||||||
|
.and_then(|models| models.as_array())
|
||||||
|
.and_then(|models| {
|
||||||
|
models.iter().find(|model| {
|
||||||
|
model.get("slug").and_then(|slug| slug.as_str())
|
||||||
|
== Some(CODEX_MODEL_CATALOG_TEMPLATE_SLUG)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_codex_model_template_from_cache() -> Result<Option<Value>, AppError> {
|
||||||
|
let path = get_codex_config_dir().join("models_cache.json");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
let catalog: Value = serde_json::from_str(&text).map_err(|e| AppError::json(&path, e))?;
|
||||||
|
Ok(find_codex_model_template(&catalog))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
|
||||||
|
let output = match Command::new("codex")
|
||||||
|
.args(["debug", "models", "--bundled"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(err) => {
|
||||||
|
log::debug!("failed to run `codex debug models --bundled`: {err}");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
log::debug!("`codex debug models --bundled` failed: {stderr}");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
|
||||||
|
AppError::Message(format!(
|
||||||
|
"Failed to parse `codex debug models --bundled` output: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(find_codex_model_template(&catalog))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
|
||||||
|
if let Some(template) = load_codex_model_template_from_cache()? {
|
||||||
|
return Ok(template);
|
||||||
|
}
|
||||||
|
if let Some(template) = load_codex_model_template_from_bundled()? {
|
||||||
|
return Ok(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::Message(format!(
|
||||||
|
"Codex model catalog template `{CODEX_MODEL_CATALOG_TEMPLATE_SLUG}` not found. Please start Codex once so models_cache.json is available, or ensure the `codex` CLI is on PATH."
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_model_catalog_from_specs(specs: &[CodexCatalogModelSpec], template: &Value) -> Value {
|
||||||
|
let entries: Vec<Value> = specs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, spec)| {
|
||||||
|
codex_catalog_model_entry(
|
||||||
|
template,
|
||||||
|
&spec.model,
|
||||||
|
&spec.display_name,
|
||||||
|
spec.context_window,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({ "models": entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_model_catalog_from_settings(
|
||||||
|
settings: &Value,
|
||||||
|
config_text: &str,
|
||||||
|
) -> Result<Option<Value>, AppError> {
|
||||||
|
let specs = codex_catalog_model_specs(settings, config_text);
|
||||||
|
if specs.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = load_codex_model_catalog_template()?;
|
||||||
|
Ok(Some(codex_model_catalog_from_specs(&specs, &template)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_codex_model_catalog_json_field(
|
||||||
|
config_text: &str,
|
||||||
|
catalog_path: Option<&Path>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let mut doc = config_text
|
||||||
|
.parse::<DocumentMut>()
|
||||||
|
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||||
|
let generated_path = get_codex_model_catalog_path();
|
||||||
|
|
||||||
|
match catalog_path {
|
||||||
|
Some(path) => {
|
||||||
|
doc["model_catalog_json"] = toml_edit::value(path.to_string_lossy().as_ref());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let should_remove = doc
|
||||||
|
.get("model_catalog_json")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(|path| {
|
||||||
|
path == generated_path.to_string_lossy().as_ref()
|
||||||
|
|| Path::new(path).file_name().and_then(|name| name.to_str())
|
||||||
|
== Some(CC_SWITCH_CODEX_MODEL_CATALOG_FILENAME)
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if should_remove {
|
||||||
|
doc.as_table_mut().remove("model_catalog_json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate Codex `model_catalog_json` from provider settings and inject/remove
|
||||||
|
/// the top-level TOML field that points Codex to the generated file.
|
||||||
|
pub fn prepare_codex_config_text_with_model_catalog(
|
||||||
|
settings: &Value,
|
||||||
|
config_text: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let catalog_path = get_codex_model_catalog_path();
|
||||||
|
|
||||||
|
if let Some(catalog) = codex_model_catalog_from_settings(settings, config_text)? {
|
||||||
|
let config_text = set_codex_model_catalog_json_field(config_text, Some(&catalog_path))?;
|
||||||
|
write_json_file(&catalog_path, &catalog)?;
|
||||||
|
Ok(config_text)
|
||||||
|
} else {
|
||||||
|
set_codex_model_catalog_json_field(config_text, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified helper: write Codex live config with model catalog preparation.
|
||||||
|
/// Replaces scattered `prepare_codex_config_text_with_model_catalog` calls.
|
||||||
|
pub fn write_codex_live_with_catalog(
|
||||||
|
settings: &Value,
|
||||||
|
auth: &Value,
|
||||||
|
config_text: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let prepared_config = config_text
|
||||||
|
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
write_codex_live_atomic_with_stable_provider(auth, prepared_config.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
|
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
|
||||||
///
|
///
|
||||||
/// Supported fields:
|
/// Supported fields:
|
||||||
@@ -420,7 +685,7 @@ pub fn write_codex_live_atomic_with_stable_provider(
|
|||||||
/// otherwise falls back to top-level `base_url`.
|
/// otherwise falls back to top-level `base_url`.
|
||||||
/// - `"wire_api"`: writes to `[model_providers.<current>].wire_api` if `model_provider` exists,
|
/// - `"wire_api"`: writes to `[model_providers.<current>].wire_api` if `model_provider` exists,
|
||||||
/// otherwise falls back to top-level `wire_api`.
|
/// otherwise falls back to top-level `wire_api`.
|
||||||
/// - `"model"`: writes to top-level `model` field.
|
/// - `"model"` / `"model_catalog_json"`: writes to top-level field.
|
||||||
///
|
///
|
||||||
/// Empty value removes the field.
|
/// Empty value removes the field.
|
||||||
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
|
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
|
||||||
@@ -467,11 +732,11 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
|||||||
doc[field] = toml_edit::value(trimmed);
|
doc[field] = toml_edit::value(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"model" => {
|
"model" | "model_catalog_json" => {
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
doc.as_table_mut().remove("model");
|
doc.as_table_mut().remove(field);
|
||||||
} else {
|
} else {
|
||||||
doc["model"] = toml_edit::value(trimmed);
|
doc[field] = toml_edit::value(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(format!("unsupported field: {field}")),
|
_ => return Err(format!("unsupported field: {field}")),
|
||||||
@@ -1010,4 +1275,130 @@ base_url = "https://production.api/v1"
|
|||||||
.and_then(|v| v.as_str());
|
.and_then(|v| v.as_str());
|
||||||
assert_eq!(base_url, Some("https://production.api/v1"));
|
assert_eq!(base_url, Some("https://production.api/v1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_model_catalog_uses_provider_models_and_context() {
|
||||||
|
let template = json!({
|
||||||
|
"slug": "gpt-5.5",
|
||||||
|
"display_name": "GPT-5.5",
|
||||||
|
"description": "Frontier model",
|
||||||
|
"base_instructions": "gpt-5.5 base instructions",
|
||||||
|
"model_messages": {
|
||||||
|
"instructions_template": "gpt-5.5 instructions template",
|
||||||
|
"instructions_variables": {
|
||||||
|
"personality_default": "",
|
||||||
|
"personality_friendly": "",
|
||||||
|
"personality_pragmatic": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additional_speed_tiers": ["fast"],
|
||||||
|
"service_tiers": [
|
||||||
|
{
|
||||||
|
"id": "priority",
|
||||||
|
"name": "Fast",
|
||||||
|
"description": "1.5x speed, increased usage"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"availability_nux": {
|
||||||
|
"message": "GPT-5.5 is now available."
|
||||||
|
},
|
||||||
|
"upgrade": {
|
||||||
|
"target": "gpt-5.5"
|
||||||
|
},
|
||||||
|
"context_window": 272000,
|
||||||
|
"max_context_window": 272000
|
||||||
|
});
|
||||||
|
let settings = json!({
|
||||||
|
"modelCatalog": {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"displayName": "DeepSeek V4 Flash",
|
||||||
|
"contextWindow": "64000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "kimi-k2",
|
||||||
|
"display_name": "Kimi K2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
|
||||||
|
let catalog = codex_model_catalog_from_specs(&specs, &template);
|
||||||
|
let models = catalog
|
||||||
|
.get("models")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.expect("models should be an array");
|
||||||
|
|
||||||
|
assert_eq!(models.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
models[0].get("slug").and_then(|value| value.as_str()),
|
||||||
|
Some("deepseek-v4-flash")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
models[0]
|
||||||
|
.get("context_window")
|
||||||
|
.and_then(|value| value.as_u64()),
|
||||||
|
Some(64_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
models[1]
|
||||||
|
.get("context_window")
|
||||||
|
.and_then(|value| value.as_u64()),
|
||||||
|
Some(128_000)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
models[0].get("model_messages").is_some(),
|
||||||
|
"Codex requires model_messages in custom catalogs"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
models[0]
|
||||||
|
.get("base_instructions")
|
||||||
|
.and_then(|value| value.as_str()),
|
||||||
|
Some("gpt-5.5 base instructions")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
models[0].get("model_messages"),
|
||||||
|
template.get("model_messages"),
|
||||||
|
"custom catalog entries should keep the gpt-5.5 agent template"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
models[0].get("additional_speed_tiers"),
|
||||||
|
Some(&json!([])),
|
||||||
|
"generated third-party entries should not inherit OpenAI speed tiers"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
models[0]
|
||||||
|
.get("availability_nux")
|
||||||
|
.is_some_and(|value| value.is_null()),
|
||||||
|
"generated third-party entries should not inherit GPT-5.5 launch messaging"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_catalog_json_field_operates_on_top_level() {
|
||||||
|
let input = r#"model_provider = "any"
|
||||||
|
|
||||||
|
[model_providers.any]
|
||||||
|
name = "any"
|
||||||
|
"#;
|
||||||
|
let catalog_path = Path::new("/tmp/cc-switch-model-catalog.json");
|
||||||
|
|
||||||
|
let result = set_codex_model_catalog_json_field(input, Some(catalog_path)).unwrap();
|
||||||
|
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed
|
||||||
|
.get("model_catalog_json")
|
||||||
|
.and_then(|value| value.as_str()),
|
||||||
|
Some("/tmp/cc-switch-model-catalog.json")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed
|
||||||
|
.get("model_providers")
|
||||||
|
.and_then(|value| value.get("any"))
|
||||||
|
.and_then(|value| value.get("model_catalog_json"))
|
||||||
|
.is_none(),
|
||||||
|
"model_catalog_json should stay top-level"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ impl ConfigService {
|
|||||||
}
|
}
|
||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?;
|
crate::codex_config::write_codex_live_with_catalog(
|
||||||
|
&provider.settings_config,
|
||||||
|
auth,
|
||||||
|
cfg_text,
|
||||||
|
)?;
|
||||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ use serde_json::{json, Value};
|
|||||||
use toml_edit::{DocumentMut, Item, TableLike};
|
use toml_edit::{DocumentMut, Item, TableLike};
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::codex_config::{
|
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||||
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider,
|
|
||||||
};
|
|
||||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -728,11 +726,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
let auth = obj
|
let auth = obj
|
||||||
.get("auth")
|
.get("auth")
|
||||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||||
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
let config_str = obj.get("config").and_then(|v| v.as_str());
|
||||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?;
|
crate::codex_config::write_codex_live_with_catalog(
|
||||||
|
&provider.settings_config,
|
||||||
|
auth,
|
||||||
|
config_str,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
// Delegate to write_gemini_live which handles env file writing correctly
|
// Delegate to write_gemini_live which handles env file writing correctly
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ impl ProxyService {
|
|||||||
Some(provider),
|
Some(provider),
|
||||||
);
|
);
|
||||||
effective_settings["config"] = json!(updated_config);
|
effective_settings["config"] = json!(updated_config);
|
||||||
|
Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));
|
||||||
|
|
||||||
self.write_codex_live(&effective_settings)?;
|
self.write_codex_live(&effective_settings)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1148,6 +1149,10 @@ impl ProxyService {
|
|||||||
codex_provider.as_ref(),
|
codex_provider.as_ref(),
|
||||||
);
|
);
|
||||||
live_config["config"] = json!(updated_config);
|
live_config["config"] = json!(updated_config);
|
||||||
|
Self::attach_codex_model_catalog_from_provider(
|
||||||
|
&mut live_config,
|
||||||
|
codex_provider.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
self.write_codex_live(&live_config)?;
|
self.write_codex_live(&live_config)?;
|
||||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
||||||
@@ -1209,6 +1214,10 @@ impl ProxyService {
|
|||||||
codex_provider.as_ref(),
|
codex_provider.as_ref(),
|
||||||
);
|
);
|
||||||
live_config["config"] = json!(updated_config);
|
live_config["config"] = json!(updated_config);
|
||||||
|
Self::attach_codex_model_catalog_from_provider(
|
||||||
|
&mut live_config,
|
||||||
|
codex_provider.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
self.write_codex_live(&live_config)?;
|
self.write_codex_live(&live_config)?;
|
||||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}");
|
||||||
@@ -1283,6 +1292,10 @@ impl ProxyService {
|
|||||||
codex_provider.as_ref(),
|
codex_provider.as_ref(),
|
||||||
);
|
);
|
||||||
live_config["config"] = json!(updated_config);
|
live_config["config"] = json!(updated_config);
|
||||||
|
Self::attach_codex_model_catalog_from_provider(
|
||||||
|
&mut live_config,
|
||||||
|
codex_provider.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
let _ = self.write_codex_live(&live_config);
|
let _ = self.write_codex_live(&live_config);
|
||||||
}
|
}
|
||||||
@@ -1930,17 +1943,7 @@ impl ProxyService {
|
|||||||
crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses")
|
crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses")
|
||||||
.unwrap_or(updated);
|
.unwrap_or(updated);
|
||||||
|
|
||||||
if provider
|
if let Some(upstream_model) =
|
||||||
.map(crate::proxy::providers::codex_provider_uses_chat_completions)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
updated = crate::codex_config::update_codex_toml_field(
|
|
||||||
&updated,
|
|
||||||
"model",
|
|
||||||
crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL,
|
|
||||||
)
|
|
||||||
.unwrap_or(updated);
|
|
||||||
} else if let Some(upstream_model) =
|
|
||||||
provider.and_then(crate::proxy::providers::codex_provider_upstream_model)
|
provider.and_then(crate::proxy::providers::codex_provider_upstream_model)
|
||||||
{
|
{
|
||||||
updated =
|
updated =
|
||||||
@@ -1951,6 +1954,25 @@ impl ProxyService {
|
|||||||
updated
|
updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attach_codex_model_catalog_from_provider(
|
||||||
|
live_config: &mut Value,
|
||||||
|
provider: Option<&Provider>,
|
||||||
|
) {
|
||||||
|
let Some(provider) = provider else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let model_catalog = provider
|
||||||
|
.settings_config
|
||||||
|
.get("modelCatalog")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({ "models": [] }));
|
||||||
|
|
||||||
|
if let Some(root) = live_config.as_object_mut() {
|
||||||
|
root.insert("modelCatalog".to_string(), model_catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn read_claude_live(&self) -> Result<Value, String> {
|
fn read_claude_live(&self) -> Result<Value, String> {
|
||||||
let path = get_claude_settings_path();
|
let path = get_claude_settings_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -2014,9 +2036,7 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_codex_live(&self, config: &Value) -> Result<(), String> {
|
fn write_codex_live(&self, config: &Value) -> Result<(), String> {
|
||||||
use crate::codex_config::{
|
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||||
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth = config.get("auth");
|
let auth = config.get("auth");
|
||||||
let config_str = config.get("config").and_then(|v| v.as_str());
|
let config_str = config.get("config").and_then(|v| v.as_str());
|
||||||
@@ -2024,8 +2044,11 @@ impl ProxyService {
|
|||||||
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
|
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
|
||||||
// through write_live_with_common_config(), which normalizes Codex provider ids.
|
// through write_live_with_common_config(), which normalizes Codex provider ids.
|
||||||
match (auth, config_str) {
|
match (auth, config_str) {
|
||||||
(Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))
|
(Some(auth), Some(cfg)) => {
|
||||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?,
|
// Use unified helper to prepare catalog if present
|
||||||
|
crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))
|
||||||
|
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?;
|
||||||
|
}
|
||||||
(Some(auth), None) => {
|
(Some(auth), None) => {
|
||||||
let auth_path = get_codex_auth_path();
|
let auth_path = get_codex_auth_path();
|
||||||
write_json_file(&auth_path, auth)
|
write_json_file(&auth_path, auth)
|
||||||
@@ -2420,7 +2443,7 @@ wire_api = "chat"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_codex_proxy_toml_config_uses_safe_client_model_for_chat_provider() {
|
fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {
|
||||||
let input = r#"
|
let input = r#"
|
||||||
model_provider = "deepseek"
|
model_provider = "deepseek"
|
||||||
model = "deepseek-v4-flash"
|
model = "deepseek-v4-flash"
|
||||||
@@ -2454,7 +2477,7 @@ wire_api = "responses"
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.get("model").and_then(|v| v.as_str()),
|
parsed.get("model").and_then(|v| v.as_str()),
|
||||||
Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL)
|
Some("deepseek-v4-flash")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed
|
parsed
|
||||||
@@ -3409,7 +3432,7 @@ requires_openai_auth = true
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn hot_switch_codex_chat_provider_uses_safe_model_without_changing_live_provider() {
|
async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {
|
||||||
let _home = TempHome::new();
|
let _home = TempHome::new();
|
||||||
crate::settings::reload_settings().expect("reload settings");
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
@@ -3508,7 +3531,7 @@ requires_openai_auth = true
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed_live.get("model").and_then(|v| v.as_str()),
|
parsed_live.get("model").and_then(|v| v.as_str()),
|
||||||
Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL)
|
Some("deepseek-v4-flash")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
live.get("auth")
|
live.get("auth")
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
import {
|
import { Input } from "@/components/ui/input";
|
||||||
Select,
|
import { Switch } from "@/components/ui/switch";
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
|
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
|
||||||
import {
|
import {
|
||||||
fetchModelsForConfig,
|
fetchModelsForConfig,
|
||||||
showFetchModelsError,
|
showFetchModelsError,
|
||||||
type FetchedModel,
|
type FetchedModel,
|
||||||
} from "@/lib/api/model-fetch";
|
} from "@/lib/api/model-fetch";
|
||||||
import type { CodexApiFormat, ProviderCategory } from "@/types";
|
import type {
|
||||||
|
CodexApiFormat,
|
||||||
|
CodexCatalogModel,
|
||||||
|
ProviderCategory,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
interface EndpointCandidate {
|
interface EndpointCandidate {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -48,18 +47,46 @@ interface CodexFormFieldsProps {
|
|||||||
onAutoSelectChange: (checked: boolean) => void;
|
onAutoSelectChange: (checked: boolean) => void;
|
||||||
|
|
||||||
// API Format
|
// API Format
|
||||||
|
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
|
||||||
apiFormat: CodexApiFormat;
|
apiFormat: CodexApiFormat;
|
||||||
onApiFormatChange: (format: CodexApiFormat) => void;
|
onApiFormatChange: (format: CodexApiFormat) => void;
|
||||||
|
|
||||||
// Model Name
|
// Model Catalog
|
||||||
shouldShowModelField?: boolean;
|
catalogModels?: CodexCatalogModel[];
|
||||||
modelName?: string;
|
onCatalogModelsChange?: (models: CodexCatalogModel[]) => void;
|
||||||
onModelNameChange?: (model: string) => void;
|
|
||||||
|
|
||||||
// Speed Test Endpoints
|
// Speed Test Endpoints
|
||||||
speedTestEndpoints: EndpointCandidate[];
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||||
|
|
||||||
|
function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
|
||||||
|
return {
|
||||||
|
rowId: crypto.randomUUID(),
|
||||||
|
model: seed?.model ?? "",
|
||||||
|
displayName: seed?.displayName ?? "",
|
||||||
|
contextWindow: seed?.contextWindow ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compares rows (with rowId) to incoming models (without) by data fields only,
|
||||||
|
// so both sync effects can use the same equality definition.
|
||||||
|
function catalogRowsMatchModels(
|
||||||
|
rows: Array<Pick<CodexCatalogRow, "model" | "displayName" | "contextWindow">>,
|
||||||
|
models: CodexCatalogModel[],
|
||||||
|
): boolean {
|
||||||
|
if (rows.length !== models.length) return false;
|
||||||
|
return rows.every((row, i) => {
|
||||||
|
const incoming = models[i];
|
||||||
|
return (
|
||||||
|
row.model === (incoming.model ?? "") &&
|
||||||
|
(row.displayName ?? "") === (incoming.displayName ?? "") &&
|
||||||
|
String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function CodexFormFields({
|
export function CodexFormFields({
|
||||||
providerId,
|
providerId,
|
||||||
codexApiKey,
|
codexApiKey,
|
||||||
@@ -81,16 +108,46 @@ export function CodexFormFields({
|
|||||||
onAutoSelectChange,
|
onAutoSelectChange,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
onApiFormatChange,
|
onApiFormatChange,
|
||||||
shouldShowModelField = true,
|
catalogModels = [],
|
||||||
modelName = "",
|
onCatalogModelsChange,
|
||||||
onModelNameChange,
|
|
||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
}: CodexFormFieldsProps) {
|
}: CodexFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||||
const isChatApiFormat = apiFormat === "openai_chat";
|
const needsLocalRouting = apiFormat === "openai_chat";
|
||||||
|
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||||
|
|
||||||
|
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
|
||||||
|
catalogModels.map((m) => createCatalogRow(m)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 父 → 子:仅当 prop 数据真的变化(预设切换 / 编辑加载)时才重建 rowId;
|
||||||
|
// 同 shape 时保留现有 rowId,避免编辑过程中焦点丢失。
|
||||||
|
useEffect(() => {
|
||||||
|
setCatalogRows((current) => {
|
||||||
|
if (catalogRowsMatchModels(current, catalogModels)) return current;
|
||||||
|
return catalogModels.map((m) => createCatalogRow(m));
|
||||||
|
});
|
||||||
|
}, [catalogModels]);
|
||||||
|
|
||||||
|
// 子 → 父:rowId 是视图层概念,不应进入持久化数据;剥离后再回传。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onCatalogModelsChange) return;
|
||||||
|
if (catalogRowsMatchModels(catalogRows, catalogModels)) return;
|
||||||
|
const next: CodexCatalogModel[] = catalogRows.map(
|
||||||
|
({ rowId: _rowId, ...rest }) => rest,
|
||||||
|
);
|
||||||
|
onCatalogModelsChange(next);
|
||||||
|
}, [catalogRows, catalogModels, onCatalogModelsChange]);
|
||||||
|
|
||||||
|
const handleLocalRoutingChange = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
onApiFormatChange(checked ? "openai_chat" : "openai_responses");
|
||||||
|
},
|
||||||
|
[onApiFormatChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFetchModels = useCallback(() => {
|
const handleFetchModels = useCallback(() => {
|
||||||
if (!codexBaseUrl || !codexApiKey) {
|
if (!codexBaseUrl || !codexApiKey) {
|
||||||
@@ -119,6 +176,54 @@ export function CodexFormFields({
|
|||||||
.finally(() => setIsFetchingModels(false));
|
.finally(() => setIsFetchingModels(false));
|
||||||
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
|
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
|
||||||
|
|
||||||
|
const handleAddCatalogRow = useCallback(() => {
|
||||||
|
if (!onCatalogModelsChange) return;
|
||||||
|
setCatalogRows((current) => [...current, createCatalogRow()]);
|
||||||
|
}, [onCatalogModelsChange]);
|
||||||
|
|
||||||
|
const handleUpdateCatalogRow = useCallback(
|
||||||
|
(index: number, patch: Partial<CodexCatalogModel>) => {
|
||||||
|
setCatalogRows((current) =>
|
||||||
|
current.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveCatalogRow = useCallback((index: number) => {
|
||||||
|
setCatalogRows((current) => current.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFetchModels}
|
||||||
|
disabled={isFetchingModels}
|
||||||
|
className="h-7 gap-1"
|
||||||
|
>
|
||||||
|
{isFetchingModels ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("providerForm.fetchModels")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="h-7 gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Codex API Key 输入框 */}
|
{/* Codex API Key 输入框 */}
|
||||||
@@ -158,97 +263,168 @@ export function CodexFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Codex API 格式选择 */}
|
|
||||||
{shouldShowSpeedTest && (
|
{shouldShowSpeedTest && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||||
<FormLabel htmlFor="codexApiFormat">
|
<div className="flex items-center justify-between gap-4">
|
||||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
<div className="space-y-1">
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<Select
|
{t("codexConfig.localRoutingToggle", {
|
||||||
value={apiFormat}
|
defaultValue: "需要本地路由映射",
|
||||||
onValueChange={(value) =>
|
|
||||||
onApiFormatChange(value as CodexApiFormat)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="codexApiFormat" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="openai_responses">
|
|
||||||
{t("providerForm.codexApiFormatResponses", {
|
|
||||||
defaultValue: "OpenAI Responses API (原生)",
|
|
||||||
})}
|
})}
|
||||||
</SelectItem>
|
</FormLabel>
|
||||||
<SelectItem value="openai_chat">
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
{t("providerForm.codexApiFormatOpenAIChat", {
|
{needsLocalRouting
|
||||||
defaultValue: "OpenAI Chat Completions (需开启路由)",
|
? t("codexConfig.localRoutingOnHint", {
|
||||||
})}
|
defaultValue:
|
||||||
</SelectItem>
|
"适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。",
|
||||||
</SelectContent>
|
})
|
||||||
</Select>
|
: t("codexConfig.localRoutingOffHint", {
|
||||||
<p className="text-xs text-muted-foreground">
|
defaultValue:
|
||||||
{t("providerForm.codexApiFormatHint", {
|
"适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。",
|
||||||
defaultValue:
|
})}
|
||||||
"选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。",
|
</p>
|
||||||
})}
|
</div>
|
||||||
</p>
|
<Switch
|
||||||
|
checked={needsLocalRouting}
|
||||||
|
onCheckedChange={handleLocalRoutingChange}
|
||||||
|
aria-label={t("codexConfig.localRoutingToggle", {
|
||||||
|
defaultValue: "需要本地路由映射",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Codex Model Name 输入框 */}
|
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
|
||||||
{shouldShowModelField && onModelNameChange && (
|
{needsLocalRouting && canEditCatalog && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-1">
|
||||||
<label
|
<div className="flex items-center justify-between gap-3">
|
||||||
htmlFor="codexModelName"
|
<FormLabel>
|
||||||
className="block text-sm font-medium text-foreground"
|
{t("codexConfig.modelMappingTitle", {
|
||||||
>
|
defaultValue: "模型映射",
|
||||||
{isChatApiFormat
|
})}
|
||||||
? t("codexConfig.upstreamModelName", {
|
</FormLabel>
|
||||||
defaultValue: "上游模型名称",
|
{renderCatalogActionButtons(
|
||||||
})
|
handleAddCatalogRow,
|
||||||
: t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
t("codexConfig.addCatalogModel", {
|
||||||
</label>
|
defaultValue: "添加模型",
|
||||||
<Button
|
}),
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleFetchModels}
|
|
||||||
disabled={isFetchingModels}
|
|
||||||
className="h-7 gap-1"
|
|
||||||
>
|
|
||||||
{isFetchingModels ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
)}
|
)}
|
||||||
{t("providerForm.fetchModels")}
|
</div>
|
||||||
</Button>
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{t("codexConfig.modelMappingHint", {
|
||||||
|
defaultValue:
|
||||||
|
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ModelInputWithFetch
|
|
||||||
id="codexModelName"
|
{catalogRows.length > 0 && (
|
||||||
value={modelName}
|
<div className="space-y-2">
|
||||||
onChange={(v) => onModelNameChange!(v)}
|
{/* 列头:md+ 显示 */}
|
||||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||||
defaultValue: "例如: gpt-5.4",
|
<span>
|
||||||
})}
|
{t("codexConfig.catalogColumnDisplay", {
|
||||||
fetchedModels={fetchedModels}
|
defaultValue: "菜单显示名",
|
||||||
isLoading={isFetchingModels}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{isChatApiFormat
|
|
||||||
? t("codexConfig.upstreamModelNameHint", {
|
|
||||||
defaultValue:
|
|
||||||
"Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。",
|
|
||||||
})
|
|
||||||
: modelName.trim()
|
|
||||||
? t("codexConfig.modelNameHint", {
|
|
||||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
|
||||||
})
|
|
||||||
: t("providerForm.modelHint", {
|
|
||||||
defaultValue: "💡 留空将使用供应商的默认模型",
|
|
||||||
})}
|
})}
|
||||||
</p>
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("codexConfig.catalogColumnModel", {
|
||||||
|
defaultValue: "实际请求模型",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("codexConfig.catalogColumnContext", {
|
||||||
|
defaultValue: "上下文窗口",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catalogRows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.rowId}
|
||||||
|
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={row.displayName ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdateCatalogRow(index, {
|
||||||
|
displayName: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"codexConfig.catalogDisplayNamePlaceholder",
|
||||||
|
{
|
||||||
|
defaultValue: "例如: DeepSeek V4 Flash",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
aria-label={t("codexConfig.catalogColumnDisplay", {
|
||||||
|
defaultValue: "菜单显示名",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
value={row.model}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdateCatalogRow(index, {
|
||||||
|
model: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("codexConfig.catalogModelPlaceholder", {
|
||||||
|
defaultValue: "例如: deepseek-v4-flash",
|
||||||
|
})}
|
||||||
|
aria-label={t("codexConfig.catalogColumnModel", {
|
||||||
|
defaultValue: "实际请求模型",
|
||||||
|
})}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{fetchedModels.length > 0 && (
|
||||||
|
<ModelDropdown
|
||||||
|
models={fetchedModels}
|
||||||
|
onSelect={(id) =>
|
||||||
|
handleUpdateCatalogRow(index, {
|
||||||
|
model: id,
|
||||||
|
displayName: row.displayName?.trim()
|
||||||
|
? row.displayName
|
||||||
|
: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={row.contextWindow ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleUpdateCatalogRow(index, {
|
||||||
|
contextWindow: event.target.value.replace(/[^\d]/g, ""),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("codexConfig.contextWindowPlaceholder", {
|
||||||
|
defaultValue: "例如: 128000",
|
||||||
|
})}
|
||||||
|
aria-label={t("codexConfig.catalogColumnContext", {
|
||||||
|
defaultValue: "上下文窗口",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleRemoveCatalogRow(index)}
|
||||||
|
title={t("common.delete", { defaultValue: "删除" })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ProviderTestConfig,
|
ProviderTestConfig,
|
||||||
ClaudeApiFormat,
|
ClaudeApiFormat,
|
||||||
CodexApiFormat,
|
CodexApiFormat,
|
||||||
|
CodexCatalogModel,
|
||||||
ClaudeApiKeyField,
|
ClaudeApiKeyField,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
|||||||
import {
|
import {
|
||||||
extractCodexWireApi,
|
extractCodexWireApi,
|
||||||
setCodexWireApi,
|
setCodexWireApi,
|
||||||
|
setCodexModelName as setCodexModelNameInConfig,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
@@ -142,6 +144,36 @@ const codexApiFormatFromWireApi = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeCodexCatalogModelsForSave = (
|
||||||
|
models: CodexCatalogModel[],
|
||||||
|
): CodexCatalogModel[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: CodexCatalogModel[] = [];
|
||||||
|
|
||||||
|
for (const item of models) {
|
||||||
|
const model = item.model.trim();
|
||||||
|
if (!model || seen.has(model)) continue;
|
||||||
|
seen.add(model);
|
||||||
|
|
||||||
|
const displayName = item.displayName?.trim();
|
||||||
|
const rawContextWindow = String(item.contextWindow ?? "").replace(
|
||||||
|
/[^\d]/g,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const contextWindow = rawContextWindow
|
||||||
|
? Number.parseInt(rawContextWindow, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
normalized.push({
|
||||||
|
model,
|
||||||
|
...(displayName ? { displayName } : {}),
|
||||||
|
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ProviderFormProps {
|
export interface ProviderFormProps {
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
@@ -440,13 +472,13 @@ function ProviderFormFull({
|
|||||||
codexConfig,
|
codexConfig,
|
||||||
codexApiKey,
|
codexApiKey,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
codexModelName,
|
codexCatalogModels,
|
||||||
codexAuthError,
|
codexAuthError,
|
||||||
setCodexAuth,
|
setCodexAuth,
|
||||||
setCodexConfig,
|
setCodexConfig,
|
||||||
|
setCodexCatalogModels,
|
||||||
handleCodexApiKeyChange,
|
handleCodexApiKeyChange,
|
||||||
handleCodexBaseUrlChange,
|
handleCodexBaseUrlChange,
|
||||||
handleCodexModelNameChange,
|
|
||||||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||||||
resetCodexConfig,
|
resetCodexConfig,
|
||||||
} = useCodexConfigState({ initialData });
|
} = useCodexConfigState({ initialData });
|
||||||
@@ -476,10 +508,6 @@ function ProviderFormFull({
|
|||||||
const handleCodexConfigChange = useCallback(
|
const handleCodexConfigChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
originalHandleCodexConfigChange(value);
|
originalHandleCodexConfigChange(value);
|
||||||
const nextFormat = codexApiFormatFromWireApi(extractCodexWireApi(value));
|
|
||||||
if (nextFormat) {
|
|
||||||
setLocalCodexApiFormat(nextFormat);
|
|
||||||
}
|
|
||||||
debouncedValidate(value);
|
debouncedValidate(value);
|
||||||
},
|
},
|
||||||
[originalHandleCodexConfigChange, debouncedValidate],
|
[originalHandleCodexConfigChange, debouncedValidate],
|
||||||
@@ -488,6 +516,7 @@ function ProviderFormFull({
|
|||||||
const handleCodexApiFormatChange = useCallback(
|
const handleCodexApiFormatChange = useCallback(
|
||||||
(format: CodexApiFormat) => {
|
(format: CodexApiFormat) => {
|
||||||
setLocalCodexApiFormat(format);
|
setLocalCodexApiFormat(format);
|
||||||
|
// wire_api is always "responses" for Codex; format controls proxy-layer conversion
|
||||||
setCodexConfig((prev) => {
|
setCodexConfig((prev) => {
|
||||||
const updated = setCodexWireApi(prev, "responses");
|
const updated = setCodexWireApi(prev, "responses");
|
||||||
debouncedValidate(updated);
|
debouncedValidate(updated);
|
||||||
@@ -1109,14 +1138,32 @@ function ProviderFormFull({
|
|||||||
if (appId === "codex") {
|
if (appId === "codex") {
|
||||||
try {
|
try {
|
||||||
const authJson = JSON.parse(codexAuth);
|
const authJson = JSON.parse(codexAuth);
|
||||||
const normalizedCodexConfig =
|
let normalizedCodexConfig =
|
||||||
category !== "official" && (codexConfig ?? "").trim()
|
category !== "official" && (codexConfig ?? "").trim()
|
||||||
? setCodexWireApi(codexConfig ?? "", "responses")
|
? setCodexWireApi(codexConfig ?? "", "responses")
|
||||||
: (codexConfig ?? "");
|
: (codexConfig ?? "");
|
||||||
|
const normalizedCatalogModels =
|
||||||
|
category !== "official" && localCodexApiFormat === "openai_chat"
|
||||||
|
? normalizeCodexCatalogModelsForSave(codexCatalogModels)
|
||||||
|
: [];
|
||||||
|
// Sync first catalog row's model into config.toml so Codex uses it as default
|
||||||
|
if (normalizedCatalogModels.length > 0) {
|
||||||
|
normalizedCodexConfig = setCodexModelNameInConfig(
|
||||||
|
normalizedCodexConfig,
|
||||||
|
normalizedCatalogModels[0].model,
|
||||||
|
);
|
||||||
|
}
|
||||||
const configObj = {
|
const configObj = {
|
||||||
auth: authJson,
|
auth: authJson,
|
||||||
config: normalizedCodexConfig,
|
config: normalizedCodexConfig,
|
||||||
|
} as {
|
||||||
|
auth: unknown;
|
||||||
|
config: string;
|
||||||
|
modelCatalog?: { models: CodexCatalogModel[] };
|
||||||
};
|
};
|
||||||
|
if (normalizedCatalogModels.length > 0) {
|
||||||
|
configObj.modelCatalog = { models: normalizedCatalogModels };
|
||||||
|
}
|
||||||
settingsConfig = JSON.stringify(configObj);
|
settingsConfig = JSON.stringify(configObj);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
settingsConfig = values.settingsConfig.trim();
|
settingsConfig = values.settingsConfig.trim();
|
||||||
@@ -1937,9 +1984,8 @@ function ProviderFormFull({
|
|||||||
onAutoSelectChange={setEndpointAutoSelect}
|
onAutoSelectChange={setEndpointAutoSelect}
|
||||||
apiFormat={localCodexApiFormat}
|
apiFormat={localCodexApiFormat}
|
||||||
onApiFormatChange={handleCodexApiFormatChange}
|
onApiFormatChange={handleCodexApiFormatChange}
|
||||||
shouldShowModelField={category !== "official"}
|
catalogModels={codexCatalogModels}
|
||||||
modelName={codexModelName}
|
onCatalogModelsChange={setCodexCatalogModels}
|
||||||
onModelNameChange={handleCodexModelNameChange}
|
|
||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
extractCodexBaseUrl,
|
extractCodexBaseUrl,
|
||||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||||
extractCodexModelName,
|
|
||||||
setCodexModelName as setCodexModelNameInConfig,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
|
import type { CodexCatalogModel } from "@/types";
|
||||||
|
|
||||||
interface UseCodexConfigStateProps {
|
interface UseCodexConfigStateProps {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
@@ -22,11 +21,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
const [codexConfig, setCodexConfigState] = useState("");
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
const [codexApiKey, setCodexApiKey] = useState("");
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
const [codexModelName, setCodexModelName] = useState("");
|
const [codexCatalogModels, setCodexCatalogModels] = useState<
|
||||||
|
CodexCatalogModel[]
|
||||||
|
>([]);
|
||||||
const [codexAuthError, setCodexAuthError] = useState("");
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
|
|
||||||
const isUpdatingCodexBaseUrlRef = useRef(false);
|
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||||
const isUpdatingCodexModelNameRef = useRef(false);
|
|
||||||
|
|
||||||
// 初始化 Codex 配置(编辑模式)
|
// 初始化 Codex 配置(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,18 +45,38 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
: "";
|
: "";
|
||||||
setCodexConfigState(configStr);
|
setCodexConfigState(configStr);
|
||||||
|
|
||||||
|
const modelCatalog = (config as any).modelCatalog;
|
||||||
|
const rawCatalogModels = Array.isArray(modelCatalog?.models)
|
||||||
|
? modelCatalog.models
|
||||||
|
: [];
|
||||||
|
setCodexCatalogModels(
|
||||||
|
rawCatalogModels
|
||||||
|
.map((item: any) => ({
|
||||||
|
model: typeof item?.model === "string" ? item.model : "",
|
||||||
|
displayName:
|
||||||
|
typeof item?.displayName === "string"
|
||||||
|
? item.displayName
|
||||||
|
: typeof item?.display_name === "string"
|
||||||
|
? item.display_name
|
||||||
|
: "",
|
||||||
|
contextWindow:
|
||||||
|
typeof item?.contextWindow === "string" ||
|
||||||
|
typeof item?.contextWindow === "number"
|
||||||
|
? item.contextWindow
|
||||||
|
: typeof item?.context_window === "string" ||
|
||||||
|
typeof item?.context_window === "number"
|
||||||
|
? item.context_window
|
||||||
|
: "",
|
||||||
|
}))
|
||||||
|
.filter((item: CodexCatalogModel) => item.model.trim()),
|
||||||
|
);
|
||||||
|
|
||||||
// 提取 Base URL
|
// 提取 Base URL
|
||||||
const initialBaseUrl = extractCodexBaseUrl(configStr);
|
const initialBaseUrl = extractCodexBaseUrl(configStr);
|
||||||
if (initialBaseUrl) {
|
if (initialBaseUrl) {
|
||||||
setCodexBaseUrl(initialBaseUrl);
|
setCodexBaseUrl(initialBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 Model Name
|
|
||||||
const initialModelName = extractCodexModelName(configStr);
|
|
||||||
if (initialModelName) {
|
|
||||||
setCodexModelName(initialModelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 API Key
|
// 提取 API Key
|
||||||
try {
|
try {
|
||||||
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
@@ -77,15 +97,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
setCodexBaseUrl((prev) => (prev === extracted ? prev : extracted));
|
||||||
}, [codexConfig]);
|
}, [codexConfig]);
|
||||||
|
|
||||||
// 与 TOML 配置保持模型名称同步
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingCodexModelNameRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const extracted = extractCodexModelName(codexConfig) || "";
|
|
||||||
setCodexModelName((prev) => (prev === extracted ? prev : extracted));
|
|
||||||
}, [codexConfig]);
|
|
||||||
|
|
||||||
// 获取 API Key(从 auth JSON)
|
// 获取 API Key(从 auth JSON)
|
||||||
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||||
try {
|
try {
|
||||||
@@ -170,22 +181,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
[setCodexConfig],
|
[setCodexConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理 Codex Model Name 变化
|
// 处理 config 变化(同步 Base URL)
|
||||||
const handleCodexModelNameChange = useCallback(
|
|
||||||
(modelName: string) => {
|
|
||||||
const trimmed = modelName.trim();
|
|
||||||
setCodexModelName(trimmed);
|
|
||||||
|
|
||||||
isUpdatingCodexModelNameRef.current = true;
|
|
||||||
setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingCodexModelNameRef.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[setCodexConfig],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理 config 变化(同步 Base URL 和 Model Name)
|
|
||||||
const handleCodexConfigChange = useCallback(
|
const handleCodexConfigChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
// 归一化中文/全角/弯引号,避免 TOML 解析报错
|
||||||
@@ -198,35 +194,24 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
setCodexBaseUrl(extracted);
|
setCodexBaseUrl(extracted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUpdatingCodexModelNameRef.current) {
|
|
||||||
const extractedModel = extractCodexModelName(normalized) || "";
|
|
||||||
if (extractedModel !== codexModelName) {
|
|
||||||
setCodexModelName(extractedModel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[setCodexConfig, codexBaseUrl, codexModelName],
|
[setCodexConfig, codexBaseUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 重置配置(用于预设切换)
|
// 重置配置(用于预设切换)
|
||||||
const resetCodexConfig = useCallback(
|
const resetCodexConfig = useCallback(
|
||||||
(auth: Record<string, unknown>, config: string) => {
|
(
|
||||||
|
auth: Record<string, unknown>,
|
||||||
|
config: string,
|
||||||
|
modelCatalogModels: CodexCatalogModel[] = [],
|
||||||
|
) => {
|
||||||
const authString = JSON.stringify(auth, null, 2);
|
const authString = JSON.stringify(auth, null, 2);
|
||||||
setCodexAuth(authString);
|
setCodexAuth(authString);
|
||||||
setCodexConfig(config);
|
setCodexConfig(config);
|
||||||
|
setCodexCatalogModels(modelCatalogModels);
|
||||||
|
|
||||||
const baseUrl = extractCodexBaseUrl(config);
|
const baseUrl = extractCodexBaseUrl(config);
|
||||||
if (baseUrl) {
|
setCodexBaseUrl(baseUrl || "");
|
||||||
setCodexBaseUrl(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelName = extractCodexModelName(config);
|
|
||||||
if (modelName) {
|
|
||||||
setCodexModelName(modelName);
|
|
||||||
} else {
|
|
||||||
setCodexModelName("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 API Key
|
// 提取 API Key
|
||||||
try {
|
try {
|
||||||
@@ -247,13 +232,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
|||||||
codexConfig,
|
codexConfig,
|
||||||
codexApiKey,
|
codexApiKey,
|
||||||
codexBaseUrl,
|
codexBaseUrl,
|
||||||
codexModelName,
|
codexCatalogModels,
|
||||||
codexAuthError,
|
codexAuthError,
|
||||||
setCodexAuth,
|
setCodexAuth,
|
||||||
setCodexConfig,
|
setCodexConfig,
|
||||||
|
setCodexCatalogModels,
|
||||||
handleCodexApiKeyChange,
|
handleCodexApiKeyChange,
|
||||||
handleCodexBaseUrlChange,
|
handleCodexBaseUrlChange,
|
||||||
handleCodexModelNameChange,
|
|
||||||
handleCodexConfigChange,
|
handleCodexConfigChange,
|
||||||
resetCodexConfig,
|
resetCodexConfig,
|
||||||
getCodexAuthApiKey,
|
getCodexAuthApiKey,
|
||||||
|
|||||||
@@ -1080,12 +1080,30 @@
|
|||||||
"saveFailed": "Save failed: {{error}}",
|
"saveFailed": "Save failed: {{error}}",
|
||||||
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
|
"modelNameHint": "Specify the model to use, will be auto-updated in config.toml",
|
||||||
"modelName": "Model Name",
|
"modelName": "Model Name",
|
||||||
|
"localRoutingToggle": "Needs Local Routing",
|
||||||
|
"localRoutingOffHint": "Use this when the provider natively supports OpenAI Responses and the model can be used directly by Codex; Codex can connect to the provider without local routing.",
|
||||||
|
"localRoutingOnHint": "Use this for custom models such as DeepSeek or Kimi, or providers that only support Chat Completions; keep local routing running while in use.",
|
||||||
"upstreamModelName": "Upstream Model Name",
|
"upstreamModelName": "Upstream Model Name",
|
||||||
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; takeover writes a Codex-compatible local model and routing maps it back to this model.",
|
"upstreamModelNameHint": "For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.",
|
||||||
"modelNamePlaceholder": "e.g., gpt-5-codex",
|
"modelNamePlaceholder": "e.g., gpt-5-codex",
|
||||||
"contextWindow1M": "1M Context Window",
|
"contextWindow1M": "1M Context Window",
|
||||||
|
"contextWindow": "Context Window",
|
||||||
|
"contextWindowPlaceholder": "e.g., 128000",
|
||||||
"autoCompactLimit": "Auto Compact Limit",
|
"autoCompactLimit": "Auto Compact Limit",
|
||||||
"autoCompactLimitHint": "Auto-compacts history when context reaches this token limit"
|
"autoCompactLimitPlaceholder": "e.g., 90000",
|
||||||
|
"autoCompactLimitHint": "Auto-compacts history when context reaches this token limit",
|
||||||
|
"modelMetadataAdvanced": "Model Metadata Advanced Options",
|
||||||
|
"modelMetadataAdvancedHint": "Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.",
|
||||||
|
"modelMappingTitle": "Model Mapping",
|
||||||
|
"modelMappingHint": "Generates Codex model_catalog_json so /model can show these third-party model names; entries are saved exactly as listed. Codex must be restarted to refresh the model list after changes.",
|
||||||
|
"addCatalogModel": "Add Model",
|
||||||
|
"catalogDisplayName": "Display Name",
|
||||||
|
"catalogDisplayNamePlaceholder": "e.g., DeepSeek V4 Flash",
|
||||||
|
"catalogModelId": "Model ID",
|
||||||
|
"catalogModelPlaceholder": "e.g., deepseek-v4-flash",
|
||||||
|
"catalogColumnDisplay": "Menu Display Name",
|
||||||
|
"catalogColumnModel": "Actual Request Model",
|
||||||
|
"catalogColumnContext": "Context Window"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "Environment Variables (.env)",
|
"envFile": "Environment Variables (.env)",
|
||||||
|
|||||||
@@ -1080,12 +1080,30 @@
|
|||||||
"saveFailed": "保存に失敗しました: {{error}}",
|
"saveFailed": "保存に失敗しました: {{error}}",
|
||||||
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
|
"modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます",
|
||||||
"modelName": "モデル名",
|
"modelName": "モデル名",
|
||||||
|
"localRoutingToggle": "ローカルルーティングが必要",
|
||||||
|
"localRoutingOffHint": "プロバイダーが OpenAI Responses API をネイティブにサポートし、Codex がモデルを直接使用できる場合に適しています。Codex はローカルルーティングなしで接続できます。",
|
||||||
|
"localRoutingOnHint": "DeepSeek、Kimi などのカスタムモデル、または Chat Completions のみをサポートするプロバイダー向けです。利用中はローカルルーティングを起動したままにしてください。",
|
||||||
"upstreamModelName": "上流モデル名",
|
"upstreamModelName": "上流モデル名",
|
||||||
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。接管時は Codex 互換のローカルモデルを書き込み、ルーティングでこのモデルに戻します。",
|
"upstreamModelNameHint": "Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。",
|
||||||
"modelNamePlaceholder": "例: gpt-5-codex",
|
"modelNamePlaceholder": "例: gpt-5-codex",
|
||||||
"contextWindow1M": "1M コンテキストウィンドウ",
|
"contextWindow1M": "1M コンテキストウィンドウ",
|
||||||
|
"contextWindow": "コンテキストウィンドウ",
|
||||||
|
"contextWindowPlaceholder": "例: 128000",
|
||||||
"autoCompactLimit": "自動圧縮しきい値",
|
"autoCompactLimit": "自動圧縮しきい値",
|
||||||
"autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮"
|
"autoCompactLimitPlaceholder": "例: 90000",
|
||||||
|
"autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮",
|
||||||
|
"modelMetadataAdvanced": "モデルメタデータ詳細オプション",
|
||||||
|
"modelMetadataAdvancedHint": "未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。",
|
||||||
|
"modelMappingTitle": "モデルマッピング",
|
||||||
|
"modelMappingHint": "Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。",
|
||||||
|
"addCatalogModel": "モデルを追加",
|
||||||
|
"catalogDisplayName": "表示名",
|
||||||
|
"catalogDisplayNamePlaceholder": "例: DeepSeek V4 Flash",
|
||||||
|
"catalogModelId": "モデル ID",
|
||||||
|
"catalogModelPlaceholder": "例: deepseek-v4-flash",
|
||||||
|
"catalogColumnDisplay": "メニュー表示名",
|
||||||
|
"catalogColumnModel": "実際のリクエストモデル",
|
||||||
|
"catalogColumnContext": "コンテキストウィンドウ"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "環境変数 (.env)",
|
"envFile": "環境変数 (.env)",
|
||||||
|
|||||||
@@ -1080,12 +1080,30 @@
|
|||||||
"saveFailed": "保存失败: {{error}}",
|
"saveFailed": "保存失败: {{error}}",
|
||||||
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
|
"modelNameHint": "指定使用的模型,将自动更新到 config.toml 中",
|
||||||
"modelName": "模型名称",
|
"modelName": "模型名称",
|
||||||
|
"localRoutingToggle": "需要本地路由映射",
|
||||||
|
"localRoutingOffHint": "适合供应商原生支持 OpenAI Responses API 且模型在 Codex 中可直接使用的场景,请求可由 Codex 直连供应商。",
|
||||||
|
"localRoutingOnHint": "适合 DeepSeek、Kimi 等自定义模型或仅支持 Chat Completions 的供应商;使用时请保持本地路由开启。",
|
||||||
"upstreamModelName": "上游模型名称",
|
"upstreamModelName": "上游模型名称",
|
||||||
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;接管时 Codex 本地会使用兼容模型,并由路由映射回该模型。",
|
"upstreamModelNameHint": "Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。",
|
||||||
"modelNamePlaceholder": "例如: gpt-5-codex",
|
"modelNamePlaceholder": "例如: gpt-5-codex",
|
||||||
"contextWindow1M": "1M 上下文窗口",
|
"contextWindow1M": "1M 上下文窗口",
|
||||||
|
"contextWindow": "上下文窗口",
|
||||||
|
"contextWindowPlaceholder": "例如: 128000",
|
||||||
"autoCompactLimit": "压缩阈值",
|
"autoCompactLimit": "压缩阈值",
|
||||||
"autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史"
|
"autoCompactLimitPlaceholder": "例如: 90000",
|
||||||
|
"autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史",
|
||||||
|
"modelMetadataAdvanced": "模型元数据高级选项",
|
||||||
|
"modelMetadataAdvancedHint": "用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。",
|
||||||
|
"modelMappingTitle": "模型映射",
|
||||||
|
"modelMappingHint": "生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。",
|
||||||
|
"addCatalogModel": "添加模型",
|
||||||
|
"catalogDisplayName": "显示名称",
|
||||||
|
"catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash",
|
||||||
|
"catalogModelId": "模型 ID",
|
||||||
|
"catalogModelPlaceholder": "例如: deepseek-v4-flash",
|
||||||
|
"catalogColumnDisplay": "菜单显示名",
|
||||||
|
"catalogColumnModel": "实际请求模型",
|
||||||
|
"catalogColumnContext": "上下文窗口"
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "环境变量 (.env)",
|
"envFile": "环境变量 (.env)",
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export type ClaudeApiFormat =
|
|||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换
|
// - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换
|
||||||
export type CodexApiFormat = "openai_responses" | "openai_chat";
|
export type CodexApiFormat = "openai_responses" | "openai_chat";
|
||||||
|
|
||||||
|
export interface CodexCatalogModel {
|
||||||
|
model: string;
|
||||||
|
displayName?: string;
|
||||||
|
contextWindow?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
// Claude 认证字段类型
|
// Claude 认证字段类型
|
||||||
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeCodexCatalogModelsForSave } from "@/components/providers/forms/ProviderForm";
|
||||||
|
|
||||||
|
describe("ProviderForm Codex catalog helpers", () => {
|
||||||
|
it("normalizes catalog rows and removes empty or duplicate models", () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexCatalogModelsForSave([
|
||||||
|
{ model: " deepseek-v4-flash ", displayName: " DeepSeek " },
|
||||||
|
{ model: "deepseek-v4-flash", displayName: "Duplicate" },
|
||||||
|
{ model: "", displayName: "Empty" },
|
||||||
|
{ model: "kimi-k2", contextWindow: "128000 tokens" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ model: "deepseek-v4-flash", displayName: "DeepSeek" },
|
||||||
|
{ model: "kimi-k2", contextWindow: 128000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
extractCodexBaseUrl,
|
extractCodexBaseUrl,
|
||||||
extractCodexModelName,
|
extractCodexModelName,
|
||||||
|
extractCodexTopLevelInt,
|
||||||
|
removeCodexTopLevelField,
|
||||||
setCodexBaseUrl,
|
setCodexBaseUrl,
|
||||||
setCodexModelName,
|
setCodexModelName,
|
||||||
|
setCodexTopLevelInt,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
describe("Codex TOML utils", () => {
|
describe("Codex TOML utils", () => {
|
||||||
@@ -148,4 +151,50 @@ describe("Codex TOML utils", () => {
|
|||||||
expect(extractCodexBaseUrl(input)).toBe("https://api.example.com/v1");
|
expect(extractCodexBaseUrl(input)).toBe("https://api.example.com/v1");
|
||||||
expect(extractCodexModelName(input)).toBe("gpt-5");
|
expect(extractCodexModelName(input)).toBe("gpt-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads, writes, and removes top-level integer metadata fields", () => {
|
||||||
|
const input = [
|
||||||
|
'model_provider = "custom"',
|
||||||
|
'model = "deepseek-v4-flash"',
|
||||||
|
"",
|
||||||
|
"[model_providers.custom]",
|
||||||
|
'name = "DeepSeek"',
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const withContext = setCodexTopLevelInt(
|
||||||
|
input,
|
||||||
|
"model_context_window",
|
||||||
|
128000,
|
||||||
|
);
|
||||||
|
const withCompact = setCodexTopLevelInt(
|
||||||
|
withContext,
|
||||||
|
"model_auto_compact_token_limit",
|
||||||
|
90000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(extractCodexTopLevelInt(withCompact, "model_context_window")).toBe(
|
||||||
|
128000,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
extractCodexTopLevelInt(
|
||||||
|
withCompact,
|
||||||
|
"model_auto_compact_token_limit",
|
||||||
|
),
|
||||||
|
).toBe(90000);
|
||||||
|
expect(withCompact).toMatch(/^model_context_window = 128000$/m);
|
||||||
|
expect(withCompact).toMatch(
|
||||||
|
/^model_auto_compact_token_limit = 90000$/m,
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = removeCodexTopLevelField(
|
||||||
|
withCompact,
|
||||||
|
"model_context_window",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
extractCodexTopLevelInt(removed, "model_context_window"),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(removed).toContain("[model_providers.custom]");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user