Merge pull request #90 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-04 18:10:03 +08:00
commit 22386b8322
20 files changed
+1406 -224

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ src-tauri/target/
bin-work/
cpa-core/
agent_docs
agent_docs/
*.log
*.tmp
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.2.12",
"version": "0.2.13",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
+1 -1
View File
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.12"
version = "0.2.13"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
version = "0.2.12"
version = "0.2.13"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
@@ -0,0 +1,69 @@
{
"fallback_model": {
"context_window": 200000
},
"models": [
{ "slug": "gpt-5.6" },
{ "slug": "gpt-5.6-sol", "context_window": 372000 },
{ "slug": "gpt-5.6-terra", "context_window": 372000 },
{ "slug": "gpt-5.6-luna", "context_window": 372000 },
{ "slug": "gpt-5.5", "context_window": 272000 },
{ "slug": "gpt-5.5-pro" },
{ "slug": "gpt-5.4", "context_window": 272000 },
{ "slug": "gpt-5.4-pro" },
{ "slug": "gpt-5.4-mini", "context_window": 272000 },
{ "slug": "gpt-5.4-nano" },
{ "slug": "gpt-5.3-codex" },
{ "slug": "gpt-5.3-codex-spark", "context_window": 128000 },
{ "slug": "gpt-5.2" },
{ "slug": "gpt-5.2-pro" },
{ "slug": "gpt-5.2-codex" },
{ "slug": "gpt-5.1" },
{ "slug": "gpt-5.1-codex" },
{ "slug": "gpt-5.1-codex-mini" },
{ "slug": "gpt-5" },
{ "slug": "gpt-5-pro" },
{ "slug": "gpt-5-mini" },
{ "slug": "gpt-5-nano" },
{ "slug": "gpt-4.1" },
{ "slug": "gpt-4.1-mini" },
{ "slug": "gpt-4.1-nano" },
{ "slug": "o1" },
{ "slug": "o1-pro" },
{ "slug": "o3" },
{ "slug": "o3-mini" },
{ "slug": "o3-pro" },
{ "slug": "o4-mini" },
{ "slug": "codex-auto-review", "context_window": 272000 },
{ "slug": "claude-opus-5" },
{ "slug": "claude-opus-4-8" },
{ "slug": "claude-opus-4-7" },
{ "slug": "claude-opus-4-6" },
{ "slug": "claude-opus-4-5" },
{ "slug": "claude-opus-4-1" },
{ "slug": "claude-sonnet-4-6" },
{ "slug": "claude-sonnet-4-5" },
{ "slug": "claude-sonnet-4" },
{ "slug": "claude-haiku-4-5" },
{ "slug": "gemini-3.6-flash" },
{ "slug": "gemini-3.5-flash" },
{ "slug": "gemini-3.1-pro-preview" },
{ "slug": "gemini-3.1-flash-lite" },
{ "slug": "gemini-3-pro-preview" },
{ "slug": "gemini-3-flash-preview" },
{ "slug": "gemini-2.5-pro" },
{ "slug": "gemini-2.5-flash" },
{ "slug": "gemini-2.5-flash-lite" },
{ "slug": "deepseek-v4-flash", "display_name": "DeepSeek V4 Flash", "context_window": 1000000, "claude_code_1m": true, "claude_code_effort_level": "max" },
{ "slug": "deepseek-v4-pro", "display_name": "DeepSeek V4 Pro", "context_window": 1000000, "claude_code_1m": true, "claude_code_effort_level": "max" },
{ "slug": "deepseek-chat", "display_name": "DeepSeek Chat" },
{ "slug": "deepseek-reasoner", "display_name": "DeepSeek Reasoner" },
{ "slug": "grok-4.5" },
{ "slug": "grok-4.3" },
{ "slug": "grok-4.20" },
{ "slug": "grok-4" },
{ "slug": "grok-4-1-fast" },
{ "slug": "grok-3" },
{ "slug": "grok-3-mini" }
]
}
+235
View File
@@ -0,0 +1,235 @@
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
const MODEL_CATALOG_JSON: &str = include_str!("../resources/claude_models/model-catalog.json");
static CATALOG_STATE: OnceLock<Result<ClaudeCatalog, String>> = OnceLock::new();
#[derive(Clone, Debug)]
struct ClaudeCatalog {
fallback_context_window: u64,
context_windows: HashMap<String, u64>,
display_names: HashMap<String, String>,
claude_code_1m_models: HashSet<String>,
claude_code_effort_levels: HashMap<String, String>,
}
pub(crate) fn context_window_for(
model_name: &str,
runtime_context_window: Option<u64>,
) -> Result<u64, String> {
let catalog = catalog_state()?;
Ok(catalog
.context_windows
.get(&normalize_id(model_name))
.copied()
.or(runtime_context_window)
.unwrap_or(catalog.fallback_context_window))
}
pub(crate) fn display_name_for(model_name: &str) -> Result<Option<String>, String> {
let catalog = catalog_state()?;
Ok(catalog
.display_names
.get(&normalize_id(model_name))
.cloned())
}
pub(crate) fn supports_claude_code_1m(model_name: &str) -> Result<bool, String> {
let catalog = catalog_state()?;
Ok(catalog
.claude_code_1m_models
.contains(&normalize_id(model_name)))
}
pub(crate) fn claude_code_effort_level_for(model_name: &str) -> Result<Option<String>, String> {
let catalog = catalog_state()?;
Ok(catalog
.claude_code_effort_levels
.get(&normalize_id(model_name))
.cloned())
}
fn catalog_state() -> Result<&'static ClaudeCatalog, String> {
CATALOG_STATE
.get_or_init(|| parse_catalog(MODEL_CATALOG_JSON))
.as_ref()
.map_err(Clone::clone)
}
fn parse_catalog(content: &str) -> Result<ClaudeCatalog, String> {
let root: Value = serde_json::from_str(content)
.map_err(|error| format!("解析内置 Claude model-catalog.json 失败: {error}"))?;
let root = root
.as_object()
.ok_or_else(|| "内置 Claude model-catalog.json 根节点必须是对象".to_string())?;
let fallback_context_window = root
.get("fallback_model")
.and_then(Value::as_object)
.and_then(|model| model.get("context_window"))
.and_then(positive_u64)
.ok_or_else(|| {
"内置 Claude model-catalog.json 缺少有效的 fallback_model.context_window".to_string()
})?;
let models = root
.get("models")
.and_then(Value::as_array)
.ok_or_else(|| "内置 Claude model-catalog.json 必须包含 models 数组".to_string())?;
let mut context_windows = HashMap::with_capacity(models.len());
let mut display_names = HashMap::with_capacity(models.len());
let mut claude_code_1m_models = HashSet::with_capacity(models.len());
let mut claude_code_effort_levels = HashMap::with_capacity(models.len());
let mut model_ids = HashSet::with_capacity(models.len());
for (index, model) in models.iter().enumerate() {
let model = model.as_object().ok_or_else(|| {
format!(
"内置 Claude model-catalog.json 第 {} 个模型必须是对象",
index + 1
)
})?;
let slug = model
.get("slug")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!(
"内置 Claude model-catalog.json 第 {} 个模型的 slug 不能为空",
index + 1
)
})?;
let key = normalize_id(slug);
if !model_ids.insert(key.clone()) {
return Err(format!(
"内置 Claude model-catalog.json 模型 slug 大小写重复: {slug}"
));
}
if let Some(context_window) = model.get("context_window").and_then(positive_u64) {
context_windows.insert(key.clone(), context_window);
}
if let Some(display_name) = model
.get("display_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(slug))
{
display_names.insert(key.clone(), display_name.to_string());
}
if model
.get("claude_code_1m")
.and_then(Value::as_bool)
.unwrap_or(false)
{
claude_code_1m_models.insert(key.clone());
}
if let Some(effort_level) = model
.get("claude_code_effort_level")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
claude_code_effort_levels.insert(key, effort_level.to_string());
}
}
Ok(ClaudeCatalog {
fallback_context_window,
context_windows,
display_names,
claude_code_1m_models,
claude_code_effort_levels,
})
}
fn positive_u64(value: &Value) -> Option<u64> {
value
.as_u64()
.or_else(|| {
value
.as_i64()
.filter(|value| *value > 0)
.map(|value| value as u64)
})
.or_else(|| value.as_str()?.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
}
fn normalize_id(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::{parse_catalog, MODEL_CATALOG_JSON};
#[test]
fn bundled_catalog_marks_deepseek_v4_as_1m() {
let catalog = parse_catalog(MODEL_CATALOG_JSON).unwrap();
assert_eq!(catalog.context_windows["deepseek-v4-flash"], 1_000_000);
assert_eq!(catalog.context_windows["deepseek-v4-pro"], 1_000_000);
assert!(catalog.claude_code_1m_models.contains("deepseek-v4-flash"));
assert!(catalog.claude_code_1m_models.contains("deepseek-v4-pro"));
assert_eq!(
catalog.claude_code_effort_levels["deepseek-v4-flash"],
"max"
);
assert_eq!(catalog.claude_code_effort_levels["deepseek-v4-pro"], "max");
assert!(!catalog.claude_code_1m_models.contains("gpt-5.6-luna"));
assert_eq!(
catalog.display_names["deepseek-v4-flash"],
"DeepSeek V4 Flash"
);
assert_eq!(catalog.display_names["deepseek-v4-pro"], "DeepSeek V4 Pro");
}
#[test]
fn catalog_prefers_file_override_over_runtime_metadata() {
let catalog = parse_catalog(
r#"{
"fallback_model": {"context_window": 200000},
"models": [{
"slug": "gpt-test",
"display_name": "GPT Test",
"context_window": 272000,
"claude_code_1m": true,
"claude_code_effort_level": "max"
}]
}"#,
)
.unwrap();
assert_eq!(catalog.context_windows["gpt-test"], 272000);
assert_eq!(catalog.display_names["gpt-test"], "GPT Test");
assert!(catalog.claude_code_1m_models.contains("gpt-test"));
assert_eq!(catalog.claude_code_effort_levels["gpt-test"], "max");
}
#[test]
fn catalog_rejects_duplicate_model_ids() {
let error = parse_catalog(
r#"{
"fallback_model": {"context_window": 200000},
"models": [
{"slug": "gpt-test", "context_window": 272000},
{"slug": "GPT-TEST", "context_window": 128000}
]
}"#,
)
.unwrap_err();
assert!(error.contains("slug 大小写重复"));
}
#[test]
fn catalog_allows_model_entries_without_context_override() {
let catalog = parse_catalog(
r#"{
"fallback_model": {"context_window": 200000},
"models": [{"slug": "runtime-model"}]
}"#,
)
.unwrap();
assert!(catalog.context_windows.is_empty());
assert!(catalog.display_names.is_empty());
assert!(catalog.claude_code_1m_models.is_empty());
assert!(catalog.claude_code_effort_levels.is_empty());
}
}
+507 -53
View File
@@ -1,5 +1,6 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod claude_catalog;
mod codex_catalog;
mod codex_sessions;
mod usage;
@@ -2287,6 +2288,17 @@ async fn fetch_prepared_agent_models(
if matches!(client, AgentClient::ClaudeCode | AgentClient::ClaudeDesktop) {
let content = fetch_management_config_yaml(config).await?;
mark_configured_agent_model_aliases(&mut models, &content)?;
for model in &mut models {
model.context_window = Some(claude_catalog::context_window_for(
&model.name,
model.context_window,
)?);
if !model.is_alias {
if let Some(display_name) = claude_catalog::display_name_for(&model.name)? {
model.alias = Some(display_name);
}
}
}
}
Ok(PreparedAgentModels {
models,
@@ -2754,24 +2766,38 @@ async fn launch_agent(
.ok_or_else(|| "Claude Code model environment is not configured".to_string())?;
let prepared = fetch_prepared_agent_models(client, &config).await?;
let max_context_tokens = claude_code_max_context_tokens(&mappings, &prepared.models)?;
let max_context_override =
(!claude_code_model_supports_1m(&prepared.models, &mappings.sonnet)?)
.then_some(max_context_tokens);
{
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "Claude Code configuration lock is poisoned".to_string())?;
update_claude_code_context_window(&claude_config_path, max_context_tokens)?;
update_claude_code_context_window(&claude_config_path, max_context_override)?;
}
let environment = claude_code_launch_environment(
&format!("http://127.0.0.1:{port}"),
effective_agent_api_key(&config),
&mappings,
&prepared.models,
max_context_tokens,
);
)?;
let mut environment_to_remove = vec!["ANTHROPIC_API_KEY"];
if !environment
.iter()
.any(|(key, _)| key == "CLAUDE_CODE_EFFORT_LEVEL")
{
environment_to_remove.push("CLAUDE_CODE_EFFORT_LEVEL");
}
if max_context_override.is_none() {
environment_to_remove.push(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV);
}
return launch_cli_agent(
&executable,
client.name(),
&selected_working_directory,
&environment,
&["ANTHROPIC_API_KEY"],
&environment_to_remove,
);
}
launch_cli_agent(&executable, client.name(), &home, &[], &[])
@@ -5051,6 +5077,7 @@ fn inspect_claude_agent_config(
.or_else(|| root.get("model").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(strip_claude_code_context_suffix)
.map(str::to_string);
Ok((configured, model))
}
@@ -5080,6 +5107,7 @@ fn inspect_claude_code_model_mappings(
.map(str::trim)
.filter(|value| !value.is_empty())
.or(fallback)
.map(strip_claude_code_context_suffix)
.map(str::to_string)
};
let Some(opus) = read_model("ANTHROPIC_DEFAULT_OPUS_MODEL") else {
@@ -5098,7 +5126,36 @@ fn inspect_claude_code_model_mappings(
}))
}
fn strip_claude_code_context_suffix(value: &str) -> &str {
let mut value = value.trim();
loop {
let Some(suffix_start) = value.len().checked_sub(4) else {
return value;
};
if !value[suffix_start..].eq_ignore_ascii_case("[1m]") {
return value;
}
value = value[..suffix_start].trim_end();
}
}
fn agent_model_source_name<'a>(models: &'a [AgentModelOption], model_name: &'a str) -> &'a str {
let model_name = strip_claude_code_context_suffix(model_name);
let Some(model) = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))
else {
return model_name;
};
if model.is_alias {
model.alias.as_deref().unwrap_or(model.name.as_str())
} else {
model.name.as_str()
}
}
fn agent_model_context_window(models: &[AgentModelOption], model_name: &str) -> Option<u64> {
let model_name = strip_claude_code_context_suffix(model_name);
let selected = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))?;
@@ -5126,46 +5183,169 @@ fn claude_code_max_context_tokens(
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
) -> Result<u64, String> {
[
("Opus", mappings.opus.as_str()),
("Sonnet", mappings.sonnet.as_str()),
("Haiku", mappings.haiku.as_str()),
let model = mappings.sonnet.as_str();
let context_window = agent_model_context_window(models, model)
.ok_or_else(|| format!("CPA 模型 API 未返回 Claude Code 主模型 {model} 的上下文窗口"))?;
if context_window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
&& !claude_code_model_supports_1m(models, model)?
{
return Ok(DEFAULT_CLAUDE_CONTEXT_WINDOW);
}
Ok(context_window)
}
fn agent_model_display_name<'a>(models: &'a [AgentModelOption], model_name: &'a str) -> &'a str {
let model_name = strip_claude_code_context_suffix(model_name);
let Some(model) = models
.iter()
.find(|model| model.name.eq_ignore_ascii_case(model_name))
else {
return model_name;
};
if model.is_alias {
model.name.as_str()
} else {
model.alias.as_deref().unwrap_or(model.name.as_str())
}
}
fn claude_code_model_supports_1m(
models: &[AgentModelOption],
model_name: &str,
) -> Result<bool, String> {
claude_catalog::supports_claude_code_1m(agent_model_source_name(models, model_name))
}
fn claude_code_model_effort_level(
models: &[AgentModelOption],
model_name: &str,
) -> Result<Option<String>, String> {
claude_catalog::claude_code_effort_level_for(agent_model_source_name(models, model_name))
}
fn claude_code_model_setting(
models: &[AgentModelOption],
model_name: &str,
enable_1m_variant: bool,
) -> Result<String, String> {
let model_name = strip_claude_code_context_suffix(model_name);
if enable_1m_variant && claude_code_model_supports_1m(models, model_name)? {
Ok(format!("{model_name}[1m]"))
} else {
Ok(model_name.to_string())
}
}
fn claude_code_model_settings(
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
) -> Result<ClaudeDesktopModelMappings, String> {
Ok(ClaudeDesktopModelMappings {
opus: claude_code_model_setting(models, &mappings.opus, true)?,
sonnet: claude_code_model_setting(models, &mappings.sonnet, true)?,
haiku: claude_code_model_setting(models, &mappings.haiku, false)?,
})
}
fn format_context_window(context_window: u64) -> String {
if context_window % 1_000_000 == 0 {
format!("{}M", context_window / 1_000_000)
} else if context_window % 1_000 == 0 {
format!("{}K", context_window / 1_000)
} else {
context_window.to_string()
}
}
fn claude_code_model_presentation(
models: &[AgentModelOption],
model_name: &str,
role: Option<&str>,
) -> (String, String) {
let display_name = agent_model_display_name(models, model_name);
let context_window =
agent_model_context_window(models, model_name).unwrap_or(DEFAULT_CLAUDE_CONTEXT_WINDOW);
let context_label = format_context_window(context_window);
let name = match role {
Some(role) => format!("{display_name} ({role}, {context_label} context)"),
None => format!("{display_name} ({context_label} context)"),
};
let description = format!("CPA model {model_name} - {context_label} context window");
(name, description)
}
fn claude_code_model_presentation_environment(
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
) -> Result<Vec<(String, String)>, String> {
let opus = claude_code_model_presentation(models, &mappings.opus, Some("Opus mapping"));
let sonnet = claude_code_model_presentation(models, &mappings.sonnet, Some("Sonnet mapping"));
let haiku = claude_code_model_presentation(models, &mappings.haiku, Some("Haiku mapping"));
let fable = claude_code_model_presentation(models, &mappings.sonnet, Some("Fable mapping"));
let custom = claude_code_model_presentation(models, &mappings.sonnet, None);
let custom_model = claude_code_model_setting(models, &mappings.sonnet, true)?;
Ok([
("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", opus.0),
("ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", opus.1),
("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", sonnet.0),
("ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", sonnet.1),
("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", haiku.0),
("ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", haiku.1),
("ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", fable.0),
("ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", fable.1),
("ANTHROPIC_CUSTOM_MODEL_OPTION", custom_model),
("ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", custom.0),
("ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", custom.1),
]
.into_iter()
.map(|(role, model)| {
agent_model_context_window(models, model).ok_or_else(|| {
format!("CPA 模型 API 未返回 Claude Code {role} 映射模型 {model} 的上下文窗口")
})
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.min()
.ok_or_else(|| "CPA 模型 API 未返回 Claude Code 上下文窗口".to_string())
.map(|(key, value)| (key.to_string(), value))
.collect())
}
fn claude_code_launch_environment(
base_url: &str,
api_key: &str,
mappings: &ClaudeDesktopModelMappings,
models: &[AgentModelOption],
max_context_tokens: u64,
) -> Vec<(String, String)> {
) -> Result<Vec<(String, String)>, String> {
let model_settings = claude_code_model_settings(mappings, models)?;
let subagent_model = strip_claude_code_context_suffix(&mappings.haiku).to_string();
let mut environment = [
("ANTHROPIC_BASE_URL", base_url),
("ANTHROPIC_AUTH_TOKEN", api_key),
("ANTHROPIC_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()),
("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()),
("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_MODEL", model_settings.sonnet.as_str()),
(
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
model_settings.haiku.as_str(),
),
(
"ANTHROPIC_DEFAULT_SONNET_MODEL",
model_settings.sonnet.as_str(),
),
("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()),
(
"ANTHROPIC_DEFAULT_FABLE_MODEL",
model_settings.sonnet.as_str(),
),
("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()),
]
.into_iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<_>>();
environment.push((
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
max_context_tokens.to_string(),
));
environment
environment.extend(claude_code_model_presentation_environment(
mappings, models,
)?);
if let Some(effort_level) = claude_code_model_effort_level(models, &mappings.sonnet)? {
environment.push(("CLAUDE_CODE_EFFORT_LEVEL".to_string(), effort_level));
}
if !claude_code_model_supports_1m(models, &mappings.sonnet)? {
environment.push((
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
max_context_tokens.to_string(),
));
}
Ok(environment)
}
fn inspect_claude_desktop_agent_config(
@@ -5645,29 +5825,56 @@ fn build_claude_agent_config(
.cloned()
.unwrap_or_else(|| ClaudeDesktopModelMappings::all(model));
let max_context_tokens = claude_code_max_context_tokens(&mappings, models)?;
let use_max_context_override = !claude_code_model_supports_1m(models, &mappings.sonnet)?;
let model_settings = claude_code_model_settings(&mappings, models)?;
let subagent_model = strip_claude_code_context_suffix(&mappings.haiku).to_string();
let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?;
let env = ensure_json_object_entry(root, "env");
env.remove("ANTHROPIC_API_KEY");
env.remove("CLAUDE_CODE_EFFORT_LEVEL");
env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV);
for (key, value) in [
("ANTHROPIC_BASE_URL", base_url),
("ANTHROPIC_AUTH_TOKEN", api_key),
("ANTHROPIC_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_DEFAULT_HAIKU_MODEL", mappings.haiku.as_str()),
("ANTHROPIC_DEFAULT_SONNET_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_DEFAULT_OPUS_MODEL", mappings.opus.as_str()),
("ANTHROPIC_DEFAULT_FABLE_MODEL", mappings.sonnet.as_str()),
("ANTHROPIC_MODEL", model_settings.sonnet.as_str()),
(
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
model_settings.haiku.as_str(),
),
(
"ANTHROPIC_DEFAULT_SONNET_MODEL",
model_settings.sonnet.as_str(),
),
("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()),
(
"ANTHROPIC_DEFAULT_FABLE_MODEL",
model_settings.sonnet.as_str(),
),
("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.as_str()),
] {
env.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
env.insert(
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
serde_json::Value::String(max_context_tokens.to_string()),
);
if use_max_context_override {
env.insert(
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
serde_json::Value::String(max_context_tokens.to_string()),
);
}
for (key, value) in claude_code_model_presentation_environment(&mappings, models)? {
env.insert(key, serde_json::Value::String(value));
}
if let Some(effort_level) = effort_level {
env.insert(
"CLAUDE_CODE_EFFORT_LEVEL".to_string(),
serde_json::Value::String(effort_level),
);
}
root.insert(
"model".to_string(),
serde_json::Value::String(mappings.sonnet),
serde_json::Value::String(model_settings.sonnet),
);
let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone()))
.map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?;
@@ -5692,7 +5899,10 @@ fn remove_claude_code_conflicting_api_key(path: &Path) -> Result<bool, String> {
})
}
fn update_claude_code_context_window(path: &Path, max_context_tokens: u64) -> Result<bool, String> {
fn update_claude_code_context_window(
path: &Path,
max_context_tokens: Option<u64>,
) -> Result<bool, String> {
update_agent_json_file(path, "Claude Code configuration", |root| {
let managed = root
.get("env")
@@ -5703,8 +5913,11 @@ fn update_claude_code_context_window(path: &Path, max_context_tokens: u64) -> Re
if !managed {
return false;
}
let expected = max_context_tokens.to_string();
let env = ensure_json_object_entry(root, "env");
let Some(max_context_tokens) = max_context_tokens else {
return env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV).is_some();
};
let expected = max_context_tokens.to_string();
if env
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.and_then(serde_json::Value::as_str)
@@ -5814,10 +6027,7 @@ fn build_claude_desktop_profile(
mappings: Option<&ClaudeDesktopModelMappings>,
) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?;
root.insert(
"coworkEgressAllowedHosts".to_string(),
serde_json::json!(["*"]),
);
root.remove("coworkEgressAllowedHosts");
root.insert(
"disableDeploymentModeChooser".to_string(),
serde_json::json!(true),
@@ -5879,9 +6089,15 @@ fn claude_desktop_inference_model(
route_model
}),
);
if context_window.is_some_and(|window| window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW) {
entry.insert("supports1m".to_string(), serde_json::json!(true));
entry.insert("prefer1m".to_string(), serde_json::json!(true));
if let Some(context_window) = context_window {
entry.insert(
"contextWindow".to_string(),
serde_json::json!(context_window),
);
if context_window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW {
entry.insert("supports1m".to_string(), serde_json::json!(true));
entry.insert("prefer1m".to_string(), serde_json::json!(true));
}
}
serde_json::Value::Object(entry)
}
@@ -19855,7 +20071,9 @@ model:
#[test]
fn claude_agent_config_preserves_existing_fields() {
let rendered = build_claude_agent_config(
Some(r#"{"theme":"dark","env":{"KEEP":"yes","ANTHROPIC_API_KEY":"legacy-key"}}"#),
Some(
r#"{"theme":"dark","env":{"KEEP":"yes","ANTHROPIC_API_KEY":"legacy-key","CLAUDE_CODE_EFFORT_LEVEL":"max"}}"#,
),
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"claude-test",
@@ -19871,9 +20089,46 @@ model:
assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:8317");
assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY);
assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test");
assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none());
assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test");
assert_eq!(
value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"],
"claude-test (200K context)"
);
assert_eq!(value["model"], "claude-test");
}
#[test]
fn claude_code_inspection_normalizes_1m_suffix() {
let directory = agent_test_home("claude-code-1m-inspection");
let path = directory.join("settings.json");
fs::write(
&path,
r#"{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317",
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_MODEL": "deepseek-v4-pro[1m]",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1m]",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash"
},
"model": "deepseek-v4-pro[1m]"
}"#,
)
.unwrap();
let (configured, model) = inspect_claude_agent_config(&path, 8317, "test-key").unwrap();
let mappings = inspect_claude_code_model_mappings(&path).unwrap().unwrap();
assert!(configured);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
assert_eq!(mappings.opus, "deepseek-v4-pro");
assert_eq!(mappings.sonnet, "deepseek-v4-pro");
assert_eq!(mappings.haiku, "deepseek-v4-flash");
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn claude_code_role_mappings_drive_settings_and_launch_environment() {
let mappings = ClaudeDesktopModelMappings {
@@ -19904,7 +20159,7 @@ model:
name: mappings.haiku.clone(),
alias: None,
is_alias: false,
context_window: Some(512_000),
context_window: Some(128_000),
},
];
let rendered = build_claude_agent_config(
@@ -19922,13 +20177,19 @@ model:
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "gpt-haiku");
assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "272000");
assert_eq!(
value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"],
"gpt-sonnet (272K context)"
);
let environment = claude_code_launch_environment(
"http://127.0.0.1:8317",
"test-key",
&mappings,
&models,
claude_code_max_context_tokens(&mappings, &models).unwrap(),
);
)
.unwrap();
let read = |key: &str| {
environment
.iter()
@@ -19941,9 +20202,178 @@ model:
assert_eq!(read("ANTHROPIC_DEFAULT_OPUS_MODEL"), Some("gpt-opus"));
assert_eq!(read("ANTHROPIC_DEFAULT_SONNET_MODEL"), Some("gpt-sonnet"));
assert_eq!(read("ANTHROPIC_DEFAULT_HAIKU_MODEL"), Some("gpt-haiku"));
assert_eq!(read("CLAUDE_CODE_SUBAGENT_MODEL"), Some("gpt-haiku"));
assert_eq!(read("CLAUDE_CODE_EFFORT_LEVEL"), None);
assert_eq!(
read("ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"),
Some("gpt-sonnet (272K context)")
);
assert_eq!(read(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV), Some("272000"));
}
#[test]
fn claude_code_deepseek_picker_shows_catalog_name_and_1m_context() {
let mappings = ClaudeDesktopModelMappings {
opus: "deepseek-v4-pro".to_string(),
sonnet: "deepseek-v4-pro".to_string(),
haiku: "deepseek-v4-flash".to_string(),
};
let models = vec![
AgentModelOption {
name: "deepseek-v4-pro".to_string(),
alias: Some("DeepSeek V4 Pro".to_string()),
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
name: "deepseek-v4-flash".to_string(),
alias: Some("DeepSeek V4 Flash".to_string()),
is_alias: false,
context_window: Some(1_000_000),
},
];
let rendered = build_claude_agent_config(
None,
"http://127.0.0.1:8317",
"test-key",
"deepseek-v4-pro",
&models,
Some(&mappings),
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert!(value["env"]
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.is_none());
assert_eq!(value["env"]["ANTHROPIC_MODEL"], "deepseek-v4-pro[1m]");
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"],
"deepseek-v4-pro[1m]"
);
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"],
"deepseek-v4-pro[1m]"
);
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"],
"deepseek-v4-flash"
);
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL"],
"deepseek-v4-pro[1m]"
);
assert_eq!(
value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"],
"deepseek-v4-flash"
);
assert_eq!(value["env"]["CLAUDE_CODE_EFFORT_LEVEL"], "max");
assert_eq!(
value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION"],
"deepseek-v4-pro[1m]"
);
assert_eq!(
value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"],
"DeepSeek V4 Pro (1M context)"
);
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL_NAME"],
"DeepSeek V4 Pro (Fable mapping, 1M context)"
);
assert_eq!(value["model"], "deepseek-v4-pro[1m]");
let environment = claude_code_launch_environment(
"http://127.0.0.1:8317",
"test-key",
&mappings,
&models,
claude_code_max_context_tokens(&mappings, &models).unwrap(),
)
.unwrap();
assert!(!environment
.iter()
.any(|(key, _)| key == CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV));
}
#[test]
fn claude_code_1m_suffix_requires_explicit_catalog_support() {
let models = vec![
AgentModelOption {
name: "deepseek-v4-flash".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
name: "gpt-runtime-1m".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
];
assert_eq!(
claude_code_model_setting(&models, "deepseek-v4-flash", true).unwrap(),
"deepseek-v4-flash[1m]"
);
assert_eq!(
claude_code_model_setting(&models, "deepseek-v4-flash", false).unwrap(),
"deepseek-v4-flash"
);
assert_eq!(
claude_code_model_setting(&models, "gpt-runtime-1m", true).unwrap(),
"gpt-runtime-1m"
);
assert_eq!(
claude_code_max_context_tokens(
&ClaudeDesktopModelMappings::all("gpt-runtime-1m"),
&models,
)
.unwrap(),
DEFAULT_CLAUDE_CONTEXT_WINDOW
);
}
#[test]
fn claude_code_context_window_follows_primary_model_alias_source() {
let mappings = ClaudeDesktopModelMappings {
opus: "small-model".to_string(),
sonnet: "primary-alias".to_string(),
haiku: "large-model".to_string(),
};
let models = vec![
AgentModelOption {
name: mappings.opus.clone(),
alias: None,
is_alias: false,
context_window: Some(128_000),
},
AgentModelOption {
name: "primary-model".to_string(),
alias: None,
is_alias: false,
context_window: Some(372_000),
},
AgentModelOption {
name: mappings.sonnet.clone(),
alias: Some("primary-model".to_string()),
is_alias: true,
context_window: Some(200_000),
},
AgentModelOption {
name: mappings.haiku.clone(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
];
assert_eq!(
claude_code_max_context_tokens(&mappings, &models).unwrap(),
372_000
);
}
#[test]
fn claude_code_uses_200k_when_cpa_context_metadata_is_missing() {
let mappings = ClaudeDesktopModelMappings::all("custom-model");
@@ -19971,8 +20401,8 @@ model:
.unwrap();
assert!(remove_claude_code_conflicting_api_key(&path).unwrap());
assert!(update_claude_code_context_window(&path, 272_000).unwrap());
assert!(!update_claude_code_context_window(&path, 272_000).unwrap());
assert!(update_claude_code_context_window(&path, Some(272_000)).unwrap());
assert!(!update_claude_code_context_window(&path, Some(272_000)).unwrap());
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["env"].get("ANTHROPIC_API_KEY").is_none());
@@ -19981,6 +20411,26 @@ model:
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn claude_code_1m_variant_removes_global_context_override() {
let directory = agent_test_home("claude-code-remove-context-override");
let path = directory.join("settings.json");
fs::write(
&path,
r#"{"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:8317","CLAUDE_CODE_MAX_CONTEXT_TOKENS":"1000000"}}"#,
)
.unwrap();
assert!(update_claude_code_context_window(&path, None).unwrap());
assert!(!update_claude_code_context_window(&path, None).unwrap());
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(value["env"]
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.is_none());
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn claude_code_working_directory_must_be_absolute_and_exist() {
let directory = agent_test_home("claude-code-working-directory");
@@ -20175,7 +20625,7 @@ model:
#[test]
fn claude_desktop_config_builds_gateway_profile_and_index() {
let profile = build_claude_desktop_profile(
Some(r#"{"keep":true}"#),
Some(r#"{"keep":true,"coworkEgressAllowedHosts":["*"]}"#),
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"claude-sonnet-test",
@@ -20193,6 +20643,7 @@ model:
let meta: serde_json::Value = serde_json::from_str(&meta).unwrap();
assert_eq!(profile["keep"], true);
assert!(profile.get("coworkEgressAllowedHosts").is_none());
assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY);
assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317");
assert_eq!(
@@ -20257,12 +20708,14 @@ model:
serde_json::json!([
{
"name": CLAUDE_DESKTOP_OPUS_MODEL_ID,
"contextWindow": 1000000,
"supports1m": true,
"prefer1m": true
},
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID },
{ "name": CLAUDE_DESKTOP_SONNET_MODEL_ID, "contextWindow": 272000 },
{
"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID,
"contextWindow": 1000000,
"supports1m": true,
"prefer1m": true
}
@@ -20722,6 +21175,7 @@ oauth-model-alias:
profile["inferenceModels"],
serde_json::json!([{
"name": "gpt-high",
"contextWindow": 1_000_000,
"supports1m": true,
"prefer1m": true
}])
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EasyCLIProxyAPI",
"version": "0.2.12",
"version": "0.2.13",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",
+7
View File
@@ -1,6 +1,10 @@
import type { MessageKey } from './resources';
export const jaOverrides = {
'apiAccess.models.add': 'カスタムモデルを追加',
'apiAccess.models.namePlaceholder': 'モデル名(例: gpt-4o)',
'apiAccess.models.aliasPlaceholder': 'モデル別名(任意)',
'apiAccess.models.remove': 'モデルを削除',
'app.language': '言語',
'app.theme.light': 'ライトモード',
'app.theme.dark': 'ダークモード',
@@ -206,6 +210,7 @@ export const jaOverrides = {
'aliases.error.emptyEffort': '推論レベルを入力してください',
'aliases.error.emptyOptions': '推論レベルを設定するか、Fast を有効にしてください',
'aliases.error.unsupportedEffort': 'このモデルでは推論レベルを設定できないため、Fast オプションのみ使用できます',
'aliases.error.unsupportedFast': 'このモデルでは Fast を利用できません',
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
'aliases.createdCombined': '{alias} を作成し、推論レベル {effort} と Fast を有効にしました',
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルと Fast の上書きルールも削除されます。',
@@ -237,6 +242,7 @@ export const jaOverrides = {
'aliases.customHelp': '対象モデルが実際に対応するレベル名のみ入力してください',
'aliases.aliasName.title': 'エイリアスモデル名',
'aliases.aliasName.description': '新しい入口のみを追加し、元のモデル名は変更しません',
'aliases.aliasName.autoDescription': 'モデルと選択したオプションから自動生成します。元のモデル名は変更せず編集できます',
'aliases.aliasName.example': '例:{model}-{effort}',
'aliases.aliasName.selectFirst': '先に元のモデルを選択し、クライアントで使用するエイリアスを入力してください',
'aliases.notSelected': 'モデル未選択',
@@ -593,6 +599,7 @@ export const jaOverrides = {
'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。',
'agents.oauthLoginRequired.confirm': '了解しました',
'agents.modify.apply': '設定変更を適用',
'agents.modify.update': '設定を更新',
'agents.modify.close': '設定変更を終了',
'agents.modify.default': 'デフォルト設定',
'agents.modify.clear': '設定を消去',
+16 -2
View File
@@ -4,6 +4,10 @@ import { jaOverrides } from './ja';
export type MessageVariables = Record<string, string | number>;
export const zhCN = {
'apiAccess.models.add': '添加自定义模型',
'apiAccess.models.namePlaceholder': '模型名称,例如 gpt-4o',
'apiAccess.models.aliasPlaceholder': '模型别名(可选)',
'apiAccess.models.remove': '删除模型',
'app.language': '语言',
'app.theme.light': '日间模式',
'app.theme.dark': '夜间模式',
@@ -209,6 +213,7 @@ export const zhCN = {
'aliases.error.emptyEffort': '思考强度不能为空',
'aliases.error.emptyOptions': '请至少设置思考强度或启用 Fast',
'aliases.error.unsupportedEffort': '该模型不支持设置思考强度,只能使用 Fast 选项',
'aliases.error.unsupportedFast': '该模型当前不支持 Fast 选项',
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
'aliases.createdCombined': '已创建 {alias},思考强度为 {effort},同时启用 Fast',
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度和 Fast 覆盖规则。',
@@ -230,7 +235,7 @@ export const zhCN = {
'aliases.effort.unsupported': '这个模型没有可用的思考强度,已切换为仅启用 Fast。',
'aliases.fast.title': '速度选项',
'aliases.fast.description': '固定使用 Fast 请求层级(service_tier=priority)',
'aliases.fast.enabled': '已启用,可与思考强度同时生效',
'aliases.fast.enabled': '已启用',
'aliases.fast.disabled': '未启用',
'aliases.customLevel': '自定义等级:{effort}',
'aliases.customLevelHint': '输入模型提供商支持的其他思考等级',
@@ -240,6 +245,7 @@ export const zhCN = {
'aliases.customHelp': '仅填写目标模型实际支持的等级名称',
'aliases.aliasName.title': '别名模型名称',
'aliases.aliasName.description': '由你填写,仅新增入口,不会改名原模型',
'aliases.aliasName.autoDescription': '根据原模型和已选选项自动生成,可按需修改;不会改名原模型',
'aliases.aliasName.example': '例如 {model}-{effort}',
'aliases.aliasName.selectFirst': '先选择原模型,再填写客户端使用的别名',
'aliases.notSelected': '未选择模型',
@@ -596,6 +602,7 @@ export const zhCN = {
'agents.oauthLoginRequired.launchDescription': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。',
'agents.oauthLoginRequired.confirm': '我知道了',
'agents.modify.apply': '应用配置修改',
'agents.modify.update': '更新配置',
'agents.modify.close': '关闭配置修改',
'agents.modify.default': '默认配置',
'agents.modify.clear': '清空配置',
@@ -799,7 +806,7 @@ export const zhCN = {
'kernel.update.statusChecking': '正在检查',
'kernel.update.notYetChecked': '尚未检查',
'kernel.update.latest': '已是最新版本',
'kernel.update.available': '可安装新版本',
'kernel.update.available': '请在首页中关闭运行后安装新版本',
'appUpdate.eyebrow': '软件升级',
'appUpdate.title': '软件版本管理',
'appUpdate.current': '当前版本',
@@ -956,6 +963,10 @@ export const zhCN = {
export type MessageKey = keyof typeof zhCN;
export const en: Record<MessageKey, string> = {
'apiAccess.models.add': 'Add Custom Model',
'apiAccess.models.namePlaceholder': 'Model name, for example gpt-4o',
'apiAccess.models.aliasPlaceholder': 'Model alias (optional)',
'apiAccess.models.remove': 'Remove model',
'app.language': 'Language',
'app.theme.light': 'Light mode',
'app.theme.dark': 'Dark mode',
@@ -1161,6 +1172,7 @@ export const en: Record<MessageKey, string> = {
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
'aliases.error.emptyOptions': 'Set a reasoning effort or enable Fast',
'aliases.error.unsupportedEffort': 'This model does not support a reasoning-effort override; only Fast can be used',
'aliases.error.unsupportedFast': 'Fast is not available for this model',
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled',
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.',
@@ -1192,6 +1204,7 @@ export const en: Record<MessageKey, string> = {
'aliases.customHelp': 'Enter only a level name supported by the target model',
'aliases.aliasName.title': 'Model Alias',
'aliases.aliasName.description': 'Adds another entry point without renaming the original model',
'aliases.aliasName.autoDescription': 'Generated from the model and selected options; edit it as needed without renaming the original',
'aliases.aliasName.example': 'For example: {model}-{effort}',
'aliases.aliasName.selectFirst': 'Select the original model, then enter the alias clients will use',
'aliases.notSelected': 'No model selected',
@@ -1548,6 +1561,7 @@ export const en: Record<MessageKey, string> = {
'agents.oauthLoginRequired.launchDescription': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.',
'agents.oauthLoginRequired.confirm': 'Got it',
'agents.modify.apply': 'Apply Configuration Changes',
'agents.modify.update': 'Update Configuration',
'agents.modify.close': 'Close Configuration Changes',
'agents.modify.default': 'Default Configuration',
'agents.modify.clear': 'Clear Configuration',
+27 -16
View File
@@ -41,6 +41,11 @@ import {
resolveAgentModelForAliasMode,
resolveAgentModelSelection,
} from '../services/agentModelPicker';
import {
resolveAgentConfigurationAction,
sameAgentModel,
sameAgentModelMappings,
} from '../services/agentConfigurationDraft';
import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionAutoRestoreCard } from './CodexSessionAutoRestoreCard';
@@ -119,11 +124,6 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
haiku: model,
});
const sameClaudeModelMappings = (
left: ClaudeModelMappings,
right: ClaudeModelMappings,
) => left.opus === right.opus && left.sonnet === right.sonnet && left.haiku === right.haiku;
const claudeMappingRoles = [
{
key: 'opus',
@@ -676,6 +676,7 @@ export function AgentsPage() {
setCloseAppNotice('');
setCloseAppConfirmOpen(false);
setOauthLoginRequiredAction(null);
setOauthConfigurationDraft(null);
setClaudeCodeLaunchDialogOpen(false);
setClaudeCodeDirectoryError('');
claudeModelMappingsDirtyRef.current = false;
@@ -719,7 +720,7 @@ export function AgentsPage() {
sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel,
haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel,
};
return sameClaudeModelMappings(current, next) ? current : next;
return sameAgentModelMappings(current, next) ? current : next;
});
}, [
activeStatus?.claudeCodeModelMappings,
@@ -738,7 +739,7 @@ export function AgentsPage() {
const modelDraftChanged = !isClaudeModelMappingClient && Boolean(
selectedModel.trim()
&& appliedModel.trim()
&& selectedModel.trim() !== appliedModel.trim(),
&& !sameAgentModel(selectedModel, appliedModel),
);
const appliedClaudeModelMappings = (selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
@@ -750,13 +751,23 @@ export function AgentsPage() {
);
const claudeMappingDraftChanged = isClaudeModelMappingClient
&& activeStatus?.modificationState === 'applied'
&& !sameClaudeModelMappings(
&& !sameAgentModelMappings(
claudeModelMappingsDraft,
appliedClaudeModelMappings,
);
const oauthConfigurationChanged = selected === 'codex'
&& oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration);
const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged;
const configurationAction = resolveAgentConfigurationAction({
client: selected,
modificationState: activeStatus?.modificationState ?? 'unconfigured',
selectedModel,
appliedModel,
oauthConfiguration,
appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration),
modelMappings: claudeModelMappingsDraft,
appliedModelMappings: appliedClaudeModelMappings,
});
const canEnable = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
@@ -829,7 +840,7 @@ export function AgentsPage() {
sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled),
haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled),
};
if (!sameClaudeModelMappings(current, next)) {
if (!sameAgentModelMappings(current, next)) {
claudeModelMappingsDirtyRef.current = true;
}
return next;
@@ -1299,7 +1310,6 @@ export function AgentsPage() {
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
<AgentModelPicker
models={models}
@@ -1349,7 +1359,6 @@ export function AgentsPage() {
<span className="switch-track" />
</span>
</label>
{claudeMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
</div>
<div className="agent-claude-desktop-mapping-grid">
@@ -1446,20 +1455,22 @@ export function AgentsPage() {
<button
type="button"
className="primary-button"
onClick={() => void (activeStatus?.modificationState === 'applied'
onClick={() => void (configurationAction === 'close'
? closeConfigurationChanges()
: applyConfigurationChanges())}
disabled={
busy
|| (activeStatus?.modificationState === 'applied' ? false : !canEnable)
|| (configurationAction === 'close' ? false : !canEnable)
}
>
{busyAction === 'apply' || busyAction === 'close-config'
? <LoaderCircle size={16} className="spin" />
: null}
{activeStatus?.modificationState === 'applied'
? t('agents.modify.close')
: t('agents.modify.apply')}
{configurationAction === 'update'
? t('agents.modify.update')
: configurationAction === 'close'
? t('agents.modify.close')
: t('agents.modify.apply')}
</button>
<button
type="button"
+181 -92
View File
@@ -101,12 +101,12 @@ export type ProviderDraft = {
};
const providerDefinitions: ProviderDefinition[] = [
{ id: 'codex-api-key', section: 'codex-api-key', responseKey: 'codex-api-key', label: 'Codex', icon: codexIcon, openAi: false },
{ id: 'codex-api-key', section: 'codex-api-key', responseKey: 'codex-api-key', label: 'Codex API', icon: codexIcon, openAi: false },
{
id: 'openai-compatibility',
section: 'openai-compatibility',
responseKey: 'openai-compatibility',
label: 'OpenAI Compatible',
label: 'OpenAI 兼容',
icon: openaiIcon,
openAi: true,
},
@@ -244,8 +244,12 @@ export const stripResponseFields = (record: Record<string, unknown>) => {
const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
const existing = Array.isArray(current) ? current : [];
return selected.map((model) => {
const seen = new Set<string>();
return selected.reduce<Record<string, unknown>[]>((models, model) => {
const name = model.name.trim();
const key = name.toLowerCase();
if (!name || seen.has(key)) return models;
seen.add(key);
const matched = existing.find(
(item) => isRecord(item) && readString(item, 'name').toLowerCase() === name.toLowerCase(),
);
@@ -255,8 +259,9 @@ const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
if (alias && alias !== name) next.alias = alias;
else delete next.alias;
if (model.thinking) next.thinking = { ...model.thinking };
return next;
});
models.push(next);
return models;
}, []);
};
export const exclusionsForModelSelection = (
@@ -316,12 +321,14 @@ export const modelSelectionForDiscovery = (
export const allModelSelectionForDiscovery = (models: ModelOption[]) =>
new Set(models.map((model) => model.name.trim().toLowerCase()).filter(Boolean));
export const parseProviderApiKeys = (value: string) => value
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item, index, values) => item && values.indexOf(item) === index);
const mergeOpenAiApiKeyEntries = (current: unknown, apiKey: string) => {
const entries = Array.isArray(current) ? current.filter(isRecord) : [];
const keys = apiKey
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value, index, values) => value && values.indexOf(value) === index);
const keys = parseProviderApiKeys(apiKey);
const usedIndexes = new Set<number>();
return keys.map((key, index) => {
let matchedIndex = entries.findIndex(
@@ -351,41 +358,45 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
return levels;
};
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
name: row.name,
apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark || (definitionFor(row.section).openAi ? row.name : ''),
baseUrl: row.baseUrl,
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
prefix: readString(row.record, 'prefix'),
headersText: isRecord(row.record.headers)
? Object.entries(row.record.headers)
.map(([key, value]) => `${key}: ${String(value)}`)
.join('\n')
: '',
excludedModelsText: Array.isArray(row.record['excluded-models'])
? row.record['excluded-models'].map(String).filter((model) => model.trim() !== '*').join('\n')
: '',
disableCooling: readBoolean(row.record, 'disable-cooling', 'disableCooling'),
websockets: readBoolean(row.record, 'websockets'),
testModel: readString(row.record, 'test-model', 'testModel'),
thinkingLevels: definitionFor(row.section).openAi
? thinkingLevelsFromModels(row.models)
: undefined,
disabled: row.disabled,
cloakMode: isRecord(row.record.cloak) ? readString(row.record.cloak, 'mode') : '',
cloakStrictMode: isRecord(row.record.cloak)
? readBoolean(row.record.cloak, 'strict-mode', 'strictMode')
: false,
cloakSensitiveWordsText:
isRecord(row.record.cloak) && Array.isArray(row.record.cloak['sensitive-words'])
? row.record.cloak['sensitive-words'].map(String).join('\n')
const draftFromRow = (row: ProviderRow): ProviderDraft => {
const definition = definitionFor(row.section);
const isDeepSeek = row.section === 'openai-compatibility' && isDeepSeekRecord(row.record);
return {
name: row.name,
apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''),
baseUrl: row.baseUrl,
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
prefix: readString(row.record, 'prefix'),
headersText: isRecord(row.record.headers)
? Object.entries(row.record.headers)
.map(([key, value]) => `${key}: ${String(value)}`)
.join('\n')
: '',
cloakCacheUserId: isRecord(row.record.cloak)
? readBoolean(row.record.cloak, 'cache-user-id', 'cacheUserId')
: false,
});
excludedModelsText: Array.isArray(row.record['excluded-models'])
? row.record['excluded-models'].map(String).filter((model) => model.trim() !== '*').join('\n')
: '',
disableCooling: readBoolean(row.record, 'disable-cooling', 'disableCooling'),
websockets: readBoolean(row.record, 'websockets'),
testModel: readString(row.record, 'test-model', 'testModel'),
thinkingLevels: definition.openAi
? thinkingLevelsFromModels(row.models)
: undefined,
disabled: row.disabled,
cloakMode: isRecord(row.record.cloak) ? readString(row.record.cloak, 'mode') : '',
cloakStrictMode: isRecord(row.record.cloak)
? readBoolean(row.record.cloak, 'strict-mode', 'strictMode')
: false,
cloakSensitiveWordsText:
isRecord(row.record.cloak) && Array.isArray(row.record.cloak['sensitive-words'])
? row.record.cloak['sensitive-words'].map(String).join('\n')
: '',
cloakCacheUserId: isRecord(row.record.cloak)
? readBoolean(row.record.cloak, 'cache-user-id', 'cacheUserId')
: false,
};
};
const emptyProviderDraft = (): ProviderDraft => ({
name: '',
@@ -414,7 +425,7 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
return {
...draft,
name: 'DeepSeek',
remark: 'DeepSeek',
remark: '',
baseUrl: DEEPSEEK_BASE_URL,
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
};
@@ -423,9 +434,11 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
export const applyProviderRemarkIdentity = (
category: ProviderCategory,
draft: ProviderDraft,
): ProviderDraft => definitionFor(category).openAi
? { ...draft, name: draft.remark.trim() }
: draft;
): ProviderDraft => category === 'deepseek'
? { ...draft, name: draft.name.trim() || 'DeepSeek' }
: definitionFor(category).openAi
? { ...draft, name: draft.remark.trim() }
: draft;
export const applyProviderPreset = (
category: ProviderCategory,
@@ -765,14 +778,16 @@ export function ApiAccessPage() {
activeCategory,
applyProviderPreset(activeCategory, nextDraft),
);
const preparedDraftForSave = {
...preparedDraft,
models: preparedDraft.models.filter((model) => model.name.trim()),
};
const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key';
const parsedApiKeys = preparedDraft.apiKey
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const remarkRequired = definition.openAi && activeCategory !== 'deepseek';
const parsedApiKeys = parseProviderApiKeys(preparedDraft.apiKey);
if (
parsedApiKeys.length === 0
|| (definition.openAi && !preparedDraft.remark.trim())
|| (remarkRequired && !preparedDraft.remark.trim())
|| (baseUrlRequired && !preparedDraft.baseUrl.trim())
) {
setError(
@@ -801,7 +816,7 @@ export function ApiAccessPage() {
setBusy(true);
setError('');
try {
let draftToSave = { ...preparedDraft, baseUrl };
let draftToSave = { ...preparedDraftForSave, baseUrl };
if (definition.openAi && draftToSave.models.length === 0) {
const fetchedModels = await fetchModels(
'openai',
@@ -821,34 +836,42 @@ export function ApiAccessPage() {
const latestConfig = await managementApi.get('/config');
const current = sectionRecordsFromConfig(latestConfig, activeSection);
let nextList: Record<string, unknown>[];
let targetIndex = -1;
let currentRecord: Record<string, unknown> | undefined;
if (editingRow) {
const targetIndex = resolveProviderRecordIndex(current, editingRow);
targetIndex = resolveProviderRecordIndex(current, editingRow);
if (targetIndex < 0) {
throw new Error(t('apiAccess.error.stale'));
}
const nextRecord = buildProviderRecord(
activeSection,
draftToSave,
current[targetIndex],
);
nextList = current.map((record, index) =>
index === targetIndex ? nextRecord : record,
);
} else {
const duplicate = current.some((record) =>
definition.openAi
? readString(record, 'name') === preparedDraft.name.trim()
: readString(record, 'api-key', 'apiKey') === preparedDraft.apiKey.trim()
&& readString(record, 'base-url', 'baseUrl') === baseUrl,
);
if (duplicate) throw new Error(t('apiAccess.error.duplicate'));
nextList = [
...current,
buildProviderRecord(activeSection, draftToSave),
];
currentRecord = current[targetIndex];
}
const duplicate = current.some((record, index) => {
if (index === targetIndex) return false;
if (definition.openAi) return readString(record, 'name') === preparedDraft.name.trim();
return parsedApiKeys.some((apiKey) => (
readString(record, 'api-key', 'apiKey').trim() === apiKey
&& readString(record, 'base-url', 'baseUrl').trim() === baseUrl
));
});
if (duplicate) throw new Error(t('apiAccess.error.duplicate'));
const recordsToSave = definition.openAi
? [buildProviderRecord(activeSection, draftToSave, currentRecord)]
: parsedApiKeys.map((apiKey) => buildProviderRecord(
activeSection,
{ ...draftToSave, apiKey },
currentRecord,
));
nextList = editingRow
? [
...current.slice(0, targetIndex),
...recordsToSave,
...current.slice(targetIndex + 1),
]
: [...current, ...recordsToSave];
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
await invoke('save_api_access_remark', {
update: {
@@ -1093,10 +1116,9 @@ type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResu
function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
const { t } = useI18n();
const testModel = readString(row.record, 'test-model', 'testModel');
const configuredModels = useMemo(
() => mergeProviderHealthModels([], row.models, testModel),
[row.models, testModel],
() => mergeProviderHealthModels([], row.models),
[row.models],
);
const [models, setModels] = useState<ModelOption[]>(configuredModels);
const [modelLoading, setModelLoading] = useState(true);
@@ -1126,7 +1148,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
healthOptions.customHeaders,
healthOptions.timeoutMs,
).then((discovered) => {
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models, testModel));
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models));
}).catch((requestError) => {
if (!disposed) setModelError(String(requestError).replace(/^Error:\s*/i, ''));
}).finally(() => {
@@ -1135,7 +1157,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
return () => {
disposed = true;
};
}, [healthOptions, row.apiKeys, row.models, testModel]);
}, [healthOptions, row.apiKeys, row.models]);
const visibleModels = useMemo(() => {
const query = search.trim().toLowerCase();
@@ -1324,6 +1346,11 @@ function ApiProviderDialog({
return Array.from(options.values());
}, [discoveredModels, draft.models]);
const configuredModels = useMemo(
() => draft.models.filter((model) => model.name.trim()),
[draft.models],
);
const visibleModelOptions = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
if (!query) return modelOptions;
@@ -1349,6 +1376,29 @@ function ApiProviderDialog({
setDraft((current) => ({ ...current, [field]: value }));
};
const updateModel = (index: number, patch: Partial<ModelOption>) => {
setDraft((current) => ({
...current,
models: current.models.map((model, modelIndex) =>
modelIndex === index ? { ...model, ...patch } : model,
),
}));
};
const addModel = () => {
setDraft((current) => ({
...current,
models: [...current.models, { name: '', alias: '' }],
}));
};
const removeModel = (index: number) => {
setDraft((current) => ({
...current,
models: current.models.filter((_, modelIndex) => modelIndex !== index),
}));
};
const addThinkingLevel = () => {
const level = thinkingLevelInput.trim().toLowerCase();
if (!level) return;
@@ -1400,7 +1450,11 @@ function ApiProviderDialog({
{ ...draft, models: fetchedModels },
).models;
setDiscoveredModels(models);
setSelectedModelNames(allModelSelectionForDiscovery(models));
setSelectedModelNames(new Set(
[...models, ...draft.models]
.map((model) => model.name.trim().toLowerCase())
.filter(Boolean),
));
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
} catch (requestError) {
setDiscoveredModels([]);
@@ -1470,15 +1524,15 @@ function ApiProviderDialog({
const hasModelExclusions = activeSection !== 'openai-compatibility'
&& Boolean(draft.excludedModelsText?.trim());
const modelSummaryTitle = draft.models.length > 0
? t('apiAccess.models.selected', { count: draft.models.length })
const modelSummaryTitle = configuredModels.length > 0
? t('apiAccess.models.selected', { count: configuredModels.length })
: hasModelExclusions
? t('apiAccess.models.restricted')
: activeSection === 'openai-compatibility'
? t('apiAccess.models.autoAll')
: t('apiAccess.models.upstreamDefault');
const modelSummaryDetail = draft.models.length > 0
? draft.models.slice(0, 3).map((model) => model.name).join('、')
const modelSummaryDetail = configuredModels.length > 0
? configuredModels.slice(0, 3).map((model) => model.name).join('、')
: hasModelExclusions
? t('apiAccess.models.hiddenHint')
: activeSection === 'openai-compatibility'
@@ -1499,13 +1553,15 @@ function ApiProviderDialog({
</button>
</div>
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
<label className={definition.openAi ? 'multiline-field' : undefined}>
<span>{definition.openAi ? t('apiAccess.field.keysMany') : t('apiAccess.field.key')}</span>
{definition.openAi ? (
<textarea value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder={'sk-...\nsk-...'} rows={3} />
) : (
<input autoFocus={!definition.openAi} type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
)}
<label className="multiline-field">
<span>{t('apiAccess.field.keysMany')}</span>
<textarea
autoFocus={!definition.openAi}
value={draft.apiKey}
onChange={(event) => updateTextField('apiKey', event.currentTarget.value)}
placeholder={'sk-...\nsk-...'}
rows={3}
/>
</label>
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
{activeCategory === 'deepseek' ? (
@@ -1570,10 +1626,43 @@ function ApiProviderDialog({
<RefreshCw size={15} />{t('apiAccess.models.fetch')}
</button>
</div>
<div className={`model-config-summary ${draft.models.length || hasModelExclusions ? 'has-models' : ''}`}>
<div className={`model-config-summary ${configuredModels.length || hasModelExclusions ? 'has-models' : ''}`}>
<strong>{modelSummaryTitle}</strong>
<span>{modelSummaryDetail}</span>
</div>
<div className="model-config-entries">
{draft.models.map((model, index) => (
<div className="model-config-entry" key={index}>
<input
value={model.name}
onChange={(event) => updateModel(index, { name: event.currentTarget.value })}
placeholder={t('apiAccess.models.namePlaceholder')}
aria-label={t('apiAccess.models.namePlaceholder')}
disabled={busy}
/>
<input
value={model.alias ?? ''}
onChange={(event) => updateModel(index, { alias: event.currentTarget.value })}
placeholder={t('apiAccess.models.aliasPlaceholder')}
aria-label={t('apiAccess.models.aliasPlaceholder')}
disabled={busy}
/>
<button
type="button"
className="icon-button quiet danger"
onClick={() => removeModel(index)}
disabled={busy}
title={t('apiAccess.models.remove')}
aria-label={t('apiAccess.models.remove')}
>
<Trash2 size={14} />
</button>
</div>
))}
<button type="button" className="secondary-button compact-button model-config-add" onClick={addModel} disabled={busy}>
<Plus size={14} />{t('apiAccess.models.add')}
</button>
</div>
{modelError && !modelDiscoveryOpen ? <small className="model-picker-error">{modelError}</small> : null}
</div>
<label><span>{t('apiAccess.field.priority')}</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder={t('common.optional')} /></label>
+91 -15
View File
@@ -53,6 +53,7 @@ type ThinkingAliasSource = {
type ModelAliasSource = ThinkingAliasSource & {
supportsReasoning: boolean;
supportsFast: boolean;
};
const effortOptions = [
@@ -96,16 +97,51 @@ export const combineModelAliasSources = (
speedSources: ThinkingAliasSource[],
): ModelAliasSource[] => {
const reasoningSourceIds = new Set(thinkingSources.map((source) => source.id));
const speedSourceIds = new Set(speedSources.map((source) => source.id));
const sources = new Map<string, ModelAliasSource>();
[...speedSources, ...thinkingSources].forEach((source) => {
sources.set(source.id, {
...source,
supportsReasoning: reasoningSourceIds.has(source.id),
supportsFast: speedSourceIds.has(source.id),
});
});
return [...sources.values()];
};
export const defaultModelAlias = (
model: string | null | undefined,
effort: string,
fast: boolean,
) => {
const normalizedModel = model?.trim() ?? '';
const normalizedEffort = effort.trim().toLowerCase();
if (!normalizedModel || (!normalizedEffort && !fast)) return '';
return `${normalizedModel}${normalizedEffort ? `-${normalizedEffort}` : ''}${fast ? '-fast' : ''}`;
};
export const uniqueModelAlias = (
alias: string,
existingModelNames: string[],
) => {
const normalizedAlias = alias.trim();
if (!normalizedAlias) return '';
const existingNames = new Set(
existingModelNames
.map((name) => name.trim().toLowerCase())
.filter(Boolean),
);
if (!existingNames.has(normalizedAlias.toLowerCase())) return normalizedAlias;
let suffix = 2;
let candidate = `${normalizedAlias}-${suffix}`;
while (existingNames.has(candidate.toLowerCase())) {
suffix += 1;
candidate = `${normalizedAlias}-${suffix}`;
}
return candidate;
};
export const thinkingAliasSourceKindLabel = (kind: string) => {
if (kind === 'codex-oauth') return 'Codex OAuth';
if (kind === 'codex-api') return 'Codex API';
@@ -130,7 +166,7 @@ export function ThinkingAliasesPage() {
const [thinkingSources, setThinkingSources] = useState<ThinkingAliasSource[]>([]);
const [speedSources, setSpeedSources] = useState<ThinkingAliasSource[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState('');
const [effort, setEffort] = useState('xhigh');
const [effort, setEffort] = useState('');
const [fastEnabled, setFastEnabled] = useState(false);
const [customEffortOpen, setCustomEffortOpen] = useState(false);
const [alias, setAlias] = useState('');
@@ -138,6 +174,7 @@ export function ThinkingAliasesPage() {
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
const modelPickerRef = useRef<HTMLDivElement>(null);
const generatedAliasRef = useRef('');
const [loading, setLoading] = useState(true);
const [busyAlias, setBusyAlias] = useState('');
const [busyAction, setBusyAction] = useState<'create' | 'delete' | ''>('');
@@ -236,9 +273,14 @@ export function ThinkingAliasesPage() {
setSelectedSourceId(source.id);
if (!source.supportsReasoning) {
setEffort('');
setFastEnabled(true);
setCustomEffortOpen(false);
setFastEnabled(source.supportsFast);
} else {
setEffort('');
setFastEnabled(false);
}
setCustomEffortOpen(false);
setAlias('');
generatedAliasRef.current = '';
setModelPickerOpen(false);
setSearch('');
};
@@ -274,11 +316,23 @@ export function ThinkingAliasesPage() {
};
const normalizedEffort = effort.trim().toLowerCase();
const defaultAlias = defaultModelAlias(selectedSource?.model, normalizedEffort, fastEnabled);
const uniqueDefaultAlias = uniqueModelAlias(defaultAlias, sources.map((source) => source.model));
const customEffortSelected = Boolean(
normalizedEffort
&& !effortOptions.some((option) => option.value === normalizedEffort),
);
useEffect(() => {
setAlias((current) => {
const currentValue = current.trim();
const canReplace = !currentValue || currentValue === generatedAliasRef.current;
if (!canReplace) return current;
generatedAliasRef.current = uniqueDefaultAlias;
return uniqueDefaultAlias;
});
}, [uniqueDefaultAlias]);
const createAlias = async () => {
if (!selectedSource) {
setError(t('aliases.error.selectModel'));
@@ -293,6 +347,10 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyOptions'));
return;
}
if (fastEnabled && !selectedSource.supportsFast) {
setError(t('aliases.error.unsupportedFast'));
return;
}
if (normalizedEffort && !selectedSource.supportsReasoning) {
setError(t('aliases.error.unsupportedEffort'));
return;
@@ -396,8 +454,20 @@ export function ThinkingAliasesPage() {
event.currentTarget.select();
}}
onChange={(event) => {
setSearch(event.currentTarget.value);
const nextSearch = event.currentTarget.value;
setSearch(nextSearch);
setModelPickerOpen(true);
if (
selectedSource
&& nextSearch.trim().toLowerCase() !== selectedSource.model.trim().toLowerCase()
) {
setSelectedSourceId('');
setEffort('');
setFastEnabled(false);
setCustomEffortOpen(false);
setAlias('');
generatedAliasRef.current = '';
}
}}
onKeyDown={handleModelSearchKeyDown}
placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')}
@@ -549,23 +619,29 @@ export function ThinkingAliasesPage() {
<strong>{t('aliases.fast.title')}</strong>
<span>{t('aliases.fast.description')}</span>
</div>
<button
type="button"
className={`thinking-speed-toggle${fastEnabled ? ' active' : ''}`}
aria-pressed={fastEnabled}
onClick={() => setFastEnabled((current) => !current)}
disabled={Boolean(busyAlias)}
>
<span>Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</button>
<label className={`thinking-fast-option${fastEnabled ? ' active' : ''}`}>
<span className="thinking-fast-option-copy">
<span><Zap size={15} /> Fast</span>
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
</span>
<span className="switch-control thinking-fast-switch">
<input
type="checkbox"
checked={fastEnabled}
onChange={(event) => setFastEnabled(event.currentTarget.checked)}
disabled={Boolean(busyAlias) || !selectedSource || !selectedSource.supportsFast}
aria-label={t('aliases.fast.title')}
/>
<span className="switch-track" />
</span>
</label>
</div>
<div className="thinking-alias-section-divider" aria-hidden="true" />
<div className="thinking-alias-field">
<div className="thinking-field-heading">
<strong>{t('aliases.aliasName.title')}</strong>
<span>{t('aliases.aliasName.description')}</span>
<span>{t('aliases.aliasName.autoDescription')}</span>
</div>
<input
id="thinking-alias-name"
+68
View File
@@ -0,0 +1,68 @@
export type AgentConfigurationClientId =
| 'claude-code'
| 'claude-desktop'
| 'codex'
| 'opencode'
| 'openclaw'
| 'hermes'
| 'pi';
export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid';
export type AgentConfigurationAction = 'apply' | 'update' | 'close';
export type AgentModelMappings = {
opus: string;
sonnet: string;
haiku: string;
};
type ResolveAgentConfigurationActionOptions = {
client: AgentConfigurationClientId;
modificationState: AgentConfigurationModificationState;
selectedModel: string;
appliedModel: string;
oauthConfiguration: boolean;
appliedOauthConfiguration: boolean;
modelMappings: AgentModelMappings;
appliedModelMappings: AgentModelMappings;
};
const normalizedModel = (value: string) => value.trim().toLocaleLowerCase();
export const sameAgentModel = (left: string, right: string) => (
normalizedModel(left) === normalizedModel(right)
);
export const sameAgentModelMappings = (
left: AgentModelMappings,
right: AgentModelMappings,
) => sameAgentModel(left.opus, right.opus)
&& sameAgentModel(left.sonnet, right.sonnet)
&& sameAgentModel(left.haiku, right.haiku);
export function resolveAgentConfigurationAction({
client,
modificationState,
selectedModel,
appliedModel,
oauthConfiguration,
appliedOauthConfiguration,
modelMappings,
appliedModelMappings,
}: ResolveAgentConfigurationActionOptions): AgentConfigurationAction {
if (modificationState !== 'applied') return 'apply';
if (client === 'pi') return 'close';
const modelChanged = client === 'claude-code' || client === 'claude-desktop'
? !sameAgentModelMappings(modelMappings, appliedModelMappings)
: Boolean(
selectedModel.trim()
&& appliedModel.trim()
&& !sameAgentModel(selectedModel, appliedModel),
);
const oauthChanged = client === 'codex'
&& oauthConfiguration !== appliedOauthConfiguration;
return modelChanged || oauthChanged ? 'update' : 'close';
}
+10 -9
View File
@@ -65,20 +65,21 @@ export function primaryProviderHealthCredential(apiKeys: string[]): string {
export function mergeProviderHealthModels(
discoveredModels: ModelOption[],
configuredModels: ModelOption[],
testModel = '',
): ModelOption[] {
const models = new Map<string, ModelOption>();
[...discoveredModels, ...configuredModels].forEach((model) => {
const configured = new Map<string, ModelOption>();
configuredModels.forEach((model) => {
const name = model.name.trim();
if (!name) return;
const key = name.toLowerCase();
models.set(key, { ...models.get(key), ...model, name });
configured.set(key, { ...configured.get(key), ...model, name });
});
const normalizedTestModel = testModel.trim();
if (normalizedTestModel && !models.has(normalizedTestModel.toLowerCase())) {
models.set(normalizedTestModel.toLowerCase(), { name: normalizedTestModel });
}
return Array.from(models.values()).sort((left, right) =>
discoveredModels.forEach((model) => {
const key = model.name.trim().toLowerCase();
const selected = configured.get(key);
if (!selected) return;
configured.set(key, { ...model, ...selected });
});
return Array.from(configured.values()).sort((left, right) =>
left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }),
);
}
+70 -24
View File
@@ -2895,6 +2895,32 @@ textarea:focus-visible {
font-size: var(--font-size-meta);
}
.model-config-entries {
display: grid;
gap: 6px;
}
.model-config-entry {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.model-config-entry input {
width: 100%;
min-width: 0;
}
.model-config-entry .icon-button {
width: 32px;
height: 32px;
}
.model-config-add {
justify-self: start;
}
.model-picker-error {
color: var(--theme-9f2d20);
font-size: var(--font-size-meta);
@@ -4280,39 +4306,70 @@ textarea:focus-visible {
opacity: 0.45;
}
.thinking-speed-toggle {
.thinking-fast-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 42px;
padding: 7px 10px;
min-height: 48px;
padding: 8px 10px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 7px;
background: var(--theme-fff);
color: var(--theme-7a746c);
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.thinking-speed-toggle span {
font-size: var(--font-size-section);
font-weight: 800;
.thinking-fast-option:hover {
border-color: var(--theme-d7a447);
}
.thinking-speed-toggle small {
color: var(--theme-9a938a);
font-size: var(--font-size-label);
}
.thinking-speed-toggle.active {
.thinking-fast-option.active {
border-color: var(--theme-d7a447);
background: var(--theme-fff8eb);
color: var(--theme-805a24);
}
.thinking-speed-toggle.active small {
.thinking-fast-option-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.thinking-fast-option-copy > span {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--font-size-section);
font-weight: 800;
}
.thinking-fast-option-copy small {
color: var(--theme-9a938a);
font-size: var(--font-size-label);
}
.thinking-fast-option.active .thinking-fast-option-copy small {
color: var(--theme-805a24);
}
.thinking-fast-option:has(input:disabled) {
cursor: not-allowed;
opacity: 0.55;
}
.thinking-fast-switch {
width: 42px;
height: 23px;
}
.thinking-fast-switch input:checked + .switch-track {
border-color: var(--theme-d7a447);
background: var(--theme-d7a447);
}
.thinking-effort-custom {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
@@ -5532,17 +5589,6 @@ textarea:focus-visible {
overflow-wrap: anywhere;
}
.agent-pending-badge {
flex: none;
padding: 4px 8px;
border: 1px solid var(--theme-e4d0af);
border-radius: 999px;
background: var(--theme-fff8eb);
color: var(--theme-805a24);
font-size: var(--font-size-meta);
font-weight: 700;
}
.agent-model-picker {
position: relative;
min-width: 0;
+58
View File
@@ -8,6 +8,10 @@ import {
resolveAgentModelForAliasMode,
resolveAgentModelSelection,
} from '../src/services/agentModelPicker';
import {
resolveAgentConfigurationAction,
type AgentConfigurationClientId,
} from '../src/services/agentConfigurationDraft';
const models = [
{ name: 'claude-sonnet-4-5', alias: 'Sonnet' },
@@ -69,3 +73,57 @@ describe('智能体模型选择器', () => {
expect(resolveAgentModelForAliasMode([], 'gpt-original', true)).toBe('');
});
});
const appliedConfiguration = (
client: AgentConfigurationClientId,
selectedModel: string,
appliedModel = 'model-a',
) => resolveAgentConfigurationAction({
client,
modificationState: 'applied',
selectedModel,
appliedModel,
oauthConfiguration: false,
appliedOauthConfiguration: false,
modelMappings: {
opus: selectedModel,
sonnet: selectedModel,
haiku: selectedModel,
},
appliedModelMappings: {
opus: appliedModel,
sonnet: appliedModel,
haiku: appliedModel,
},
});
describe('agent configuration update action', () => {
test.each<AgentConfigurationClientId>([
'claude-code',
'claude-desktop',
'codex',
'opencode',
'openclaw',
'hermes',
])('%s updates only while its model draft differs', (client) => {
expect(appliedConfiguration(client, 'model-b')).toBe('update');
expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close');
});
test('Codex also updates when only OAuth configuration changes', () => {
expect(resolveAgentConfigurationAction({
client: 'codex',
modificationState: 'applied',
selectedModel: 'model-a',
appliedModel: 'model-a',
oauthConfiguration: true,
appliedOauthConfiguration: false,
modelMappings: { opus: '', sonnet: '', haiku: '' },
appliedModelMappings: { opus: '', sonnet: '', haiku: '' },
})).toBe('update');
});
test('Pi never exposes the model update action', () => {
expect(appliedConfiguration('pi', 'model-b')).toBe('close');
});
});
+25 -2
View File
@@ -10,6 +10,7 @@ import {
exclusionsForModelSelection,
modelSelectionForDiscovery,
parseProviderHeaders,
parseProviderApiKeys,
providerCategoryMatchesRecord,
providerRecordWithDisabledState,
providerSectionOrder,
@@ -18,6 +19,26 @@ import {
stripResponseFields,
} from '../src/pages/ApiAccessPage';
it('saves non-empty custom model names and removes duplicate or blank entries', () => {
const result = buildProviderRecord('openai-compatibility', {
name: 'custom-provider',
apiKey: 'provider-key',
baseUrl: 'https://api.example.com',
priority: '',
models: [
{ name: ' custom-model ', alias: ' Custom Alias ' },
{ name: ' ' },
{ name: 'CUSTOM-MODEL', alias: 'duplicate' },
],
});
expect(result.models).toEqual([{ name: 'custom-model', alias: 'Custom Alias' }]);
});
it('parses multiline API keys into unique trimmed entries', () => {
expect(parseProviderApiKeys(' key-a\n\nkey-b\r\nkey-a ')).toEqual(['key-a', 'key-b']);
});
describe('API 接入配置合并', () => {
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
expect(providerSectionOrder).toEqual([
@@ -41,10 +62,12 @@ describe('API 接入配置合并', () => {
apiKey: 'deepseek-key',
models: discovered,
});
const result = buildProviderRecord('openai-compatibility', prepared);
const identified = applyProviderRemarkIdentity('deepseek', prepared);
const result = buildProviderRecord('openai-compatibility', identified);
expect(draft.name).toBe('DeepSeek');
expect(draft.remark).toBe('DeepSeek');
expect(draft.remark).toBe('');
expect(identified.name).toBe('DeepSeek');
expect(draft.baseUrl).toBe(DEEPSEEK_BASE_URL);
expect(draft.models).toEqual([]);
expect(result).toMatchObject({
+14 -6
View File
@@ -7,21 +7,29 @@ import {
} from '../src/services/providerHealthCheck';
describe('API 接入健康检测', () => {
it('合并发现模型、已配置模型和 test-model,并按名称去重', () => {
it('健康检测只保留已勾选模型,并使用发现结果补充模型信息', () => {
const models = mergeProviderHealthModels(
[{ name: 'model-b' }, { name: 'model-a' }],
[
{ name: 'model-b' },
{ name: 'model-a', contextWindow: 200_000 },
{ name: 'model-c' },
],
[{ name: 'MODEL-A', alias: 'model-a-alias' }, { name: 'model-c' }],
'health-model',
);
expect(models).toEqual([
{ name: 'health-model' },
{ name: 'MODEL-A', alias: 'model-a-alias' },
{ name: 'model-b' },
{ name: 'MODEL-A', alias: 'model-a-alias', contextWindow: 200_000 },
{ name: 'model-c' },
]);
});
it('没有勾选模型时不允许发现列表自动加入健康检测', () => {
expect(mergeProviderHealthModels(
[{ name: 'model-a' }, { name: 'model-b' }],
[],
)).toEqual([]);
});
it('逐模型检测只使用当前接入的首个有效密钥', () => {
expect(primaryProviderHealthCredential(['', ' first-key ', 'second-key'])).toBe('first-key');
expect(primaryProviderHealthCredential([])).toBe('');
+23
View File
@@ -2,9 +2,30 @@ import { describe, expect, test } from 'bun:test';
import {
combineModelAliasEntries,
combineModelAliasSources,
defaultModelAlias,
thinkingAliasSourceKindLabel,
uniqueModelAlias,
} from '../src/pages/ThinkingAliasesPage';
describe('模型别名默认名称', () => {
test('按思考强度和 Fast 选项生成可编辑的默认名称', () => {
expect(defaultModelAlias('gpt-5.6-sol', 'XHigh', false)).toBe('gpt-5.6-sol-xhigh');
expect(defaultModelAlias('gpt-5.6-sol', '', true)).toBe('gpt-5.6-sol-fast');
expect(defaultModelAlias('gpt-5.6-sol', 'xhigh', true)).toBe('gpt-5.6-sol-xhigh-fast');
expect(defaultModelAlias('gpt-5.6-sol', '', false)).toBe('');
});
test('默认别名与现有模型重名时递增数字后缀', () => {
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['gpt-5.6-sol-fast'])).toBe('gpt-5.6-sol-fast-2');
expect(uniqueModelAlias('gpt-5.6-sol-fast', [
'gpt-5.6-sol-fast',
'gpt-5.6-sol-fast-2',
])).toBe('gpt-5.6-sol-fast-3');
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['GPT-5.6-SOL-FAST'])).toBe('gpt-5.6-sol-fast-2');
expect(uniqueModelAlias('gpt-5.6-sol-fast', ['gpt-5.6-sol'])).toBe('gpt-5.6-sol-fast');
});
});
describe('思考别名', () => {
test('区分同名模型的接入来源', () => {
expect(thinkingAliasSourceKindLabel('codex-oauth')).toBe('Codex OAuth');
@@ -73,6 +94,8 @@ describe('统一模型别名列表', () => {
expect(sources).toHaveLength(2);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsReasoning).toBe(true);
expect(sources.find((source) => source.id === 'reasoning-and-fast')?.supportsFast).toBe(true);
expect(sources.find((source) => source.id === 'fast-only')?.supportsReasoning).toBe(false);
expect(sources.find((source) => source.id === 'fast-only')?.supportsFast).toBe(true);
});
});