diff --git a/package.json b/package.json index 1bf485644..147441196 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65db84933..d82cfdc38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1048,6 +1051,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -3890,6 +3906,29 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b814be2e3..2649b3d94 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -746,6 +746,7 @@ dependencies = [ "tower-http 0.5.2", "url", "uuid", + "webkit2gtk", "winreg 0.52.0", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cb571fd00..f892a1ea0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -65,6 +65,9 @@ uuid = { version = "1.11", features = ["v4"] } [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" +[target.'cfg(target_os = "linux")'.dependencies] +webkit2gtk = { version = "2.0.1", features = ["v2_16"] } + [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 109e972dd..8056a01cb 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -168,7 +168,7 @@ impl Database { /// 获取整流器配置 /// - /// 返回整流器配置,如果不存在则返回默认值(全部启用) + /// 返回整流器配置,如果不存在则返回默认值(全部开启) pub fn get_rectifier_config(&self) -> Result { match self.get_setting("rectifier_config")? { Some(json) => serde_json::from_str(&json) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 57366b0b0..7f75b99e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -769,6 +769,21 @@ pub fn run() { restore_proxy_state_on_startup(&state).await; }); + // Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏 + #[cfg(target_os = "linux")] + { + if let Some(window) = app.get_webview_window("main") { + let _ = window.with_webview(|webview| { + use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy}; + let wk_webview = webview.inner(); + if let Some(settings) = WebViewExt::settings(&wk_webview) { + SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never); + log::info!("已禁用 WebKitGTK 硬件加速"); + } + }); + } + } + // 静默启动:根据设置决定是否显示主窗口 let settings = crate::settings::get_settings(); if let Some(window) = app.get_webview_window("main") { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 2c741b8a6..c26ceabf4 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -8,7 +8,10 @@ use super::{ failover_switch::FailoverSwitchManager, provider_router::ProviderRouter, providers::{get_adapter, ProviderAdapter, ProviderType}, - thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature}, + thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, + thinking_rectifier::{ + normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, + }, types::{ProxyStatus, RectifierConfig}, ProxyError, }; @@ -157,6 +160,7 @@ impl RequestForwarder { // 整流器重试标记:确保整流最多触发一次 let mut rectifier_retried = false; + let mut budget_rectifier_retried = false; // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) let bypass_circuit_breaker = providers.len() == 1; @@ -258,6 +262,7 @@ impl RequestForwarder { provider_type, ProviderType::Claude | ProviderType::ClaudeAuth ); + let mut signature_rectifier_non_retryable_client_error = false; if is_anthropic_provider { let error_message = extract_error_message(&e); @@ -293,12 +298,185 @@ impl RequestForwarder { // 首次触发:整流请求体 let rectified = rectify_anthropic_request(&mut body); - // 整流未生效:直接返回错误(不可重试客户端错误) + // 整流未生效:继续尝试 budget 整流路径,避免误判后短路 if !rectified.applied { log::warn!( - "[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试" + "[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回" + ); + signature_rectifier_non_retryable_client_error = true; + } else { + log::info!( + "[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields", + app_type_str, + rectified.removed_thinking_blocks, + rectified.removed_redacted_thinking_blocks, + rectified.removed_signature_fields + ); + + // 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展) + let _ = std::mem::replace(&mut rectifier_retried, true); + + // 使用同一供应商重试(不计入熔断器) + match self + .forward(provider, endpoint, &body, &headers, adapter.as_ref()) + .await + { + Ok(response) => { + log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); + // 记录成功 + let _ = self + .router + .record_result( + &provider.id, + app_type_str, + used_half_open_permit, + true, + None, + ) + .await; + + // 更新当前应用类型使用的 provider + { + let mut current_providers = + self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + (provider.id.clone(), provider.name.clone()), + ); + } + + // 更新成功统计 + { + let mut status = self.status.write().await; + status.success_requests += 1; + status.last_error = None; + let should_switch = + self.current_provider_id_at_start.as_str() + != provider.id.as_str(); + if should_switch { + status.failover_count += 1; + + // 异步触发供应商切换,更新 UI/托盘 + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = provider.id.clone(); + let pname = provider.name.clone(); + let at = app_type_str.to_string(); + + tokio::spawn(async move { + let _ = fm + .try_switch(ah.as_ref(), &at, &pid, &pname) + .await; + }); + } + if status.total_requests > 0 { + status.success_rate = (status.success_requests + as f32 + / status.total_requests as f32) + * 100.0; + } + } + + return Ok(ForwardResult { + response, + provider: provider.clone(), + }); + } + Err(retry_err) => { + // 整流重试仍失败:区分错误类型决定是否记录熔断器 + log::warn!( + "[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}" + ); + + // 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit + let is_provider_error = match &retry_err { + ProxyError::Timeout(_) + | ProxyError::ForwardFailed(_) => true, + ProxyError::UpstreamError { status, .. } => { + *status >= 500 + } + _ => false, + }; + + if is_provider_error { + // Provider 问题:记录失败到熔断器 + let _ = self + .router + .record_result( + &provider.id, + app_type_str, + used_half_open_permit, + false, + Some(retry_err.to_string()), + ) + .await; + } else { + // 客户端问题:仅释放 permit,不记录熔断器 + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + } + + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(retry_err.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: retry_err, + provider: Some(provider.clone()), + }); + } + } + } + } + } + + // 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商) + if is_anthropic_provider { + let error_message = extract_error_message(&e); + if should_rectify_thinking_budget( + error_message.as_deref(), + &self.rectifier_config, + ) { + // 已经重试过:直接返回错误(不可重试客户端错误) + if budget_rectifier_retried { + log::warn!( + "[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试" + ); + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + + let budget_rectified = rectify_thinking_budget(&mut body); + if !budget_rectified.applied { + log::warn!( + "[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试" ); - // 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题) self.router .release_permit_neutral( &provider.id, @@ -321,15 +499,13 @@ impl RequestForwarder { } log::info!( - "[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields", + "[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}", app_type_str, - rectified.removed_thinking_blocks, - rectified.removed_redacted_thinking_blocks, - rectified.removed_signature_fields + budget_rectified.before, + budget_rectified.after ); - // 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展) - let _ = std::mem::replace(&mut rectifier_retried, true); + let _ = std::mem::replace(&mut budget_rectifier_retried, true); // 使用同一供应商重试(不计入熔断器) match self @@ -337,8 +513,7 @@ impl RequestForwarder { .await { Ok(response) => { - log::info!("[{app_type_str}] [RECT-002] 整流重试成功"); - // 记录成功 + log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功"); let _ = self .router .record_result( @@ -350,7 +525,6 @@ impl RequestForwarder { ) .await; - // 更新当前应用类型使用的 provider { let mut current_providers = self.current_providers.write().await; @@ -360,7 +534,6 @@ impl RequestForwarder { ); } - // 更新成功统计 { let mut status = self.status.write().await; status.success_requests += 1; @@ -370,14 +543,11 @@ impl RequestForwarder { != provider.id.as_str(); if should_switch { status.failover_count += 1; - - // 异步触发供应商切换,更新 UI/托盘 let fm = self.failover_manager.clone(); let ah = self.app_handle.clone(); let pid = provider.id.clone(); let pname = provider.name.clone(); let at = app_type_str.to_string(); - tokio::spawn(async move { let _ = fm .try_switch(ah.as_ref(), &at, &pid, &pname) @@ -397,12 +567,10 @@ impl RequestForwarder { }); } Err(retry_err) => { - // 整流重试仍失败:区分错误类型决定是否记录熔断器 log::warn!( - "[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}" + "[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}" ); - // 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit let is_provider_error = match &retry_err { ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => { true @@ -412,7 +580,6 @@ impl RequestForwarder { }; if is_provider_error { - // Provider 问题:记录失败到熔断器 let _ = self .router .record_result( @@ -424,7 +591,6 @@ impl RequestForwarder { ) .await; } else { - // 客户端问题:仅释放 permit,不记录熔断器 self.router .release_permit_neutral( &provider.id, @@ -451,6 +617,28 @@ impl RequestForwarder { } } + if signature_rectifier_non_retryable_client_error { + self.router + .release_permit_neutral( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + let mut status = self.status.write().await; + status.failed_requests += 1; + status.last_error = Some(e.to_string()); + if status.total_requests > 0 { + status.success_rate = (status.success_requests as f32 + / status.total_requests as f32) + * 100.0; + } + return Err(ForwardError { + error: e, + provider: Some(provider.clone()), + }); + } + // 失败:记录失败并更新熔断器 let _ = self .router @@ -575,6 +763,9 @@ impl RequestForwarder { let (mapped_body, _original_model, _mapped_model) = super::model_mapper::apply_model_mapping(body.clone(), provider); + // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) + let mapped_body = normalize_thinking_type(mapped_body); + // 转换请求体(如果需要) let request_body = if needs_transform { adapter.transform_request(mapped_body, provider)? diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 338b845f6..fd6e0ba88 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -21,6 +21,7 @@ pub mod response_handler; pub mod response_processor; pub(crate) mod server; pub mod session; +pub mod thinking_budget_rectifier; pub mod thinking_rectifier; pub(crate) mod types; pub mod usage; diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index a0eca7d1e..4f0f94d81 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -97,11 +97,21 @@ impl ModelMapping { /// 检测请求是否启用了 thinking 模式 pub fn has_thinking_enabled(body: &Value) -> bool { - body.get("thinking") + match body + .get("thinking") .and_then(|v| v.as_object()) .and_then(|o| o.get("type")) .and_then(|t| t.as_str()) - == Some("enabled") + { + Some("enabled") | Some("adaptive") => true, + Some("disabled") | None => false, + Some(other) => { + log::warn!( + "[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型" + ); + false + } + } } /// 对请求体应用模型映射 @@ -300,6 +310,30 @@ mod tests { assert!(mapped.is_none()); } + #[test] + fn test_thinking_adaptive() { + let provider = create_provider_with_mapping(); + let body = json!({ + "model": "claude-sonnet-4-5", + "thinking": {"type": "adaptive"} + }); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "reasoning-model"); + assert_eq!(mapped, Some("reasoning-model".to_string())); + } + + #[test] + fn test_thinking_unknown_type() { + let provider = create_provider_with_mapping(); + let body = json!({ + "model": "claude-sonnet-4-5", + "thinking": {"type": "some_future_type"} + }); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); + } + #[test] fn test_case_insensitive() { let provider = create_provider_with_mapping(); diff --git a/src-tauri/src/proxy/thinking_budget_rectifier.rs b/src-tauri/src/proxy/thinking_budget_rectifier.rs new file mode 100644 index 000000000..aa0836878 --- /dev/null +++ b/src-tauri/src/proxy/thinking_budget_rectifier.rs @@ -0,0 +1,359 @@ +//! Thinking Budget 整流器 +//! +//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。 +//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。 + +use super::types::RectifierConfig; +use serde_json::Value; + +/// 最大 thinking budget tokens +const MAX_THINKING_BUDGET: u64 = 32000; + +/// 最大 max_tokens 值 +const MAX_TOKENS_VALUE: u64 = 64000; + +/// max_tokens 必须大于 budget_tokens +const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1; + +/// Budget 整流结果 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BudgetRectifySnapshot { + /// max_tokens + pub max_tokens: Option, + /// thinking.type + pub thinking_type: Option, + /// thinking.budget_tokens + pub thinking_budget_tokens: Option, +} + +/// Budget 整流结果 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BudgetRectifyResult { + /// 是否应用了整流 + pub applied: bool, + /// 整流前快照 + pub before: BudgetRectifySnapshot, + /// 整流后快照 + pub after: BudgetRectifySnapshot, +} + +/// 检测是否需要触发 thinking budget 整流器 +/// +/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束 +pub fn should_rectify_thinking_budget( + error_message: Option<&str>, + config: &RectifierConfig, +) -> bool { + // 检查总开关 + if !config.enabled { + return false; + } + // 检查子开关 + if !config.request_thinking_budget { + return false; + } + + let Some(msg) = error_message else { + return false; + }; + let lower = msg.to_lowercase(); + + // 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发 + let has_budget_tokens_reference = + lower.contains("budget_tokens") || lower.contains("budget tokens"); + let has_thinking_reference = lower.contains("thinking"); + let has_1024_constraint = lower.contains("greater than or equal to 1024") + || lower.contains(">= 1024") + || (lower.contains("1024") && lower.contains("input should be")); + if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint { + return true; + } + + false +} + +/// 对请求体执行 budget 整流 +/// +/// 整流动作: +/// - `thinking.type = "enabled"` +/// - `thinking.budget_tokens = 32000` +/// - 如果 `max_tokens < 32001`,设为 `64000` +pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult { + let before = snapshot_budget(body); + + // 与 CCH 对齐:adaptive 请求不改写 + if before.thinking_type.as_deref() == Some("adaptive") { + return BudgetRectifyResult { + applied: false, + before: before.clone(), + after: before, + }; + } + + // 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流 + if !body.get("thinking").is_some_and(Value::is_object) { + body["thinking"] = Value::Object(serde_json::Map::new()); + } + + let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else { + return BudgetRectifyResult { + applied: false, + before: before.clone(), + after: before, + }; + }; + + thinking.insert("type".to_string(), Value::String("enabled".to_string())); + thinking.insert( + "budget_tokens".to_string(), + Value::Number(MAX_THINKING_BUDGET.into()), + ); + + if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) { + body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into()); + } + + let after = snapshot_budget(body); + BudgetRectifyResult { + applied: before != after, + before, + after, + } +} + +fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot { + let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64()); + let thinking = body.get("thinking").and_then(|t| t.as_object()); + let thinking_type = thinking + .and_then(|t| t.get("type")) + .and_then(|v| v.as_str()) + .map(ToString::to_string); + let thinking_budget_tokens = thinking + .and_then(|t| t.get("budget_tokens")) + .and_then(|v| v.as_u64()); + BudgetRectifySnapshot { + max_tokens, + thinking_type, + thinking_budget_tokens, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn enabled_config() -> RectifierConfig { + RectifierConfig { + enabled: true, + request_thinking_signature: true, + request_thinking_budget: true, + } + } + + fn budget_disabled_config() -> RectifierConfig { + RectifierConfig { + enabled: true, + request_thinking_signature: true, + request_thinking_budget: false, + } + } + + fn master_disabled_config() -> RectifierConfig { + RectifierConfig { + enabled: false, + request_thinking_signature: true, + request_thinking_budget: true, + } + } + + // ==================== should_rectify_thinking_budget 测试 ==================== + + #[test] + fn test_detect_budget_tokens_thinking_error() { + assert!(should_rectify_thinking_budget( + Some("thinking.budget_tokens: Input should be greater than or equal to 1024"), + &enabled_config() + )); + } + + #[test] + fn test_detect_budget_tokens_max_tokens_error() { + assert!(!should_rectify_thinking_budget( + Some("budget_tokens must be less than max_tokens"), + &enabled_config() + )); + } + + #[test] + fn test_detect_budget_tokens_1024_error() { + assert!(!should_rectify_thinking_budget( + Some("budget_tokens: value must be at least 1024"), + &enabled_config() + )); + } + + #[test] + fn test_detect_budget_tokens_with_thinking_and_1024_error() { + assert!(should_rectify_thinking_budget( + Some("thinking budget_tokens must be >= 1024"), + &enabled_config() + )); + } + + #[test] + fn test_no_trigger_for_unrelated_error() { + assert!(!should_rectify_thinking_budget( + Some("Request timeout"), + &enabled_config() + )); + assert!(!should_rectify_thinking_budget(None, &enabled_config())); + } + + #[test] + fn test_disabled_budget_config() { + assert!(!should_rectify_thinking_budget( + Some("thinking.budget_tokens: Input should be greater than or equal to 1024"), + &budget_disabled_config() + )); + } + + #[test] + fn test_master_disabled() { + assert!(!should_rectify_thinking_budget( + Some("thinking.budget_tokens: Input should be greater than or equal to 1024"), + &master_disabled_config() + )); + } + + // ==================== rectify_thinking_budget 测试 ==================== + + #[test] + fn test_rectify_budget_basic() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 512 }, + "max_tokens": 1024 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(result.applied); + assert_eq!(result.before.thinking_type.as_deref(), Some("enabled")); + assert_eq!(result.after.thinking_type.as_deref(), Some("enabled")); + assert_eq!(result.before.thinking_budget_tokens, Some(512)); + assert_eq!( + result.after.thinking_budget_tokens, + Some(MAX_THINKING_BUDGET) + ); + assert_eq!(result.before.max_tokens, Some(1024)); + assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE)); + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET); + assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE); + } + + #[test] + fn test_rectify_budget_skips_adaptive() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive", "budget_tokens": 512 }, + "max_tokens": 1024 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(!result.applied); + assert_eq!(result.before, result.after); + assert_eq!(body["thinking"]["type"], "adaptive"); + assert_eq!(body["thinking"]["budget_tokens"], 512); + assert_eq!(body["max_tokens"], 1024); + } + + #[test] + fn test_rectify_budget_preserves_large_max_tokens() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 512 }, + "max_tokens": 100000 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(result.applied); + assert_eq!(result.before.max_tokens, Some(100000)); + assert_eq!(result.after.max_tokens, Some(100000)); + assert_eq!(body["max_tokens"], 100000); + } + + #[test] + fn test_rectify_budget_creates_thinking_object_when_missing() { + let mut body = json!({ + "model": "claude-test", + "max_tokens": 1024 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(result.applied); + assert_eq!(result.before.thinking_type, None); + assert_eq!(result.after.thinking_type.as_deref(), Some("enabled")); + assert_eq!( + result.after.thinking_budget_tokens, + Some(MAX_THINKING_BUDGET) + ); + assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE)); + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET); + assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE); + } + + #[test] + fn test_rectify_budget_no_max_tokens() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 512 } + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(result.applied); + assert_eq!(result.before.max_tokens, None); + assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE)); + assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE); + } + + #[test] + fn test_rectify_budget_normalizes_non_enabled_type() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "disabled", "budget_tokens": 512 }, + "max_tokens": 1024 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(result.applied); + assert_eq!(result.before.thinking_type.as_deref(), Some("disabled")); + assert_eq!(result.after.thinking_type.as_deref(), Some("enabled")); + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET); + assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE); + } + + #[test] + fn test_rectify_budget_no_change_when_already_valid() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 32000 }, + "max_tokens": 64001 + }); + + let result = rectify_thinking_budget(&mut body); + + assert!(!result.applied); + assert_eq!(result.before, result.after); + assert_eq!(body["thinking"]["budget_tokens"], 32000); + assert_eq!(body["max_tokens"], 64001); + } +} diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs index 827ce5618..ce71503c8 100644 --- a/src-tauri/src/proxy/thinking_rectifier.rs +++ b/src-tauri/src/proxy/thinking_rectifier.rs @@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature( } // 场景3: expected thinking or redacted_thinking, found tool_use + // 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。 // 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`" if lower.contains("expected") && (lower.contains("thinking") || lower.contains("redacted_thinking")) && lower.contains("found") + && lower.contains("tool_use") { return true; } @@ -73,6 +75,28 @@ pub fn should_rectify_thinking_signature( return true; } + // 场景5: signature 字段不被接受(第三方渠道) + // 错误示例: "xxx.signature: Extra inputs are not permitted" + if lower.contains("signature") && lower.contains("extra inputs are not permitted") { + return true; + } + + // 场景6: thinking/redacted_thinking 块被修改 + // 错误示例: "thinking or redacted_thinking blocks ... cannot be modified" + if (lower.contains("thinking") || lower.contains("redacted_thinking")) + && lower.contains("cannot be modified") + { + return true; + } + + // 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底) + if lower.contains("非法请求") + || lower.contains("illegal request") + || lower.contains("invalid request") + { + return true; + } + false } @@ -159,11 +183,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult { /// 判断是否需要删除顶层 thinking 字段 fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool { // 检查 thinking 是否启用 - let thinking_enabled = body + let thinking_type = body .get("thinking") .and_then(|t| t.get("type")) - .and_then(|t| t.as_str()) - == Some("enabled"); + .and_then(|t| t.as_str()); + + // 与 CCH 对齐:仅 type=enabled 视为开启 + let thinking_enabled = thinking_type == Some("enabled"); if !thinking_enabled { return false; @@ -202,6 +228,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool { .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use")) } +/// 与 CCH 对齐:请求前不做 thinking type 主动改写。 +pub fn normalize_thinking_type(body: Value) -> Value { + body +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +242,7 @@ mod tests { RectifierConfig { enabled: true, request_thinking_signature: true, + request_thinking_budget: true, } } @@ -218,6 +250,7 @@ mod tests { RectifierConfig { enabled: true, request_thinking_signature: false, + request_thinking_budget: false, } } @@ -225,6 +258,7 @@ mod tests { RectifierConfig { enabled: false, request_thinking_signature: true, + request_thinking_budget: true, } } @@ -264,6 +298,14 @@ mod tests { )); } + #[test] + fn test_no_detect_thinking_expected_without_tool_use() { + assert!(!should_rectify_thinking_signature( + Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."), + &enabled_config() + )); + } + #[test] fn test_detect_must_start_with_thinking() { assert!(should_rectify_thinking_signature( @@ -418,4 +460,230 @@ mod tests { // 此时会触发删除顶层 thinking 的逻辑 // 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking } + + // ==================== 新增错误场景检测测试 ==================== + + #[test] + fn test_detect_signature_extra_inputs() { + // 场景5: signature 字段不被接受 + assert!(should_rectify_thinking_signature( + Some("xxx.signature: Extra inputs are not permitted"), + &enabled_config() + )); + } + + #[test] + fn test_detect_thinking_cannot_be_modified() { + // 场景6: thinking blocks cannot be modified + assert!(should_rectify_thinking_signature( + Some("thinking or redacted_thinking blocks in the response cannot be modified"), + &enabled_config() + )); + } + + #[test] + fn test_detect_invalid_request() { + // 场景7: 非法请求(与 CCH 对齐,统一触发) + assert!(should_rectify_thinking_signature( + Some("非法请求:thinking signature 不合法"), + &enabled_config() + )); + assert!(should_rectify_thinking_signature( + Some("illegal request: tool_use block mismatch"), + &enabled_config() + )); + assert!(should_rectify_thinking_signature( + Some("invalid request: malformed JSON"), + &enabled_config() + )); + } + + #[test] + fn test_do_not_detect_thinking_type_tag_mismatch() { + // 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器 + assert!(!should_rectify_thinking_signature( + Some("Input tag 'adaptive' found using 'type' does not match expected tags"), + &enabled_config() + )); + } + + // ==================== adaptive thinking type 测试 ==================== + + #[test] + fn test_rectify_keeps_adaptive_when_no_legacy_blocks() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive" }, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": "hello" }] + }] + }); + + let result = rectify_anthropic_request(&mut body); + + assert!(!result.applied); + assert_eq!(body["thinking"]["type"], "adaptive"); + assert!(body["thinking"].get("budget_tokens").is_none()); + } + + #[test] + fn test_rectify_adaptive_preserves_existing_budget_tokens() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive", "budget_tokens": 5000 }, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": "hello" }] + }] + }); + + let result = rectify_anthropic_request(&mut body); + + assert!(!result.applied); + assert_eq!(body["thinking"]["type"], "adaptive"); + assert_eq!(body["thinking"]["budget_tokens"], 5000); + } + + #[test] + fn test_rectify_does_not_change_enabled_type() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 1024 }, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": "hello" }] + }] + }); + + let result = rectify_anthropic_request(&mut body); + + assert!(!result.applied); + assert_eq!(body["thinking"]["type"], "enabled"); + } + + #[test] + fn test_rectify_removes_top_level_thinking_adaptive() { + // 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除 + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive" }, + "messages": [{ + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} } + ] + }, { + "role": "user", + "content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }] + }] + }); + + let result = rectify_anthropic_request(&mut body); + + assert!(!result.applied); + assert_eq!(body["thinking"]["type"], "adaptive"); + } + + #[test] + fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() { + let mut body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive" }, + "messages": [{ + "role": "assistant", + "content": [ + { "type": "thinking", "thinking": "t", "signature": "sig_thinking" }, + { "type": "text", "text": "hello", "signature": "sig_text" } + ] + }] + }); + + let result = rectify_anthropic_request(&mut body); + + assert!(result.applied); + assert_eq!(result.removed_thinking_blocks, 1); + let content = body["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + assert!(content[0].get("signature").is_none()); + assert_eq!(body["thinking"]["type"], "adaptive"); + } + + // ==================== normalize_thinking_type 测试 ==================== + + #[test] + fn test_normalize_thinking_type_adaptive_unchanged() { + let body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive" } + }); + + let result = normalize_thinking_type(body); + + assert_eq!(result["thinking"]["type"], "adaptive"); + assert!(result["thinking"].get("budget_tokens").is_none()); + } + + #[test] + fn test_normalize_thinking_type_enabled_unchanged() { + let body = json!({ + "model": "claude-test", + "thinking": { "type": "enabled", "budget_tokens": 2048 } + }); + + let result = normalize_thinking_type(body); + + assert_eq!(result["thinking"]["type"], "enabled"); + assert_eq!(result["thinking"]["budget_tokens"], 2048); + } + + #[test] + fn test_normalize_thinking_type_disabled_unchanged() { + let body = json!({ + "model": "claude-test", + "thinking": { "type": "disabled" } + }); + + let result = normalize_thinking_type(body); + + assert_eq!(result["thinking"]["type"], "disabled"); + } + + #[test] + fn test_normalize_thinking_type_preserves_budget() { + let body = json!({ + "model": "claude-test", + "thinking": { "type": "adaptive", "budget_tokens": 5000 } + }); + + let result = normalize_thinking_type(body); + + assert_eq!(result["thinking"]["type"], "adaptive"); + assert_eq!(result["thinking"]["budget_tokens"], 5000); + } + + #[test] + fn test_normalize_thinking_type_no_thinking() { + let body = json!({ + "model": "claude-test" + }); + + let result = normalize_thinking_type(body); + + assert!(result.get("thinking").is_none()); + } + + #[test] + fn test_normalize_thinking_type_unknown_unchanged() { + let body = json!({ + "model": "claude-test", + "thinking": { "type": "unexpected", "budget_tokens": 100 } + }); + + let result = normalize_thinking_type(body); + + assert_eq!(result["thinking"]["type"], "unexpected"); + assert_eq!(result["thinking"]["budget_tokens"], 100); + } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 82ad47cb6..cf8dd7c8c 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -195,17 +195,22 @@ pub struct AppProxyConfig { /// 整流器配置 /// /// 存储在 settings 表中 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RectifierConfig { - /// 总开关:是否启用整流器 - #[serde(default)] + /// 总开关:是否启用整流器(默认开启) + #[serde(default = "default_true")] pub enabled: bool, - /// 请求整流:启用 thinking 签名整流器 + /// 请求整流:启用 thinking 签名整流器(默认开启) /// /// 处理错误:Invalid 'signature' in 'thinking' block - #[serde(default)] + #[serde(default = "default_true")] pub request_thinking_signature: bool, + /// 请求整流:启用 thinking budget 整流器(默认开启) + /// + /// 处理错误:budget_tokens + thinking 相关约束 + #[serde(default = "default_true")] + pub request_thinking_budget: bool, } fn default_true() -> bool { @@ -216,6 +221,16 @@ fn default_log_level() -> String { "info".to_string() } +impl Default for RectifierConfig { + fn default() -> Self { + Self { + enabled: true, + request_thinking_signature: true, + request_thinking_budget: true, + } + } +} + /// 日志配置 /// /// 存储在 settings 表的 log_config 字段中(JSON 格式) @@ -261,32 +276,49 @@ mod tests { use super::*; #[test] - fn test_rectifier_config_default_disabled() { - // 验证 RectifierConfig::default() 返回全禁用状态 + fn test_rectifier_config_default_enabled() { + // 验证 RectifierConfig::default() 返回全开启状态 let config = RectifierConfig::default(); - assert!(!config.enabled, "整流器总开关默认应为 false"); + assert!(config.enabled, "整流器总开关默认应为 true"); assert!( - !config.request_thinking_signature, - "thinking 签名整流器默认应为 false" + config.request_thinking_signature, + "thinking 签名整流器默认应为 true" + ); + assert!( + config.request_thinking_budget, + "thinking budget 整流器默认应为 true" ); } #[test] fn test_rectifier_config_serde_default() { - // 验证反序列化缺字段时使用默认值 false + // 验证反序列化缺字段时使用默认值 true let json = "{}"; let config: RectifierConfig = serde_json::from_str(json).unwrap(); - assert!(!config.enabled); - assert!(!config.request_thinking_signature); + assert!(config.enabled); + assert!(config.request_thinking_signature); + assert!(config.request_thinking_budget); } #[test] fn test_rectifier_config_serde_explicit_true() { // 验证显式设置 true 时正确反序列化 - let json = r#"{"enabled": true, "requestThinkingSignature": true}"#; + let json = + r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#; let config: RectifierConfig = serde_json::from_str(json).unwrap(); assert!(config.enabled); assert!(config.request_thinking_signature); + assert!(config.request_thinking_budget); + } + + #[test] + fn test_rectifier_config_serde_partial_fields() { + // 验证只设置部分字段时,缺失字段使用默认值 true + let json = r#"{"enabled": true, "requestThinkingSignature": false}"#; + let config: RectifierConfig = serde_json::from_str(json).unwrap(); + assert!(config.enabled); + assert!(!config.request_thinking_signature); + assert!(config.request_thinking_budget); } #[test] diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index 8bd690d29..1f39fdb99 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -311,10 +311,22 @@ impl OmoService { let obj = Self::read_jsonc_object(&actual_path)?; + Ok(Self::build_local_file_data_from_obj( + &obj, + actual_path.to_string_lossy().to_string(), + last_modified, + )) + } + + fn build_local_file_data_from_obj( + obj: &Map, + file_path: String, + last_modified: Option, + ) -> OmoLocalFileData { let agents = obj.get("agents").cloned(); let categories = obj.get("categories").cloned(); - let other = Self::extract_other_fields(&obj); + let other = Self::extract_other_fields(obj); let other_fields = if other.is_empty() { None } else { @@ -322,16 +334,17 @@ impl OmoService { }; let mut global = OmoGlobalConfig::default(); - Self::merge_global_from_obj(&obj, &mut global); + Self::merge_global_from_obj(obj, &mut global); + global.other_fields = other_fields.clone(); - Ok(OmoLocalFileData { + OmoLocalFileData { agents, categories, other_fields, global, - file_path: actual_path.to_string_lossy().to_string(), + file_path, last_modified, - }) + } } fn strip_jsonc_comments(input: &str) -> String { @@ -425,7 +438,7 @@ mod tests { ..Default::default() }; let agents = Some(serde_json::json!({ - "Sisyphus": { "model": "claude-opus-4-5" } + "sisyphus": { "model": "claude-opus-4-5" } })); let categories = None; let other_fields = None; @@ -436,7 +449,7 @@ mod tests { assert_eq!(obj["$schema"], "https://example.com/schema.json"); assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"])); assert!(obj.contains_key("agents")); - assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5"); + assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5"); } #[test] @@ -447,7 +460,7 @@ mod tests { ..Default::default() }; let agents = Some(serde_json::json!({ - "Sisyphus": { "model": "claude-opus-4-5" } + "sisyphus": { "model": "claude-opus-4-5" } })); let categories = None; let other_fields = None; @@ -459,4 +472,61 @@ mod tests { assert!(!obj.contains_key("disabled_agents")); assert!(obj.contains_key("agents")); } + + #[test] + fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() { + let obj = serde_json::json!({ + "$schema": "https://example.com/schema.json", + "disabled_agents": ["oracle"], + "agents": { + "sisyphus": { "model": "claude-opus-4-6" } + }, + "categories": { + "code": { "model": "gpt-5.3" } + }, + "custom_top_level": { + "enabled": true + } + }); + let obj_map = obj.as_object().unwrap().clone(); + + let data = OmoService::build_local_file_data_from_obj( + &obj_map, + "/tmp/oh-my-opencode.jsonc".to_string(), + None, + ); + + assert_eq!( + data.global.schema_url.as_deref(), + Some("https://example.com/schema.json") + ); + assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]); + + assert_eq!( + data.other_fields, + Some(serde_json::json!({ + "custom_top_level": { "enabled": true } + })) + ); + assert_eq!(data.global.other_fields, data.other_fields); + } + + #[test] + fn test_merge_config_ignores_non_object_other_fields() { + let global = OmoGlobalConfig { + other_fields: Some(serde_json::json!(["global_non_object"])), + ..Default::default() + }; + let agents = None; + let categories = None; + let other_fields = Some(serde_json::json!("profile_non_object")); + let profile_data = (agents, categories, other_fields, true); + + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("0")); + assert!(!obj.contains_key("global_non_object")); + assert!(!obj.contains_key("profile_non_object")); + } } diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 2ee356a3c..b4b859ba8 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -12,6 +12,19 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Plus, Trash2, @@ -21,6 +34,10 @@ import { Settings, FolderInput, Loader2, + HelpCircle, + Check, + ChevronsUpDown, + X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -43,6 +60,13 @@ const ADVANCED_PLACEHOLDER = `{ interface OmoFormFieldsProps { modelOptions: Array<{ value: string; label: string }>; modelVariantsMap?: Record; + presetMetaMap?: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >; agents: Record>; onAgentsChange: (agents: Record>) => void; categories: Record>; @@ -53,19 +77,149 @@ interface OmoFormFieldsProps { onOtherFieldsStrChange: (value: string) => void; } -type CustomModelItem = { key: string; model: string }; +export type CustomModelItem = { + key: string; + model: string; + sourceKey?: string; +}; type BuiltinModelDef = Pick< OmoAgentDef | OmoCategoryDef, - "key" | "display" | "descZh" | "descEn" | "recommended" + "key" | "display" | "descKey" | "recommended" | "tooltipKey" >; type ModelOption = { value: string; label: string }; +function DeferredKeyInput({ + value, + onCommit, + placeholder, + className, +}: { + value: string; + onCommit: (value: string) => void; + placeholder?: string; + className?: string; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( + setDraft(e.target.value)} + onBlur={() => { + if (draft !== value) { + onCommit(draft); + } + }} + placeholder={placeholder} + className={className} + /> + ); +} + const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key)); const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key)); -const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__"; -const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__"; const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__"; -const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__"; + +function ModelCombobox({ + value, + options, + recommended, + onChange, +}: { + value: string; + options: ModelOption[]; + recommended?: string; + onChange: (value: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const selectedLabel = options.find((o) => o.value === value)?.label; + + const selectModelText = t("omo.selectModel", { + defaultValue: "Select configured model", + }); + const placeholderText = recommended + ? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})` + : selectModelText; + + return ( + + + + + + + + + + {t("omo.noEnabledModels", { + defaultValue: "No configured models", + })} + + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} function getAdvancedStr(config: Record | undefined): string { if (!config) return ""; @@ -86,24 +240,58 @@ function collectCustomModels( customs.push({ key: k, model: ((v as Record).model as string) || "", + sourceKey: k, }); } } return customs; } -function mergeCustomModelsIntoStore( +export function mergeCustomModelsIntoStore( store: Record>, builtinKeys: Set, customs: CustomModelItem[], + modelVariantsMap: Record, ): Record> { - const updated = { ...store }; - for (const key of Object.keys(updated)) { - if (!builtinKeys.has(key)) delete updated[key]; + const updated: Record> = {}; + + for (const [key, value] of Object.entries(store)) { + if (builtinKeys.has(key)) { + updated[key] = { ...value }; + } } + for (const custom of customs) { - if (custom.key.trim()) { - updated[custom.key] = { ...updated[custom.key], model: custom.model }; + const targetKey = custom.key.trim(); + if (!targetKey) continue; + + const sourceKey = (custom.sourceKey || targetKey).trim(); + const sourceEntry = store[sourceKey] ?? store[targetKey]; + const nextEntry = { + ...(updated[targetKey] || {}), + ...(sourceEntry || {}), + }; + + if (custom.model.trim()) { + nextEntry.model = custom.model; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[custom.model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; + } + } + updated[targetKey] = nextEntry; + continue; + } + + delete nextEntry.model; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + updated[targetKey] = nextEntry; + } else { + delete updated[targetKey]; } } return updated; @@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore( export function OmoFormFields({ modelOptions, modelVariantsMap = {}, + presetMetaMap: _presetMetaMap = {}, agents, onAgentsChange, categories, @@ -119,8 +308,7 @@ export function OmoFormFields({ otherFieldsStr, onOtherFieldsStrChange, }: OmoFormFieldsProps) { - const { t, i18n } = useTranslation(); - const isZh = i18n.language?.startsWith("zh"); + const { t } = useTranslation(); const [mainAgentsOpen, setMainAgentsOpen] = useState(true); const [subAgentsOpen, setSubAgentsOpen] = useState(true); @@ -159,19 +347,29 @@ export function OmoFormFields({ const syncCustomAgents = useCallback( (customs: CustomModelItem[]) => { onAgentsChange( - mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs), + mergeCustomModelsIntoStore( + agents, + BUILTIN_AGENT_KEYS, + customs, + modelVariantsMap, + ), ); }, - [agents, onAgentsChange], + [agents, onAgentsChange, modelVariantsMap], ); const syncCustomCategories = useCallback( (customs: CustomModelItem[]) => { onCategoriesChange( - mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs), + mergeCustomModelsIntoStore( + categories, + BUILTIN_CATEGORY_KEYS, + customs, + modelVariantsMap, + ), ); }, - [categories, onCategoriesChange], + [categories, onCategoriesChange, modelVariantsMap], ); const buildEffectiveModelOptions = useCallback( @@ -212,43 +410,16 @@ export function OmoFormFields({ const renderModelSelect = ( currentModel: string, onChange: (value: string) => void, - placeholder?: string, + recommended?: string, ) => { const options = buildEffectiveModelOptions(currentModel); return ( - + ); }; @@ -268,11 +439,21 @@ export function OmoFormFields({ currentVariant: string, onChange: (value: string) => void, ) => { + const hasModel = Boolean(currentModel); + const modelVariantKeys = hasModel + ? modelVariantsMap[currentModel] || [] + : []; + const hasVariants = modelVariantKeys.length > 0; + const shouldShow = hasModel && (hasVariants || Boolean(currentVariant)); + + if (!shouldShow) { + return null; + } + const variantOptions = buildEffectiveVariantOptions( currentModel, currentVariant, ); - const hasModel = Boolean(currentModel); const firstIsUnavailable = Boolean(currentVariant) && !(modelVariantsMap[currentModel] || []).includes(currentVariant); @@ -283,9 +464,8 @@ export function OmoFormFields({ onValueChange={(value) => onChange(value === EMPTY_VARIANT_VALUE ? "" : value) } - disabled={!hasModel} > - + {t("omo.defaultWrapped", { defaultValue: "(Default)" })} - {!hasModel ? ( - - {t("omo.selectModelFirst", { - defaultValue: "Select model first", - })} + {variantOptions.map((variant, index) => ( + + {firstIsUnavailable && index === 0 + ? t("omo.currentValueUnavailable", { + value: variant, + defaultValue: "{{value}} (current value, unavailable)", + }) + : variant} - ) : variantOptions.length === 0 ? ( - - {t("omo.noVariantsForModel", { - defaultValue: "No variants for model", - })} - - ) : ( - variantOptions.map((variant, index) => ( - - {firstIsUnavailable && index === 0 - ? t("omo.currentValueUnavailable", { - value: variant, - defaultValue: "{{value}} (current value, unavailable)", - }) - : variant} - - )) - )} + ))} ); @@ -536,7 +702,7 @@ export function OmoFormFields({ toast.warning( t("omo.noEnabledModelsWarning", { defaultValue: - "No enabled models available. Configure and enable OpenCode models first.", + "No configured models available. Configure OpenCode models first.", }), ); return; @@ -641,9 +807,17 @@ export function OmoFormFields({
-
{def.display}
+
+ {def.display} + + + + {t(def.tooltipKey)} + + +
- {isZh ? def.descZh : def.descEn} + {t(def.descKey)}
{renderModelSelect( @@ -727,16 +901,14 @@ export function OmoFormFields({ className="border-b border-border/30 last:border-b-0" >
- updateCustom({ key: e.target.value })} + onCommit={(value) => updateCustom({ key: value })} placeholder={keyPlaceholder} className="w-32 shrink-0 h-8 text-sm text-primary" /> - {renderModelSelect( - item.model, - (value) => updateCustom({ model: value }), - t("omo.modelNamePlaceholder", { defaultValue: "model-name" }), + {renderModelSelect(item.model, (value) => + updateCustom({ model: value }), )} {renderVariantSelect(item.model, currentVariant, (value) => { if (!item.key) return; @@ -877,11 +1049,17 @@ export function OmoFormFields({ const addCustomModel = (scope: AdvancedScope) => { if (scope === "agent") { - setCustomAgents((prev) => [...prev, { key: "", model: "" }]); + setCustomAgents((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setSubAgentsOpen(true); return; } - setCustomCategories((prev) => [...prev, { key: "", model: "" }]); + setCustomCategories((prev) => [ + ...prev, + { key: "", model: "", sourceKey: "" }, + ]); setCategoriesOpen(true); }; @@ -931,7 +1109,7 @@ export function OmoFormFields({ ·{" "} {t("omo.enabledModelsCount", { count: modelOptions.length, - defaultValue: "{{count}} enabled models available", + defaultValue: "{{count}} configured models available", })} {localFilePath && ( diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index f54e38c8c..4fecaaae4 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -32,6 +32,7 @@ import { } from "@/config/geminiProviderPresets"; import { opencodeProviderPresets, + OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; @@ -59,7 +60,7 @@ import { OmoFormFields } from "./OmoFormFields"; import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields"; import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor"; import type { OmoGlobalConfig } from "@/types/omo"; -import { mergeOmoConfigPreview } from "@/types/omo"; +import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo"; import { ProviderAdvancedConfig, type PricingModelSourceOption, @@ -151,21 +152,25 @@ const isKnownOpencodeOptionKey = (key: string) => function parseOpencodeConfig( settingsConfig?: Record, ): OpenCodeProviderConfig { + const normalize = ( + parsed: Partial, + ): OpenCodeProviderConfig => ({ + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }); + try { const parsed = JSON.parse( settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, ) as Partial; - return { - npm: parsed.npm || OPENCODE_DEFAULT_NPM, - options: - parsed.options && typeof parsed.options === "object" - ? (parsed.options as OpenCodeProviderConfig["options"]) - : {}, - models: - parsed.models && typeof parsed.models === "object" - ? (parsed.models as Record) - : {}, - }; + return normalize(parsed); } catch { return { npm: OPENCODE_DEFAULT_NPM, @@ -175,6 +180,25 @@ function parseOpencodeConfig( } } +function parseOpencodeConfigStrict( + settingsConfig?: Record, +): OpenCodeProviderConfig { + const parsed = JSON.parse( + settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, + ) as Partial; + return { + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }; +} + function toOpencodeExtraOptions( options: OpenCodeProviderConfig["options"], ): Record { @@ -234,8 +258,10 @@ function buildOmoProfilePreview( } if (otherFieldsStr.trim()) { try { - const other = JSON.parse(otherFieldsStr); - Object.assign(profileOnly, other); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (other) { + Object.assign(profileOnly, other); + } } catch {} } return profileOnly; @@ -659,19 +685,24 @@ export function ProviderForm({ ); }, [opencodeProvidersData?.providers, providerId]); const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState< - string[] - >([]); + string[] | null + >(null); + const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false); + const lastOmoModelSourceWarningRef = useRef(""); useEffect(() => { let active = true; - if (!isOmoCategory) { - setEnabledOpencodeProviderIds([]); + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); return () => { active = false; }; } + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); + (async () => { try { const ids = await providersApi.getOpenCodeLiveProviderIds(); @@ -679,9 +710,13 @@ export function ProviderForm({ setEnabledOpencodeProviderIds(ids); } } catch (error) { - console.error("Failed to load OpenCode live provider ids:", error); + console.warn( + "[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids", + error, + ); if (active) { - setEnabledOpencodeProviderIds([]); + setOmoLiveIdsLoadFailed(true); + setEnabledOpencodeProviderIds(null); } } })(); @@ -691,23 +726,71 @@ export function ProviderForm({ }; }, [isOmoCategory]); - const omoModelOptions = useMemo(() => { - if (!isOmoCategory) return []; + const omoModelBuild = useMemo(() => { + const empty = { + options: [] as Array<{ value: string; label: string }>, + variantsMap: {} as Record, + presetMetaMap: {} as Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >, + parseFailedProviders: [] as string[], + usedFallbackSource: false, + }; + if (!isOmoCategory) { + return empty; + } const allProviders = opencodeProvidersData?.providers; - if (!allProviders) return []; + if (!allProviders) { + return empty; + } - const enabledSet = new Set(enabledOpencodeProviderIds); - if (enabledSet.size === 0) return []; + const shouldFilterByLive = !omoLiveIdsLoadFailed; + if (shouldFilterByLive && enabledOpencodeProviderIds === null) { + return empty; + } + const liveSet = + shouldFilterByLive && enabledOpencodeProviderIds + ? new Set(enabledOpencodeProviderIds) + : null; const dedupedOptions = new Map(); + const variantsMap: Record = {}; + const presetMetaMap: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + > = {}; + const parseFailedProviders: string[] = []; for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo" || !enabledSet.has(providerKey)) { + if (provider.category === "omo") { + continue; + } + if (liveSet && !liveSet.has(providerKey)) { continue; } - const parsedConfig = parseOpencodeConfig(provider.settingsConfig); + let parsedConfig: OpenCodeProviderConfig; + try { + parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); + } catch (error) { + parseFailedProviders.push(providerKey); + console.warn( + "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", + { + providerKey, + error, + }, + ); + continue; + } for (const [modelId, model] of Object.entries( parsedConfig.models || {}, )) { @@ -724,63 +807,107 @@ export function ProviderForm({ if (!dedupedOptions.has(value)) { dedupedOptions.set(value, label); } - } - } - return Array.from(dedupedOptions.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")); - }, [ - isOmoCategory, - opencodeProvidersData?.providers, - enabledOpencodeProviderIds, - ]); - const omoModelVariantsMap = useMemo(() => { - const variantsMap: Record = {}; - if (!isOmoCategory) { - return variantsMap; - } - - const allProviders = opencodeProvidersData?.providers; - if (!allProviders) { - return variantsMap; - } - - const enabledSet = new Set(enabledOpencodeProviderIds); - if (enabledSet.size === 0) { - return variantsMap; - } - - for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo" || !enabledSet.has(providerKey)) { - continue; - } - - const parsedConfig = parseOpencodeConfig(provider.settingsConfig); - for (const [modelId, model] of Object.entries( - parsedConfig.models || {}, - )) { const rawVariants = model.variants; if ( - !rawVariants || - typeof rawVariants !== "object" || - Array.isArray(rawVariants) + rawVariants && + typeof rawVariants === "object" && + !Array.isArray(rawVariants) ) { - continue; + const variantKeys = Object.keys(rawVariants).filter(Boolean); + if (variantKeys.length > 0) { + variantsMap[value] = variantKeys; + } } - const variantKeys = Object.keys(rawVariants).filter(Boolean); - if (variantKeys.length === 0) { - continue; + } + + // Preset fallback: for models without config-defined variants, + // check if the npm package has preset variant definitions. + // Also collect preset metadata (options, limit) for enrichment. + const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; + if (presetModels) { + for (const modelId of Object.keys(parsedConfig.models || {})) { + const fullKey = `${providerKey}/${modelId}`; + const preset = presetModels.find((p) => p.id === modelId); + if (!preset) continue; + + // Variant fallback + if (!variantsMap[fullKey] && preset.variants) { + const presetKeys = Object.keys(preset.variants).filter(Boolean); + if (presetKeys.length > 0) { + variantsMap[fullKey] = presetKeys; + } + } + + // Collect preset metadata for model enrichment + const meta: (typeof presetMetaMap)[string] = {}; + if (preset.options) meta.options = preset.options; + if (preset.contextLimit || preset.outputLimit) { + meta.limit = {}; + if (preset.contextLimit) meta.limit.context = preset.contextLimit; + if (preset.outputLimit) meta.limit.output = preset.outputLimit; + } + if (Object.keys(meta).length > 0) { + presetMetaMap[fullKey] = meta; + } } - variantsMap[`${providerKey}/${modelId}`] = variantKeys; } } - return variantsMap; + return { + options: Array.from(dedupedOptions.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")), + variantsMap, + presetMetaMap, + parseFailedProviders, + usedFallbackSource: omoLiveIdsLoadFailed, + }; }, [ isOmoCategory, opencodeProvidersData?.providers, enabledOpencodeProviderIds, + omoLiveIdsLoadFailed, + ]); + const omoModelOptions = omoModelBuild.options; + const omoModelVariantsMap = omoModelBuild.variantsMap; + const omoPresetMetaMap = omoModelBuild.presetMetaMap; + + useEffect(() => { + if (!isOmoCategory) return; + const failed = omoModelBuild.parseFailedProviders; + const fallback = omoModelBuild.usedFallbackSource; + if (failed.length === 0 && !fallback) return; + + const signature = `${fallback ? "fallback:" : ""}${failed + .slice() + .sort() + .join(",")}`; + if (lastOmoModelSourceWarningRef.current === signature) return; + lastOmoModelSourceWarningRef.current = signature; + + if (failed.length > 0) { + toast.warning( + t("omo.modelSourcePartialWarning", { + count: failed.length, + defaultValue: + "Some provider model configs are invalid and were skipped.", + }), + ); + } + if (fallback) { + toast.warning( + t("omo.modelSourceFallbackWarning", { + defaultValue: + "Failed to load live provider state. Falling back to configured providers.", + }), + ); + } + }, [ + isOmoCategory, + omoModelBuild.parseFailedProviders, + omoModelBuild.usedFallbackSource, + t, ]); const initialOmoSettings = @@ -1297,7 +1424,19 @@ export function ProviderForm({ } if (omoOtherFieldsStr.trim()) { try { - omoConfig.otherFields = JSON.parse(omoOtherFieldsStr); + const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr); + if (!otherFields) { + toast.error( + t("omo.jsonMustBeObject", { + field: t("omo.otherFields", { + defaultValue: "Other Config", + }), + defaultValue: "{{field}} must be a JSON object", + }), + ); + return; + } + omoConfig.otherFields = otherFields; } catch { toast.error( t("omo.invalidJson", { @@ -1857,6 +1996,7 @@ export function ProviderForm({ ({ enabled: true, requestThinkingSignature: true, + requestThinkingBudget: true, }); const [isLoading, setIsLoading] = useState(true); @@ -69,6 +70,21 @@ export function RectifierConfigPanel() { } />
+
+
+ +

+ {t("settings.advanced.rectifier.thinkingBudgetDescription")} +

+
+ + handleChange({ requestThinkingBudget: checked }) + } + /> +
); diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 000000000..34895b269 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Command = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandInput = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandItem.displayName = CommandPrimitive.Item.displayName; + +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 000000000..9f00cbca2 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; +const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 26e71a2ad..c41ce2b25 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -24,6 +24,384 @@ export const opencodeNpmPackages = [ { value: "@ai-sdk/google", label: "Google (Gemini)" }, ] as const; +export interface PresetModelVariant { + id: string; + name?: string; + contextLimit?: number; + outputLimit?: number; + modalities?: { input: string[]; output: string[] }; + options?: Record; + variants?: Record>; +} + +export const OPENCODE_PRESET_MODEL_VARIANTS: Record< + string, + PresetModelVariant[] +> = { + "@ai-sdk/openai-compatible": [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + contextLimit: 204800, + outputLimit: 131072, + modalities: { input: ["text"], output: ["text"] }, + }, + { + id: "glm-4.7", + name: "GLM 4.7", + contextLimit: 204800, + outputLimit: 131072, + modalities: { input: ["text"], output: ["text"] }, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + contextLimit: 262144, + outputLimit: 262144, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + }, + ], + "@ai-sdk/google": [ + { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + auto: { + thinkingConfig: { includeThoughts: true, thinkingBudget: -1 }, + }, + "no-thinking": { thinkingConfig: { thinkingBudget: 0 } }, + }, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + minimal: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" }, + }, + low: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "low" }, + }, + medium: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" }, + }, + high: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "high" }, + }, + }, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + contextLimit: 1048576, + outputLimit: 65536, + modalities: { + input: ["text", "image", "pdf", "video", "audio"], + output: ["text"], + }, + variants: { + low: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "low" }, + }, + high: { + thinkingConfig: { includeThoughts: true, thinkingLevel: "high" }, + }, + }, + }, + ], + "@ai-sdk/openai": [ + { + id: "gpt-5", + name: "GPT-5", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "low", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "high", + }, + }, + }, + { + id: "gpt-5.1", + name: "GPT-5.1", + contextLimit: 400000, + outputLimit: 272000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "low", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "high", + }, + }, + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.2", + name: "GPT-5.2", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextLimit: 400000, + outputLimit: 128000, + modalities: { input: ["text", "image"], output: ["text"] }, + options: { include: ["reasoning.encrypted_content"], store: false }, + variants: { + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + medium: { + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + high: { + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + xhigh: { + reasoningEffort: "xhigh", + reasoningSummary: "auto", + textVerbosity: "medium", + }, + }, + }, + ], + "@ai-sdk/anthropic": [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinking: { budgetTokens: 5000, type: "enabled" } }, + medium: { thinking: { budgetTokens: 13000, type: "enabled" } }, + high: { thinking: { budgetTokens: 18000, type: "enabled" } }, + }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextLimit: 1000000, + outputLimit: 128000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + max: { effort: "max" }, + }, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + { + id: "gemini-claude-opus-4-5-thinking", + name: "Antigravity - Claude Opus 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { effort: "low" }, + medium: { effort: "medium" }, + high: { effort: "high" }, + }, + }, + { + id: "gemini-claude-sonnet-4-5-thinking", + name: "Antigravity - Claude Sonnet 4.5", + contextLimit: 200000, + outputLimit: 64000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinking: { budgetTokens: 5000, type: "enabled" } }, + medium: { thinking: { budgetTokens: 13000, type: "enabled" } }, + high: { thinking: { budgetTokens: 18000, type: "enabled" } }, + }, + }, + ], +}; + +/** + * Look up preset metadata for a model by npm package and model ID. + * Returns enrichment fields (options, limit, modalities) that can be + * merged into a model definition when the user's config doesn't already + * provide them. + */ +export function getPresetModelDefaults( + npm: string, + modelId: string, +): PresetModelVariant | undefined { + const models = OPENCODE_PRESET_MODEL_VARIANTS[npm]; + if (!models) return undefined; + return models.find((m) => m.id === modelId); +} + export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ { name: "DeepSeek", @@ -664,7 +1042,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "gpt-5.2": { name: "GPT-5.2" }, - "gpt-5.2-codex": { name: "GPT-5.2 Codex" }, + "gpt-5.2-codex": { + name: "GPT-5.2 Codex", + options: { include: ["reasoning.encrypted_content"], store: false }, + }, }, }, category: "third_party", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 947f410f6..6a838719c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -222,7 +222,9 @@ "requestGroup": "Request Rectification", "responseGroup": "Response Rectification", "thinkingSignature": "Thinking Signature Rectification", - "thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures" + "thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider", + "thinkingBudget": "Thinking Budget Rectification", + "thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once" }, "logConfig": { "title": "Log Management", @@ -1588,16 +1590,21 @@ "clearWrapped": "(Clear)", "defaultWrapped": "(Default)", "variantPlaceholder": "variant", - "selectEnabledModel": "Select enabled model", + "selectEnabledModel": "Select configured model", + "selectModel": "Select configured model", + "recommendedHint": "Recommended: {{model}}", + "searchModel": "Search model...", "selectModelFirst": "Select model first", - "noEnabledModels": "No enabled models", + "noEnabledModels": "No configured models", "noVariantsForModel": "No variants for model", - "currentValueNotEnabled": "{{value}} (current value, not enabled)", + "currentValueNotEnabled": "{{value}} (current value, not configured)", "currentValueUnavailable": "{{value}} (current value, unavailable)", "advancedLabel": "Advanced", "advancedJsonInvalid": "Advanced JSON is invalid", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults", - "noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.", + "noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.", + "modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.", + "modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.", "importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields", "importLocalFailed": "Failed to read local file: {{error}}", "agentKeyPlaceholder": "agent key", @@ -1608,7 +1615,7 @@ "modelConfiguration": "Model Configuration", "fillRecommended": "Fill Recommended", "configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params", - "enabledModelsCount": "{{count}} enabled models available", + "enabledModelsCount": "{{count}} configured models available", "source": "from:", "otherFieldsJson": "Other Fields (JSON)", "searchOrType": "Search or type custom value...", @@ -1634,6 +1641,52 @@ "advancedExperimental": "Experimental Features", "advancedBackgroundTask": "Background Tasks", "advancedBrowserAutomation": "Browser Automation", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "Main orchestrator", + "hephaestus": "Autonomous deep worker", + "prometheus": "Strategic planner", + "atlas": "Task manager", + "oracle": "Strategic advisor", + "librarian": "Multi-repo researcher", + "explore": "Fast code search", + "multimodalLooker": "Media analyzer", + "metis": "Pre-plan analysis advisor", + "momus": "Plan reviewer", + "sisyphusJunior": "Delegated task executor" + }, + "agentTooltip": { + "sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.", + "atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.", + "prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.", + "hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.", + "oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.", + "librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.", + "explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.", + "multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.", + "metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.", + "momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.", + "sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops." + }, + "categoryDesc": { + "visualEngineering": "Visual/frontend engineering", + "ultrabrain": "Ultra thinking", + "deep": "Deep work", + "artistry": "Creative/artistic", + "quick": "Quick response", + "unspecifiedLow": "General low tier", + "unspecifiedHigh": "General high tier", + "writing": "Writing" + }, + "categoryTooltip": { + "visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.", + "ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.", + "deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.", + "artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.", + "quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.", + "unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.", + "unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.", + "writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model." + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e6fedd4f8..e70f459f6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -222,7 +222,9 @@ "requestGroup": "リクエスト整流", "responseGroup": "レスポンス整流", "thinkingSignature": "Thinking 署名整流", - "thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正" + "thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします", + "thinkingBudget": "Thinking Budget 整流", + "thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします" }, "logConfig": { "title": "ログ管理", @@ -1569,16 +1571,21 @@ "clearWrapped": "(クリア)", "defaultWrapped": "(デフォルト)", "variantPlaceholder": "variant", - "selectEnabledModel": "有効なモデルを選択", + "selectEnabledModel": "設定済みモデルを選択", + "selectModel": "設定済みモデルを選択", + "recommendedHint": "推奨: {{model}}", + "searchModel": "モデルを検索...", "selectModelFirst": "先にモデルを選択", - "noEnabledModels": "有効なモデルがありません", + "noEnabledModels": "設定済みモデルがありません", "noVariantsForModel": "このモデルには思考レベルがありません", - "currentValueNotEnabled": "{{value}} (現在値・未有効)", + "currentValueNotEnabled": "{{value}} (現在値・未設定)", "currentValueUnavailable": "{{value}} (現在値・利用不可)", "advancedLabel": "詳細", "advancedJsonInvalid": "詳細 JSON が不正です", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します", - "noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。", + "noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。", + "modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。", + "modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。", "importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました", "importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}", "agentKeyPlaceholder": "agent キー", @@ -1589,7 +1596,7 @@ "modelConfiguration": "モデル設定", "fillRecommended": "推奨を入力", "configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開", - "enabledModelsCount": "有効モデル {{count}} 件", + "enabledModelsCount": "設定済みモデル {{count}} 件", "source": "出典:", "otherFieldsJson": "その他のフィールド (JSON)", "searchOrType": "検索またはカスタム値を入力...", @@ -1615,6 +1622,52 @@ "advancedExperimental": "実験的機能", "advancedBackgroundTask": "バックグラウンドタスク", "advancedBrowserAutomation": "ブラウザ自動化", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "メインオーケストレーター", + "hephaestus": "自律型ディープワーカー", + "prometheus": "戦略プランナー", + "atlas": "タスクマネージャー", + "oracle": "戦略アドバイザー", + "librarian": "マルチリポジトリ研究員", + "explore": "高速コード検索", + "multimodalLooker": "メディアアナライザー", + "metis": "計画前分析アドバイザー", + "momus": "プランレビュアー", + "sisyphusJunior": "委任タスクエグゼキューター" + }, + "agentTooltip": { + "sisyphus": "メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。", + "atlas": "メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。", + "prometheus": "戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。", + "hephaestus": "自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。", + "oracle": "アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。", + "librarian": "マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。", + "explore": "高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。", + "multimodalLooker": "ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。", + "metis": "プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。", + "momus": "プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。", + "sisyphusJunior": "カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。" + }, + "categoryDesc": { + "visualEngineering": "ビジュアル/フロントエンド工学", + "ultrabrain": "超深度思考", + "deep": "ディープワーク", + "artistry": "クリエイティブ/芸術", + "quick": "クイックレスポンス", + "unspecifiedLow": "汎用ロースペック", + "unspecifiedHigh": "汎用ハイスペック", + "writing": "ライティング" + }, + "categoryTooltip": { + "visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。", + "ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。", + "deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。", + "artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。", + "quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。", + "unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。", + "unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。", + "writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 130830865..523970736 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -222,7 +222,9 @@ "requestGroup": "请求整流", "responseGroup": "响应整流", "thinkingSignature": "Thinking 签名整流", - "thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误" + "thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次", + "thinkingBudget": "Thinking Budget 整流", + "thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次" }, "logConfig": { "title": "日志管理", @@ -1588,16 +1590,21 @@ "clearWrapped": "(清空)", "defaultWrapped": "(默认)", "variantPlaceholder": "思考等级", - "selectEnabledModel": "选择已启用模型", + "selectEnabledModel": "选择已配置模型", + "selectModel": "选择已配置模型", + "recommendedHint": "推荐: {{model}}", + "searchModel": "搜索模型...", "selectModelFirst": "先选择模型", - "noEnabledModels": "暂无已启用模型", + "noEnabledModels": "暂无已配置模型", "noVariantsForModel": "该模型无思考等级", - "currentValueNotEnabled": "{{value}}(当前值,未启用)", - "currentValueUnavailable": "{{value}}(当前值,未启用)", + "currentValueNotEnabled": "{{value}}(当前值,未配置)", + "currentValueUnavailable": "{{value}}(当前值,不可用)", "advancedLabel": "高级参数", "advancedJsonInvalid": "高级参数 JSON 无效", "advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值", - "noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型", + "noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型", + "modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。", + "modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。", "importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields", "importLocalFailed": "读取本地文件失败: {{error}}", "agentKeyPlaceholder": "agent 键名", @@ -1608,7 +1615,7 @@ "modelConfiguration": "模型配置", "fillRecommended": "填充推荐", "configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数", - "enabledModelsCount": "可选已启用模型 {{count}} 个", + "enabledModelsCount": "可选已配置模型 {{count}} 个", "source": "来源:", "otherFieldsJson": "其他字段 (JSON)", "searchOrType": "搜索或输入自定义值...", @@ -1634,6 +1641,52 @@ "advancedExperimental": "实验性功能", "advancedBackgroundTask": "后台任务", "advancedBrowserAutomation": "浏览器自动化", - "advancedClaudeCode": "Claude Code" + "advancedClaudeCode": "Claude Code", + "agentDesc": { + "sisyphus": "主编排者", + "hephaestus": "自主深度工作者", + "prometheus": "战略规划者", + "atlas": "任务管理者", + "oracle": "战略顾问", + "librarian": "多仓库研究员", + "explore": "快速代码搜索", + "multimodalLooker": "媒体分析器", + "metis": "规划前分析顾问", + "momus": "计划审查者", + "sisyphusJunior": "委托任务执行器" + }, + "agentTooltip": { + "sisyphus": "主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。", + "atlas": "主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。", + "prometheus": "战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。", + "hephaestus": "自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。", + "oracle": "架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。", + "librarian": "多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。", + "explore": "快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。", + "multimodalLooker": "视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。", + "metis": "计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。", + "momus": "计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。", + "sisyphusJunior": "类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。" + }, + "categoryDesc": { + "visualEngineering": "视觉/前端工程", + "ultrabrain": "超级思考", + "deep": "深度工作", + "artistry": "创意/文艺", + "quick": "快速响应", + "unspecifiedLow": "通用低配", + "unspecifiedHigh": "通用高配", + "writing": "写作" + }, + "categoryTooltip": { + "visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3 Pro 模型。", + "ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。", + "deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。", + "artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。", + "quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。", + "unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。", + "unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。", + "writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。" + } } } diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 43daeed37..cd8de3666 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -155,6 +155,7 @@ export const settingsApi = { export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean; + requestThinkingBudget: boolean; } export interface LogConfig { diff --git a/src/types/omo.ts b/src/types/omo.ts index df9102d65..8b6059223 100644 --- a/src/types/omo.ts +++ b/src/types/omo.ts @@ -27,8 +27,8 @@ export interface OmoLocalFileData { export interface OmoAgentDef { key: string; display: string; - descZh: string; - descEn: string; + descKey: string; + tooltipKey: string; recommended?: string; group: "main" | "sub"; } @@ -36,97 +36,97 @@ export interface OmoAgentDef { export interface OmoCategoryDef { key: string; display: string; - descZh: string; - descEn: string; + descKey: string; + tooltipKey: string; recommended?: string; } export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [ { - key: "Sisyphus", + key: "sisyphus", display: "Sisyphus", - descZh: "主编排者", - descEn: "Main orchestrator", + descKey: "omo.agentDesc.sisyphus", + tooltipKey: "omo.agentTooltip.sisyphus", recommended: "claude-opus-4-6", group: "main", }, { - key: "Hephaestus", + key: "hephaestus", display: "Hephaestus", - descZh: "自主深度工作者", - descEn: "Autonomous deep worker", + descKey: "omo.agentDesc.hephaestus", + tooltipKey: "omo.agentTooltip.hephaestus", recommended: "gpt-5.3-codex", group: "main", }, { - key: "Prometheus", + key: "prometheus", display: "Prometheus", - descZh: "战略规划者", - descEn: "Strategic planner", + descKey: "omo.agentDesc.prometheus", + tooltipKey: "omo.agentTooltip.prometheus", recommended: "claude-opus-4-6", group: "main", }, { - key: "Atlas", + key: "atlas", display: "Atlas", - descZh: "任务管理者", - descEn: "Task manager", + descKey: "omo.agentDesc.atlas", + tooltipKey: "omo.agentTooltip.atlas", recommended: "kimi-k2.5", group: "main", }, { key: "oracle", display: "Oracle", - descZh: "战略顾问", - descEn: "Strategic advisor", + descKey: "omo.agentDesc.oracle", + tooltipKey: "omo.agentTooltip.oracle", recommended: "gpt-5.3", group: "sub", }, { key: "librarian", display: "Librarian", - descZh: "多仓库研究员", - descEn: "Multi-repo researcher", + descKey: "omo.agentDesc.librarian", + tooltipKey: "omo.agentTooltip.librarian", recommended: "glm-4.7", group: "sub", }, { key: "explore", display: "Explore", - descZh: "快速代码搜索", - descEn: "Fast code search", + descKey: "omo.agentDesc.explore", + tooltipKey: "omo.agentTooltip.explore", recommended: "grok-code-fast-1", group: "sub", }, { key: "multimodal-looker", display: "Multimodal-Looker", - descZh: "媒体分析器", - descEn: "Media analyzer", + descKey: "omo.agentDesc.multimodalLooker", + tooltipKey: "omo.agentTooltip.multimodalLooker", recommended: "gemini-3-flash", group: "sub", }, { - key: "Metis", + key: "metis", display: "Metis", - descZh: "规划前分析顾问", - descEn: "Pre-plan analysis advisor", + descKey: "omo.agentDesc.metis", + tooltipKey: "omo.agentTooltip.metis", recommended: "claude-opus-4-6", group: "sub", }, { - key: "Momus", + key: "momus", display: "Momus", - descZh: "计划审查者", - descEn: "Plan reviewer", + descKey: "omo.agentDesc.momus", + tooltipKey: "omo.agentTooltip.momus", recommended: "gpt-5.3", group: "sub", }, { - key: "Sisyphus-Junior", + key: "sisyphus-junior", display: "Sisyphus-Junior", - descZh: "委托任务执行器", - descEn: "Delegated task executor", + descKey: "omo.agentDesc.sisyphusJunior", + tooltipKey: "omo.agentTooltip.sisyphusJunior", group: "sub", }, ]; @@ -135,57 +135,57 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [ { key: "visual-engineering", display: "Visual Engineering", - descZh: "视觉/前端工程", - descEn: "Visual/frontend engineering", + descKey: "omo.categoryDesc.visualEngineering", + tooltipKey: "omo.categoryTooltip.visualEngineering", recommended: "gemini-3-pro", }, { key: "ultrabrain", display: "Ultrabrain", - descZh: "超级思考", - descEn: "Ultra thinking", + descKey: "omo.categoryDesc.ultrabrain", + tooltipKey: "omo.categoryTooltip.ultrabrain", recommended: "claude-opus-4-6", }, { key: "deep", display: "Deep", - descZh: "深度工作", - descEn: "Deep work", + descKey: "omo.categoryDesc.deep", + tooltipKey: "omo.categoryTooltip.deep", recommended: "gpt-5.3-codex", }, { key: "artistry", display: "Artistry", - descZh: "创意/文艺", - descEn: "Creative/artistic", + descKey: "omo.categoryDesc.artistry", + tooltipKey: "omo.categoryTooltip.artistry", recommended: "claude-opus-4-6", }, { key: "quick", display: "Quick", - descZh: "快速响应", - descEn: "Quick response", + descKey: "omo.categoryDesc.quick", + tooltipKey: "omo.categoryTooltip.quick", recommended: "gemini-3-flash", }, { key: "unspecified-low", display: "Unspecified Low", - descZh: "通用低配", - descEn: "General low tier", + descKey: "omo.categoryDesc.unspecifiedLow", + tooltipKey: "omo.categoryTooltip.unspecifiedLow", recommended: "gemini-3-flash", }, { key: "unspecified-high", display: "Unspecified High", - descZh: "通用高配", - descEn: "General high tier", + descKey: "omo.categoryDesc.unspecifiedHigh", + tooltipKey: "omo.categoryTooltip.unspecifiedHigh", recommended: "gpt-5.3-codex", }, { key: "writing", display: "Writing", - descZh: "写作", - descEn: "Writing", + descKey: "omo.categoryDesc.writing", + tooltipKey: "omo.categoryTooltip.writing", recommended: "claude-opus-4-6", }, ]; @@ -316,6 +316,17 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{ "plugins": true }`; +export function parseOmoOtherFieldsObject( + raw: string, +): Record | undefined { + if (!raw.trim()) return undefined; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return undefined; + } + return parsed as Record; +} + export function mergeOmoConfigPreview( global: OmoGlobalConfig, agents: Record>, @@ -351,7 +362,8 @@ export function mergeOmoConfigPreview( if (Object.keys(agents).length > 0) result["agents"] = agents; if (Object.keys(categories).length > 0) result["categories"] = categories; try { - const other = JSON.parse(otherFieldsStr || "{}"); + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (!other) return result; for (const [k, v] of Object.entries(other)) { result[k] = v; } diff --git a/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts new file mode 100644 index 000000000..b77b79e60 --- /dev/null +++ b/tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + mergeCustomModelsIntoStore, + type CustomModelItem, +} from "@/components/providers/forms/OmoFormFields"; + +describe("mergeCustomModelsIntoStore", () => { + it("保留自定义项高级字段,并在模型变更时仅按需清理非法 variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.2, + permission: { edit: "allow" }, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "model-b", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-b": ["precise"] }, + ); + + expect(merged.sisyphus).toEqual({ model: "builtin-model" }); + expect(merged["custom-agent"]).toEqual({ + model: "model-b", + temperature: 0.2, + permission: { edit: "allow" }, + }); + }); + + it("重命名自定义 key 时迁移原有 variant 和高级字段", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent-old": { + model: "model-a", + variant: "fast", + maxTokens: 8192, + }, + }; + const customs: CustomModelItem[] = [ + { + key: "custom-agent-new", + sourceKey: "custom-agent-old", + model: "model-a", + }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast", "balanced"] }, + ); + + expect(merged["custom-agent-old"]).toBeUndefined(); + expect(merged["custom-agent-new"]).toEqual({ + model: "model-a", + variant: "fast", + maxTokens: 8192, + }); + }); + + it("custom 列表为空时移除旧自定义项但保留内置项", () => { + const store = { + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + "custom-agent": { model: "model-a", temperature: 0.3 }, + }; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus", "hephaestus"]), + [], + {}, + ); + + expect(merged).toEqual({ + sisyphus: { model: "builtin-model" }, + hephaestus: { model: "builtin-model-2" }, + }); + }); + + it("清空 model 时保留高级字段并移除 model/variant", () => { + const store = { + sisyphus: { model: "builtin-model" }, + "custom-agent": { + model: "model-a", + variant: "fast", + temperature: 0.7, + }, + }; + const customs: CustomModelItem[] = [ + { key: "custom-agent", model: "", sourceKey: "custom-agent" }, + ]; + + const merged = mergeCustomModelsIntoStore( + store, + new Set(["sisyphus"]), + customs, + { "model-a": ["fast"] }, + ); + + expect(merged["custom-agent"]).toEqual({ temperature: 0.7 }); + }); +}); diff --git a/tests/utils/omoConfig.test.ts b/tests/utils/omoConfig.test.ts new file mode 100644 index 000000000..5a5de6c91 --- /dev/null +++ b/tests/utils/omoConfig.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + mergeOmoConfigPreview, + parseOmoOtherFieldsObject, + type OmoGlobalConfig, +} from "@/types/omo"; + +const EMPTY_GLOBAL: OmoGlobalConfig = { + id: "global", + disabledAgents: [], + disabledMcps: [], + disabledHooks: [], + disabledSkills: [], + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("parseOmoOtherFieldsObject", () => { + it("解析对象 JSON", () => { + expect(parseOmoOtherFieldsObject('{ "foo": 1 }')).toEqual({ foo: 1 }); + }); + + it("数组/字符串返回 undefined", () => { + expect(parseOmoOtherFieldsObject('["a"]')).toBeUndefined(); + expect(parseOmoOtherFieldsObject('"hello"')).toBeUndefined(); + }); + + it("非法 JSON 抛出异常", () => { + expect(() => parseOmoOtherFieldsObject("{")).toThrow(); + }); +}); + +describe("mergeOmoConfigPreview", () => { + it("只合并 otherFields 的对象值,忽略数组", () => { + const mergedFromArray = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '["a", "b"]', + ); + expect(mergedFromArray).toEqual({}); + + const mergedFromObject = mergeOmoConfigPreview( + EMPTY_GLOBAL, + {}, + {}, + '{ "foo": "bar" }', + ); + expect(mergedFromObject).toEqual({ foo: "bar" }); + }); +});