20 files changed
+1406
-224
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ src-tauri/target/
|
||||
bin-work/
|
||||
cpa-core/
|
||||
|
||||
agent_docs
|
||||
agent_docs/
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
Generated
+1
-1
@@ -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,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" }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user