diff --git a/README.ja.md b/README.ja.md index c9e71de..95b8d8b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -71,6 +71,14 @@ API 接続画面では、プロトコルまたはプロバイダーごとに上 統一されたローカル CLIProxyAPI エンドポイントから利用できます。リクエストとレスポンスは、 OpenAI、Claude、Gemini、およびその他の互換形式の間で変換できます。 +### プラグイン管理 + +**プラグイン**画面では、コアプラグインの有効化・無効化、削除、型に応じた設定編集ができます。 +ストアからのインストール・更新・バージョン指定、追加ストアと環境変数を使う認証の設定、 +有効なプラグインが提供するページの表示と OAuth アカウント認証に対応します。サードパーティ製のインストールには +ソースの確認と確認文字列の入力が必要です。非対応コアには案内を表示し、再起動が必要な操作後は +再起動ボタンを表示します。 + ### 使用履歴と Token 統計 ![使用履歴と Token 統計](docs/screenshots/jp/4.png) diff --git a/README.md b/README.md index 8f57d52..70d81ad 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,14 @@ You can add multiple connections, search existing entries, refresh provider stat through the unified local CLIProxyAPI endpoint. Requests and responses can be converted between supported OpenAI, Claude, Gemini, and compatible formats. +### Plugins + +The **Plugins** page manages installed core plugins, their enabled state and typed configuration. +Browse the plugin store to install, update or select a release version, configure additional stores +and environment-based authentication, open pages and sign in to accounts provided by enabled plugins. Third-party +installations require source review and a typed confirmation. The page detects unsupported core +builds and shows a restart action when an installation or removal requires it. + ### Usage history and token analytics ![Usage history and token analytics](docs/screenshots/en/4.png) diff --git a/README.zh-CN.md b/README.zh-CN.md index 84dc3e3..e79b7be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,6 +81,13 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 然后通过统一的本地 CLIProxyAPI 地址调用它们。请求和响应可以在 OpenAI、Claude、Gemini 及其他兼容协议之间转换。 +### 插件管理 + +**插件**页面支持已安装插件的启停、卸载和按字段类型编辑配置。可在插件商店安装、更新或指定 +发布版本,配置附加商店来源与基于环境变量的认证规则,打开插件页面并完成插件提供的 OAuth 账号授权。 +第三方插件安装前需核对来源并输入确认内容;不支持插件的内核会显示说明,需重启的安装或卸载 +会提供重启入口。 + ### 使用记录与 Token 统计 ![使用记录与 Token 统计](docs/screenshots/zh-CN/4.png) diff --git a/scripts/portable.mjs b/scripts/portable.mjs index c1a2eaf..4011d6f 100644 --- a/scripts/portable.mjs +++ b/scripts/portable.mjs @@ -92,17 +92,20 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({ }, null, 2)}\n`); const coreOutput = join(output, 'cpa-core'); -if (preserveRuntimeConfig) { - await mkdir(coreOutput, { recursive: true }); - const entries = await readdir(coreOutput, { withFileTypes: true }); - await Promise.all(entries - .filter((entry) => entry.name !== 'config.yaml') - .map((entry) => rm(join(coreOutput, entry.name), { recursive: true, force: true }))); -} else { +if (!preserveRuntimeConfig) { await rm(coreOutput, { recursive: true, force: true }); } await mkdir(coreOutput, { recursive: true }); await copyFile(sourceArchive, join(coreOutput, assetName)); +if (preserveRuntimeConfig) { + // Local rebuilds share this directory with installed plugins and other runtime data. + // Replace only bundled archives; the app handles installed core version upgrades. + const entries = await readdir(coreOutput, { withFileTypes: true }); + await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name !== assetName + && /^CLIProxyAPI_.+_(?:windows|linux|darwin)_.+\.(?:zip|tar\.gz)$/.test(entry.name)) + .map((entry) => rm(join(coreOutput, entry.name)))); +} const coreEntries = await readdir(coreOutput, { withFileTypes: true }); const bundledArchive = coreEntries.find((entry) => entry.isFile() && entry.name === assetName); if (!bundledArchive) { @@ -113,4 +116,4 @@ if (!preserveRuntimeConfig && coreEntries.length !== 1) { } console.log(`Prepared portable directory: ${output}`); -console.log(`Bundled core: ${basename(sourceArchive)}${preserveRuntimeConfig ? ' (preserved cpa-core/config.yaml)' : ' (archive only)'}`); +console.log(`Bundled core: ${basename(sourceArchive)}${preserveRuntimeConfig ? ' (preserved installed plugins and runtime data)' : ' (archive only)'}`); diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 72a66ff..67f31f9 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -722,6 +722,14 @@ pub(crate) async fn fetch_agent_models( port: u16, api_key: &str, ) -> Result, String> { + let payload = fetch_agent_model_payload(port, api_key).await?; + parse_agent_model_options(&payload) +} + +pub(crate) async fn fetch_agent_model_payload( + port: u16, + api_key: &str, +) -> Result { if port == 0 { return Err("Invalid kernel port".to_string()); } @@ -760,7 +768,7 @@ pub(crate) async fn fetch_agent_models( truncate_for_error(&body) ) })?; - return parse_agent_model_options(&payload); + return Ok(payload); } let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405); diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 1641529..d118839 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -146,7 +146,7 @@ pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str> "kimi" | "moonshot" => Some("kimi"), "devin" | "cognition" => Some("devin"), "xai" | "x-ai" | "grok" => Some("xai"), - "meta" => Some("meta"), + "meta" | "muse" => Some("meta"), _ => None, } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4cd2589..63eeaa2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,6 +20,7 @@ mod network_proxy; ))] mod native_i18n; mod oauth_browser; +mod plugins; mod progress; mod provider_health; #[cfg(any( @@ -2686,7 +2687,11 @@ fn main() { set_core_management_secret_key, clear_core_management_secret_key, management_api::management_request, + plugins::get_plugin_support, + plugins::get_plugin_resource_url, provider_health::provider_health_probe, + provider_health::get_core_models, + provider_health::core_health_probe, management_api::upload_auth_file, management_api::open_auth_files_directory, management_api::open_core_logs_directory, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 93b85b8..93f6d85 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -21,6 +21,9 @@ use std::{ pub(crate) struct OAuthStartResult { url: String, state: Option, + user_code: Option, + flow: Option, + expires_in: Option, opened: bool, open_error: Option, } @@ -36,6 +39,9 @@ pub(crate) struct OAuthStatusResult { struct OAuthStartApiResponse { url: Option, state: Option, + user_code: Option, + flow: Option, + expires_in: Option, error: Option, #[serde(rename = "error_message")] error_message: Option, @@ -178,10 +184,11 @@ pub(crate) async fn start_oauth_login( gui_config_state: tauri::State<'_, GuiConfigState>, provider: String, browser: Option, + plugin_provider: Option, ) -> Result { super::network_proxy::prepare_oauth(&app).await; let config = gui_config_state.snapshot()?; - let provider_key = normalize_management_oauth_provider(&provider)?; + let provider_key = normalize_oauth_request_provider(&provider, plugin_provider.unwrap_or(false))?; let client = management_http_client()?; let mut request = client .get(management_endpoint(&config, "oauth/auth-url")?) @@ -195,6 +202,15 @@ pub(crate) async fn start_oauth_login( .await .map_err(|err| format_management_request_error("Failed to request OAuth login URL", &err))?; let payload = read_management_json::(response).await?; + let mut result = normalize_oauth_start_response(payload)?; + match open_oauth_url_inner(&app, &result.url, browser.as_deref()) { + Ok(()) => result.opened = true, + Err(error) => result.open_error = Some(error), + } + Ok(result) +} + +fn normalize_oauth_start_response(payload: OAuthStartApiResponse) -> Result { if let Some(error) = payload .error .or(payload.error_message) @@ -212,16 +228,20 @@ pub(crate) async fn start_oauth_login( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - let (opened, open_error) = match open_oauth_url_inner(&app, &url, browser.as_deref()) { - Ok(()) => (true, None), - Err(error) => (false, Some(error)), - }; - Ok(OAuthStartResult { url, state, - opened, - open_error, + user_code: payload + .user_code + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + flow: payload + .flow + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()), + expires_in: payload.expires_in.filter(|value| *value > 0), + opened: false, + open_error: None, }) } @@ -264,13 +284,14 @@ pub(crate) async fn submit_oauth_callback( gui_config_state: tauri::State<'_, GuiConfigState>, provider: String, redirect_url: String, + plugin_provider: Option, ) -> Result<(), String> { let redirect_url = redirect_url.trim().to_string(); if redirect_url.is_empty() { return Err("Callback URL cannot be empty".to_string()); } let config = gui_config_state.snapshot()?; - let provider_key = normalize_management_oauth_provider(&provider)?; + let provider_key = normalize_oauth_request_provider(&provider, plugin_provider.unwrap_or(false))?; let client = management_http_client()?; let body = serde_json::json!({ "provider": provider_key, @@ -355,12 +376,30 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result< Ok(format!("{origin}/v8/management/{path}")) } +fn normalize_oauth_request_provider(provider: &str, plugin_provider: bool) -> Result { + if !plugin_provider { + return normalize_management_oauth_provider(provider); + } + // Plugin provider IDs are literal backend identifiers. A plugin named grok + // or cognition must not be redirected to the built-in xai or devin flow. + let key = provider.trim().to_ascii_lowercase(); + if key.is_empty() + || !key + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err("Invalid OAuth provider".to_string()); + } + Ok(key) +} + fn normalize_management_oauth_provider(provider: &str) -> Result { let key = provider.trim().to_ascii_lowercase().replace('_', "-"); let key = match key.as_str() { "claude" | "anthropic" => "claude".to_string(), "anti-gravity" => "antigravity".to_string(), "cognition" => "devin".to_string(), + "muse" => "meta".to_string(), "grok" | "x-ai" | "x.ai" => "xai".to_string(), other => other.to_string(), }; @@ -468,6 +507,45 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn plugin_oauth_preserves_literal_provider_ids_without_builtin_aliases() { + for provider in [ + "grok", + "cognition", + "anti-gravity", + "anthropic", + "custom-sso", + ] { + assert_eq!( + normalize_oauth_request_provider(provider, true).unwrap(), + provider + ); + } + assert_eq!( + normalize_oauth_request_provider(" Custom-SSO ", true).unwrap(), + "custom-sso" + ); + for provider in ["", "custom_sso", "custom/sso", "custom.sso", "custom sso"] { + assert!(normalize_oauth_request_provider(provider, true).is_err()); + } + } + + #[test] + fn builtin_oauth_retains_aliases_when_plugin_provider_is_not_requested() { + for (provider, expected) in [ + ("grok", "xai"), + ("cognition", "devin"), + ("anti-gravity", "antigravity"), + ("anthropic", "claude"), + ("custom_sso", "custom-sso"), + ] { + assert_eq!( + normalize_oauth_request_provider(provider, false).unwrap(), + expected + ); + } + } + #[test] fn management_errors_include_v8_validation_details() { assert_eq!( @@ -503,6 +581,62 @@ mod tests { } } + #[test] + fn meta_oauth_uses_the_device_flow() { + for provider in ["Meta", " muse "] { + let key = normalize_management_oauth_provider(provider).unwrap(); + assert_eq!(key, "meta"); + assert!(!management_oauth_uses_webui_callback(&key)); + } + } + + #[test] + fn oauth_start_preserves_device_authorization_fields_for_the_frontend() { + let payload = serde_json::from_value::(serde_json::json!({ + "status": "ok", + "url": " https://auth.meta.com/device ", + "state": " meta-device-state ", + "user_code": " ABCD-EFGH ", + "flow": "device", + "expires_in": 900, + })) + .unwrap(); + let result = normalize_oauth_start_response(payload).unwrap(); + let value = serde_json::to_value(result).unwrap(); + + assert_eq!(value["url"], "https://auth.meta.com/device"); + assert_eq!(value["state"], "meta-device-state"); + assert_eq!(value["userCode"], "ABCD-EFGH"); + assert_eq!(value["flow"], "device"); + assert_eq!(value["expiresIn"], 900); + assert_eq!(value["opened"], false); + assert!(value["openError"].is_null()); + } + + #[test] + fn oauth_start_keeps_device_metadata_optional_for_existing_providers() { + for extra in [serde_json::json!({}), serde_json::json!({ + "user_code": " ", "flow": " ", "expires_in": 0, + })] { + let mut payload = serde_json::json!({ + "url": "https://example.com/oauth", + "state": "oauth-state", + }); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let result = normalize_oauth_start_response( + serde_json::from_value::(payload).unwrap(), + ) + .unwrap(); + + assert!(result.user_code.is_none()); + assert!(result.flow.is_none()); + assert!(result.expires_in.is_none()); + } + } + #[test] fn core_logs_follow_the_default_auth_directory() { let base_dir = PathBuf::from("test-base"); diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs new file mode 100644 index 0000000..332083a --- /dev/null +++ b/src-tauri/src/plugins.rs @@ -0,0 +1,224 @@ +use super::{ + core_origin, current_core_tls_settings, format_management_request_error, + management_authorization, management_endpoint, management_http_client, read_management_value, + GuiConfigFile, GuiConfigState, +}; + +#[tauri::command] +pub(crate) async fn get_plugin_support( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let config = gui_config_state.snapshot()?; + let response = request_plugins(&config).await?; + read_plugin_support(response).await +} + +#[tauri::command] +pub(crate) async fn get_plugin_resource_url( + gui_config_state: tauri::State<'_, GuiConfigState>, + plugin_id: String, + menu_index: usize, +) -> Result { + let config = gui_config_state.snapshot()?; + let response = request_plugins(&config).await?; + let plugins = read_management_value(response).await?; + let path = active_plugin_menu_path(&plugins, &plugin_id, menu_index)?; + let origin = core_origin( + &config.host, + config.port, + current_core_tls_settings()?.enabled, + ); + resolve_plugin_resource_url(&origin, path) +} + +async fn request_plugins(config: &GuiConfigFile) -> Result { + management_http_client()? + .get(management_endpoint(config, "plugins")?) + .header("Authorization", management_authorization(config)?) + .send() + .await + .map_err(|error| format_management_request_error("Failed to query plugins", &error)) +} + +async fn read_plugin_support(response: reqwest::Response) -> Result { + if let Some(supported) = response + .headers() + .get("X-CPA-SUPPORT-PLUGIN") + .and_then(|value| value.to_str().ok()) + .and_then(parse_plugin_support_header) + { + return Ok(supported); + } + if matches!(response.status().as_u16(), 404 | 501) { + return Ok(false); + } + let value = read_management_value(response).await?; + if value + .get("plugins") + .is_some_and(serde_json::Value::is_array) + { + return Ok(true); + } + Err("Kernel returned an invalid plugin capability response".to_string()) +} + +fn parse_plugin_support_header(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +fn active_plugin_menu_path<'a>( + payload: &'a serde_json::Value, + plugin_id: &str, + menu_index: usize, +) -> Result<&'a str, String> { + let plugin = payload + .get("plugins") + .and_then(serde_json::Value::as_array) + .and_then(|plugins| { + plugins.iter().find(|plugin| { + plugin.get("id").and_then(serde_json::Value::as_str) == Some(plugin_id) + && plugin + .get("effective_enabled") + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + }) + .ok_or_else(|| "Plugin is unavailable or disabled".to_string())?; + plugin + .get("menus") + .and_then(serde_json::Value::as_array) + .and_then(|menus| menus.get(menu_index)) + .and_then(|menu| menu.get("path")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "Plugin page is unavailable".to_string()) +} + +fn resolve_plugin_resource_url(origin: &str, path: &str) -> Result { + // Plugin resources are public, browser-navigable routes. Preserve the declared + // versioned path and never add the management key to the iframe URL. + if !path.starts_with('/') + || path.starts_with("//") + || path.contains('\\') + || path.chars().any(char::is_control) + { + return Err("Invalid plugin resource path".to_string()); + } + let base = reqwest::Url::parse(origin).map_err(|_| "Invalid kernel origin".to_string())?; + let url = base + .join(path) + .map_err(|_| "Invalid plugin resource path".to_string())?; + if url.origin() != base.origin() { + return Err("Plugin resources must use the kernel origin".to_string()); + } + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_support_respects_build_capability_headers() { + for value in ["1", "TRUE", " yes ", "on"] { + assert_eq!(parse_plugin_support_header(value), Some(true)); + } + for value in ["0", "FALSE", " no ", "off"] { + assert_eq!(parse_plugin_support_header(value), Some(false)); + } + assert_eq!(parse_plugin_support_header("unknown"), None); + assert_eq!(parse_plugin_support_header(""), None); + } + + #[tokio::test] + async fn plugin_support_uses_headers_before_list_fallback() { + let response = reqwest::Response::from( + tauri::http::Response::builder() + .status(200) + .header("X-CPA-SUPPORT-PLUGIN", "false") + .body(r#"{"plugins":[]}"#) + .unwrap(), + ); + assert!(!read_plugin_support(response).await.unwrap()); + let response = reqwest::Response::from( + tauri::http::Response::builder() + .status(200) + .body(r#"{"plugins":[]}"#) + .unwrap(), + ); + assert!(read_plugin_support(response).await.unwrap()); + for status in [404, 501] { + let response = reqwest::Response::from( + tauri::http::Response::builder() + .status(status) + .body("") + .unwrap(), + ); + assert!(!read_plugin_support(response).await.unwrap()); + } + for (status, body) in [ + (401, r#"{"error":"unauthorized"}"#), + (200, "error"), + ] { + let response = reqwest::Response::from( + tauri::http::Response::builder() + .status(status) + .body(body) + .unwrap(), + ); + assert!(read_plugin_support(response).await.is_err()); + } + } + + #[test] + fn plugin_pages_require_a_current_enabled_menu_declaration() { + let payload = serde_json::json!({"plugins": [ + {"id": "enabled", "effective_enabled": true, "menus": [ + {"path": "/v0/resource/plugins/enabled/status"}, + {"path": "/v9/resource/plugins/enabled/settings"} + ]}, + {"id": "disabled", "effective_enabled": false, "menus": [ + {"path": "/v0/resource/plugins/disabled/status"} + ]} + ]}); + assert_eq!( + active_plugin_menu_path(&payload, "enabled", 1).unwrap(), + "/v9/resource/plugins/enabled/settings" + ); + assert!(active_plugin_menu_path(&payload, "enabled", 2).is_err()); + assert!(active_plugin_menu_path(&payload, "disabled", 0).is_err()); + assert!(active_plugin_menu_path(&payload, "deleted", 0).is_err()); + } + + #[test] + fn plugin_resource_urls_preserve_declared_paths_and_local_tls_origin() { + for (origin, path, expected) in [ + ( + "http://127.0.0.1:8317", + "/v0/resource/plugins/demo/page", + "http://127.0.0.1:8317/v0/resource/plugins/demo/page", + ), + ( + "https://[::1]:8443", + "/v9/resource/plugins/demo/page?mode=full#section", + "https://[::1]:8443/v9/resource/plugins/demo/page?mode=full#section", + ), + ] { + assert_eq!(resolve_plugin_resource_url(origin, path).unwrap(), expected); + } + for path in [ + "https://example.com/page", + "//example.com/page", + "/\\example.com/page", + "javascript:alert(1)", + "/page\n", + ] { + assert!(resolve_plugin_resource_url("http://127.0.0.1:8317", path).is_err()); + } + } +} diff --git a/src-tauri/src/provider_health.rs b/src-tauri/src/provider_health.rs index a9ee885..e15ce38 100644 --- a/src-tauri/src/provider_health.rs +++ b/src-tauri/src/provider_health.rs @@ -1,17 +1,22 @@ use super::{ - build_http_client_with_proxy, is_loopback_host, usage, GuiConfigState, APP_USER_AGENT, + build_http_client_with_proxy, effective_agent_api_key, fetch_agent_model_payload, is_loopback_host, + managed_core_loopback_origin, managed_core_tls_enabled, usage, validate_agent_model, + GuiConfigFile, GuiConfigState, APP_USER_AGENT, }; use chrono::Local; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, time::{Duration, Instant}, }; const MAX_PROVIDER_HEALTH_STREAM_BYTES: usize = 256 * 1024; static PROVIDER_HEALTH_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); +#[cfg(test)] +mod tests; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProviderHealthProbeRequest { @@ -160,6 +165,22 @@ fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json: return event == Some("interaction.complete") && value.pointer("/interaction/status").and_then(serde_json::Value::as_str) == Some("completed"); } + if protocol == "openai-chat" { + // CPA translates Gemini's exhausted thinking budget into OpenAI chunks. + // A confirmed reasoning-only generation is usable, but has no text TTFT. + let exhausted_thinking_budget = value + .get("choices") + .and_then(serde_json::Value::as_array) + .is_some_and(|choices| choices.iter().any(|choice| { + matches!(choice.get("finish_reason").and_then(serde_json::Value::as_str), + Some("length" | "max_tokens")) + })); + let thoughts = value.pointer("/usage/completion_tokens_details/reasoning_tokens") + .and_then(serde_json::Value::as_u64).unwrap_or_default(); + let output = value.pointer("/usage/completion_tokens") + .and_then(serde_json::Value::as_u64).unwrap_or_default(); + return exhausted_thinking_budget && thoughts > 0 && output >= thoughts; + } if protocol != "gemini" { return false; } @@ -302,11 +323,149 @@ pub(crate) fn provider_health_content_type_is_streaming(content_type: &str) -> b || content_type.contains("application/json-seq") } +#[tauri::command] +pub(crate) async fn get_core_models( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + fetch_core_models(config.port, effective_agent_api_key(&config)).await +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CoreHealthModel { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + display_name: Option, +} + +async fn fetch_core_models(port: u16, api_key: &str) -> Result, String> { + let payload = fetch_agent_model_payload(port, api_key).await?; + parse_core_models(&payload) +} + +fn parse_core_models(payload: &serde_json::Value) -> Result, String> { + let object_models = payload.get("models").and_then(serde_json::Value::as_object).map(|models| { + models.iter().map(|(id, value)| { + let mut entry = value.as_object().cloned().unwrap_or_default(); + entry.entry("id").or_insert(serde_json::json!(id)); + serde_json::Value::Object(entry) + }).collect::>() + }); + let entries = payload.as_array() + .or_else(|| payload.get("data").and_then(serde_json::Value::as_array)) + .or_else(|| payload.get("models").and_then(serde_json::Value::as_array)) + .or(object_models.as_ref()) + .ok_or_else(|| "Local model list response is missing a data array or models catalog".to_string())?; + let mut seen = HashSet::new(); + let mut models = Vec::new(); + for entry in entries { + let name = entry.as_str() + .or_else(|| entry.get("id").and_then(serde_json::Value::as_str)) + .or_else(|| entry.get("name").and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| "Local model list contains an invalid model ID".to_string())?; + // Agent configuration may resolve aliases or ignore case. Health checks + // must use each ID exactly as published by the inference endpoint. + if !seen.insert(name.to_string()) { + continue; + } + let display_name = ["display_name", "displayName", "name"].into_iter() + .find_map(|key| entry.get(key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|display| !display.is_empty() && *display != name) + .map(str::to_string); + models.push(CoreHealthModel { name: name.to_string(), display_name }); + } + Ok(models) +} + +#[tauri::command] +pub(crate) async fn core_health_probe( + gui_config_state: tauri::State<'_, GuiConfigState>, + model: String, + timeout_ms: Option, +) -> Result { + let config = gui_config_state.snapshot()?; + probe_core_model(&config, &model, timeout_ms).await +} + +async fn probe_core_model( + config: &GuiConfigFile, + model: &str, + timeout_ms: Option, +) -> Result { + let model = validate_agent_model(model)?; + if config.port == 0 { + return Err("Invalid kernel port".to_string()); + } + let tls_enabled = managed_core_tls_enabled(); + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(3)) + .timeout(health_probe_timeout(timeout_ms)) + .danger_accept_invalid_certs(tls_enabled) + .build() + .map_err(|error| format!("Failed to create kernel health check client: {error}"))?; + let request = ProviderHealthProbeRequest { + url: format!("{}/v1/chat/completions", managed_core_loopback_origin(config.port)), + header: HashMap::from([ + ("Authorization".to_string(), format!("Bearer {}", effective_agent_api_key(config))), + ("Content-Type".to_string(), "application/json".to_string()), + ("Accept".to_string(), "text/event-stream".to_string()), + ]), + data: serde_json::json!({ + "model": model, + "messages": [{ "role": "user", "content": "Reply with OK." }], + "max_tokens": 16, + "stream": true, + }).to_string(), + protocol: "openai-chat".to_string(), + source_provider: None, + timeout_ms, + model, + source: String::new(), + auth_index: String::new(), + }; + // The kernel records these requests itself. Only direct upstream probes need a + // desktop usage event, otherwise one health check appears twice in usage. + execute_health_probe(None, client, request).await +} + +fn health_probe_timeout(timeout_ms: Option) -> Duration { + Duration::from_millis(timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000)) +} + #[tauri::command] pub(crate) async fn provider_health_probe( app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, request: ProviderHealthProbeRequest, +) -> Result { + let url = reqwest::Url::parse(request.url.trim()) + .map_err(|error| format!("Invalid health check URL: {error}"))?; + let config = gui_config_state.snapshot()?; + let proxy_url = url + .host_str() + .filter(|host| !is_loopback_host(host)) + .map(|_| config.proxy_url.as_str()) + .unwrap_or_default(); + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(health_probe_timeout(request.timeout_ms)), + proxy_url, + "Failed to create health check client", + )?; + execute_health_probe(Some(&app), client, request).await +} + +async fn execute_health_probe( + usage_app: Option<&tauri::AppHandle>, + client: reqwest::Client, + request: ProviderHealthProbeRequest, ) -> Result { let url = reqwest::Url::parse(request.url.trim()) .map_err(|error| format!("Invalid health check URL: {error}"))?; @@ -328,20 +487,6 @@ pub(crate) async fn provider_health_probe( .acquire() .await .map_err(|error| error.to_string())?; - let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000)); - let config = gui_config_state.snapshot()?; - let proxy_url = url - .host_str() - .filter(|host| !is_loopback_host(host)) - .map(|_| config.proxy_url.as_str()) - .unwrap_or_default(); - let client = build_http_client_with_proxy( - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(timeout), - proxy_url, - "Failed to create health check client", - )?; let mut headers = reqwest::header::HeaderMap::new(); for (name, value) in &request.header { let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) @@ -395,21 +540,25 @@ pub(crate) async fn provider_health_probe( received.extend_from_slice(&chunk); let elapsed_ms = started_at.elapsed().as_millis().max(1) as u64; if provider_health_stream_has_text(&request.protocol, &received) { - persist_provider_health_success( - &app, - &request, - &endpoint, - elapsed_ms, - Some(elapsed_ms), - &received, - ); + if let Some(app) = usage_app { + persist_provider_health_success( + app, + &request, + &endpoint, + elapsed_ms, + Some(elapsed_ms), + &received, + ); + } return Ok(ProviderHealthProbeResponse { first_token_latency_ms: Some(elapsed_ms), response_latency_ms: elapsed_ms, }); } if provider_health_stream_has_terminal_success(&request.protocol, &received) { - persist_provider_health_success(&app, &request, &endpoint, elapsed_ms, None, &received); + if let Some(app) = usage_app { + persist_provider_health_success(app, &request, &endpoint, elapsed_ms, None, &received); + } return Ok(ProviderHealthProbeResponse { first_token_latency_ms: None, response_latency_ms: elapsed_ms, diff --git a/src-tauri/src/provider_health/tests.rs b/src-tauri/src/provider_health/tests.rs new file mode 100644 index 0000000..a179321 --- /dev/null +++ b/src-tauri/src/provider_health/tests.rs @@ -0,0 +1,191 @@ +use super::*; +use crate::GuiApiKeyEntry; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn mock_core_response( + status: &str, + content_type: &str, + initial_body: &str, + delayed_body: &str, +) -> (u16, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let head = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{initial_body}", + initial_body.len() + delayed_body.len(), + ); + let delayed_body = delayed_body.to_string(); + let server = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(5), async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut received = Vec::new(); + loop { + let mut chunk = [0; 2048]; + let size = socket.read(&mut chunk).await.unwrap(); + assert!(size > 0, "request ended before its body"); + received.extend_from_slice(&chunk[..size]); + let Some(end) = received.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&received[..end]); + let length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }).unwrap_or(0); + if received.len() >= end + 4 + length { + break; + } + } + socket.write_all(head.as_bytes()).await.unwrap(); + if !delayed_body.is_empty() { + tokio::time::sleep(Duration::from_millis(60)).await; + let _ = socket.write_all(delayed_body.as_bytes()).await; + } + String::from_utf8(received).unwrap() + }).await.expect("mock kernel timed out") + }); + (port, server) +} + +fn probe_config(port: u16) -> GuiConfigFile { + GuiConfigFile { + port, + api_keys: vec![ + GuiApiKeyEntry { key: " ".to_string(), remark: String::new() }, + GuiApiKeyEntry { key: " test-client-access-key ".to_string(), remark: String::new() }, + ], + management_secret_key: "must-never-be-used-for-inference".to_string(), + // A local kernel probe must succeed independently of upstream proxy settings. + proxy_url: "http://127.0.0.1:1".to_string(), + ..GuiConfigFile::default() + } +} + +#[tokio::test] +async fn core_health_probe_uses_local_inference_key_and_waits_for_model_text() { + let metadata = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n"; + let text = "data: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}]}\n\n"; + let (port, server) = mock_core_response("200 OK", "text/event-stream", metadata, text).await; + let result = probe_core_model(&probe_config(port), " custom/model-alias ", Some(2_000)) + .await.unwrap(); + assert!(result.first_token_latency_ms.unwrap() >= 50); + assert_eq!(result.first_token_latency_ms, Some(result.response_latency_ms)); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1\r\n")); + assert!(request.to_ascii_lowercase().contains("authorization: bearer test-client-access-key\r\n")); + assert!(!request.contains("must-never-be-used-for-inference")); + let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["model"], "custom/model-alias"); + assert_eq!(body["stream"], true); + assert_eq!(body["max_tokens"], 16); + assert_eq!(body["messages"][0]["role"], "user"); + let payload = serde_json::to_value(result).unwrap(); + assert!(payload["firstTokenLatencyMs"].as_u64().is_some()); + assert!(payload["responseLatencyMs"].as_u64().is_some()); +} + +#[tokio::test] +async fn core_health_probe_rejects_http_errors_redirects_and_empty_streams() { + for (status, content_type, body, expected) in [ + ("401 Unauthorized", "application/json", "access denied", "HTTP 401"), + ("200 OK", "application/json", "{\"choices\":[]}", "streaming response"), + ("200 OK", "text/event-stream", "data: [DONE]\n\n", "first token"), + ("302 Found\r\nLocation: http://127.0.0.1:1/v1/chat/completions", "text/plain", "", "HTTP 302"), + ] { + let (port, server) = mock_core_response(status, content_type, body, "").await; + let error = probe_core_model(&probe_config(port), "test-model", Some(2_000)).await.err().unwrap(); + assert!(error.contains(expected), "unexpected probe error: {error}"); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn core_health_probe_rejects_invalid_model_and_port_without_a_request() { + let config = probe_config(0); + for model in ["", " ", "bad\nmodel", &"a".repeat(241)] { + let error = probe_core_model(&config, model, None).await.err().unwrap(); + assert!(error.contains("model"), "unexpected validation error: {error}"); + } + assert!(probe_core_model(&config, "valid-model", None).await.err().unwrap().contains("port")); +} + +#[tokio::test] +async fn core_health_model_list_preserves_all_provider_models() { + let body = r#"{"data":[{"id":"gpt-test"},{"id":"claude-test"},{"id":"gemini-test"},{"id":"custom-alias"},{"id":"Foo","alias":"metadata-alias"},{"id":"foo"},{"id":"Foo"}]}"#; + let (port, server) = mock_core_response("200 OK", "application/json", body, "").await; + let config = probe_config(port); + let models = fetch_core_models(config.port, effective_agent_api_key(&config)).await.unwrap(); + assert_eq!(models.iter().map(|model| model.name.as_str()).collect::>(), + vec!["gpt-test", "claude-test", "gemini-test", "custom-alias", "Foo", "foo"]); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1\r\n")); + assert!(request.to_ascii_lowercase().contains("authorization: bearer test-client-access-key\r\n")); + assert!(!request.contains("must-never-be-used-for-inference")); +} + +#[test] +fn core_health_probe_timeout_is_bounded() { + assert_eq!(health_probe_timeout(None), Duration::from_secs(15)); + assert_eq!(health_probe_timeout(Some(0)), Duration::from_secs(1)); + assert_eq!(health_probe_timeout(Some(u64::MAX)), Duration::from_secs(120)); +} + +#[test] +fn core_model_parser_keeps_public_ids_without_agent_alias_rewrites() { + let models = parse_core_models(&serde_json::json!({ "data": [ + { "id": "Foo", "name": "Display Foo", "alias": "never-use-this-alias" }, + { "id": "foo", "displayName": "Lowercase foo" }, + { "id": "Foo", "displayName": "duplicate" }, + { "name": "legacy-model", "alias": "legacy-alias" }, + ] })).unwrap(); + assert_eq!(serde_json::to_value(models).unwrap(), serde_json::json!([ + { "name": "Foo", "displayName": "Display Foo" }, + { "name": "foo", "displayName": "Lowercase foo" }, + { "name": "legacy-model" }, + ])); + let catalog = parse_core_models(&serde_json::json!({ "models": { + "public-id": { "alias": "private-alias", "display_name": "Public model" }, + } })).unwrap(); + assert_eq!(catalog[0].name, "public-id"); + assert!(parse_core_models(&serde_json::json!({ "data": [{}] })).is_err()); + assert!(parse_core_models(&serde_json::json!({ "data": null })).is_err()); + assert!(parse_core_models(&serde_json::json!({ "data": [] })).unwrap().is_empty()); +} + +#[tokio::test] +async fn core_health_accepts_confirmed_reasoning_only_budget_exhaustion_without_ttft() { + for finish_reason in ["max_tokens", "length"] { + let body = format!("data: {}\n\ndata: [DONE]\n\n", serde_json::json!({ + "choices": [{ "delta": {}, "finish_reason": finish_reason }], + "usage": { "completion_tokens": 16, "completion_tokens_details": { "reasoning_tokens": 16 } }, + })); + let (port, server) = mock_core_response("200 OK", "text/event-stream", &body, "").await; + let result = probe_core_model(&probe_config(port), "gemini-thinking", Some(2_000)).await.unwrap(); + assert!(result.first_token_latency_ms.is_none()); + assert!(result.response_latency_ms > 0); + server.await.unwrap(); + } +} + +#[test] +fn openai_terminal_success_requires_both_exhausted_budget_and_valid_reasoning_usage() { + for value in [ + serde_json::json!({ "choices": [{ "delta": {}, "finish_reason": "max_tokens" }] }), + serde_json::json!({ "choices": [{ "finish_reason": "max_tokens" }], "usage": { + "completion_tokens": 16, "completion_tokens_details": { "reasoning_tokens": 0 }, + } }), + serde_json::json!({ "choices": [{ "finish_reason": "max_tokens" }], "usage": { + "completion_tokens": 4, "completion_tokens_details": { "reasoning_tokens": 16 }, + } }), + serde_json::json!({ "choices": [{ "finish_reason": "content_filter" }], "usage": { + "completion_tokens": 16, "completion_tokens_details": { "reasoning_tokens": 16 }, + } }), + serde_json::json!({ "choices": [], "usage": { + "completion_tokens": 16, "completion_tokens_details": { "reasoning_tokens": 16 }, + } }), + ] { + let body = format!("data: {value}\n\ndata: [DONE]\n\n"); + assert!(!provider_health_stream_has_terminal_success("openai-chat", body.as_bytes())); + } +} diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index f6b77f0..ba50802 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -50,6 +50,7 @@ fn oauth_auth_provider_names_map_to_alias_channels() { ); assert_eq!(normalize_oauth_alias_channel("grok"), Some("xai")); assert_eq!(normalize_oauth_alias_channel("meta"), Some("meta")); + assert_eq!(normalize_oauth_alias_channel(" Muse "), Some("meta")); } #[test] diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index b0ee788..dce9402 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -47,7 +47,7 @@ const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5"; const USAGE_DATABASE_MAX_MB_KEY: &str = "max_database_size_mb"; const USAGE_UPDATED_EVENT: &str = "usage-records-updated"; const USAGE_SCHEMA_VERSION: u8 = 1; -const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5; +const USAGE_DATABASE_SCHEMA_VERSION: i64 = 6; const MAX_USAGE_FAILURE_BODY_CHARS: usize = 2_000; const USAGE_QUEUE_BATCH_SIZE: usize = 10_000; const USAGE_INBOX_PROCESS_LIMIT: usize = 500; @@ -225,6 +225,8 @@ pub(crate) struct UsageRecord { #[serde(default)] model: String, #[serde(default)] + response_model: String, + #[serde(default)] alias: String, #[serde(default, skip_serializing)] client_ip: Option, @@ -1214,6 +1216,7 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { failure_body TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', + response_model TEXT NOT NULL DEFAULT '', alias TEXT NOT NULL DEFAULT '', reasoning_effort TEXT NOT NULL DEFAULT '', service_tier TEXT NOT NULL DEFAULT '', @@ -1303,6 +1306,7 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { ) .map_err(|error| format!("Failed to initialize SQLite usage records schema: {error}"))?; ensure_usage_failure_columns(connection)?; + ensure_usage_response_model_column(connection)?; connection .execute( "CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)", @@ -1314,6 +1318,20 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { .map_err(|error| format!("Failed to update SQLite usage records version: {error}")) } +fn ensure_usage_response_model_column(connection: &Connection) -> Result<(), String> { + if !usage_table_columns(connection, "usage_events")?.contains("response_model") { + connection + .execute( + "ALTER TABLE usage_events ADD COLUMN response_model TEXT NOT NULL DEFAULT ''", + [], + ) + .map_err(|error| { + format!("Failed to add SQLite usage record response_model field: {error}") + })?; + } + Ok(()) +} + fn ensure_usage_failure_columns(connection: &Connection) -> Result<(), String> { let mut columns = usage_table_columns(connection, "usage_events")?; for (column, definition) in [ @@ -2555,12 +2573,12 @@ fn insert_usage_records_in_transaction( generate, cached_tokens, collector_source, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, cache_creation_tokens, total_tokens, canceled, failure_status, - failure_body, created_at + failure_body, created_at, response_model ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, - ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40 + ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41 ) "#, ) @@ -2631,6 +2649,7 @@ fn insert_usage_records_in_transaction( i64::from(record.failure_status), record.failure_body, created_at, + record.response_model, ]) .map_err(|error| format!("Failed to write SQLite usage records: {error}"))?, ); @@ -2747,6 +2766,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result) -> rusqlite::Result { provider: row.get(7)?, api_group_key: row.get(20)?, model: row.get(8)?, + response_model: row.get(37)?, alias: row.get(9)?, client_ip: row.get(21)?, x_forwarded_for: row.get(22)?, @@ -5022,6 +5043,7 @@ mod tests { provider: "openai".to_string(), api_group_key: "hash".to_string(), model: model.to_string(), + response_model: String::new(), alias: String::new(), client_ip: None, x_forwarded_for: None, @@ -5188,6 +5210,126 @@ mod tests { assert!(!record.api_key_hash.is_empty()); } + #[test] + fn normalizes_response_model_when_present_and_defaults_when_missing() { + let config = GuiConfigFile::default(); + for (response_model, expected) in [ + (None, ""), + (Some(serde_json::json!(null)), ""), + (Some(serde_json::json!(" ")), ""), + ( + Some(serde_json::json!(" gemini-3.8-flash ")), + "gemini-3.8-flash", + ), + ] { + let mut value = serde_json::json!({ + "request_id": "response-model-normalization", + "model": "gemini-3.8-flash-high", + "alias": "client-alias" + }); + if let Some(response_model) = response_model { + value["response_model"] = response_model; + } + let record = normalize_usage_record(value, &config).unwrap(); + + assert_eq!(record.response_model, expected); + assert_eq!(record.model, "gemini-3.8-flash-high"); + assert_eq!(record.alias, "client-alias"); + assert_eq!( + serde_json::to_value(&record).unwrap()["response_model"], + expected + ); + } + } + + #[test] + fn response_model_json_roundtrip_accepts_legacy_records_without_the_field() { + let mut record = sample_record("response-json", "2026-07-17T20:30:00+08:00", "gpt-a"); + record.response_model = "gpt-upstream".to_string(); + let mut encoded = serde_json::to_value(&record).unwrap(); + let decoded: UsageRecord = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(decoded.response_model, "gpt-upstream"); + + encoded.as_object_mut().unwrap().remove("response_model"); + let legacy: UsageRecord = serde_json::from_value(encoded).unwrap(); + assert!(legacy.response_model.is_empty()); + assert_eq!(legacy.model, "gpt-a"); + } + + #[test] + fn response_model_survives_sqlite_roundtrip() { + let root = test_root("response-model-roundtrip"); + let mut connection = open_test_database(&root); + let mut record = sample_record( + "response-model-roundtrip", + "2026-07-17T20:30:00+08:00", + "gemini-alias", + ); + record.response_model = "gemini-3.8-flash-high".to_string(); + insert_usage_records(&mut connection, &[record]).unwrap(); + let page = load_usage_events( + &connection, + &UsageQuery::default(), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].response_model, "gemini-3.8-flash-high"); + assert_eq!(page.items[0].model, "gemini-alias"); + assert_eq!(page.items[0].tokens.total_tokens, 30); + assert_eq!( + serde_json::to_value(&page.items[0]).unwrap()["response_model"], + "gemini-3.8-flash-high" + ); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn response_model_migration_preserves_v5_records_and_is_idempotent() { + let root = test_root("response-model-v6-migration"); + let mut connection = open_test_database(&root); + let record = sample_record("legacy-v5", "2026-07-17T20:30:00+08:00", "gpt-a"); + insert_usage_records(&mut connection, &[record]).unwrap(); + connection + .execute_batch( + "ALTER TABLE usage_events DROP COLUMN response_model; PRAGMA user_version = 5;", + ) + .unwrap(); + assert!(!usage_table_columns(&connection, "usage_events") + .unwrap() + .contains("response_model")); + drop(connection); + + initialize_usage_storage_at(&root).unwrap(); + let mut connection = open_usage_database_at(&root).unwrap(); + let config = GuiConfigFile::default(); + let legacy_page = load_usage_events(&connection, &UsageQuery::default(), &config).unwrap(); + assert_eq!(legacy_page.total, 1); + assert!(legacy_page.items[0].response_model.is_empty()); + assert_eq!(legacy_page.items[0].tokens.total_tokens, 30); + + let mut record = sample_record("new-v6", "2026-07-17T20:31:00+08:00", "gpt-b"); + record.response_model = "gpt-upstream".to_string(); + insert_usage_records(&mut connection, &[record]).unwrap(); + drop(connection); + + initialize_usage_storage_at(&root).unwrap(); + let connection = open_usage_database_at(&root).unwrap(); + let page = load_usage_events(&connection, &UsageQuery::default(), &config).unwrap(); + assert_eq!(page.total, 2); + assert_eq!(page.items[0].response_model, "gpt-upstream"); + assert!(page.items[1].response_model.is_empty()); + assert_eq!( + connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .unwrap(), + USAGE_DATABASE_SCHEMA_VERSION + ); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn preserves_explicit_cache_read_and_only_backfills_missing_alias() { let config = GuiConfigFile::default(); @@ -5809,6 +5951,9 @@ mod tests { let user_version = connection .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) .unwrap(); + let response_model_column = usage_table_columns(&connection, "usage_events") + .unwrap() + .contains("response_model"); let migrated = connection .query_row( r#" @@ -5842,6 +5987,7 @@ mod tests { .unwrap(); assert_eq!(user_version, USAGE_DATABASE_SCHEMA_VERSION); + assert!(response_model_column); assert_eq!(migrated.0, 1); assert_eq!(migrated.1, 30); assert_eq!(migrated.2, "legacy-hash"); @@ -6254,8 +6400,15 @@ mod tests { let inbox_dir = root.join(LEGACY_USAGE_INBOX_DIR); fs::create_dir_all(&events_dir).unwrap(); fs::create_dir_all(&inbox_dir).unwrap(); - let first = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a"); - let second = sample_record("request-2", "2026-07-17T20:31:00+08:00", "gpt-b"); + let mut first = serde_json::to_value(sample_record( + "request-1", + "2026-07-17T20:30:00+08:00", + "gpt-a", + )) + .unwrap(); + first.as_object_mut().unwrap().remove("response_model"); + let mut second = sample_record("request-2", "2026-07-17T20:31:00+08:00", "gpt-b"); + second.response_model = "gpt-b-upstream".to_string(); fs::write( events_dir.join("2026-07-17-20.json"), serde_json::to_vec(&serde_json::json!({ @@ -6295,6 +6448,14 @@ mod tests { assert_eq!(count, 2); assert_eq!(marker, "2"); + let page = load_usage_events( + &connection, + &UsageQuery::default(), + &GuiConfigFile::default(), + ) + .unwrap(); + assert_eq!(page.items[0].response_model, "gpt-b-upstream"); + assert!(page.items[1].response_model.is_empty()); drop(connection); fs::remove_dir_all(root).unwrap(); } diff --git a/src/App.tsx b/src/App.tsx index 28c0d19..2c94018 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { MessageCircle, Network, PackageOpen, + Puzzle, ServerCog, Settings, X, @@ -30,6 +31,7 @@ import { OAuthManagementPage } from './pages/ManagementPages'; import { AgentsPage } from './pages/AgentsPage'; import { EasyModePage } from './pages/EasyModePage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; +import { PluginsPage } from './pages/PluginsPage'; import { languageOptions, useI18n } from './i18n'; import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; import { appUpdateIndicatorState } from './appUpdateModel'; @@ -77,6 +79,12 @@ const pages = [ icon: History, component: UsageRecordsPage, }, + { + id: 'plugins', + labelKey: 'app.nav.plugins', + icon: Puzzle, + component: PluginsPage, + }, { id: 'config', labelKey: 'app.nav.config', diff --git a/src/assets/icons/meta.svg b/src/assets/icons/meta.svg new file mode 100644 index 0000000..a8a8d94 --- /dev/null +++ b/src/assets/icons/meta.svg @@ -0,0 +1 @@ +Meta \ No newline at end of file diff --git a/src/components/OAuthPageToolbar.css b/src/components/OAuthPageToolbar.css new file mode 100644 index 0000000..5de60c7 --- /dev/null +++ b/src/components/OAuthPageToolbar.css @@ -0,0 +1,91 @@ +.oauth-page-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 24px; + min-width: 0; + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-card); +} + +.oauth-page-toolbar-summary { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + color: var(--text-secondary); + font-size: var(--font-size-label); + font-weight: var(--font-weight-medium); + line-height: 1.5; + font-variant-numeric: tabular-nums; +} + +.oauth-page-toolbar-icon { + display: inline-flex; + flex: 0 0 34px; + align-items: center; + justify-content: center; + height: 34px; + border-radius: 9px; + background: var(--ui-accent-soft); + color: var(--primary-color); +} + +.oauth-page-toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; + margin-left: auto; +} + +.oauth-page-toolbar-actions > button { + flex: 0 0 auto; + gap: 7px; + min-height: 36px; + margin: 0; + padding: 0 12px; + border-radius: 8px; + box-shadow: none; +} + +.oauth-page-toolbar-actions > .primary-button { + color: var(--primary-contrast); +} + +.oauth-page-toolbar .oauth-page-toolbar-actions > button.oauth-toolbar-quiet { + border-color: transparent; + background: transparent; + color: var(--text-tertiary); +} + +.oauth-page-toolbar .oauth-page-toolbar-actions > button.oauth-toolbar-quiet:hover:not(:disabled) { + border-color: transparent; + background: var(--bg-hover); + color: var(--text-primary); +} + +.oauth-page-toolbar-actions > button:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 3px; +} + +@container workspace (max-width: 760px) { + .oauth-page-toolbar { + gap: 12px; + padding: 12px; + } + + .oauth-page-toolbar-actions { + flex-basis: 100%; + justify-content: flex-start; + margin-left: 0; + padding-top: 12px; + border-top: 1px solid var(--border-color); + } +} diff --git a/src/components/OAuthPageToolbar.tsx b/src/components/OAuthPageToolbar.tsx new file mode 100644 index 0000000..3b57b2d --- /dev/null +++ b/src/components/OAuthPageToolbar.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import './OAuthPageToolbar.css'; + +type OAuthPageToolbarProps = { + icon: ReactNode; + summary: ReactNode; + children: ReactNode; +}; + +export function OAuthPageToolbar({ icon, summary, children }: OAuthPageToolbarProps) { + return ( +
+
+ + {summary} +
+
{children}
+
+ ); +} diff --git a/src/components/ProviderGroupKeysEditor.tsx b/src/components/ProviderGroupKeysEditor.tsx index 0299de3..5049b74 100644 --- a/src/components/ProviderGroupKeysEditor.tsx +++ b/src/components/ProviderGroupKeysEditor.tsx @@ -1,126 +1,75 @@ import { Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { useI18n } from '../i18n'; -import type { MessageKey } from '../i18n/resources'; -import { isRecord, maskSecret, readString } from '../services/managementApi'; -import { effectiveProviderKey, providerKeyDraft, providerKeyOverrides, type ProviderKeyDraft } from '../services/providerGroups'; +import { readString } from '../services/managementApi'; +import { providerKeyDraft, type ProviderKeyDraft } from '../services/providerGroups'; import '../styles/provider-groups.css'; -import { ProviderKeyTemplateFields, ProviderModelFields } from './ProviderTemplateFields'; type Props = { keys: ProviderKeyDraft[]; - shared: Record; - section: string; disabled: boolean; onChange: (keys: ProviderKeyDraft[]) => void; }; -export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onChange }: Props) { +export function ProviderGroupKeysEditor({ keys, disabled, onChange }: Props) { const { t } = useI18n(); const [visible, setVisible] = useState>({}); - const update = (id: string, patch: (draft: ProviderKeyDraft) => ProviderKeyDraft) => - onChange(keys.map((draft) => draft.id === id ? patch(draft) : draft)); - const set = (id: string, field: string, value: unknown) => update(id, (draft) => { - const next = { ...draft.value }; - if (value === undefined) delete next[field]; - else next[field] = value; - const text = { ...draft.text }; - delete text[field]; - return { ...draft, value: next, text }; - }); - const text = (id: string, field: string, value: string) => update(id, (draft) => ({ ...draft, text: { ...draft.text, [field]: value } })); - const fields: { field: string; label: MessageKey; kind: 'number' | 'text' | 'boolean' | 'lines'; initial: unknown }[] = [ - { field: 'priority', label: 'apiAccess.field.priority', kind: 'number', initial: 0 }, - { field: 'proxy-url', label: 'apiAccess.field.proxyUrl', kind: 'text', initial: '' }, - { field: 'prefix', label: 'apiAccess.field.prefix', kind: 'text', initial: '' }, - { field: 'request-retry', label: 'apiAccess.groups.retry', kind: 'number', initial: 0 }, - { field: 'disable-cooling', label: 'apiAccess.cooling.title', kind: 'boolean', initial: false }, - { field: 'headers', label: 'apiAccess.field.headers', kind: 'lines', initial: {} }, - { field: 'excluded-models', label: 'apiAccess.field.excludedModels', kind: 'lines', initial: [] }, - ]; - return
-
{t('apiAccess.groups.keys')}

{t('apiAccess.groups.keysHint')}

+ const set = (id: string, field: string, value: unknown) => { + onChange(keys.map((draft) => { + if (draft.id !== id) return draft; + const next = { ...draft.value }; + if (value === undefined) delete next[field]; + else next[field] = value; + const text = { ...draft.text }; + delete text[field]; + return { ...draft, value: next, text }; + })); + }; + + return
+
+ {t('apiAccess.entries.keys')} + onClick={() => onChange([...keys, providerKeyDraft({ 'api-key': '' })])}> + {t('apiAccess.entries.addKey')} +
- {!keys.length ?

{t('apiAccess.groups.emptyKeys')}

: null} + {!keys.length ?

{t('apiAccess.entries.emptyKeys')}

: null} {keys.map((draft, index) => { const key = draft.value; - const overrideCount = providerKeyOverrides(key).length; - const effective = effectiveProviderKey(shared, key); + const isVisible = Boolean(visible[draft.id]); return
- {t('apiAccess.groups.keyNumber', { number: index + 1 })} + {t('apiAccess.entries.keyNumber', { number: index + 1 })}
- - - + + +
- {t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')} - -

{t('apiAccess.groups.overrideHint')}

- {fields.filter(({ field }) => section !== 'openai-compatibility' || field === 'proxy-url').map(({ field, label, kind, initial }) => { - const overridden = key[field] != null; - const content = draft.text?.[field] ?? (field === 'headers' && isRecord(key[field]) - ? Object.entries(key[field]).map(([name, value]) => `${name}: ${value}`).join('\n') - : Array.isArray(key[field]) ? key[field].join('\n') : String(key[field] ?? '')); - return
- - {overridden ? kind === 'lines' ?