diff --git a/Cargo.lock b/Cargo.lock index 9a2c9082..78173165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -1908,6 +1909,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" diff --git a/Cargo.toml b/Cargo.toml index bdf8ecef..1c6c517f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] # HTTP server -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tokio = { version = "1", features = ["full"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } diff --git a/src/core/executor/api_key.rs b/src/core/executor/api_key.rs index 7de4f335..fc9667c7 100644 --- a/src/core/executor/api_key.rs +++ b/src/core/executor/api_key.rs @@ -216,7 +216,28 @@ impl ApiKeyExecutor { } fn transform_request(&self, body: &Value) -> Value { - body.clone() + let mut transformed = body.clone(); + normalize_developer_role(&mut transformed); + transformed + } +} + +/// Many OpenAI-format providers (Deepseek, Groq, Mistral, Perplexity, Together, +/// Fireworks, Cerebras, xAI, NVIDIA, …) reject `role: "developer"` with a 400 +/// — they only accept `system`, `user`, `assistant`, `tool`. OpenAI itself uses +/// `developer` for its newer Responses API. Rewrite at the dispatch boundary so +/// the upstream sees the role it understands. +fn normalize_developer_role(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { + return; + }; + for message in messages { + let Some(role) = message.get_mut("role") else { + continue; + }; + if role.as_str() == Some("developer") { + *role = Value::String("system".to_string()); + } } } @@ -349,3 +370,55 @@ pub fn get_api_key_provider_config(provider: &str) -> Option<(&'static str, &'st pub fn is_api_key_provider(provider: &str) -> bool { API_KEY_PROVIDERS.contains_key(provider) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalize_developer_role_rewrites_developer_to_system() { + let mut body = json!({ + "model": "deepseek-chat", + "messages": [ + { "role": "developer", "content": "be terse" }, + { "role": "user", "content": "hi" }, + ] + }); + normalize_developer_role(&mut body); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["role"], "user"); + } + + #[test] + fn normalize_developer_role_leaves_other_roles_alone() { + let mut body = json!({ + "messages": [ + { "role": "system", "content": "you are helpful" }, + { "role": "user", "content": "hello" }, + { "role": "assistant", "content": "hi" }, + { "role": "tool", "content": "{}" }, + ] + }); + let original = body.clone(); + normalize_developer_role(&mut body); + assert_eq!(body, original); + } + + #[test] + fn normalize_developer_role_handles_missing_messages_field() { + let mut body = json!({ "model": "x" }); + normalize_developer_role(&mut body); + assert_eq!(body, json!({ "model": "x" })); + } + + #[test] + fn normalize_developer_role_handles_messages_without_role_field() { + let mut body = json!({ + "messages": [{ "content": "no role" }] + }); + let original = body.clone(); + normalize_developer_role(&mut body); + assert_eq!(body, original); + } +} diff --git a/src/core/model/provider_catalog.json b/src/core/model/provider_catalog.json index 348a610f..0c2e800b 100644 --- a/src/core/model/provider_catalog.json +++ b/src/core/model/provider_catalog.json @@ -266,6 +266,11 @@ "name": "GPT 5 Codex Mini Review", "kind": "llm" }, + { + "id": "gpt-5.5-image", + "name": "GPT 5.5 Image", + "kind": "image" + }, { "id": "gpt-5.4-image", "name": "GPT 5.4 Image", diff --git a/src/core/usage/mod.rs b/src/core/usage/mod.rs index 84cffb99..c50f1409 100644 --- a/src/core/usage/mod.rs +++ b/src/core/usage/mod.rs @@ -18,6 +18,7 @@ //! Subscriptions (Claude Code, Codex, Copilot, Cursor) are tracked via their own quota systems. mod pricing; +pub mod quota_fetcher; mod tracker; pub use pricing::{CostModel, ModelPricing, Pricing}; diff --git a/src/core/usage/quota_fetcher.rs b/src/core/usage/quota_fetcher.rs new file mode 100644 index 00000000..37dcecdd --- /dev/null +++ b/src/core/usage/quota_fetcher.rs @@ -0,0 +1,477 @@ +//! Live provider quota fetchers (GLM, MiniMax). +//! +//! Each provider exposes a small JSON API that reports remaining quota for the +//! current billing window. These functions issue a one-shot GET and normalize +//! the response into the canonical `quotas` shape used by the dashboard: +//! +//! ```jsonc +//! { +//! "plan": "Pro", // optional, GLM only +//! "quotas": { +//! "session (5h)": { +//! "used": 12.3, +//! "total": 100, +//! "remaining": 87.7, +//! "remainingPercentage": 87.7, +//! "resetAt": "2026-05-12T18:30:00Z", +//! "unlimited": false +//! } +//! } +//! } +//! ``` +//! +//! Mirrors `open-sse/services/usage.js` from decolua/9router. + +use serde_json::{json, Value}; +use std::time::Duration; + +const GLM_INTL_URL: &str = "https://api.z.ai/api/monitor/usage/quota/limit"; +const GLM_CN_URL: &str = "https://open.bigmodel.cn/api/monitor/usage/quota/limit"; + +// Tried in order; later entries are fallbacks for transient errors only. +const MINIMAX_INTL_URLS: &[&str] = &[ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", +]; +const MINIMAX_CN_URLS: &[&str] = &[ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", +]; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Fetch GLM (z.ai / open.bigmodel.cn) quota using the provider's API key. +/// `provider` is one of `glm` (intl) or `glm-cn` (china). +pub async fn fetch_glm_quota(api_key: &str, provider: &str) -> Value { + if api_key.is_empty() { + return json!({ "message": "GLM API key not available." }); + } + let url = if provider == "glm-cn" { + GLM_CN_URL + } else { + GLM_INTL_URL + }; + + let client = http_client(); + let response = match client + .get(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .send() + .await + { + Ok(r) => r, + Err(e) => return json!({ "message": format!("GLM error: {e}") }), + }; + + let status = response.status(); + if !status.is_success() { + let msg = if status.as_u16() == 401 { + "GLM API key invalid or expired.".to_string() + } else { + format!("GLM quota API error ({}).", status.as_u16()) + }; + return json!({ "message": msg }); + } + + let body: Value = match response.json().await { + Ok(v) => v, + Err(e) => return json!({ "message": format!("GLM error: {e}") }), + }; + + let data = body.get("data").cloned().unwrap_or_else(|| json!({})); + let limits = data + .get("limits") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut quotas = serde_json::Map::new(); + for limit in &limits { + if limit.get("type").and_then(|v| v.as_str()) != Some("TOKENS_LIMIT") { + continue; + } + let used_percent = limit + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let reset_ms = limit + .get("nextResetTime") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let remaining = (100.0 - used_percent).max(0.0); + let reset_at = if reset_ms > 0 { + chrono::DateTime::::from_timestamp_millis(reset_ms) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + } else { + None + }; + quotas.insert( + "session".to_string(), + json!({ + "used": used_percent, + "total": 100, + "remaining": remaining, + "remainingPercentage": remaining, + "resetAt": reset_at, + "unlimited": false, + }), + ); + } + + let plan = data + .get("level") + .and_then(|v| v.as_str()) + .map(|raw| { + let mut chars = raw.chars(); + match chars.next() { + Some(c) => { + c.to_ascii_uppercase().to_string() + + chars.as_str().to_ascii_lowercase().as_str() + } + None => "Unknown".to_string(), + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + json!({ "plan": plan, "quotas": Value::Object(quotas) }) +} + +fn minimax_field<'a>(model: &'a Value, snake: &str, camel: &str) -> Option<&'a Value> { + model.get(snake).or_else(|| model.get(camel)) +} + +fn minimax_num(model: &Value, snake: &str, camel: &str) -> f64 { + minimax_field(model, snake, camel) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) +} + +fn is_text_quota_model(name: &str) -> bool { + let n = name.trim().to_lowercase(); + n.starts_with("minimax-m") || n.starts_with("coding-plan") +} + +fn build_minimax_quota( + total: f64, + count: f64, + reset_at: Option, + count_is_remaining: bool, +) -> Value { + let safe_total = total.max(0.0); + let used = if count_is_remaining { + (safe_total - count).max(0.0) + } else { + count.max(0.0).min(safe_total) + }; + let remaining = (safe_total - used).max(0.0); + let remaining_pct = if safe_total > 0.0 { + ((remaining / safe_total) * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + json!({ + "used": used, + "total": safe_total, + "remaining": remaining, + "remainingPercentage": remaining_pct, + "resetAt": reset_at, + "unlimited": false, + }) +} + +fn pick_representative<'a, F: Fn(&Value) -> f64>( + models: &'a [Value], + get_total: F, +) -> Option<&'a Value> { + let with_quota: Vec<&Value> = models.iter().filter(|m| get_total(m) > 0.0).collect(); + let pool = if !with_quota.is_empty() { + with_quota + } else { + models.iter().collect() + }; + pool.into_iter().max_by(|a, b| { + get_total(a) + .partial_cmp(&get_total(b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) +} + +fn minimax_reset_at( + model: &Value, + captured_at_ms: i64, + remains_snake: &str, + remains_camel: &str, + end_snake: &str, + end_camel: &str, +) -> Option { + let remains_ms = minimax_num(model, remains_snake, remains_camel); + if remains_ms > 0.0 { + return chrono::DateTime::::from_timestamp_millis( + captured_at_ms + remains_ms as i64, + ) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + } + minimax_field(model, end_snake, end_camel) + .and_then(|v| v.as_i64()) + .and_then(|ms| { + chrono::DateTime::::from_timestamp_millis(ms) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + }) +} + +/// Fetch MiniMax token-plan / coding-plan quota. `provider` is one of +/// `minimax` (intl) or `minimax-cn` (china). +pub async fn fetch_minimax_quota(api_key: &str, provider: &str) -> Value { + if api_key.is_empty() { + return json!({ "message": "MiniMax API key not available." }); + } + let urls: &[&str] = if provider == "minimax-cn" { + MINIMAX_CN_URLS + } else { + MINIMAX_INTL_URLS + }; + + let client = http_client(); + let mut last_error: Option = None; + + for (index, url) in urls.iter().enumerate() { + let can_fallback = index + 1 < urls.len(); + let response = match client + .get(*url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .send() + .await + { + Ok(r) => r, + Err(e) => { + last_error = Some(e.to_string()); + if can_fallback { + continue; + } + break; + } + }; + + let status = response.status(); + let raw_text = response.text().await.unwrap_or_default(); + let payload: Value = if raw_text.is_empty() { + json!({}) + } else { + serde_json::from_str(&raw_text).unwrap_or_else(|_| json!({})) + }; + let base_resp = payload + .get("base_resp") + .or_else(|| payload.get("baseResp")) + .cloned() + .unwrap_or_else(|| json!({})); + let api_status = base_resp + .get("status_code") + .or_else(|| base_resp.get("statusCode")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let api_msg = base_resp + .get("status_msg") + .or_else(|| base_resp.get("statusMsg")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let combined = format!("{api_msg} {raw_text}").to_lowercase(); + let auth_like = [ + "token plan", + "coding plan", + "invalid api key", + "invalid key", + "unauthorized", + "inactive", + ] + .iter() + .any(|needle| combined.contains(needle)); + + if status.as_u16() == 401 || status.as_u16() == 403 || api_status == 1004 || auth_like { + return json!({ "message": "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." }); + } + + if !status.is_success() { + let err = format!("MiniMax usage endpoint error ({})", status.as_u16()); + last_error = Some(err.clone()); + let transient = matches!(status.as_u16(), 404 | 405) || status.as_u16() >= 500; + if transient && can_fallback { + continue; + } + return json!({ "message": format!("MiniMax connected. {err}") }); + } + + if api_status != 0 { + let msg = if api_msg.is_empty() { + "Upstream quota API error".to_string() + } else { + api_msg + }; + return json!({ "message": format!("MiniMax connected. {msg}") }); + } + + let model_remains = payload + .get("model_remains") + .or_else(|| payload.get("modelRemains")); + let all_models: Vec = model_remains + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let text_models: Vec = all_models + .into_iter() + .filter(|m| { + let name = minimax_field(m, "model_name", "modelName") + .and_then(|v| v.as_str()) + .unwrap_or(""); + is_text_quota_model(name) + }) + .collect(); + + if text_models.is_empty() { + return json!({ "message": "MiniMax connected. No text quota data was returned." }); + } + + let captured_at_ms = chrono::Utc::now().timestamp_millis(); + let count_is_remaining = url.contains("/coding_plan/remains"); + let mut quotas = serde_json::Map::new(); + + if let Some(session_model) = pick_representative(&text_models, |m| { + minimax_num( + m, + "current_interval_total_count", + "currentIntervalTotalCount", + ) + }) { + let total = minimax_num( + session_model, + "current_interval_total_count", + "currentIntervalTotalCount", + ); + let count = minimax_num( + session_model, + "current_interval_usage_count", + "currentIntervalUsageCount", + ) + .max(0.0); + let reset_at = minimax_reset_at( + session_model, + captured_at_ms, + "remains_time", + "remainsTime", + "end_time", + "endTime", + ); + quotas.insert( + "session (5h)".to_string(), + build_minimax_quota(total, count, reset_at, count_is_remaining), + ); + } + + if let Some(weekly_model) = pick_representative(&text_models, |m| { + minimax_num(m, "current_weekly_total_count", "currentWeeklyTotalCount") + }) { + let weekly_total = minimax_num( + weekly_model, + "current_weekly_total_count", + "currentWeeklyTotalCount", + ); + if weekly_total > 0.0 { + let count = minimax_num( + weekly_model, + "current_weekly_usage_count", + "currentWeeklyUsageCount", + ) + .max(0.0); + let reset_at = minimax_reset_at( + weekly_model, + captured_at_ms, + "weekly_remains_time", + "weeklyRemainsTime", + "weekly_end_time", + "weeklyEndTime", + ); + quotas.insert( + "weekly (7d)".to_string(), + build_minimax_quota(weekly_total, count, reset_at, count_is_remaining), + ); + } + } + + if quotas.is_empty() { + return json!({ "message": "MiniMax connected. Unable to extract quota usage." }); + } + + return json!({ "quotas": Value::Object(quotas) }); + } + + let msg = match last_error { + Some(e) => format!("MiniMax connected. Unable to fetch usage: {e}"), + None => "MiniMax connected. Unable to fetch usage.".to_string(), + }; + json!({ "message": msg }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_text_quota_model() { + assert!(is_text_quota_model("MiniMax-M2.7")); + assert!(is_text_quota_model("minimax-m2.5")); + assert!(is_text_quota_model("Coding-Plan-Pro")); + assert!(!is_text_quota_model("voice-1")); + assert!(!is_text_quota_model("")); + } + + #[test] + fn test_build_minimax_quota_count_means_used() { + let q = build_minimax_quota(100.0, 30.0, None, false); + assert_eq!(q["used"], 30.0); + assert_eq!(q["remaining"], 70.0); + assert_eq!(q["remainingPercentage"], 70.0); + } + + #[test] + fn test_build_minimax_quota_count_means_remaining() { + let q = build_minimax_quota(100.0, 30.0, None, true); + assert_eq!(q["used"], 70.0); + assert_eq!(q["remaining"], 30.0); + assert_eq!(q["remainingPercentage"], 30.0); + } + + #[test] + fn test_build_minimax_quota_zero_total() { + let q = build_minimax_quota(0.0, 0.0, None, false); + assert_eq!(q["total"], 0.0); + assert_eq!(q["remainingPercentage"], 0.0); + } + + #[test] + fn test_pick_representative_prefers_with_quota() { + let models = vec![ + json!({"current_interval_total_count": 0}), + json!({"current_interval_total_count": 50}), + json!({"current_interval_total_count": 100}), + ]; + let pick = pick_representative(&models, |m| { + minimax_num( + m, + "current_interval_total_count", + "currentIntervalTotalCount", + ) + }); + assert_eq!(pick.unwrap()["current_interval_total_count"], 100); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 3fc3b2d4..d9dc9b82 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -29,11 +29,25 @@ pub struct Db { impl Db { pub async fn load() -> anyhow::Result { - let data_dir = std::env::var_os("DATA_DIR") - .map(PathBuf::from) - .unwrap_or_else(default_data_dir); - - Self::load_from(&data_dir).await + let configured = std::env::var_os("DATA_DIR").map(PathBuf::from); + let default = default_data_dir(); + + match &configured { + Some(dir) => match Self::load_from(dir).await { + Ok(db) => Ok(db), + Err(err) if is_permission_denied(&err) && *dir != default => { + tracing::warn!( + target: "openproxy::db", + configured = %dir.display(), + fallback = %default.display(), + "DATA_DIR not writable (permission denied); falling back to default" + ); + Self::load_from(&default).await + } + Err(err) => Err(err), + }, + None => Self::load_from(&default).await, + } } pub async fn load_from(data_dir: impl AsRef) -> anyhow::Result { @@ -157,6 +171,14 @@ fn default_data_dir() -> PathBuf { } } +fn is_permission_denied(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::PermissionDenied) + }) +} + async fn load_or_init_app_db(path: &Path) -> anyhow::Result { if !fs::try_exists(path).await? { let value = AppDb::default(); diff --git a/src/server/api/chat.rs b/src/server/api/chat.rs index f573db9f..d26f46a1 100644 --- a/src/server/api/chat.rs +++ b/src/server/api/chat.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashSet}; +use std::time::Duration; use axum::body::Body; use axum::extract::rejection::JsonRejection; @@ -29,6 +30,12 @@ use crate::types::{AppDb, ProviderConnection, TokenUsage}; use super::auth_error_response; +/// Maximum time we'll wait for the next byte from an upstream SSE stream before +/// considering the connection stalled. 3 minutes matches what most providers +/// use for their keep-alive heartbeats (OpenAI sends a comment every ~30s, +/// Anthropic every ~60s, Gemini every ~30s — 180s is well past any of them). +const SSE_STALL_TIMEOUT: Duration = Duration::from_secs(180); + pub async fn cors_options() -> Response { cors_preflight_response("GET, POST, OPTIONS") } @@ -948,8 +955,24 @@ async fn proxy_response_with_pending_tracking( let stream = async_stream::stream! { let mut upstream = response.bytes_stream(); loop { - match upstream.try_next().await { - Ok(Some(chunk)) => { + let next = tokio::time::timeout(SSE_STALL_TIMEOUT, upstream.try_next()).await; + match next { + Err(_elapsed) => { + // Upstream went silent for SSE_STALL_TIMEOUT; treat + // as an error so the client can retry. + tracing::warn!( + target: "openproxy::chat::stream", + provider = %provider, + model = %model, + "SSE stalled, closing stream" + ); + state + .usage_live + .finish_request(&model, &provider, connection_id.as_deref(), true) + .await; + return; + } + Ok(Ok(Some(chunk))) => { if let Some(transformer) = transformer.as_mut() { for line in transform_dashboard_sse_chunk(&chunk, transformer.as_mut(), &mut pending_text) { if let Some(frame) = sse_frame_for_dashboard(&line) { @@ -960,8 +983,8 @@ async fn proxy_response_with_pending_tracking( yield Ok::(chunk); } } - Ok(None) => break, - Err(_) => { + Ok(Ok(None)) => break, + Ok(Err(_)) => { state .usage_live .finish_request(&model, &provider, connection_id.as_deref(), true) @@ -993,7 +1016,25 @@ async fn proxy_response_with_pending_tracking( let mut transformer = transformer; let mut pending_text = String::new(); let stream = async_stream::stream! { - while let Some(frame_result) = body.frame().await { + loop { + let next = tokio::time::timeout(SSE_STALL_TIMEOUT, body.frame()).await; + let frame_result = match next { + Err(_elapsed) => { + tracing::warn!( + target: "openproxy::chat::stream", + provider = %provider, + model = %model, + "SSE stalled, closing stream" + ); + state + .usage_live + .finish_request(&model, &provider, connection_id.as_deref(), true) + .await; + return; + } + Ok(Some(result)) => result, + Ok(None) => break, + }; match frame_result { Ok(frame) => { if let Ok(data) = frame.into_data() { diff --git a/src/server/api/cli_tools.rs b/src/server/api/cli_tools.rs index 5a1ccaed..92c7983f 100644 --- a/src/server/api/cli_tools.rs +++ b/src/server/api/cli_tools.rs @@ -1,6 +1,8 @@ mod claude_settings; +mod cline_settings; mod cowork_settings; mod hermes_settings; +mod kilo_settings; use std::collections::BTreeMap; use std::env; @@ -2380,8 +2382,10 @@ async fn delete_mitm_alias( pub fn routes() -> Router { Router::new() .merge(claude_settings::routes()) + .merge(cline_settings::routes()) .merge(cowork_settings::routes()) .merge(hermes_settings::routes()) + .merge(kilo_settings::routes()) .route("/api/cli-tools", get(list_tools)) .route("/api/cli-tools/execute", post(execute_command)) .route("/api/cli-tools/run/{tool_name}", post(run_tool)) diff --git a/src/server/api/cli_tools/cline_settings.rs b/src/server/api/cli_tools/cline_settings.rs new file mode 100644 index 00000000..4c8b5dfa --- /dev/null +++ b/src/server/api/cli_tools/cline_settings.rs @@ -0,0 +1,329 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::Result as AnyhowResult; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use tokio::{fs, process::Command}; + +use crate::server::state::AppState; + +pub fn routes() -> Router { + Router::new().route( + "/api/cli-tools/cline-settings", + get(get_cline_settings) + .post(save_cline_settings) + .delete(delete_cline_settings), + ) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SaveClineSettingsRequest { + base_url: String, + api_key: String, + model: String, +} + +async fn get_cline_settings(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + let installed = check_installed().await; + if !installed { + return Json(json!({ + "installed": false, + "settings": Value::Null, + "message": "Cline CLI is not installed", + })) + .into_response(); + } + + match read_global_state().await { + Ok(global_state) => { + let has_openproxy = has_openproxy_config(&global_state); + let settings = json!({ + "actModeApiProvider": global_state.as_ref().and_then(|s| s.get("actModeApiProvider")).cloned().unwrap_or(Value::Null), + "planModeApiProvider": global_state.as_ref().and_then(|s| s.get("planModeApiProvider")).cloned().unwrap_or(Value::Null), + "openAiBaseUrl": global_state.as_ref().and_then(|s| s.get("openAiBaseUrl")).cloned().unwrap_or(Value::Null), + "openAiModelId": global_state.as_ref().and_then(|s| s.get("openAiModelId")).cloned().unwrap_or(Value::Null), + }); + Json(json!({ + "installed": true, + "settings": settings, + "hasOpenProxy": has_openproxy, + "globalStatePath": global_state_path().to_string_lossy().to_string(), + })) + .into_response() + } + Err(error) => { + tracing::warn!(?error, "failed to read cline settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to check cline settings" })), + ) + .into_response() + } + } +} + +async fn save_cline_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + if body.base_url.trim().is_empty() + || body.api_key.trim().is_empty() + || body.model.trim().is_empty() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "baseUrl, apiKey and model are required" })), + ) + .into_response(); + } + + match write_cline_settings(&body).await { + Ok(()) => Json(json!({ + "success": true, + "message": "Cline settings applied successfully!", + "globalStatePath": global_state_path().to_string_lossy().to_string(), + })) + .into_response(), + Err(error) => { + tracing::warn!(?error, "failed to write cline settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to update cline settings" })), + ) + .into_response() + } + } +} + +async fn delete_cline_settings(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + match reset_cline_settings().await { + Ok(payload) => Json(payload).into_response(), + Err(error) => { + tracing::warn!(?error, "failed to reset cline settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to reset cline settings" })), + ) + .into_response() + } + } +} + +async fn check_installed() -> bool { + if command_exists("cline", true).await { + return true; + } + fs::metadata(global_state_path()).await.is_ok() +} + +async fn read_global_state() -> AnyhowResult> { + read_json_optional(&global_state_path()).await +} + +fn has_openproxy_config(global_state: &Option) -> bool { + let Some(state) = global_state else { + return false; + }; + let is_openai = matches!( + state.get("actModeApiProvider").and_then(Value::as_str), + Some("openai") + ) || matches!( + state.get("planModeApiProvider").and_then(Value::as_str), + Some("openai") + ); + if !is_openai { + return false; + } + let base_url = state + .get("openAiBaseUrl") + .and_then(Value::as_str) + .unwrap_or(""); + base_url.contains("localhost") + || base_url.contains("127.0.0.1") + || base_url.contains("openproxy") +} + +async fn write_cline_settings(body: &SaveClineSettingsRequest) -> AnyhowResult<()> { + fs::create_dir_all(&data_dir()).await?; + + // Cline expects base WITHOUT /v1 + let normalized_base_url = body + .base_url + .strip_suffix("/v1") + .map(str::to_string) + .unwrap_or_else(|| body.base_url.clone()); + + let mut global_state = read_json_optional(&global_state_path()) + .await? + .and_then(|value| match value { + Value::Object(fields) => Some(fields), + _ => None, + }) + .unwrap_or_default(); + + global_state.insert( + "actModeApiProvider".to_string(), + Value::String("openai".to_string()), + ); + global_state.insert( + "planModeApiProvider".to_string(), + Value::String("openai".to_string()), + ); + global_state.insert( + "openAiBaseUrl".to_string(), + Value::String(normalized_base_url), + ); + global_state.insert( + "openAiModelId".to_string(), + Value::String(body.model.clone()), + ); + global_state.insert( + "planModeOpenAiModelId".to_string(), + Value::String(body.model.clone()), + ); + + write_json(&global_state_path(), &Value::Object(global_state)).await?; + + let mut secrets = read_json_optional(&secrets_path()) + .await? + .and_then(|value| match value { + Value::Object(fields) => Some(fields), + _ => None, + }) + .unwrap_or_default(); + secrets.insert( + "openAiApiKey".to_string(), + Value::String(body.api_key.clone()), + ); + write_json(&secrets_path(), &Value::Object(secrets)).await +} + +async fn reset_cline_settings() -> AnyhowResult { + let global_state = read_json_optional(&global_state_path()).await?; + let Some(Value::Object(mut state)) = global_state else { + return Ok(json!({ + "success": true, + "message": "No settings file to reset", + })); + }; + + if matches!( + state.get("actModeApiProvider").and_then(Value::as_str), + Some("openai") + ) { + state.remove("openAiBaseUrl"); + state.remove("openAiModelId"); + state.remove("planModeOpenAiModelId"); + state.insert( + "actModeApiProvider".to_string(), + Value::String("cline".to_string()), + ); + state.insert( + "planModeApiProvider".to_string(), + Value::String("cline".to_string()), + ); + } + write_json(&global_state_path(), &Value::Object(state)).await?; + + let mut secrets: Map = read_json_optional(&secrets_path()) + .await? + .and_then(|value| match value { + Value::Object(fields) => Some(fields), + _ => None, + }) + .unwrap_or_default(); + secrets.remove("openAiApiKey"); + write_json(&secrets_path(), &Value::Object(secrets)).await?; + + Ok(json!({ + "success": true, + "message": "OpenProxy settings removed from Cline", + })) +} + +async fn command_exists(program: &str, inject_windows_npm_path: bool) -> bool { + let finder = if cfg!(windows) { "where" } else { "which" }; + let mut command = Command::new(finder); + command.arg(program); + + if cfg!(windows) && inject_windows_npm_path { + if let Some(path) = windows_npm_augmented_path() { + command.env("PATH", path); + } + } + + command + .status() + .await + .map(|status| status.success()) + .unwrap_or(false) +} + +fn windows_npm_augmented_path() -> Option { + let appdata = env::var_os("APPDATA")?; + let current_path = env::var_os("PATH").unwrap_or_default(); + let npm_dir = PathBuf::from(appdata).join("npm"); + Some(format!( + "{};{}", + npm_dir.to_string_lossy(), + PathBuf::from(current_path).to_string_lossy() + )) +} + +async fn read_json_optional(path: &Path) -> AnyhowResult> { + let content = match fs::read_to_string(path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(Some(serde_json::from_str(&content)?)) +} + +async fn write_json(path: &Path, value: &Value) -> AnyhowResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + fs::write(path, serde_json::to_vec_pretty(value)?).await?; + Ok(()) +} + +fn data_dir() -> PathBuf { + home_dir().join(".cline").join("data") +} + +fn global_state_path() -> PathBuf { + data_dir().join("globalState.json") +} + +fn secrets_path() -> PathBuf { + data_dir().join("secrets.json") +} + +fn home_dir() -> PathBuf { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("/")) +} diff --git a/src/server/api/cli_tools/kilo_settings.rs b/src/server/api/cli_tools/kilo_settings.rs new file mode 100644 index 00000000..d3095633 --- /dev/null +++ b/src/server/api/cli_tools/kilo_settings.rs @@ -0,0 +1,308 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::Result as AnyhowResult; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::{fs, process::Command}; + +use crate::server::state::AppState; + +pub fn routes() -> Router { + Router::new().route( + "/api/cli-tools/kilo-settings", + get(get_kilo_settings) + .post(save_kilo_settings) + .delete(delete_kilo_settings), + ) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SaveKiloSettingsRequest { + base_url: String, + api_key: String, + model: String, +} + +async fn get_kilo_settings(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + let installed = check_installed().await; + if !installed { + return Json(json!({ + "installed": false, + "settings": Value::Null, + "message": "Kilo Code CLI is not installed", + })) + .into_response(); + } + + match read_auth().await { + Ok(auth) => { + let has_openproxy = has_openproxy_config(&auth); + let auth_keys = auth + .as_ref() + .and_then(|value| value.as_object()) + .map(|object| object.keys().cloned().collect::>()) + .unwrap_or_default(); + Json(json!({ + "installed": true, + "settings": { "auth": auth_keys }, + "hasOpenProxy": has_openproxy, + "authPath": auth_path().to_string_lossy().to_string(), + })) + .into_response() + } + Err(error) => { + tracing::warn!(?error, "failed to read kilo settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to check kilo settings" })), + ) + .into_response() + } + } +} + +async fn save_kilo_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + if body.base_url.trim().is_empty() + || body.api_key.trim().is_empty() + || body.model.trim().is_empty() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "baseUrl, apiKey and model are required" })), + ) + .into_response(); + } + + match write_kilo_settings(&body).await { + Ok(()) => Json(json!({ + "success": true, + "message": "Kilo Code settings applied successfully!", + "authPath": auth_path().to_string_lossy().to_string(), + })) + .into_response(), + Err(error) => { + tracing::warn!(?error, "failed to write kilo settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to update kilo settings" })), + ) + .into_response() + } + } +} + +async fn delete_kilo_settings(State(state): State, headers: HeaderMap) -> Response { + if let Err(response) = super::super::require_dashboard_or_management_api_key(&headers, &state) { + return response; + } + + match reset_kilo_settings().await { + Ok(payload) => Json(payload).into_response(), + Err(error) => { + tracing::warn!(?error, "failed to reset kilo settings"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to reset kilo settings" })), + ) + .into_response() + } + } +} + +async fn check_installed() -> bool { + if command_exists("kilo", true).await { + return true; + } + fs::metadata(auth_path()).await.is_ok() +} + +async fn read_auth() -> AnyhowResult> { + read_json_optional(&auth_path()).await +} + +fn has_openproxy_config(auth: &Option) -> bool { + let Some(auth) = auth else { return false }; + let entry = auth + .get("openai-compatible") + .or_else(|| auth.get("openproxy")) + .or_else(|| auth.get("9router")); + let Some(entry) = entry else { return false }; + let base_url = entry + .get("baseUrl") + .or_else(|| entry.get("baseURL")) + .and_then(Value::as_str) + .unwrap_or(""); + base_url.contains("localhost") + || base_url.contains("127.0.0.1") + || base_url.contains("openproxy") +} + +async fn write_kilo_settings(body: &SaveKiloSettingsRequest) -> AnyhowResult<()> { + fs::create_dir_all(&data_dir()).await?; + + let normalized_base_url = if body.base_url.ends_with("/v1") { + body.base_url.clone() + } else { + format!("{}/v1", body.base_url) + }; + + let mut auth = read_json_optional(&auth_path()) + .await? + .and_then(|value| match value { + Value::Object(fields) => Some(fields), + _ => None, + }) + .unwrap_or_default(); + auth.insert( + "openai-compatible".to_string(), + json!({ + "type": "api-key", + "apiKey": body.api_key, + "baseUrl": normalized_base_url, + "model": body.model, + }), + ); + write_json(&auth_path(), &Value::Object(auth)).await?; + + // Best-effort: update VS Code extension settings (ignore failures). + if let Some(vscode_path) = vscode_settings_path() { + if let Ok(Some(Value::Object(mut vscode))) = read_json_optional(&vscode_path).await { + vscode.insert( + "kilocode.customProvider".to_string(), + json!({ + "name": "OpenProxy", + "baseURL": normalized_base_url, + "apiKey": body.api_key, + }), + ); + vscode.insert( + "kilocode.defaultModel".to_string(), + Value::String(body.model.clone()), + ); + let _ = write_json(&vscode_path, &Value::Object(vscode)).await; + } + } + + Ok(()) +} + +async fn reset_kilo_settings() -> AnyhowResult { + let auth = read_json_optional(&auth_path()).await?; + let Some(Value::Object(mut auth)) = auth else { + return Ok(json!({ + "success": true, + "message": "No settings file to reset", + })); + }; + auth.remove("openai-compatible"); + auth.remove("openproxy"); + auth.remove("9router"); + write_json(&auth_path(), &Value::Object(auth)).await?; + + if let Some(vscode_path) = vscode_settings_path() { + if let Ok(Some(Value::Object(mut vscode))) = read_json_optional(&vscode_path).await { + let modified = vscode.remove("kilocode.customProvider").is_some() + | vscode.remove("kilocode.defaultModel").is_some(); + if modified { + let _ = write_json(&vscode_path, &Value::Object(vscode)).await; + } + } + } + + Ok(json!({ + "success": true, + "message": "OpenProxy settings removed from Kilo Code", + })) +} + +async fn command_exists(program: &str, inject_windows_npm_path: bool) -> bool { + let finder = if cfg!(windows) { "where" } else { "which" }; + let mut command = Command::new(finder); + command.arg(program); + + if cfg!(windows) && inject_windows_npm_path { + if let Some(path) = windows_npm_augmented_path() { + command.env("PATH", path); + } + } + + command + .status() + .await + .map(|status| status.success()) + .unwrap_or(false) +} + +fn windows_npm_augmented_path() -> Option { + let appdata = env::var_os("APPDATA")?; + let current_path = env::var_os("PATH").unwrap_or_default(); + let npm_dir = PathBuf::from(appdata).join("npm"); + Some(format!( + "{};{}", + npm_dir.to_string_lossy(), + PathBuf::from(current_path).to_string_lossy() + )) +} + +async fn read_json_optional(path: &Path) -> AnyhowResult> { + let content = match fs::read_to_string(path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(Some(serde_json::from_str(&content)?)) +} + +async fn write_json(path: &Path, value: &Value) -> AnyhowResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + fs::write(path, serde_json::to_vec_pretty(value)?).await?; + Ok(()) +} + +fn data_dir() -> PathBuf { + home_dir().join(".local").join("share").join("kilo") +} + +fn auth_path() -> PathBuf { + data_dir().join("auth.json") +} + +fn vscode_settings_path() -> Option { + Some( + home_dir() + .join(".config") + .join("Code") + .join("User") + .join("settings.json"), + ) +} + +fn home_dir() -> PathBuf { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("/")) +} diff --git a/src/server/api/mod.rs b/src/server/api/mod.rs index fe4477f6..e1ab0304 100644 --- a/src/server/api/mod.rs +++ b/src/server/api/mod.rs @@ -23,6 +23,7 @@ pub mod provider_nodes; mod provider_validate; pub mod providers; pub mod shutdown; +pub mod stt; pub mod tags; pub mod translator; pub mod tunnel; @@ -84,7 +85,7 @@ pub fn routes() -> Router { ) .route( "/v1/audio/transcriptions", - post(media::audio_transcriptions).options(media::cors_options), + post(stt::audio_transcriptions).options(stt::cors_options), ) .route( "/v1/audio/speech", diff --git a/src/server/api/stt.rs b/src/server/api/stt.rs new file mode 100644 index 00000000..b6d45deb --- /dev/null +++ b/src/server/api/stt.rs @@ -0,0 +1,1255 @@ +//! Speech-to-text (`/v1/audio/transcriptions`) pipeline. +//! +//! Ported from the upstream 9router `open-sse/handlers/sttCore.js` + `src/sse/handlers/stt.js`. +//! +//! Accepts either OpenAI-compatible **multipart/form-data** (with `file`, `model`, +//! and optional `language`/`prompt`/`response_format`/`temperature`) or a JSON body +//! with `file_b64` + `file_name` (legacy OpenProxy CLI shape — kept for backwards +//! compatibility). Resolves the model to a provider, then dispatches by the +//! provider's STT `format`: +//! +//! * **`openai`** — multipart POST to the provider's `/audio/transcriptions`. +//! * **`deepgram`** — raw binary POST + query params (`smart_format`, `punctuate`, +//! `language`/`detect_language`). +//! * **`assemblyai`** — upload bytes, submit transcript job, poll until done. +//! * **`nvidia-asr`** — multipart POST, normalize response to `{ text }`. +//! * **`huggingface-asr`** — raw binary POST to `{baseUrl}/{model_id}`. +//! * **`gemini-stt`** — `generateContent` with `inline_data` audio + transcription prompt. +//! +//! On per-connection failures the pipeline falls back through other active +//! credentialed connections for the same provider, skipping accounts that are +//! currently rate-limited. + +use std::collections::HashSet; +use std::time::Duration; + +use axum::extract::{FromRequest, Multipart, Request, State}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use tracing::debug; + +use crate::core::model::{get_model_info, ModelRouteKind}; +use crate::server::auth::require_api_key; +use crate::server::state::AppState; +use crate::types::{AppDb, ProviderConnection}; + +use super::auth_error_response; + +// --------------------------------------------------------------------------- +// STT provider catalog (Rust-side mirror of web/src/shared/constants/providers.ts). +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SttFormat { + /// OpenAI-compatible multipart (`file`, `model`, optional `language`, `prompt`, …). + OpenaiCompatible, + /// Deepgram `/v1/listen` — raw binary body + query params. + Deepgram, + /// AssemblyAI v2 — upload, submit, poll. + AssemblyAi, + /// NVIDIA NIM ASR — multipart, response normalized to `{ text }`. + NvidiaAsr, + /// HuggingFace inference API — raw binary to `{baseUrl}/{model_id}`. + HuggingfaceAsr, + /// Gemini `generateContent` with `inline_data` audio. + GeminiStt, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SttAuthHeader { + Bearer, + Token, + XApiKey, + Key, + None, +} + +#[derive(Clone, Copy, Debug)] +pub struct SttProviderConfig { + pub base_url: &'static str, + pub auth_type_none: bool, + pub auth_header: SttAuthHeader, + pub format: SttFormat, +} + +/// Returns the STT config for a built-in provider, or `None` if the provider +/// does not support STT (or is a custom node — those go through the `openai` +/// fall-through path with their own `baseUrl`). +pub fn stt_config(provider: &str) -> Option { + Some(match provider { + "openai" => SttProviderConfig { + base_url: "https://api.openai.com/v1/audio/transcriptions", + auth_type_none: false, + auth_header: SttAuthHeader::Bearer, + format: SttFormat::OpenaiCompatible, + }, + "groq" => SttProviderConfig { + base_url: "https://api.groq.com/openai/v1/audio/transcriptions", + auth_type_none: false, + auth_header: SttAuthHeader::Bearer, + format: SttFormat::OpenaiCompatible, + }, + "deepgram" => SttProviderConfig { + base_url: "https://api.deepgram.com/v1/listen", + auth_type_none: false, + auth_header: SttAuthHeader::Token, + format: SttFormat::Deepgram, + }, + "assemblyai" => SttProviderConfig { + base_url: "https://api.assemblyai.com/v2/transcript", + auth_type_none: false, + auth_header: SttAuthHeader::Bearer, + format: SttFormat::AssemblyAi, + }, + "huggingface" => SttProviderConfig { + base_url: "https://api-inference.huggingface.co/models", + auth_type_none: false, + auth_header: SttAuthHeader::Bearer, + format: SttFormat::HuggingfaceAsr, + }, + "gemini" => SttProviderConfig { + base_url: "https://generativelanguage.googleapis.com/v1beta/models", + auth_type_none: false, + auth_header: SttAuthHeader::Key, + format: SttFormat::GeminiStt, + }, + _ => return None, + }) +} + +// --------------------------------------------------------------------------- +// Public Axum handler. +// --------------------------------------------------------------------------- + +pub async fn cors_options() -> Response { + cors_preflight_response("POST, OPTIONS") +} + +/// `POST /v1/audio/transcriptions` — content-type aware: +/// * `multipart/form-data` → real STT pipeline. +/// * `application/json` → legacy CLI shape (`{ model, file_b64, file_name, … }`). +pub async fn audio_transcriptions(State(state): State, request: Request) -> Response { + let (parts, body) = request.into_parts(); + let headers = parts.headers.clone(); + + if let Err(error) = require_api_key(&headers, &state.db) { + return with_cors(auth_error_response(error)); + } + + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + let req = if content_type.starts_with("multipart/") { + let mut multipart = + match Multipart::from_request(Request::from_parts(parts, body), &state).await { + Ok(m) => m, + Err(err) => { + return with_cors(json_error( + StatusCode::BAD_REQUEST, + &format!("Invalid multipart body: {}", err), + )); + } + }; + match parse_multipart_request(&mut multipart).await { + Ok(req) => req, + Err(err) => return with_cors(json_error(err.status, &err.message)), + } + } else if content_type.starts_with("application/json") { + let body_bytes = match axum::body::to_bytes(body, MAX_JSON_BODY).await { + Ok(b) => b, + Err(err) => { + return with_cors(json_error( + StatusCode::PAYLOAD_TOO_LARGE, + &format!("Body too large or unreadable: {}", err), + )); + } + }; + match parse_json_request(&body_bytes) { + Ok(req) => req, + Err(err) => return with_cors(json_error(err.status, &err.message)), + } + } else { + return with_cors(json_error( + StatusCode::BAD_REQUEST, + "Content-Type must be multipart/form-data or application/json", + )); + }; + + let snapshot = state.db.snapshot(); + let resolved = get_model_info(&req.model, &snapshot); + match resolved.route_kind { + ModelRouteKind::Combo => with_cors(json_error( + StatusCode::BAD_REQUEST, + "Combos not supported for audio/transcriptions", + )), + ModelRouteKind::Direct => { + let provider = match resolved.provider.as_deref() { + Some(p) if !p.is_empty() => p.to_string(), + _ => { + return with_cors(json_error(StatusCode::BAD_REQUEST, "Invalid model format")); + } + }; + let model = resolved.model.clone(); + with_cors(dispatch_with_fallback(&state, &snapshot, &provider, &model, &req).await) + } + } +} + +const MAX_JSON_BODY: usize = 200 * 1024 * 1024; // 200 MiB — audio uploads can be large. + +// --------------------------------------------------------------------------- +// Parsed-request shape. +// --------------------------------------------------------------------------- + +struct SttRequest { + model: String, + file_bytes: Vec, + file_name: String, + file_content_type: Option, + language: Option, + prompt: Option, + response_format: Option, + temperature: Option, +} + +struct RequestError { + status: StatusCode, + message: String, +} + +fn req_err(status: StatusCode, message: impl Into) -> RequestError { + RequestError { + status, + message: message.into(), + } +} + +async fn parse_multipart_request(mp: &mut Multipart) -> Result { + let mut model: Option = None; + let mut file_bytes: Option> = None; + let mut file_name: Option = None; + let mut file_content_type: Option = None; + let mut language: Option = None; + let mut prompt: Option = None; + let mut response_format: Option = None; + let mut temperature: Option = None; + + while let Some(field) = mp + .next_field() + .await + .map_err(|e| req_err(StatusCode::BAD_REQUEST, format!("Invalid multipart: {}", e)))? + { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + file_name = field.file_name().map(str::to_string); + file_content_type = field.content_type().map(str::to_string); + let bytes = field.bytes().await.map_err(|e| { + req_err( + StatusCode::BAD_REQUEST, + format!("Failed reading file field: {}", e), + ) + })?; + file_bytes = Some(bytes.to_vec()); + } + "model" => model = Some(read_text_field(field).await?), + "language" => language = Some(read_text_field(field).await?), + "prompt" => prompt = Some(read_text_field(field).await?), + "response_format" => response_format = Some(read_text_field(field).await?), + "temperature" => temperature = Some(read_text_field(field).await?), + _ => { + // Drop unknown fields silently to keep parity with upstream behavior. + let _ = field.bytes().await; + } + } + } + + let model = model + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| req_err(StatusCode::BAD_REQUEST, "Missing model"))?; + let file_bytes = file_bytes + .ok_or_else(|| req_err(StatusCode::BAD_REQUEST, "Missing required field: file"))?; + let file_name = file_name.unwrap_or_else(|| "audio.wav".to_string()); + + Ok(SttRequest { + model, + file_bytes, + file_name, + file_content_type, + language: trim_opt(language), + prompt: trim_opt(prompt), + response_format: trim_opt(response_format), + temperature: trim_opt(temperature), + }) +} + +async fn read_text_field( + field: axum::extract::multipart::Field<'_>, +) -> Result { + let bytes = field + .bytes() + .await + .map_err(|e| req_err(StatusCode::BAD_REQUEST, format!("Bad field: {}", e)))?; + String::from_utf8(bytes.to_vec()) + .map_err(|e| req_err(StatusCode::BAD_REQUEST, format!("Non-UTF8 field: {}", e))) +} + +fn parse_json_request(bytes: &[u8]) -> Result { + let value: Value = serde_json::from_slice(bytes) + .map_err(|e| req_err(StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)))?; + + let model = value + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| req_err(StatusCode::BAD_REQUEST, "Missing model"))? + .to_string(); + let file_b64 = value + .get("file_b64") + .and_then(Value::as_str) + .ok_or_else(|| req_err(StatusCode::BAD_REQUEST, "Missing required field: file_b64"))?; + let file_bytes = B64 + .decode(file_b64.trim().as_bytes()) + .map_err(|e| req_err(StatusCode::BAD_REQUEST, format!("Invalid base64: {}", e)))?; + let file_name = value + .get("file_name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| "audio.wav".to_string()); + + Ok(SttRequest { + model, + file_bytes, + file_name, + file_content_type: None, + language: value + .get("language") + .and_then(Value::as_str) + .map(str::to_string) + .and_then(non_empty), + prompt: value + .get("prompt") + .and_then(Value::as_str) + .map(str::to_string) + .and_then(non_empty), + response_format: value + .get("response_format") + .and_then(Value::as_str) + .map(str::to_string) + .and_then(non_empty), + temperature: value + .get("temperature") + .map(|v| v.to_string()) + .and_then(non_empty), + }) +} + +fn trim_opt(v: Option) -> Option { + v.map(|s| s.trim().to_string()).and_then(non_empty) +} + +fn non_empty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +// --------------------------------------------------------------------------- +// Provider dispatch + fallback loop. +// --------------------------------------------------------------------------- + +async fn dispatch_with_fallback( + state: &AppState, + snapshot: &AppDb, + provider: &str, + model: &str, + req: &SttRequest, +) -> Response { + let Some(cfg) = stt_config(provider) else { + return json_error( + StatusCode::BAD_REQUEST, + &format!("Provider '{}' does not support STT", provider), + ); + }; + + if cfg.auth_type_none { + match transcribe(state, provider, &cfg, model, req, None).await { + DispatchResult::Ok(resp) => return resp, + DispatchResult::Err { status, message } => { + return json_error(status, &message); + } + } + } + + let mut excluded: HashSet = HashSet::new(); + let mut last_message: Option = None; + let mut last_status: Option = None; + let now = Utc::now(); + + loop { + let Some(connection) = select_stt_connection(snapshot, provider, &excluded, now) else { + if excluded.is_empty() { + return json_error( + StatusCode::BAD_REQUEST, + &format!("No credentials for provider: {}", provider), + ); + } + return json_error( + last_status.unwrap_or(StatusCode::SERVICE_UNAVAILABLE), + last_message + .as_deref() + .unwrap_or("All accounts unavailable"), + ); + }; + + let token = connection + .api_key + .as_deref() + .or(connection.access_token.as_deref()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let token_ref = token.as_deref(); + match transcribe(state, provider, &cfg, model, req, token_ref).await { + DispatchResult::Ok(resp) => return resp, + DispatchResult::Err { status, message } => { + if should_fallback(status) { + debug!(provider, model, connection = %connection.id, status = %status, "stt: marking connection failed, falling back"); + excluded.insert(connection.id.clone()); + last_message = Some(message); + last_status = Some(status); + continue; + } + return json_error(status, &message); + } + } + } +} + +fn should_fallback(status: StatusCode) -> bool { + // Per upstream: fallback on auth, quota, rate-limit, and 5xx errors. + matches!(status.as_u16(), 401 | 402 | 403 | 408 | 429) || status.is_server_error() +} + +fn select_stt_connection( + snapshot: &AppDb, + provider: &str, + excluded: &HashSet, + now: DateTime, +) -> Option { + let mut candidates: Vec<_> = snapshot + .provider_connections + .iter() + .filter(|c| { + c.provider == provider + && c.is_active() + && connection_has_credentials(c) + && !excluded.contains(&c.id) + && !is_rate_limited(c, now) + }) + .cloned() + .collect(); + candidates.sort_by_key(|c| c.priority.unwrap_or(999)); + candidates.into_iter().next() +} + +fn connection_has_credentials(connection: &ProviderConnection) -> bool { + connection + .api_key + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .is_some() + || connection + .access_token + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .is_some() +} + +fn is_rate_limited(connection: &ProviderConnection, now: DateTime) -> bool { + connection + .rate_limited_until + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .is_some_and(|until| until > now) +} + +// --------------------------------------------------------------------------- +// Per-format transcription implementations. +// --------------------------------------------------------------------------- + +enum DispatchResult { + Ok(Response), + Err { status: StatusCode, message: String }, +} + +async fn transcribe( + state: &AppState, + provider: &str, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + if !cfg.auth_type_none && token.is_none() { + return DispatchResult::Err { + status: StatusCode::UNAUTHORIZED, + message: format!("No credentials for STT provider: {}", provider), + }; + } + + let client = match state.client_pool.get(provider, None) { + Ok(c) => c, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Client error: {}", e), + }; + } + }; + + match cfg.format { + SttFormat::OpenaiCompatible => transcribe_openai(&client, cfg, model, req, token).await, + SttFormat::Deepgram => transcribe_deepgram(&client, cfg, model, req, token).await, + SttFormat::AssemblyAi => transcribe_assemblyai(&client, cfg, model, req, token).await, + SttFormat::NvidiaAsr => transcribe_nvidia(&client, cfg, model, req, token).await, + SttFormat::HuggingfaceAsr => transcribe_huggingface(&client, cfg, model, req, token).await, + SttFormat::GeminiStt => transcribe_gemini(&client, cfg, model, req, token).await, + } +} + +fn build_auth_header(cfg: &SttProviderConfig, token: Option<&str>) -> Option<(String, String)> { + let token = token?; + match cfg.auth_header { + SttAuthHeader::Bearer => Some(("Authorization".into(), format!("Bearer {}", token))), + SttAuthHeader::Token => Some(("Authorization".into(), format!("Token {}", token))), + SttAuthHeader::XApiKey => Some(("x-api-key".into(), token.to_string())), + SttAuthHeader::Key => Some(("Authorization".into(), format!("Key {}", token))), + SttAuthHeader::None => None, + } +} + +fn audio_mime_for(req: &SttRequest) -> String { + if let Some(ct) = req.file_content_type.as_deref() { + let lower = ct.to_ascii_lowercase(); + if lower.starts_with("audio/") { + return lower; + } + } + audio_mime_from_filename(&req.file_name) +} + +pub fn audio_mime_from_filename(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + let ext = lower.rsplit('.').next().unwrap_or(""); + match ext { + "mp3" => "audio/mpeg", + "mp4" | "m4a" => "audio/mp4", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + "flac" => "audio/flac", + "webm" => "audio/webm", + "aac" => "audio/aac", + "opus" => "audio/opus", + _ => "application/octet-stream", + } + .to_string() +} + +async fn upstream_error(res: reqwest::Response) -> DispatchResult { + let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let text = res.text().await.unwrap_or_default(); + let message = parse_upstream_error_message(&text) + .unwrap_or_else(|| format!("Upstream error ({})", status)); + DispatchResult::Err { status, message } +} + +fn parse_upstream_error_message(text: &str) -> Option { + if text.is_empty() { + return None; + } + if let Ok(v) = serde_json::from_str::(text) { + if let Some(m) = v + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + { + return Some(m.to_string()); + } + if let Some(m) = v.get("error").and_then(Value::as_str) { + return Some(m.to_string()); + } + if let Some(m) = v.get("message").and_then(Value::as_str) { + return Some(m.to_string()); + } + } + Some(text.to_string()) +} + +fn ok_json(body: Value) -> DispatchResult { + DispatchResult::Ok( + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json")], + body.to_string(), + ) + .into_response(), + ) +} + +fn ok_passthrough(content_type: Option, body: String) -> DispatchResult { + let mut response = (StatusCode::OK, body).into_response(); + if let Some(ct) = content_type { + if let Ok(v) = HeaderValue::from_str(&ct) { + response.headers_mut().insert(header::CONTENT_TYPE, v); + } + } + DispatchResult::Ok(response) +} + +// --- openai-compatible (multipart) --- + +async fn transcribe_openai( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + let mut form = reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::bytes(req.file_bytes.clone()) + .file_name(req.file_name.clone()) + .mime_str(&audio_mime_for(req)) + .unwrap_or_else(|_| { + reqwest::multipart::Part::bytes(req.file_bytes.clone()) + .file_name(req.file_name.clone()) + }), + ); + form = form.text("model", model.to_string()); + if let Some(lang) = &req.language { + form = form.text("language", lang.clone()); + } + if let Some(prompt) = &req.prompt { + form = form.text("prompt", prompt.clone()); + } + if let Some(rf) = &req.response_format { + form = form.text("response_format", rf.clone()); + } + if let Some(temp) = &req.temperature { + form = form.text("temperature", temp.clone()); + } + + let mut request = client.post(cfg.base_url).multipart(form); + if let Some((k, v)) = build_auth_header(cfg, token) { + request = request.header(k, v); + } + + let res = match request.send().await { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Request failed: {}", e), + }; + } + }; + if !res.status().is_success() { + return upstream_error(res).await; + } + let ct = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body = res.text().await.unwrap_or_default(); + ok_passthrough(ct, body) +} + +// --- deepgram (raw bytes + query string) --- + +async fn transcribe_deepgram( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + let url = build_deepgram_url(cfg.base_url, model, req.language.as_deref()); + let mime = audio_mime_for(req); + + let mut request = client + .post(&url) + .header(reqwest::header::CONTENT_TYPE, mime) + .body(req.file_bytes.clone()); + if let Some((k, v)) = build_auth_header(cfg, token) { + request = request.header(k, v); + } + + let res = match request.send().await { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Request failed: {}", e), + }; + } + }; + if !res.status().is_success() { + return upstream_error(res).await; + } + let value: Value = match res.json().await { + Ok(v) => v, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Failed parsing Deepgram response: {}", e), + }; + } + }; + let text = value + .pointer("/results/channels/0/alternatives/0/transcript") + .and_then(Value::as_str) + .unwrap_or(""); + ok_json(json!({ "text": text })) +} + +pub fn build_deepgram_url(base: &str, model: &str, language: Option<&str>) -> String { + let mut url = url::Url::parse(base).unwrap_or_else(|_| { + url::Url::parse("https://api.deepgram.com/v1/listen").expect("valid fallback URL") + }); + { + let mut q = url.query_pairs_mut(); + q.append_pair("model", model); + q.append_pair("smart_format", "true"); + q.append_pair("punctuate", "true"); + match language { + Some(lang) if !lang.trim().is_empty() => { + q.append_pair("language", lang.trim()); + } + _ => { + q.append_pair("detect_language", "true"); + } + } + } + url.to_string() +} + +// --- assemblyai (upload → submit → poll) --- + +async fn transcribe_assemblyai( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + let auth = match build_auth_header(cfg, token) { + Some(h) => h, + None => { + return DispatchResult::Err { + status: StatusCode::UNAUTHORIZED, + message: "AssemblyAI requires credentials".to_string(), + }; + } + }; + + // 1. Upload audio bytes. + let up = match client + .post("https://api.assemblyai.com/v2/upload") + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(&auth.0, &auth.1) + .body(req.file_bytes.clone()) + .send() + .await + { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("AssemblyAI upload failed: {}", e), + }; + } + }; + if !up.status().is_success() { + return upstream_error(up).await; + } + let upload_url = match up.json::().await { + Ok(v) => v + .get("upload_url") + .and_then(Value::as_str) + .map(str::to_string), + Err(_) => None, + }; + let Some(upload_url) = upload_url else { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: "AssemblyAI upload returned no upload_url".to_string(), + }; + }; + + // 2. Submit transcript job. + let sub = match client + .post(cfg.base_url) + .header(&auth.0, &auth.1) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "audio_url": upload_url, + "speech_models": [model], + "language_detection": true, + })) + .send() + .await + { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("AssemblyAI submit failed: {}", e), + }; + } + }; + if !sub.status().is_success() { + return upstream_error(sub).await; + } + let id = match sub.json::().await { + Ok(v) => v.get("id").and_then(Value::as_str).map(str::to_string), + Err(_) => None, + }; + let Some(id) = id else { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: "AssemblyAI submit returned no transcript id".to_string(), + }; + }; + + // 3. Poll up to 120s. + let poll_url = format!("{}/{}", cfg.base_url.trim_end_matches('/'), id); + let start = tokio::time::Instant::now(); + while start.elapsed() < Duration::from_secs(120) { + tokio::time::sleep(Duration::from_secs(2)).await; + let poll = match client.get(&poll_url).header(&auth.0, &auth.1).send().await { + Ok(r) => r, + Err(_) => continue, + }; + if !poll.status().is_success() { + continue; + } + let v: Value = match poll.json().await { + Ok(v) => v, + Err(_) => continue, + }; + match v.get("status").and_then(Value::as_str) { + Some("completed") => { + let text = v.get("text").and_then(Value::as_str).unwrap_or(""); + return ok_json(json!({ "text": text })); + } + Some("error") => { + let msg = v + .get("error") + .and_then(Value::as_str) + .unwrap_or("AssemblyAI failed"); + return DispatchResult::Err { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: msg.to_string(), + }; + } + _ => continue, + } + } + DispatchResult::Err { + status: StatusCode::GATEWAY_TIMEOUT, + message: "AssemblyAI timeout after 120s".to_string(), + } +} + +// --- nvidia-asr (multipart, normalized response) --- + +async fn transcribe_nvidia( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + let form = reqwest::multipart::Form::new() + .part( + "file", + reqwest::multipart::Part::bytes(req.file_bytes.clone()) + .file_name(req.file_name.clone()) + .mime_str(&audio_mime_for(req)) + .unwrap_or_else(|_| { + reqwest::multipart::Part::bytes(req.file_bytes.clone()) + .file_name(req.file_name.clone()) + }), + ) + .text("model", model.to_string()); + + let mut request = client.post(cfg.base_url).multipart(form); + if let Some((k, v)) = build_auth_header(cfg, token) { + request = request.header(k, v); + } + + let res = match request.send().await { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Request failed: {}", e), + }; + } + }; + if !res.status().is_success() { + return upstream_error(res).await; + } + let value: Value = match res.json().await { + Ok(v) => v, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Failed parsing NVIDIA response: {}", e), + }; + } + }; + let text = value + .get("text") + .and_then(Value::as_str) + .or_else(|| value.get("transcript").and_then(Value::as_str)) + .unwrap_or(""); + ok_json(json!({ "text": text })) +} + +// --- huggingface-asr (raw bytes to {baseUrl}/{model_id}) --- + +async fn transcribe_huggingface( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + if model.contains("..") || model.contains("//") { + return DispatchResult::Err { + status: StatusCode::BAD_REQUEST, + message: "Invalid model ID".to_string(), + }; + } + let url = format!("{}/{}", cfg.base_url.trim_end_matches('/'), model); + let mime = audio_mime_for(req); + let mut request = client + .post(&url) + .header(reqwest::header::CONTENT_TYPE, mime) + .body(req.file_bytes.clone()); + if let Some((k, v)) = build_auth_header(cfg, token) { + request = request.header(k, v); + } + + let res = match request.send().await { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Request failed: {}", e), + }; + } + }; + if !res.status().is_success() { + return upstream_error(res).await; + } + let value: Value = match res.json().await { + Ok(v) => v, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Failed parsing HF response: {}", e), + }; + } + }; + let text = value.get("text").and_then(Value::as_str).unwrap_or(""); + ok_json(json!({ "text": text })) +} + +// --- gemini-stt (generateContent with inline_data audio) --- + +async fn transcribe_gemini( + client: &reqwest::Client, + cfg: &SttProviderConfig, + model: &str, + req: &SttRequest, + token: Option<&str>, +) -> DispatchResult { + let token = match token { + Some(t) => t, + None => { + return DispatchResult::Err { + status: StatusCode::UNAUTHORIZED, + message: "Gemini requires an API key".to_string(), + }; + } + }; + let mime = audio_mime_for(req); + let b64 = B64.encode(&req.file_bytes); + let mut prompt_text = req + .prompt + .clone() + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| { + "Generate a transcript of the speech. Return only the transcribed text, no commentary." + .to_string() + }); + if let Some(lang) = req.language.as_deref().filter(|s| !s.is_empty()) { + prompt_text.push_str(&format!(" Language: {}.", lang)); + } + let url = format!( + "{}/{}:generateContent?key={}", + cfg.base_url.trim_end_matches('/'), + model, + urlencoding::encode(token), + ); + let body = json!({ + "contents": [{ + "parts": [ + { "text": prompt_text }, + { "inline_data": { "mime_type": mime, "data": b64 } } + ] + }] + }); + + let res = match client + .post(&url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Request failed: {}", e), + }; + } + }; + if !res.status().is_success() { + return upstream_error(res).await; + } + let value: Value = match res.json().await { + Ok(v) => v, + Err(e) => { + return DispatchResult::Err { + status: StatusCode::BAD_GATEWAY, + message: format!("Failed parsing Gemini response: {}", e), + }; + } + }; + let text = value + .pointer("/candidates/0/content/parts") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("") + }) + .unwrap_or_default(); + ok_json(json!({ "text": text })) +} + +// --------------------------------------------------------------------------- +// CORS + error helpers. +// --------------------------------------------------------------------------- + +fn json_error(status: StatusCode, message: &str) -> Response { + let body = Json(json!({ + "error": { + "message": message, + "type": status_to_type(status), + } + })); + (status, body).into_response() +} + +fn status_to_type(status: StatusCode) -> &'static str { + match status.as_u16() { + 400 | 422 => "invalid_request_error", + 401 | 403 => "authentication_error", + 404 => "not_found_error", + 429 => "rate_limit_error", + 408 | 504 => "timeout_error", + 500..=599 => "server_error", + _ => "api_error", + } +} + +fn with_cors(mut response: Response) -> Response { + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST, OPTIONS"), + ); + response +} + +fn cors_preflight_response(methods: &str) -> Response { + let mut response = StatusCode::NO_CONTENT.into_response(); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_str(methods).unwrap_or(HeaderValue::from_static("POST, OPTIONS")), + ); + response +} + +// --------------------------------------------------------------------------- +// Unit tests. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stt_catalog_has_expected_providers() { + for p in [ + "openai", + "groq", + "deepgram", + "assemblyai", + "huggingface", + "gemini", + ] { + assert!(stt_config(p).is_some(), "{} should have STT config", p); + } + assert!(stt_config("anthropic").is_none()); + assert!(stt_config("nonexistent").is_none()); + } + + #[test] + fn audio_mime_inferred_from_extension() { + assert_eq!(audio_mime_from_filename("clip.mp3"), "audio/mpeg"); + assert_eq!(audio_mime_from_filename("clip.WAV"), "audio/wav"); + assert_eq!(audio_mime_from_filename("clip.m4a"), "audio/mp4"); + assert_eq!(audio_mime_from_filename("clip.opus"), "audio/opus"); + assert_eq!( + audio_mime_from_filename("noext"), + "application/octet-stream" + ); + } + + #[test] + fn deepgram_url_uses_smart_format_and_detects_language_when_unset() { + let url = build_deepgram_url("https://api.deepgram.com/v1/listen", "nova-3", None); + assert!(url.contains("model=nova-3")); + assert!(url.contains("smart_format=true")); + assert!(url.contains("punctuate=true")); + assert!(url.contains("detect_language=true")); + // Should not include an explicit `language=...` param when nothing is supplied. + assert!(!url.contains("&language=") && !url.contains("?language=")); + } + + #[test] + fn deepgram_url_uses_explicit_language_when_set() { + let url = build_deepgram_url("https://api.deepgram.com/v1/listen", "nova-3", Some("en")); + assert!(url.contains("&language=en") || url.contains("?language=en")); + assert!(!url.contains("detect_language=true")); + } + + #[test] + fn auth_header_token_styles_match_upstream() { + let bearer_cfg = stt_config("openai").unwrap(); + assert_eq!( + build_auth_header(&bearer_cfg, Some("sk-test")), + Some(("Authorization".into(), "Bearer sk-test".into())) + ); + let token_cfg = stt_config("deepgram").unwrap(); + assert_eq!( + build_auth_header(&token_cfg, Some("dg-test")), + Some(("Authorization".into(), "Token dg-test".into())) + ); + let key_cfg = stt_config("gemini").unwrap(); + assert_eq!( + build_auth_header(&key_cfg, Some("ai-test")), + Some(("Authorization".into(), "Key ai-test".into())) + ); + } + + #[test] + fn auth_header_returns_none_when_no_token() { + let cfg = stt_config("openai").unwrap(); + assert_eq!(build_auth_header(&cfg, None), None); + } + + #[test] + fn should_fallback_classifies_errors_correctly() { + assert!(should_fallback(StatusCode::UNAUTHORIZED)); + assert!(should_fallback(StatusCode::FORBIDDEN)); + assert!(should_fallback(StatusCode::PAYMENT_REQUIRED)); + assert!(should_fallback(StatusCode::TOO_MANY_REQUESTS)); + assert!(should_fallback(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(should_fallback(StatusCode::BAD_GATEWAY)); + assert!(!should_fallback(StatusCode::BAD_REQUEST)); + assert!(!should_fallback(StatusCode::NOT_FOUND)); + assert!(!should_fallback(StatusCode::OK)); + } + + #[test] + fn parse_upstream_error_prefers_error_message_field() { + let body = r#"{"error":{"message":"Invalid API key","code":"invalid_api_key"}}"#; + assert_eq!( + parse_upstream_error_message(body), + Some("Invalid API key".into()) + ); + } + + #[test] + fn parse_upstream_error_falls_back_to_string_error() { + let body = r#"{"error":"quota exceeded"}"#; + assert_eq!( + parse_upstream_error_message(body), + Some("quota exceeded".into()) + ); + } + + #[test] + fn parse_upstream_error_returns_raw_text_when_not_json() { + let body = "Internal Server Error"; + assert_eq!( + parse_upstream_error_message(body), + Some("Internal Server Error".into()) + ); + } + + #[test] + fn parse_upstream_error_returns_none_for_empty_body() { + assert!(parse_upstream_error_message("").is_none()); + } +} diff --git a/src/server/api/usage.rs b/src/server/api/usage.rs index 2f8fbf88..74c896c6 100644 --- a/src/server/api/usage.rs +++ b/src/server/api/usage.rs @@ -10,6 +10,7 @@ use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashMap}; use tokio::time::{self, Duration}; +use crate::core::usage::quota_fetcher::{fetch_glm_quota, fetch_minimax_quota}; use crate::core::usage::{DailyUsageSummary, Pricing, ProviderUsage, UsageTracker}; use crate::server::state::AppState; use crate::server::usage_live::UsageEvent; @@ -418,14 +419,44 @@ async fn get_connection_usage( } } + // Live quota fetch for whitelisted apikey providers (GLM, MiniMax). Falls + // back to a static info message when the fetcher returns one. We never + // surface upstream errors as HTTP failures — the dashboard treats + // `quotas: {}` + `message` as "connected, but quota unavailable". + let mut live_quotas = serde_json::json!({}); + let mut live_message: Option = None; + if is_apikey_eligible { + if let Some(api_key) = connection + .api_key + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let provider = connection.provider.clone(); + let result = match provider.as_str() { + "glm" | "glm-cn" => fetch_glm_quota(api_key, &provider).await, + "minimax" | "minimax-cn" => fetch_minimax_quota(api_key, &provider).await, + _ => serde_json::json!({}), + }; + if let Some(quotas) = result.get("quotas") { + live_quotas = quotas.clone(); + } + if let Some(msg) = result.get("message").and_then(|v| v.as_str()) { + live_message = Some(msg.to_string()); + } + } + } + + let message = live_message.unwrap_or_else(|| usage_message_for_provider(&connection.provider)); + Json(ConnectionUsageResponse { connection_id, total_requests: request_count, total_prompt_tokens: prompt, total_completion_tokens: completion, total_cost: cost, - message: usage_message_for_provider(&connection.provider), - quotas: serde_json::json!({}), + message, + quotas: live_quotas, }) .into_response() } diff --git a/web/package.json b/web/package.json index 790f5553..6e9a72de 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "jose": "^6.1.3", "lowdb": "^7.0.1", "marked": "^18.0.1", + "material-symbols": "^0.44.6", "monaco-editor": "^0.55.1", "node-forge": "^1.3.3", "node-machine-id": "^1.1.12", diff --git a/web/public/providers/aws-polly.png b/web/public/providers/aws-polly.png new file mode 100644 index 00000000..eef2d601 Binary files /dev/null and b/web/public/providers/aws-polly.png differ diff --git a/web/public/providers/black-forest-labs.png b/web/public/providers/black-forest-labs.png new file mode 100644 index 00000000..c42a3134 Binary files /dev/null and b/web/public/providers/black-forest-labs.png differ diff --git a/web/public/providers/commandcode.png b/web/public/providers/commandcode.png new file mode 100644 index 00000000..ed7c8c99 Binary files /dev/null and b/web/public/providers/commandcode.png differ diff --git a/web/public/providers/fal-ai.png b/web/public/providers/fal-ai.png new file mode 100644 index 00000000..871855e3 Binary files /dev/null and b/web/public/providers/fal-ai.png differ diff --git a/web/public/providers/jina-ai.png b/web/public/providers/jina-ai.png new file mode 100644 index 00000000..bb8ee31a Binary files /dev/null and b/web/public/providers/jina-ai.png differ diff --git a/web/public/providers/recraft.png b/web/public/providers/recraft.png new file mode 100644 index 00000000..3ed12856 Binary files /dev/null and b/web/public/providers/recraft.png differ diff --git a/web/public/providers/runwayml.png b/web/public/providers/runwayml.png new file mode 100644 index 00000000..8f53a141 Binary files /dev/null and b/web/public/providers/runwayml.png differ diff --git a/web/public/providers/stability-ai.png b/web/public/providers/stability-ai.png new file mode 100644 index 00000000..31cf71c7 Binary files /dev/null and b/web/public/providers/stability-ai.png differ diff --git a/web/public/providers/topaz.png b/web/public/providers/topaz.png new file mode 100644 index 00000000..3f86008e Binary files /dev/null and b/web/public/providers/topaz.png differ diff --git a/web/src/components/CLIToolsPageClient.tsx b/web/src/components/CLIToolsPageClient.tsx index d896696f..07f66856 100644 --- a/web/src/components/CLIToolsPageClient.tsx +++ b/web/src/components/CLIToolsPageClient.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, MitmLinkCard } from "./cli-tools"; +import { ClaudeToolCard, ClineToolCard, KiloToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, MitmLinkCard } from "./cli-tools"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL: string | undefined = (import.meta.env as Record)?.PUBLIC_CLOUD_URL; @@ -12,6 +12,8 @@ const CLOUD_URL: string | undefined = (import.meta.env as Record = { claude: "/api/cli-tools/claude-settings", + cline: "/api/cli-tools/cline-settings", + kilo: "/api/cli-tools/kilo-settings", codex: "/api/cli-tools/codex-settings", opencode: "/api/cli-tools/opencode-settings", droid: "/api/cli-tools/droid-settings", @@ -181,6 +183,10 @@ export default function CLIToolsPageClient({ machineId }: CLIToolsPageClientProp initialStatus={toolStatuses.claude} /> ); + case "cline": + return ; + case "kilo": + return ; case "codex": return ; case "opencode": diff --git a/web/src/components/cli-tools/ClineToolCard.tsx b/web/src/components/cli-tools/ClineToolCard.tsx new file mode 100644 index 00000000..382296bf --- /dev/null +++ b/web/src/components/cli-tools/ClineToolCard.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { ChangeEvent } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import EndpointPresetControl from "./EndpointPresetControl"; + +interface Tool { + name: string; + description: string; +} + +interface ApiKey { + id: string; + key: string; +} + +interface ClineStatus { + installed: boolean; + error?: string; + hasOpenProxy?: boolean; + settings?: { + actModeApiProvider?: string; + planModeApiProvider?: string; + openAiBaseUrl?: string; + openAiModelId?: string; + }; + globalStatePath?: string; +} + +interface Message { + type: "success" | "error"; + text: string; +} + +interface ClineToolCardProps { + tool: Tool; + isExpanded: boolean; + onToggle: () => void; + baseUrl: string; + apiKeys: ApiKey[]; + activeProviders: string[]; + cloudEnabled: boolean; + initialStatus?: ClineStatus | null; +} + +export default function ClineToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, +}: ClineToolCardProps): React.ReactNode { + const [status, setStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + + const normalizeLocalhost = (url: string): string => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = (): string => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:4623"; + }; + + // Cline expects base WITHOUT /v1, but we render WITH /v1 for consistency with + // other tools. The backend strips /v1 before persisting. + const getDisplayUrl = (): string => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getEffectiveBaseUrl = (): string => getDisplayUrl(); + + const hasCustomSelectedApiKey = + selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); + + const getConfigStatus = (): "configured" | "not_configured" | "other" | null => { + if (!status?.installed) return null; + if (!status.hasOpenProxy) return "not_configured"; + const url = status.settings?.openAiBaseUrl || ""; + const localMatch = url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0"); + return localMatch ? "configured" : "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key); + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !status) { + void checkStatus(); + } + }, [isExpanded]); + + useEffect(() => { + if (status?.settings?.openAiModelId) setSelectedModel(status.settings.openAiModelId); + }, [status]); + + const checkStatus = async (): Promise => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/cline-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, error: (error as Error).message }); + } finally { + setChecking(false); + } + }; + + const handleApply = async (): Promise => { + setApplying(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : null); + + const res = await fetch("/api/cli-tools/cline-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async (): Promise => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model: { value: string }): void => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = (): Array<{ filename: string; content: string }> => { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : ""); + const effectiveUrl = getEffectiveBaseUrl(); + const baseWithoutV1 = effectiveUrl.endsWith("/v1") ? effectiveUrl.slice(0, -3) : effectiveUrl; + return [ + { + filename: "~/.cline/data/globalState.json", + content: JSON.stringify( + { + actModeApiProvider: "openai", + planModeApiProvider: "openai", + openAiBaseUrl: baseWithoutV1, + openAiModelId: selectedModel || "provider/model-id", + planModeOpenAiModelId: selectedModel || "provider/model-id", + }, + null, + 2, + ), + }, + { + filename: "~/.cline/data/secrets.json", + content: JSON.stringify({ openAiApiKey: keyToUse }, null, 2), + }, + ]; + }; + + return ( + +
+
+
+ {tool.name}) => { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && ( + + Connected + + )} + {configStatus === "not_configured" && ( + + Not configured + + )} + {configStatus === "other" && ( + + Other + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Cline CLI... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

+ Cline CLI not detected locally +

+

+ Install Cline from{" "} + + docs.cline.bot + {" "} + or use Manual Config below. +

+
+
+
+ +
+
+
+ )} + + {!checking && status?.installed && ( + <> +
+ {status?.settings?.openAiBaseUrl && ( +
+ + Current + + + arrow_forward + + + {status.settings.openAiBaseUrl} + +
+ )} + + + +
+ + Base URL + + + arrow_forward + + ) => setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 || selectedApiKey ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_openproxy (default)"} + + )} +
+ +
+ + Model + + + arrow_forward + +
+ ) => setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + {status?.hasOpenProxy && ( + + )} + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + toolName={tool.name} + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/web/src/components/cli-tools/KiloToolCard.tsx b/web/src/components/cli-tools/KiloToolCard.tsx new file mode 100644 index 00000000..df25c262 --- /dev/null +++ b/web/src/components/cli-tools/KiloToolCard.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { ChangeEvent } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import EndpointPresetControl from "./EndpointPresetControl"; + +interface Tool { + name: string; + description: string; +} + +interface ApiKey { + id: string; + key: string; +} + +interface KiloStatus { + installed: boolean; + error?: string; + hasOpenProxy?: boolean; + settings?: { + auth?: string[]; + }; + authPath?: string; +} + +interface Message { + type: "success" | "error"; + text: string; +} + +interface KiloToolCardProps { + tool: Tool; + isExpanded: boolean; + onToggle: () => void; + baseUrl: string; + apiKeys: ApiKey[]; + activeProviders: string[]; + cloudEnabled: boolean; + initialStatus?: KiloStatus | null; +} + +export default function KiloToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, +}: KiloToolCardProps): React.ReactNode { + const [status, setStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + + const normalizeLocalhost = (url: string): string => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = (): string => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:4623"; + }; + + // Kilo expects base WITH /v1. We always render with /v1 and the backend + // appends it if missing. + const getDisplayUrl = (): string => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const getEffectiveBaseUrl = (): string => getDisplayUrl(); + + const hasCustomSelectedApiKey = + selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); + + const getConfigStatus = (): "configured" | "not_configured" | "other" | null => { + if (!status?.installed) return null; + if (!status.hasOpenProxy) return "not_configured"; + // Kilo backend already verifies localhost/127.0.0.1/openproxy in the URL; + // if hasOpenProxy is true we treat it as connected. + return "configured"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) setSelectedApiKey(apiKeys[0].key); + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !status) { + void checkStatus(); + } + }, [isExpanded]); + + const checkStatus = async (): Promise => { + setChecking(true); + try { + const res = await fetch("/api/cli-tools/kilo-settings"); + const data = await res.json(); + setStatus(data); + } catch (error) { + setStatus({ installed: false, error: (error as Error).message }); + } finally { + setChecking(false); + } + }; + + // Kilo expects base WITH /v1 (the backend ensures the trailing /v1). + const handleApply = async (): Promise => { + setApplying(true); + setMessage(null); + try { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : null); + + const res = await fetch("/api/cli-tools/kilo-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async (): Promise => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + void checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: (error as Error).message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model: { value: string }): void => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = (): Array<{ filename: string; content: string }> => { + const keyToUse = + selectedApiKey?.trim() || + (apiKeys?.length > 0 ? apiKeys[0].key : null) || + (!cloudEnabled ? "sk_openproxy" : ""); + const effectiveUrl = getEffectiveBaseUrl(); + return [ + { + filename: "~/.local/share/kilo/auth.json", + content: JSON.stringify( + { + "openai-compatible": { + type: "api-key", + apiKey: keyToUse, + baseUrl: effectiveUrl, + model: selectedModel || "provider/model-id", + }, + }, + null, + 2, + ), + }, + ]; + }; + + return ( + +
+
+
+ {tool.name}) => { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && ( + + Connected + + )} + {configStatus === "not_configured" && ( + + Not configured + + )} + {configStatus === "other" && ( + + Other + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Kilo Code CLI... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

+ Kilo Code CLI not detected locally +

+

+ Install Kilo Code from{" "} + + kilocode.ai + {" "} + or use Manual Config below. +

+
+
+
+ +
+
+
+ )} + + {!checking && status?.installed && ( + <> +
+ {status?.settings?.openAiBaseUrl && ( +
+ + Current + + + arrow_forward + + + {status.settings.openAiBaseUrl} + +
+ )} + + + +
+ + Base URL + + + arrow_forward + + ) => setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ +
+ + API Key + + + arrow_forward + + {apiKeys.length > 0 || selectedApiKey ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_openproxy (default)"} + + )} +
+ +
+ + Model + + + arrow_forward + +
+ ) => setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+
+ + {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + +
+ + {status?.hasOpenProxy && ( + + )} + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + toolName={tool.name} + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/web/src/components/cli-tools/index.ts b/web/src/components/cli-tools/index.ts index 06b45ccc..b396c868 100644 --- a/web/src/components/cli-tools/index.ts +++ b/web/src/components/cli-tools/index.ts @@ -1,4 +1,6 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; diff --git a/web/src/components/cli-tools/index.tsx b/web/src/components/cli-tools/index.tsx index 06b45ccc..b396c868 100644 --- a/web/src/components/cli-tools/index.tsx +++ b/web/src/components/cli-tools/index.tsx @@ -1,4 +1,6 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as ClineToolCard } from "./ClineToolCard"; +export { default as KiloToolCard } from "./KiloToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; diff --git a/web/src/components/page.tsx b/web/src/components/page.tsx index ae3dea3c..d9b3a880 100644 --- a/web/src/components/page.tsx +++ b/web/src/components/page.tsx @@ -43,19 +43,29 @@ function CallbackContent() { let relayed = false; - // Check if this callback is from expected origin/port + // Trusted origins that may receive this callback. The OAuth code/state + // must only be relayed to the dashboard window we expect to be the opener + // (same origin) or the Codex helper that listens on a fixed loopback port. + // Any other origin is treated as hostile (drive-by attacker that opened + // the popup against the well-known redirect_uri to phish the code). const expectedOrigins = [ window.location.origin, // Same origin (for most providers) "http://localhost:1455", // Codex specific port ]; // Method 1: postMessage to opener (popup mode) + // Send once per expected origin. The browser delivers the message only + // when the opener's origin matches the targetOrigin we pass — using "*" + // here would leak the code/state to any opener (e.g. an attacker page + // that opened this URL in a popup), so iterate over the allowlist. if (window.opener) { - try { - window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); - relayed = true; - } catch (e) { - console.log("postMessage failed:", e); + for (const origin of expectedOrigins) { + try { + window.opener.postMessage({ type: "oauth_callback", data: callbackData }, origin); + relayed = true; + } catch (e) { + console.log("postMessage failed:", e); + } } } diff --git a/web/src/components/providers/AddApiKeyModal.tsx b/web/src/components/providers/AddApiKeyModal.tsx index 77dfea42..28924cc5 100644 --- a/web/src/components/providers/AddApiKeyModal.tsx +++ b/web/src/components/providers/AddApiKeyModal.tsx @@ -53,6 +53,47 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); const [saving, setSaving] = useState(false); + // Bulk add: one key per line in `name|apiKey` or just `apiKey` (auto-named). + // Skipped for Azure/Cloudflare/Ollama since they need extra fields per key. + const supportsBulk = !isOllamaLocal && !isAzure && !isCloudflareAi; + const [mode, setMode] = useState<"single" | "bulk">("single"); + const [bulkText, setBulkText] = useState(""); + const [bulkResult, setBulkResult] = useState<{ success: number; failed: number } | null>(null); + + const handleBulkSubmit = async (): Promise => { + if (!provider) return; + const lines = bulkText + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (!lines.length) return; + setSaving(true); + setBulkResult(null); + let success = 0; + let failed = 0; + // POST directly: onSave from the parent closes the modal on success which + // would interrupt the loop. The parent should refresh on onClose. + for (let i = 0; i < lines.length; i++) { + const parts = lines[i].split("|"); + const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim(); + const baseName = parts.length >= 2 ? parts[0].trim() : "Key"; + const name = `${baseName} ${i + 1}`; + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, name, apiKey, priority: 1, testStatus: "unknown" }), + }); + if (res.ok) success++; + else failed++; + } catch { + failed++; + } + } + setSaving(false); + setBulkResult({ success, failed }); + }; + const buildProviderSpecificData = (): any => { if (isOllamaLocal && formData.ollamaHostUrl.trim()) { return { baseUrl: formData.ollamaHostUrl.trim() }; @@ -134,6 +175,65 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa return (
+ {supportsBulk && ( +
+ + +
+ )} + + {supportsBulk && mode === "bulk" && ( +
+

+ One key per line. Format: name|apiKey or just apiKey (auto-named by index). +

+