From 33848d6b91a42d73345a464c7edf1b8834d85473 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:14:25 +0200 Subject: [PATCH 1/9] feat(providers): anillo de uso y saldo PAYG de MiniMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref #165 - `GET {base}/v1/token_plan/remains` con la **Subscription Key** del plan M/Token. - `GET {base}/account/query_balance` con la **API key de pago por uso**, en lectura best-effort: su fallo deja intacto el informe del plan. Son dos credenciales distintas en la consola de MiniMax y cada medidor usa la suya: `secrets::MINIMAX_SUBSCRIPTION_KEY` y `secrets::MINIMAX_API_KEY`. **MiniMax responde HTTP 200 con el fallo dentro de `base_resp`.** Sin chequear el sobre, una clave caducada produce un informe vacío que parece correcto, que es peor que un fallo visible. `envelope()` es estricto: sobre ausente o vacío es error, `status_code` distinto de 0 propaga `status_msg`, y un sobre sin código no se interpreta como éxito ni como fallo. El porcentaje sale de `current_interval_usage_count / current_interval_total_count`. Según MiniMax-AI/cli#104, `usage_count` es el **consumo** y `current_interval_remains_count` lo que queda: usarlo invertiría el anillo. Un test lo fija con un payload donde los dos valores son distintos. El saldo PAYG se emite como medidor con `percent: None`, porque un saldo no es un porcentaje consumido y no hay total contra el que medirlo. La UI ya lo soporta: `ProviderGauge`, `ProviderDetails`, `ProviderCard`, `alerts` y `format` filtran los medidores sin porcentaje, así que no añade un anillo ni dispara avisos. `MiniMaxConfig.base_url` cubre las dos regiones (global `api.minimax.io`, China `api.minimaxi.com`), saneado igual que el de NaN. Tests deterministas con fixtures, sin red: porcentaje, `base_resp` fallido con HTTP 200, saldo sin degradar el plan, importes como string o número, total cero, y ventana con su reinicio. --- src-tauri/src/config.rs | 21 +++ src-tauri/src/providers/minimax.rs | 252 +++++++++++++++++++++++++++++ src-tauri/src/providers/mod.rs | 5 + src-tauri/src/secrets.rs | 10 +- src/Settings.tsx | 38 +++++ src/assets/logos/minimax.svg | 9 ++ src/components/ProviderLogo.tsx | 3 + src/lib/types.ts | 9 ++ src/lib/widget-refresh.test.ts | 1 + src/lib/widget-refresh.ts | 2 +- 10 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/providers/minimax.rs create mode 100644 src/assets/logos/minimax.svg diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d4b1f38..17e1ead 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -82,6 +82,7 @@ pub struct AppConfig { pub anthropic_api: ApiCostConfig, pub openai_api: ApiCostConfig, pub nan: NanConfig, + pub minimax: MiniMaxConfig, pub nine_router: NineRouterConfig, /// Cuentas adicionales de un proveedor (la principal es la de arriba). pub accounts: Vec, @@ -295,6 +296,7 @@ impl Default for AppConfig { anthropic_api: ApiCostConfig::default(), openai_api: ApiCostConfig::default(), nan: NanConfig::default(), + minimax: MiniMaxConfig::default(), nine_router: NineRouterConfig::default(), accounts: vec![], } @@ -425,6 +427,21 @@ pub struct ApiCostConfig { pub monthly_budget_usd: Option, } +/// MiniMax. `base_url` cubre las dos regiones: global `api.minimax.io` y +/// China `api.minimaxi.com`. Cualquier otro host equivalente sirve igual. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct MiniMaxConfig { + pub enabled: bool, + pub base_url: String, +} + +impl Default for MiniMaxConfig { + fn default() -> Self { + Self { enabled: false, base_url: "https://api.minimax.io".into() } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default, rename_all = "camelCase")] pub struct NanConfig { @@ -523,6 +540,10 @@ impl AppConfig { self.nan.base_url = NanConfig::default().base_url; } self.nan.base_url = self.nan.base_url.trim().trim_end_matches('/').to_string(); + if self.minimax.base_url.trim().is_empty() { + self.minimax.base_url = MiniMaxConfig::default().base_url; + } + self.minimax.base_url = self.minimax.base_url.trim().trim_end_matches('/').to_string(); self.nan.billing_day = self.nan.billing_day.clamp(1, 28); if self.nine_router.base_url.trim().is_empty() { self.nine_router.base_url = NineRouterConfig::default().base_url; diff --git a/src-tauri/src/providers/minimax.rs b/src-tauri/src/providers/minimax.rs new file mode 100644 index 0000000..1dd788b --- /dev/null +++ b/src-tauri/src/providers/minimax.rs @@ -0,0 +1,252 @@ +//! MiniMax — API oficial. +//! · `GET {base}/v1/token_plan/remains` → cuota del plan (Subscription Key del plan M/Token) +//! · `GET {base}/account/query_balance` → saldo PAYG (API key de pago por uso, best-effort) +//! +//! Son **dos credenciales distintas** en la consola de MiniMax: la Subscription Key +//! del plan y la API key de pago por uso. Cada medidor usa la suya. +//! +//! MiniMax **responde HTTP 200 incluso cuando la operación falla**: el estado real +//! viaja en el sobre `base_resp`. Ver [[envelope]]. + +use super::{net_error, Who}; +use crate::config::MiniMaxConfig; +use crate::model::{Kind, Meter, Status, UsageReport}; +use crate::secrets; +use serde_json::Value; + +pub async fn fetch(cfg: &MiniMaxConfig, who: Who<'_>, http: &reqwest::Client) -> UsageReport { + let report = who.report("MiniMax", Kind::Subscription); + let Some(key) = who.secret(secrets::MINIMAX_SUBSCRIPTION_KEY) else { + return report.fail(Status::Unconfigured, "Añade tu Subscription Key de MiniMax en Ajustes."); + }; + let base = cfg.base_url.trim_end_matches('/'); + + let res = match http.get(format!("{base}/v1/token_plan/remains")).bearer_auth(&key).send().await { + Ok(r) => r, + Err(e) => return report.fail(Status::Error, net_error(e)), + }; + match res.status().as_u16() { + 200 => {} + 401 | 403 => return report.fail(Status::Unconfigured, "Subscription Key de MiniMax rechazada."), + code => return report.fail(Status::Error, format!("HTTP {code} en /v1/token_plan/remains")), + } + let plan: Value = match res.json().await { + Ok(v) => v, + Err(e) => return report.fail(Status::Error, format!("Respuesta inesperada: {e}")), + }; + if let Err(msg) = envelope(&plan) { + return report.fail(Status::Error, msg); + } + + // El saldo PAYG va con otra clave y es un endpoint que no está en la + // documentación pública oficial: si falla, el informe del plan sigue válido. + let balance = match who.secret(secrets::MINIMAX_API_KEY) { + Some(api_key) => balance(http, &base, &api_key).await, + None => None, + }; + + let mut report = report; + let parsed = parse(&plan, balance.as_ref()); + report.meters = parsed.meters; + report.message = parsed.message; + report +} + +pub(crate) struct Parsed { + pub meters: Vec, + pub message: Option, +} + +/// Lectura **best-effort** del saldo: cualquier fallo devuelve `None` y deja el +/// informe del plan intacto. No reintenta ni distingue causas, porque ninguna +/// cambia lo que el usuario ve. +async fn balance(http: &reqwest::Client, base: &str, api_key: &str) -> Option { + let res = http.get(format!("{base}/account/query_balance")).bearer_auth(api_key).send().await.ok()?; + if !res.status().is_success() { + return None; + } + let value: Value = res.json().await.ok()?; + envelope(&value).ok()?; + Some(value) +} + +/// MiniMax devuelve **HTTP 200 con el fallo dentro de `base_resp`**. Sin este +/// chequeo, una clave caducada o un plan inexistente producen un informe vacío +/// que parece correcto, que es peor que un fallo visible. +/// +/// Estricto con el sobre ausente: un `token_plan/remains` que no lo trae no es un +/// payload que sabemos leer, así que se reporta como error en vez de devolver +/// cero medidores en silencio. +fn envelope(v: &Value) -> Result<(), String> { + let Some(base) = v.get("base_resp") else { + return Err("MiniMax devolvió una respuesta sin base_resp".into()); + }; + if base.is_null() { + return Err("MiniMax devolvió una respuesta con base_resp vacío".into()); + } + match num(base.get("status_code")) { + Some(0.0) => Ok(()), + Some(code) => Err(base + .get("status_msg") + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map_or_else(|| format!("MiniMax rechazó la consulta (status_code {code})"), |m| format!("MiniMax: {m}"))), + // Sin código no hay veredicto: no se inventa un fallo ni un éxito. + None => Ok(()), + } +} + +/// Los importes de MiniMax llegan como cadenas decimales en varios endpoints y +/// como números en otros. Aceptamos ambos sin adivinar. +fn num(v: Option<&Value>) -> Option { + v.and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.trim().parse().ok()))) +} + +fn timestamp(v: Option<&Value>) -> Option { + num(v).map(|n| n as i64) +} + +pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { + let mut meters = vec![]; + + // El porcentaje sale de `usage / total`, nunca de `remains`: según + // MiniMax-AI/cli#104, `current_interval_usage_count` es el **consumo**, y + // `current_interval_remains_count` es lo que queda (y además no está en + // todos los payloads). Confundir ambos invierte el anillo. + let used = num(plan.get("current_interval_usage_count")); + let total = num(plan.get("current_interval_total_count")); + if let Some(limit) = total.filter(|t| *t > 0.0) { + let spent = used.unwrap_or(0.0); + let start = timestamp(plan.get("current_interval_start_time")); + let end = timestamp(plan.get("current_interval_end_time")); + meters.push(Meter { + id: "token_plan".into(), + label: "Uso del plan".into(), + percent: Some(spent / limit * 100.0), + used: Some(spent), + limit: Some(limit), + unit: None, + resets_at: end.and_then(|at| chrono::DateTime::from_timestamp(at, 0)).map(|at| at.to_rfc3339()), + window_seconds: start.zip(end).map(|(s, e)| e - s), + }); + } + + // El saldo PAYG no es un porcentaje: no hay «total» contra el que medirlo. + // Va como medidor sin `percent`, que la UI ya sabe mostrar sin añadir un anillo. + let available = balance.and_then(|b| num(b.get("available_amount"))); + if let Some(amount) = available { + meters.push(Meter { + id: "payg_balance".into(), + label: "Saldo PAYG".into(), + percent: None, + used: Some(amount), + limit: None, + unit: Some("USD".into()), + resets_at: None, + window_seconds: None, + }); + } + + let message = match (meters.first().and_then(|m| m.limit), available) { + (Some(total), Some(balance)) => Some(format!("Plan {:.0}/{:.0} · saldo {balance:.2} $", used.unwrap_or(0.0), total)), + (Some(total), None) => Some(format!("Plan {:.0}/{:.0}", used.unwrap_or(0.0), total)), + _ => None, + }; + Parsed { meters, message } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Envuelve el payload **en el mismo nivel** que `base_resp`, como lo hace + /// MiniMax: los campos del plan son hermanos del sobre, no hijos suyos. + fn ok(mut body: Value) -> Value { + body.as_object_mut() + .expect("el fixture debe ser un objeto") + .insert("base_resp".into(), json!({"status_code": 0, "status_msg": "success"})); + body + } + + #[test] + fn derives_the_percentage_from_usage_over_total_never_from_remains() { + let p = parse(&ok(json!({ + "current_interval_usage_count": 2500, + "current_interval_total_count": 10000, + "current_interval_remains_count": 7500, + })), None); + let meter = &p.meters[0]; + assert_eq!(meter.id, "token_plan"); + assert_eq!(meter.percent, Some(25.0)); + assert_eq!(meter.used, Some(2500.0)); + assert_eq!(meter.limit, Some(10000.0)); + // El anillo debe ser el consumo, no lo que queda. + assert_ne!(meter.percent, Some(75.0)); + assert_eq!(p.meters.len(), 1, "sin saldo no puede inventarse un segundo medidor"); + } + + #[test] + fn rejects_a_200_carrying_a_failed_base_resp() { + let v = json!({"base_resp": {"status_code": 1004, "status_msg": "invalid api key"}}); + assert_eq!(envelope(&v).unwrap_err(), "MiniMax: invalid api key"); + let sin_mensaje = json!({"base_resp": {"status_code": 1008}}); + assert_eq!(envelope(&sin_mensaje).unwrap_err(), "MiniMax rechazó la consulta (status_code 1008)"); + assert!(envelope(&ok(json!({}))).is_ok()); + // Sin sobre no se afirma un éxito que no se pudo verificar. + assert!(envelope(&json!({"current_interval_total_count": 10})).is_err()); + assert!(envelope(&json!({"base_resp": null})).is_err()); + // Un sobre sin código no da veredicto en ninguna dirección. + assert!(envelope(&json!({"base_resp": {}})).is_ok()); + } + + #[test] + fn payg_balance_is_a_value_meter_and_never_touches_the_ring() { + let p = parse( + &ok(json!({"current_interval_usage_count": 1, "current_interval_total_count": 4})), + Some(&ok(json!({"available_amount": "12.50", "cash_balance": "10.00", "voucher_balance": "2.50"}))), + ); + assert_eq!(p.meters.len(), 2); + assert_eq!(p.meters[1].id, "payg_balance"); + assert_eq!(p.meters[1].percent, None, "un saldo no es un porcentaje consumido"); + assert_eq!(p.meters[1].used, Some(12.50)); + assert_eq!(p.meters[1].unit.as_deref(), Some("USD")); + assert_eq!(p.message.as_deref(), Some("Plan 1/4 · saldo 12.50 $")); + } + + #[test] + fn a_missing_or_unusable_balance_leaves_the_plan_report_intact() { + let plan = ok(json!({"current_interval_usage_count": 3, "current_interval_total_count": 6})); + for balance in [None, Some(&json!({})), Some(&ok(json!({"available_amount": null})))] { + let p = parse(&plan, balance); + assert_eq!(p.meters.len(), 1, "el fallo del saldo no puede degradar el anillo del plan"); + assert_eq!(p.meters[0].percent, Some(50.0)); + } + assert_eq!(parse(&plan, None).message.as_deref(), Some("Plan 3/6")); + } + + #[test] + fn reads_amounts_as_strings_or_numbers_and_never_divides_by_zero() { + let as_string = parse(&ok(json!({"current_interval_usage_count": "40", "current_interval_total_count": "80"})), None); + assert_eq!(as_string.meters[0].percent, Some(50.0)); + let sin_total = parse(&ok(json!({"current_interval_usage_count": 40, "current_interval_total_count": 0})), None); + assert!(sin_total.meters.is_empty(), "un total de 0 no produce un anillo indefinido"); + let sin_datos = parse(&ok(json!({})), None); + assert!(sin_datos.meters.is_empty() && sin_datos.message.is_none()); + } + + #[test] + fn carries_the_interval_reset_and_window_when_minimax_sends_them() { + let p = parse(&ok(json!({ + "current_interval_usage_count": 5, + "current_interval_total_count": 10, + "current_interval_start_time": 1_735_689_600, // 2025-01-01T00:00:00Z + "current_interval_end_time": 1_738_368_000, // 2025-02-01T00:00:00Z + })), None); + assert_eq!(p.meters[0].window_seconds, Some(2_678_400)); + assert_eq!(p.meters[0].resets_at.as_deref(), Some("2025-02-01T00:00:00+00:00")); + // Sin marcas de tiempo el medidor sigue siendo válido, solo pierde el reinicio. + let sin_ventana = parse(&ok(json!({"current_interval_usage_count": 5, "current_interval_total_count": 10})), None); + assert!(sin_ventana.meters[0].resets_at.is_none() && sin_ventana.meters[0].window_seconds.is_none()); + } +} \ No newline at end of file diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 23ccc4e..1a5db06 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -8,6 +8,7 @@ pub mod copilot; pub mod cursor; pub mod gemini; pub mod nan; +pub mod minimax; pub mod nan_cloud; pub mod nine_router; pub mod nine_router_creds; @@ -83,6 +84,9 @@ pub async fn fetch_all(cfg: &AppConfig, http: &reqwest::Client) -> Vec Opt "copilot" => copilot::fetch(&cfg.copilot, Who::main("copilot"), http).await, "nan" => nan::fetch(&cfg.nan, Who::main("nan"), http).await, "openrouter" => openrouter::fetch(&cfg.openrouter, Who::main("openrouter"), http).await, + "minimax" => minimax::fetch(&cfg.minimax, Who::main("minimax"), http).await, "nine_router" => nine_router::fetch(&cfg.nine_router, Who::main("nine_router"), http).await, "anthropic_api" => anthropic_api::fetch(&cfg.anthropic_api, Who::main("anthropic_api"), http).await, "openai_api" => openai_api::fetch(&cfg.openai_api, Who::main("openai_api"), http).await, diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index bf04ffb..7d686b6 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -13,6 +13,11 @@ pub const NAN_API_KEY: &str = "nan_api_key"; pub const NAN_SESSION: &str = "nan_session"; pub const OPENROUTER_API_KEY: &str = "openrouter_api_key"; +/// Subscription Key del plan M/Token de MiniMax (endpoint `token_plan/remains`). +pub const MINIMAX_SUBSCRIPTION_KEY: &str = "minimax_subscription_key"; +/// API key de pago por uso de MiniMax (endpoint `query_balance`). Son credenciales +/// distintas en la consola: esta no da acceso al plan y la anterior no lee el saldo. +pub const MINIMAX_API_KEY: &str = "minimax_api_key"; /// Token fine-grained de GitHub con permiso «Plan» (lectura) para la facturación de Copilot. pub const GITHUB_TOKEN: &str = "github_token"; /// Token de Cursor pegado a mano (si no, se lee de la app). @@ -25,12 +30,14 @@ pub const NINE_ROUTER_SESSION: &str = "nine_router_session"; /// coincide con el destino actual, no se envían (evita credenciales de A en B). pub const NINE_ROUTER_BOUND_URL: &str = "nine_router_bound_url"; -pub const ALL: [&str; 10] = [ +pub const ALL: [&str; 12] = [ ANTHROPIC_ADMIN_KEY, OPENAI_ADMIN_KEY, NAN_API_KEY, NAN_SESSION, OPENROUTER_API_KEY, + MINIMAX_SUBSCRIPTION_KEY, + MINIMAX_API_KEY, GITHUB_TOKEN, CURSOR_TOKEN, NINE_ROUTER_PASSWORD, @@ -44,6 +51,7 @@ pub fn for_provider(provider: &str) -> &'static [&'static str] { "cursor" => &[CURSOR_TOKEN], "copilot" => &[GITHUB_TOKEN], "openrouter" => &[OPENROUTER_API_KEY], + "minimax" => &[MINIMAX_SUBSCRIPTION_KEY, MINIMAX_API_KEY], "anthropic_api" => &[ANTHROPIC_ADMIN_KEY], "openai_api" => &[OPENAI_ADMIN_KEY], "nan" => &[NAN_API_KEY, NAN_SESSION], diff --git a/src/Settings.tsx b/src/Settings.tsx index f124b36..0379244 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -37,6 +37,8 @@ const LINKS = { nanDocs: 'https://nan.builders/docs', githubToken: 'https://github.com/settings/personal-access-tokens/new', openrouterKeys: 'https://openrouter.ai/settings/keys', + minimaxKeys: 'https://www.minimax.io/', + minimaxKeysCn: 'https://www.minimaxi.com/', geminiCli: 'https://github.com/google-gemini/gemini-cli', releases: 'https://github.com/egdev6/quorb/releases', repo: 'https://github.com/egdev6/quorb', @@ -1042,6 +1044,7 @@ const PROVIDER_TABS = [ { id: 'gemini', name: 'Gemini', group: 'sub', desc: 'Cuota diaria por modelo' }, { id: 'cursor', name: 'Cursor', group: 'sub', desc: 'Uso del plan en el ciclo y on-demand' }, { id: 'copilot', name: 'GitHub Copilot', group: 'sub', desc: 'Peticiones premium del mes' }, + { id: 'minimax', name: 'MiniMax', group: 'sub', desc: 'Cuota del plan M/Token y saldo de pago por uso' }, { id: 'nan', name: 'NaN Builders', group: 'api', desc: 'Cuota del plan y tokens por día' }, { id: 'openrouter', name: 'OpenRouter', group: 'api', desc: 'Saldo y gasto por clave' }, { id: 'anthropic_api', name: 'Anthropic API', group: 'api', desc: 'Gasto del mes con clave de admin' }, @@ -1113,6 +1116,9 @@ function mainState(id: ProviderTab, cfg: AppConfig, s: SecretStatus): ProviderSt return on(cfg.nan.enabled, !!s.nan_session || !!s.nan_api_key); case 'openrouter': return on(cfg.openrouter.enabled, !!s.openrouter_api_key); + case 'minimax': + // Solo la Subscription Key es obligatoria; el saldo PAYG es un extra opcional. + return on(cfg.minimax.enabled, !!s.minimax_subscription_key); case 'anthropic_api': return on(cfg.anthropicApi.enabled, !!s.anthropic_admin_key); case 'openai_api': @@ -1938,6 +1944,38 @@ export function Settings() { )} + {tab === 'minimax' && ( +
+ setVisibility('minimax', v)} /> +

+ Cuota del plan y, si añades la segunda clave, tu saldo de pago por uso. Son + dos credenciales distintas en la consola: la primera da acceso al plan y la segunda al saldo. +

+
    +
  1. + Subscription Key del plan + Del panel M/Token en minimax.io. En la región de China, en minimaxi.com (cambia también el host en Ajustes avanzados).} + /> +
  2. +
  3. + API key de pago por uso (opcional) + Sin ella solo se muestra la cuota del plan; el saldo es opcional y su fallo no lo degrade.} + /> +
  4. + +
+
+ )} {tab === 'openrouter' && (
setVisibility('openrouter', v)} /> diff --git a/src/assets/logos/minimax.svg b/src/assets/logos/minimax.svg new file mode 100644 index 0000000..57bf258 --- /dev/null +++ b/src/assets/logos/minimax.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/components/ProviderLogo.tsx b/src/components/ProviderLogo.tsx index 8569bdf..27e2f01 100644 --- a/src/components/ProviderLogo.tsx +++ b/src/components/ProviderLogo.tsx @@ -55,6 +55,8 @@ const ALIASES: Record = { cursor: ['cursor'], copilot: ['copilot', 'github-copilot', 'microsoft-copilot', 'github'], openrouter: ['openrouter'], + // `minimax-cn` es el nombre del nodo de 9Router para la región de China. + minimax: ['minimax', 'minimax-cn', 'minimax_cn', 'minimaxi'], nine_router: ['nine_router', '9router'], // Nombres de nodos personalizados de 9Router → logo del proveedor real. zhipu: ['zhipu', 'z-ai', 'glm', 'glm-cn'], @@ -107,6 +109,7 @@ const MONOGRAM: Record = { cursor: { text: 'Cu' }, copilot: { text: 'Co' }, openrouter: { text: 'OR' }, + minimax: { text: 'MM' }, nine_router: { text: '9R' }, }; diff --git a/src/lib/types.ts b/src/lib/types.ts index a913414..0959b87 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,6 +55,12 @@ export interface ApiCostConfig { monthlyBudgetUsd: number | null; } +/** MiniMax. `baseUrl` cubre las dos regiones: global `api.minimax.io` y China `api.minimaxi.com`. */ +export interface MiniMaxConfig { + enabled: boolean; + baseUrl: string; +} + export interface NanConfig { enabled: boolean; baseUrl: string; @@ -154,6 +160,7 @@ export interface AppConfig { anthropicApi: ApiCostConfig; openaiApi: ApiCostConfig; nan: NanConfig; + minimax: MiniMaxConfig; nineRouter: NineRouterConfig; /** Cuentas adicionales de un proveedor (la principal es la de su campo) */ accounts: Account[]; @@ -195,6 +202,8 @@ export type SecretKey = | 'nan_api_key' | 'nan_session' | 'openrouter_api_key' + | 'minimax_subscription_key' + | 'minimax_api_key' | 'github_token' | 'cursor_token' | 'nine_router_password' diff --git a/src/lib/widget-refresh.test.ts b/src/lib/widget-refresh.test.ts index 8bfab07..fbbff98 100644 --- a/src/lib/widget-refresh.test.ts +++ b/src/lib/widget-refresh.test.ts @@ -13,6 +13,7 @@ function cfg(over: Partial = {}): AppConfig { copilot: { enabled: true }, openrouter: { enabled: true }, nan: { enabled: true }, + minimax: { enabled: false }, anthropicApi: { enabled: false }, openaiApi: { enabled: false }, nineRouter: { enabled: true, baseUrl: 'http://localhost:20128', period: '7d', showSubProviders: true, minSpendUsd: 0, monthlyBudgetUsd: null }, diff --git a/src/lib/widget-refresh.ts b/src/lib/widget-refresh.ts index f0ff467..ce59320 100644 --- a/src/lib/widget-refresh.ts +++ b/src/lib/widget-refresh.ts @@ -8,7 +8,7 @@ import type { AppConfig } from './types'; */ export function dataKey(c: AppConfig | null): string { return c - ? JSON.stringify([c.claude, c.codex, c.gemini, c.cursor, c.copilot, c.openrouter, c.anthropicApi, c.openaiApi, c.nan, c.nineRouter]) + ? JSON.stringify([c.claude, c.codex, c.gemini, c.cursor, c.copilot, c.openrouter, c.anthropicApi, c.openaiApi, c.nan, c.minimax, c.nineRouter]) : ''; } From 037662238f6cdad1d1465ab0adc2ef58c618c227 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:16:59 +0200 Subject: [PATCH 2/9] docs(i18n): regenerar el inventario con el proveedor MiniMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El header graba `git rev-parse HEAD:src`, así que el inventario solo puede generarse después de commitear el código. +15 strings de React (sección de Ajustes) y +11 de Rust (minimax.rs). --- docs/i18n-inventory.md | 950 +++++++++++++++++++++-------------------- 1 file changed, 493 insertions(+), 457 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 6dbfbf2..882ee20 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c64cc863ec51e3d2d82a00cbc295db13ff38c51a` and `src-tauri/src/` = `77cd2af35c177d8a5d6b0b992a0319b0abb769c5`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `f48be44591a0a96dc39642b19c29b3c5f149f4ab` and `src-tauri/src/` = `cb7730b73b07845cd07378c5be18e89c10ef4e17`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact lines. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -17,9 +17,9 @@ look like text. | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 573 | 699 | -| Rust (`src-tauri/src/`) | 291 | 345 | -| Total | 833 | 1044 | +| React (`src/`) | 588 | 717 | +| Rust (`src-tauri/src/`) | 302 | 358 | +| Total | 859 | 1075 | ## src-tauri/src/channel.rs @@ -49,8 +49,8 @@ look like text. | Line | Text | |---:|---| -| 280 | codex login | -| 563 | Cuenta {} | +| 281 | codex login | +| 584 | Cuenta {} | ## src-tauri/src/detail_geometry.rs @@ -271,18 +271,36 @@ look like text. | 127 | HTTP {code} en {method} | | 156 | model:{name} | +## src-tauri/src/providers/minimax.rs + +| Line | Text | +|---:|---| +| 20 | Añade tu Subscription Key de MiniMax en Ajustes. | +| 30 | Subscription Key de MiniMax rechazada. | +| 31 | HTTP {code} en /v1/token_plan/remains | +| 35 | Respuesta inesperada: {e} | +| 82 | MiniMax devolvió una respuesta sin base_resp | +| 85 | MiniMax devolvió una respuesta con base_resp vacío | +| 93 | MiniMax rechazó la consulta (status_code {code}) | +| 93 | MiniMax: {m} | +| 124 | Uso del plan | +| 140 | Saldo PAYG | +| 144 | USD | +| 151 | Plan {:.0}/{:.0} · saldo {balance:.2} $ | +| 152 | Plan {:.0}/{:.0} | + ## src-tauri/src/providers/mod.rs | Line | Text | |---:|---| -| 192 | Proveedor desconocido: {other} | -| 229 | No existe {} | -| 238 | No encuentro credenciales en: {} | -| 243 | No puedo leer {}: {e} | -| 244 | JSON inválido en {}: {e} | -| 286 | Tiempo de espera agotado | -| 288 | Sin conexión con el servidor | -| 290 | Error de red: {e} | +| 197 | Proveedor desconocido: {other} | +| 234 | No existe {} | +| 243 | No encuentro credenciales en: {} | +| 248 | No puedo leer {}: {e} | +| 249 | JSON inválido en {}: {e} | +| 291 | Tiempo de espera agotado | +| 293 | Sin conexión con el servidor | +| 295 | Error de red: {e} | ## src-tauri/src/providers/nan.rs @@ -416,7 +434,7 @@ look like text. | Line | Text | |---:|---| -| 71 | Clave desconocida: {key} | +| 79 | Clave desconocida: {key} | ## src-tauri/src/window_clip.rs @@ -509,447 +527,465 @@ look like text. | Line | Text | |---:|---| -| 82 | Versión actual | -| 85 | Actualización manual en macOS. No se buscan ni se instalan actualizaciones desde la app. | -| 86 | No se pudo determinar el modo de actualización. Reinicia Quorb para reintentar. | -| 87 | Consultando el modo de actualización… | -| 93 | Versión instalada | -| 103 | Hay una versión nueva: | -| 103 | Se descarga, se comprueba la firma y Quorb se reinicia. | -| 107 | Instalando${…} | -| 107 | Instalar y reiniciar | -| 121 | · estás en la última versión | -| 124 | Buscando… | -| 124 | Buscar ahora | -| 129 | Buscar actualizaciones solo | -| 129 | Al arrancar y cada 6 h. Si hay una nueva, aparece un icono de descarga en la tira. | -| 132 | Llegan firmadas desde | -| 134 | GitHub Releases | -| 136 | ; si la firma no coincide, no se instalan. | -| 202 | Guardada en el almacén seguro del sistema | -| 202 | Eliminada | -| 220 | •••••••• (configurada) | -| 220 | Pega aquí la key | -| 226 | Guardar | -| 228 | Eliminar key | -| 230 | Quitar | -| 260 | Cuenta de NaN Cloud conectada | -| 271 | Desconectar | -| 279 | Conectar tu cuenta (recomendado) | -| 282 | tu@correo.com | -| 290 | Te hemos enviado un enlace. Cópialo (sin abrirlo) y pégalo aquí. | -| 294 | Enviar enlace | -| 306 | Conectado como ${…} | -| 313 | Conectar | -| 316 | Volver | -| 321 | Igual que «nan auth login --email»: NaN te manda un enlace de un solo uso. No lo abras en el navegador (se gastaría): cópialo y pégalo aquí. La sesión se guarda en el almacén de credenciales del sistema y da los mismos datos que tu dashboard. | -| 333 | Probar la API de NaN con la key | -| 348 | Consultando… | -| 348 | Ver respuestas de /v1/models y /v1/usage | -| 368 | 9Router desconectado | -| 373 | Conectado | -| 386 | 9Router conectado · volver a conectar | -| 386 | Conectar con el dashboard | -| 392 | •••••••• (configurada) | -| 392 | Contraseña del dashboard | -| 397 | Guardar y probar | -| 400 | Desconectar 9Router | -| 402 | Quitar | -| 406 | La contraseña del dashboard de 9Router (por defecto «123456» hasta que la cambies). Se guarda en el almacén seguro del sistema. | -| 414 | Confeti | -| 415 | Glow de peligro | -| 416 | Efecto propio (vídeo o imagen animada) | -| 417 | Ninguno | -| 429 | Vídeo o imagen animada | -| 457 | Efecto | -| 472 | Probar | -| 476 | Archivo | -| 478 | Ningún archivo | -| 487 | Elegir… | -| 491 | Quitar | -| 513 | ${…}: ${…}. Pulsa para cambiarlo | -| 513 | sin atajo | -| 530 | Pulsa la combinación… (Esc cancela) | -| 530 | Sin atajo | -| 536 | Cambiar | -| 540 | Quitar | -| 569 | Falta | -| 569 | en este equipo. | -| 569 | Quorb inicia sesión con su login oficial. Instálalo desde | -| 570 | (necesita ${…}) | -| 570 | ; Quorb lo detecta solo al terminar. | -| 575 | Copiado | -| 575 | Copiar | -| 611 | Datos recibidos | -| 660 | Comprobando la conexión… | -| 661 | Funciona | -| 662 | Falta completar un paso | -| 663 | Sin sesión | -| 663 | Atención | -| 664 | No funciona | -| 669 | No se pudo comprobar ahora: ${…}. Vuelve a intentarlo. | -| 671 | Guarda los cambios para comprobar esta cuenta. | -| 680 | · oculta en el widget | -| 683 | Consultando con lo guardado… | -| 689 | Tienes cambios sin guardar: la comprobación usa lo guardado. | -| 701 | Guardar y comprobar | -| 705 | Comprobar ahora | -| 715 | Iniciar sesión | -| 797 | Instala | -| 797 | su CLI | -| 798 | Hace falta para iniciar sesión y para renovarla cuando caduque, aunque ahora funcione. | -| 803 | Fichero de sesión encontrado | -| 803 | Inicia sesión con la otra cuenta | -| 803 | Inicia sesión | -| 805 | Se abre una terminal con | -| 806 | (si no te lo pide, escribe | -| 806 | /login | -| 807 | (elige «Login with Google») | -| 807 | . El navegador hace el login oficial y el CLI guarda la sesión, que Quorb solo lee. | -| 809 | El fichero existe, pero no confirma que la sesión siga siendo válida: míralo en la comprobación de abajo. | -| 810 | Entra con la cuenta que quieres añadir, no con la principal: si el navegador ya tiene abierta la principal, usa una ventana privada. | -| 814 | Volver a abrir el login | -| 814 | Iniciar sesión | -| 814 | Guardar e iniciar sesión | -| 827 | Comprobación | -| 828 | Se hace sola en cuanto guardes e inicies sesión. | -| 878 | Comprobación | -| 879 | Guarda la cuenta para comprobar que funciona. | -| 882 | Guardar y comprobar | -| 888 | Cuenta adicional | -| 890 | Nombre | -| 890 | Se ve en la tira junto al proveedor. | -| 894 | Mostrar en el widget | -| 896 | Cómo conectar esta cuenta | -| 898 | Ponle nombre | -| 899 | Arriba, para distinguirla de la principal en la tira (por ejemplo, «Trabajo»). | -| 909 | Pega el token de esta cuenta | -| 910 | La app de Cursor guarda una sola sesión (la principal). Entra en cursor.com con esta cuenta y copia la cookie WorkosCursorSessionToken. | -| 911 | Token de la cuenta | -| 920 | Usuario y plan de GitHub | -| 922 | Usuario de GitHub | -| 925 | Plan | -| 927 | Free · 50 | -| 928 | Pro · 300 | -| 929 | Pro+ · 1500 | -| 930 | Business · 300 | -| 931 | Enterprise · 1000 | -| 932 | Otra cantidad | -| 937 | Peticiones premium incluidas al mes | -| 943 | Token de solo lectura | -| 946 | Token de GitHub de esta cuenta (fine-grained) | -| 949 | Créalo con esta cuenta en | -| 949 | GitHub › Fine-grained tokens | -| 949 | con el permiso «Plan» (solo lectura). | -| 959 | Conecta la cuenta o pega una API key | -| 961 | API key (alternativa) | -| 964 | Plan | -| 965 | Plan | -| 965 | La URL base y el resto de ajustes avanzados son los de la cuenta principal. | -| 967 | nan_community | -| 968 | nan_member | -| 969 | GLM Premium | -| 980 | API key | -| 983 | API key | -| 986 | Puede ser de otra cuenta o de la misma con otro límite. | -| 986 | openrouter.ai › Keys | -| 996 | Admin key de esta organización | -| 999 | Admin key | -| 1002 | Cada organización tiene su propia | -| 1002 | Admin key | -| 1006 | Presupuesto mensual (opcional) | -| 1007 | USD al mes | -| 1018 | Avanzado · carpeta de sesión | -| 1020 | Cada cuenta guarda su sesión en su propia carpeta, así no toca la principal: el login se abre con | -| 1021 | apuntando a ella. Si la comprobación pide renovar la sesión, «Volver a abrir el login» abre de nuevo el CLI. | -| 1023 | Carpeta de sesión | -| 1023 | Vacío = | -| 1023 | . Puedes usar una que ya tengas. Quorb lee | -| 1023 | dentro de ella. | -| 1031 | Eliminar esta cuenta | -| 1033 | Al guardar se borran también sus claves del almacén seguro del sistema. | -| 1040 | Claude | -| 1040 | Pro / Max · sesión de 5 h, semanal y por modelo | -| 1041 | ChatGPT y Codex | -| 1041 | Plus / Pro · sesión y semanal de Codex | -| 1042 | Gemini | -| 1042 | Cuota diaria por modelo | -| 1043 | Cursor | -| 1043 | Uso del plan en el ciclo y on-demand | -| 1044 | GitHub Copilot | -| 1044 | Peticiones premium del mes | -| 1045 | NaN Builders | -| 1045 | Cuota del plan y tokens por día | -| 1046 | Saldo y gasto por clave | -| 1047 | Anthropic API | -| 1047 | Gasto del mes con clave de admin | -| 1048 | OpenAI API | -| 1048 | Gasto del mes con clave de admin | -| 1050 | Pasarela local · uso y gasto agregados | -| 1055 | Proveedores | -| 1055 | Suscripciones y APIs | -| 1055 | Qué se consulta y cómo se conecta cada proveedor. | -| 1056 | Apariencia | -| 1056 | Apariencia | -| 1056 | Tamaño y color de los anillos, y dónde vive la tira. | -| 1057 | Alertas | -| 1057 | Alertas | -| 1057 | Qué pasa al superar un límite y cuando se reinicia. | -| 1058 | Atajos | -| 1058 | Atajos | -| 1058 | Combinaciones de teclado que funcionan desde cualquier app. | -| 1059 | Actualizaciones | -| 1059 | Actualizaciones | -| 1059 | Versión instalada, actualizaciones automáticas y el proyecto. | -| 1075 | Izquierda | -| 1076 | Derecha | -| 1077 | Arriba | -| 1078 | Abajo | -| 1085 | Activo | -| 1086 | Falta conectar | -| 1087 | Desactivado | -| 1170 | Quorb | -| 1178 | Secciones de ajustes | -| 1195 | Activos pero sin configurar | -| 1207 | Expandir el menú | -| 1207 | Contraer el menú | -| 1208 | Expandir el menú | -| 1208 | Contraer el menú | -| 1215 | Contraer | -| 1636 | · ${…} cuentas | -| 1641 | Conectar | -| 1641 | Configurar | -| 1672 | Arrancar al iniciar sesión | -| 1679 | No se pudo leer «Arrancar al iniciar sesión»: | -| 1686 | Reintentar | -| 1696 | Sesión de escritorio: | -| 1696 | Wayland | -| 1705 | con límites: | -| 1705 | sin límites conocidos: | -| 1714 | Proveedores | -| 1717 | Proveedores | -| 1721 | Consultar cada | -| 1723 | min | -| 1726 | Suscripciones | -| 1728 | APIs y créditos | -| 1730 | Para cambiar el orden de los anillos, arrástralos en la tira. | -| 1752 | Configuración | -| 1756 | Cerrar la configuración | -| 1767 | Cuentas | -| 1768 | Principal | -| 1782 | Los subproveedores de 9Router se descubren solos desde su historial de uso; no se añaden a mano. | -| 1785 | + Añadir cuenta | -| 1819 | Suscripción · OAuth | -| 1820 | Claude (Pro / Max) | -| 1821 | Límites de sesión (5 h), semanales y por modelo de tu plan. Quorb lee la sesión que guarda Claude Code: no ve tu contraseña ni toca los tokens. | -| 1822 | Fichero de credenciales | -| 1823 | Vacío = | -| 1823 | %USERPROFILE%\\.claude\\.credentials.json | -| 1823 | . Para WSL: | -| 1823 | \\\\wsl.localhost\\Ubuntu\\home\\TU_USUARIO\\.claude\\.credentials.json | -| 1826 | Suscripción · OAuth | -| 1827 | ChatGPT (Plus / Pro · Codex) | -| 1828 | Límites de 5 h y semanales de tu plan de ChatGPT en Codex. Quorb lee la sesión que guarda el CLI de Codex. | -| 1829 | Fichero auth.json | -| 1830 | Vacío = | -| 1830 | %CODEX_HOME% | -| 1830 | o | -| 1830 | %USERPROFILE%\\.codex\\auth.json | -| 1833 | Suscripción · sesión de Gemini CLI | -| 1834 | Gemini (Google AI Pro / Code Assist) | -| 1837 | Cuotas diarias por modelo. Quorb lee la sesión que guarda | -| 1837 | Gemini CLI | -| 1837 | . Endpoints no oficiales. | -| 1840 | Fichero de credenciales | -| 1841 | Vacío = | -| 1841 | %USERPROFILE%\\.gemini\\oauth_creds.json | -| 1846 | Mostrar en el widget | -| 1848 | Cómo conectar la cuenta | -| 1862 | Avanzado · fichero de sesión y comando de login | -| 1866 | Comando de login | -| 1875 | Suscripción · sesión de la app | -| 1875 | Cursor | -| 1876 | Mostrar en el widget | -| 1877 | Uso del plan en el ciclo actual (total, Auto y modelos API) y gasto on-demand. Endpoint no oficial. | -| 1878 | Cómo conectar la cuenta | -| 1880 | Inicia sesión en la app de Cursor | -| 1882 | Quorb lee la sesión que guarda la app de Cursor en este equipo (solo lectura). Si no usas la app aquí, pega un token manual: tiene prioridad sobre la sesión de la app. | -| 1885 | Token manual (opcional) | -| 1890 | Avanzado · base de datos de Cursor | -| 1891 | Base de datos de Cursor | -| 1891 | Vacío = | -| 1891 | %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb | -| 1899 | Suscripción · API oficial | -| 1899 | GitHub Copilot | -| 1900 | Mostrar en el widget | -| 1901 | Peticiones premium del mes con la API de facturación de GitHub. Se reinician el día 1 (UTC). | -| 1902 | Cómo conectar la cuenta | -| 1904 | Usuario y plan de GitHub | -| 1906 | Usuario de GitHub | -| 1907 | egdev6 | -| 1909 | Plan | -| 1911 | Free · 50 | -| 1912 | Pro · 300 | -| 1913 | Pro+ · 1500 | -| 1914 | Business · 300 | -| 1915 | Enterprise · 1000 | -| 1916 | Otra cantidad | -| 1921 | Peticiones premium incluidas al mes | -| 1927 | Token de solo lectura | -| 1930 | Token de GitHub (fine-grained) | -| 1933 | Créalo en | -| 1933 | GitHub › Fine-grained tokens | -| 1933 | con el permiso de cuenta «Plan»: solo lectura. | -| 1942 | Pago por uso · API oficial | -| 1942 | OpenRouter | -| 1943 | Mostrar en el widget | -| 1944 | Gasto del mes y de hoy, límite de la key (si tiene) y saldo de la cuenta. | -| 1945 | Cómo conectar la cuenta | -| 1947 | API key | -| 1950 | API key | -| 1953 | Créala en | -| 1953 | openrouter.ai › Keys | -| 1962 | Suscripción · cuenta o API key | -| 1962 | NaN Builders | -| 1963 | Mostrar en el widget | -| 1965 | Con la cuenta conectada ves lo mismo que en tu dashboard: tokens usados frente a la cuota de cada modelo, cuándo se reinicia y la serie diaria. Sin cuenta, la API key usa | -| 1966 | /v1/usage | -| 1966 | y las cuotas publicadas. | -| 1966 | Docs | -| 1968 | Cómo conectar la cuenta | -| 1970 | Conecta tu cuenta o pega una API key | -| 1974 | API key (alternativa) | -| 1977 | Créala en | -| 1977 | cloud.nan.builders | -| 1977 | › API Keys | -| 1981 | Plan | -| 1982 | Plan | -| 1984 | nan_community | -| 1985 | nan_member | -| 1986 | GLM Premium | -| 1993 | Avanzado · URL, facturación y endpoint de consumo | -| 1994 | Base URL | -| 1997 | Día de inicio de facturación | -| 1997 | Solo para GLM 5.3 (cuenta por periodo de Stripe) cuando usas la API key. | -| 2001 | Endpoint de consumo · URL | -| 2001 | Se llama con tu key como Bearer y debe devolver JSON. | -| 2005 | JSON Pointer · usado | -| 2005 | p. ej. /usage/used_tokens | -| 2008 | JSON Pointer · límite | -| 2008 | p. ej. /usage/quota_tokens | -| 2012 | Etiqueta | -| 2013 | Consumo | -| 2020 | Pasarela local · uso agregado | -| 2020 | 9Router | -| 2022 | Mostrar en el widget | -| 2027 | Quorb lee el uso y el gasto que | -| 2027 | 9Router | -| 2027 | ya registra en tu equipo: peticiones, tokens y coste por proveedor del periodo. Además del orbe agregado, cada proveedor que haya visto (Claude, Kiro, GLM…) aparece como su propia cuenta. | -| 2030 | Cómo conectar 9Router | -| 2032 | Conecta tu dashboard local | -| 2036 | Periodo y desglose | -| 2037 | Periodo de uso | -| 2037 | Rango que muestra el orbe y su historial. | -| 2039 | Hoy | -| 2040 | Últimas 24 h | -| 2041 | Últimos 7 días | -| 2042 | Últimos 30 días | -| 2043 | Últimos 60 días | -| 2044 | Todo | -| 2048 | Mostrar cada sub-proveedor como su propia cuenta | -| 2052 | Gasto mínimo por sub-proveedor (USD) | -| 2052 | No muestra un sub-orbe hasta que su gasto en el periodo supere esta cifra. 0 = cualquiera que gaste. | -| 2061 | Presupuesto (USD) | -| 2061 | Opcional: pinta la barra de gasto del periodo frente al tope que pongas. | -| 2074 | Avanzado · dirección del servidor | -| 2075 | Base URL | -| 2075 | Dónde escucha 9Router (por defecto http://localhost:20128). | -| 2085 | Suborbes ocultos de 9Router | -| 2089 | · mostrar | -| 2093 | Los ocultaste desde su panel en la tira. Pulsa para volver a mostrarlos. | -| 2105 | Pago por uso · Admin key | -| 2105 | Anthropic API | -| 2105 | OpenAI API | -| 2106 | Mostrar gasto del mes | -| 2107 | Cómo conectar la cuenta | -| 2109 | Admin key | -| 2112 | Admin API key (sk-ant-admin…) | -| 2112 | Admin key | -| 2117 | Las APIs de coste no admiten OAuth: hace falta una | -| 2117 | Admin key | -| 2117 | (solo lectura de la organización). | -| 2119 | Créala en | -| 2119 | Organization › Admin keys | -| 2125 | Presupuesto mensual (opcional) | -| 2126 | USD al mes | -| 2143 | Listo | -| 2145 | Guardando… | -| 2145 | Guardar cambios | -| 2154 | Anillos | -| 2156 | Vista previa a ${…} px | -| 2164 | Tamaño del anillo | -| 2164 | Restablecer ( | -| 2164 | px) | -| 2169 | Color de acento | -| 2172 | Usar ${…} | -| 2174 | Elegir cualquier color (RGB) | -| 2179 | Colores resultantes | -| 2188 | Es el del anillo exterior y la primera barra; los otros dos tonos salen solos. Los avisos y el exceso conservan sus colores de alerta. | -| 2189 | Restablecer rojo egdev | -| 2192 | Opacidad de la tira y el panel | -| 2196 | Círculos pequeños | -| 2196 | Un solo anillo por suscripción: el límite más cercano. | -| 2197 | Mostrar resets en los círculos | -| 2197 | El saldo de resets siempre sigue disponible en el panel de detalle. | -| 2201 | Tira | -| 2203 | Borde | -| 2211 | También puedes arrastrarla desde su asa: se pega al borde más cercano. | -| 2213 | Posición en el borde | -| 2217 | Ocultar sola | -| 2217 | Deja una franja en el borde y vuelve al acercar el ratón o cuando hay un aviso. | -| 2218 | Siempre encima | -| 2218 | Por encima del resto de ventanas. | -| 2222 | Personalización no aplicada | -| 2224 | Reintentar | -| 2233 | Español | -| 2234 | English | -| 2249 | Cuándo avisar | -| 2251 | Activar avisos | -| 2252 | Notificación del sistema | -| 2252 | Además del efecto en pantalla. | -| 2255 | 1 · Al superar el límite | -| 2256 | Avisar al llegar a | -| 2256 | Una vez por ventana (sesión de 5 h, semana, mes…). | -| 2262 | 2 · Al reiniciarse | -| 2264 | Avisar cuando el límite superado se reinicia | -| 2264 | «Claude · Sesión 5 h: disponible de nuevo» | -| 2272 | Efecto propio | -| 2274 | Un vídeo WebM (VP9) con canal alfa o una imagen animada GIF, WebP o APNG con transparencia. Se muestra centrado encima de todo, sin bloquear el ratón; el vídeo dura lo que dure (máx. 20 s) y las imágenes 4,5 s. | -| 2277 | Tamaño | -| 2279 | Pequeño | -| 2280 | Mediano | -| 2281 | Pantalla completa | -| 2285 | Con sonido | -| 2285 | Solo para vídeos. | -| 2292 | Atajos globales | -| 2294 | Activar atajos | -| 2294 | Funcionan aunque Quorb no tenga el foco. | -| 2300 | Atajos de teclado | -| 2303 | Acción | -| 2304 | Atajo | -| 2305 | Acciones | -| 2309 | Mostrar u ocultar la tira | -| 2310 | Abrir el más cargado | -| 2311 | Actualizar ahora | -| 2320 | Pulsa un atajo o «Cambiar» y teclea la combinación. Se aplican al guardar. | -| 2329 | Proyecto | -| 2330 | Quorb es open source (MIT). Si te ahorra un susto con los límites, una estrella en GitHub ayuda a que lo encuentre más gente. | -| 2332 | GitHub | -| 2333 | Pedir un proveedor | -| 2334 | egdev.es | -| 2345 | Cerrar | -| 2347 | Guardando… | -| 2347 | Guardar cambios | +| 84 | Versión actual | +| 87 | Actualización manual en macOS. No se buscan ni se instalan actualizaciones desde la app. | +| 88 | No se pudo determinar el modo de actualización. Reinicia Quorb para reintentar. | +| 89 | Consultando el modo de actualización… | +| 95 | Versión instalada | +| 105 | Hay una versión nueva: | +| 105 | Se descarga, se comprueba la firma y Quorb se reinicia. | +| 109 | Instalando${…} | +| 109 | Instalar y reiniciar | +| 123 | · estás en la última versión | +| 126 | Buscando… | +| 126 | Buscar ahora | +| 131 | Buscar actualizaciones solo | +| 131 | Al arrancar y cada 6 h. Si hay una nueva, aparece un icono de descarga en la tira. | +| 134 | Llegan firmadas desde | +| 136 | GitHub Releases | +| 138 | ; si la firma no coincide, no se instalan. | +| 204 | Guardada en el almacén seguro del sistema | +| 204 | Eliminada | +| 222 | •••••••• (configurada) | +| 222 | Pega aquí la key | +| 228 | Guardar | +| 230 | Eliminar key | +| 232 | Quitar | +| 262 | Cuenta de NaN Cloud conectada | +| 273 | Desconectar | +| 281 | Conectar tu cuenta (recomendado) | +| 284 | tu@correo.com | +| 292 | Te hemos enviado un enlace. Cópialo (sin abrirlo) y pégalo aquí. | +| 296 | Enviar enlace | +| 308 | Conectado como ${…} | +| 315 | Conectar | +| 318 | Volver | +| 323 | Igual que «nan auth login --email»: NaN te manda un enlace de un solo uso. No lo abras en el navegador (se gastaría): cópialo y pégalo aquí. La sesión se guarda en el almacén de credenciales del sistema y da los mismos datos que tu dashboard. | +| 335 | Probar la API de NaN con la key | +| 350 | Consultando… | +| 350 | Ver respuestas de /v1/models y /v1/usage | +| 370 | 9Router desconectado | +| 375 | Conectado | +| 388 | 9Router conectado · volver a conectar | +| 388 | Conectar con el dashboard | +| 394 | •••••••• (configurada) | +| 394 | Contraseña del dashboard | +| 399 | Guardar y probar | +| 402 | Desconectar 9Router | +| 404 | Quitar | +| 408 | La contraseña del dashboard de 9Router (por defecto «123456» hasta que la cambies). Se guarda en el almacén seguro del sistema. | +| 416 | Confeti | +| 417 | Glow de peligro | +| 418 | Efecto propio (vídeo o imagen animada) | +| 419 | Ninguno | +| 431 | Vídeo o imagen animada | +| 459 | Efecto | +| 474 | Probar | +| 478 | Archivo | +| 480 | Ningún archivo | +| 489 | Elegir… | +| 493 | Quitar | +| 515 | ${…}: ${…}. Pulsa para cambiarlo | +| 515 | sin atajo | +| 532 | Pulsa la combinación… (Esc cancela) | +| 532 | Sin atajo | +| 538 | Cambiar | +| 542 | Quitar | +| 571 | Falta | +| 571 | en este equipo. | +| 571 | Quorb inicia sesión con su login oficial. Instálalo desde | +| 572 | (necesita ${…}) | +| 572 | ; Quorb lo detecta solo al terminar. | +| 577 | Copiado | +| 577 | Copiar | +| 613 | Datos recibidos | +| 662 | Comprobando la conexión… | +| 663 | Funciona | +| 664 | Falta completar un paso | +| 665 | Sin sesión | +| 665 | Atención | +| 666 | No funciona | +| 671 | No se pudo comprobar ahora: ${…}. Vuelve a intentarlo. | +| 673 | Guarda los cambios para comprobar esta cuenta. | +| 682 | · oculta en el widget | +| 685 | Consultando con lo guardado… | +| 691 | Tienes cambios sin guardar: la comprobación usa lo guardado. | +| 703 | Guardar y comprobar | +| 707 | Comprobar ahora | +| 717 | Iniciar sesión | +| 799 | Instala | +| 799 | su CLI | +| 800 | Hace falta para iniciar sesión y para renovarla cuando caduque, aunque ahora funcione. | +| 805 | Fichero de sesión encontrado | +| 805 | Inicia sesión con la otra cuenta | +| 805 | Inicia sesión | +| 807 | Se abre una terminal con | +| 808 | (si no te lo pide, escribe | +| 808 | /login | +| 809 | (elige «Login with Google») | +| 809 | . El navegador hace el login oficial y el CLI guarda la sesión, que Quorb solo lee. | +| 811 | El fichero existe, pero no confirma que la sesión siga siendo válida: míralo en la comprobación de abajo. | +| 812 | Entra con la cuenta que quieres añadir, no con la principal: si el navegador ya tiene abierta la principal, usa una ventana privada. | +| 816 | Volver a abrir el login | +| 816 | Iniciar sesión | +| 816 | Guardar e iniciar sesión | +| 829 | Comprobación | +| 830 | Se hace sola en cuanto guardes e inicies sesión. | +| 880 | Comprobación | +| 881 | Guarda la cuenta para comprobar que funciona. | +| 884 | Guardar y comprobar | +| 890 | Cuenta adicional | +| 892 | Nombre | +| 892 | Se ve en la tira junto al proveedor. | +| 896 | Mostrar en el widget | +| 898 | Cómo conectar esta cuenta | +| 900 | Ponle nombre | +| 901 | Arriba, para distinguirla de la principal en la tira (por ejemplo, «Trabajo»). | +| 911 | Pega el token de esta cuenta | +| 912 | La app de Cursor guarda una sola sesión (la principal). Entra en cursor.com con esta cuenta y copia la cookie WorkosCursorSessionToken. | +| 913 | Token de la cuenta | +| 922 | Usuario y plan de GitHub | +| 924 | Usuario de GitHub | +| 927 | Plan | +| 929 | Free · 50 | +| 930 | Pro · 300 | +| 931 | Pro+ · 1500 | +| 932 | Business · 300 | +| 933 | Enterprise · 1000 | +| 934 | Otra cantidad | +| 939 | Peticiones premium incluidas al mes | +| 945 | Token de solo lectura | +| 948 | Token de GitHub de esta cuenta (fine-grained) | +| 951 | Créalo con esta cuenta en | +| 951 | GitHub › Fine-grained tokens | +| 951 | con el permiso «Plan» (solo lectura). | +| 961 | Conecta la cuenta o pega una API key | +| 963 | API key (alternativa) | +| 966 | Plan | +| 967 | Plan | +| 967 | La URL base y el resto de ajustes avanzados son los de la cuenta principal. | +| 969 | nan_community | +| 970 | nan_member | +| 971 | GLM Premium | +| 982 | API key | +| 985 | API key | +| 988 | Puede ser de otra cuenta o de la misma con otro límite. | +| 988 | openrouter.ai › Keys | +| 998 | Admin key de esta organización | +| 1001 | Admin key | +| 1004 | Cada organización tiene su propia | +| 1004 | Admin key | +| 1008 | Presupuesto mensual (opcional) | +| 1009 | USD al mes | +| 1020 | Avanzado · carpeta de sesión | +| 1022 | Cada cuenta guarda su sesión en su propia carpeta, así no toca la principal: el login se abre con | +| 1023 | apuntando a ella. Si la comprobación pide renovar la sesión, «Volver a abrir el login» abre de nuevo el CLI. | +| 1025 | Carpeta de sesión | +| 1025 | Vacío = | +| 1025 | . Puedes usar una que ya tengas. Quorb lee | +| 1025 | dentro de ella. | +| 1033 | Eliminar esta cuenta | +| 1035 | Al guardar se borran también sus claves del almacén seguro del sistema. | +| 1042 | Claude | +| 1042 | Pro / Max · sesión de 5 h, semanal y por modelo | +| 1043 | ChatGPT y Codex | +| 1043 | Plus / Pro · sesión y semanal de Codex | +| 1044 | Gemini | +| 1044 | Cuota diaria por modelo | +| 1045 | Cursor | +| 1045 | Uso del plan en el ciclo y on-demand | +| 1046 | GitHub Copilot | +| 1046 | Peticiones premium del mes | +| 1047 | Cuota del plan M/Token y saldo de pago por uso | +| 1048 | NaN Builders | +| 1048 | Cuota del plan y tokens por día | +| 1049 | Saldo y gasto por clave | +| 1050 | Anthropic API | +| 1050 | Gasto del mes con clave de admin | +| 1051 | OpenAI API | +| 1051 | Gasto del mes con clave de admin | +| 1053 | Pasarela local · uso y gasto agregados | +| 1058 | Proveedores | +| 1058 | Suscripciones y APIs | +| 1058 | Qué se consulta y cómo se conecta cada proveedor. | +| 1059 | Apariencia | +| 1059 | Apariencia | +| 1059 | Tamaño y color de los anillos, y dónde vive la tira. | +| 1060 | Alertas | +| 1060 | Alertas | +| 1060 | Qué pasa al superar un límite y cuando se reinicia. | +| 1061 | Atajos | +| 1061 | Atajos | +| 1061 | Combinaciones de teclado que funcionan desde cualquier app. | +| 1062 | Actualizaciones | +| 1062 | Actualizaciones | +| 1062 | Versión instalada, actualizaciones automáticas y el proyecto. | +| 1078 | Izquierda | +| 1079 | Derecha | +| 1080 | Arriba | +| 1081 | Abajo | +| 1088 | Activo | +| 1089 | Falta conectar | +| 1090 | Desactivado | +| 1176 | Quorb | +| 1184 | Secciones de ajustes | +| 1201 | Activos pero sin configurar | +| 1213 | Expandir el menú | +| 1213 | Contraer el menú | +| 1214 | Expandir el menú | +| 1214 | Contraer el menú | +| 1221 | Contraer | +| 1642 | · ${…} cuentas | +| 1647 | Conectar | +| 1647 | Configurar | +| 1678 | Arrancar al iniciar sesión | +| 1685 | No se pudo leer «Arrancar al iniciar sesión»: | +| 1692 | Reintentar | +| 1702 | Sesión de escritorio: | +| 1702 | Wayland | +| 1711 | con límites: | +| 1711 | sin límites conocidos: | +| 1720 | Proveedores | +| 1723 | Proveedores | +| 1727 | Consultar cada | +| 1729 | min | +| 1732 | Suscripciones | +| 1734 | APIs y créditos | +| 1736 | Para cambiar el orden de los anillos, arrástralos en la tira. | +| 1758 | Configuración | +| 1762 | Cerrar la configuración | +| 1773 | Cuentas | +| 1774 | Principal | +| 1788 | Los subproveedores de 9Router se descubren solos desde su historial de uso; no se añaden a mano. | +| 1791 | + Añadir cuenta | +| 1825 | Suscripción · OAuth | +| 1826 | Claude (Pro / Max) | +| 1827 | Límites de sesión (5 h), semanales y por modelo de tu plan. Quorb lee la sesión que guarda Claude Code: no ve tu contraseña ni toca los tokens. | +| 1828 | Fichero de credenciales | +| 1829 | Vacío = | +| 1829 | %USERPROFILE%\\.claude\\.credentials.json | +| 1829 | . Para WSL: | +| 1829 | \\\\wsl.localhost\\Ubuntu\\home\\TU_USUARIO\\.claude\\.credentials.json | +| 1832 | Suscripción · OAuth | +| 1833 | ChatGPT (Plus / Pro · Codex) | +| 1834 | Límites de 5 h y semanales de tu plan de ChatGPT en Codex. Quorb lee la sesión que guarda el CLI de Codex. | +| 1835 | Fichero auth.json | +| 1836 | Vacío = | +| 1836 | %CODEX_HOME% | +| 1836 | o | +| 1836 | %USERPROFILE%\\.codex\\auth.json | +| 1839 | Suscripción · sesión de Gemini CLI | +| 1840 | Gemini (Google AI Pro / Code Assist) | +| 1843 | Cuotas diarias por modelo. Quorb lee la sesión que guarda | +| 1843 | Gemini CLI | +| 1843 | . Endpoints no oficiales. | +| 1846 | Fichero de credenciales | +| 1847 | Vacío = | +| 1847 | %USERPROFILE%\\.gemini\\oauth_creds.json | +| 1852 | Mostrar en el widget | +| 1854 | Cómo conectar la cuenta | +| 1868 | Avanzado · fichero de sesión y comando de login | +| 1872 | Comando de login | +| 1881 | Suscripción · sesión de la app | +| 1881 | Cursor | +| 1882 | Mostrar en el widget | +| 1883 | Uso del plan en el ciclo actual (total, Auto y modelos API) y gasto on-demand. Endpoint no oficial. | +| 1884 | Cómo conectar la cuenta | +| 1886 | Inicia sesión en la app de Cursor | +| 1888 | Quorb lee la sesión que guarda la app de Cursor en este equipo (solo lectura). Si no usas la app aquí, pega un token manual: tiene prioridad sobre la sesión de la app. | +| 1891 | Token manual (opcional) | +| 1896 | Avanzado · base de datos de Cursor | +| 1897 | Base de datos de Cursor | +| 1897 | Vacío = | +| 1897 | %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb | +| 1905 | Suscripción · API oficial | +| 1905 | GitHub Copilot | +| 1906 | Mostrar en el widget | +| 1907 | Peticiones premium del mes con la API de facturación de GitHub. Se reinician el día 1 (UTC). | +| 1908 | Cómo conectar la cuenta | +| 1910 | Usuario y plan de GitHub | +| 1912 | Usuario de GitHub | +| 1913 | egdev6 | +| 1915 | Plan | +| 1917 | Free · 50 | +| 1918 | Pro · 300 | +| 1919 | Pro+ · 1500 | +| 1920 | Business · 300 | +| 1921 | Enterprise · 1000 | +| 1922 | Otra cantidad | +| 1927 | Peticiones premium incluidas al mes | +| 1933 | Token de solo lectura | +| 1936 | Token de GitHub (fine-grained) | +| 1939 | Créalo en | +| 1939 | GitHub › Fine-grained tokens | +| 1939 | con el permiso de cuenta «Plan»: solo lectura. | +| 1948 | Suscripción · API oficial | +| 1948 | MiniMax | +| 1949 | Mostrar en el widget | +| 1951 | Cuota del plan y, si añades la segunda clave, tu saldo de pago por uso. Son | +| 1952 | dos credenciales distintas | +| 1952 | en la consola: la primera da acceso al plan y la segunda al saldo. | +| 1954 | Cómo conectar la cuenta | +| 1956 | Subscription Key del plan | +| 1959 | Subscription Key | +| 1962 | Del panel M/Token en | +| 1962 | minimax.io | +| 1962 | . En la región de China, en | +| 1962 | minimaxi.com | +| 1962 | (cambia también el host en Ajustes avanzados). | +| 1966 | API key de pago por uso (opcional) | +| 1969 | API key PAYG | +| 1972 | Sin ella solo se muestra la cuota del plan; el saldo es opcional y su fallo no lo degrade. | +| 1980 | Pago por uso · API oficial | +| 1980 | OpenRouter | +| 1981 | Mostrar en el widget | +| 1982 | Gasto del mes y de hoy, límite de la key (si tiene) y saldo de la cuenta. | +| 1983 | Cómo conectar la cuenta | +| 1985 | API key | +| 1988 | API key | +| 1991 | Créala en | +| 1991 | openrouter.ai › Keys | +| 2000 | Suscripción · cuenta o API key | +| 2000 | NaN Builders | +| 2001 | Mostrar en el widget | +| 2003 | Con la cuenta conectada ves lo mismo que en tu dashboard: tokens usados frente a la cuota de cada modelo, cuándo se reinicia y la serie diaria. Sin cuenta, la API key usa | +| 2004 | /v1/usage | +| 2004 | y las cuotas publicadas. | +| 2004 | Docs | +| 2006 | Cómo conectar la cuenta | +| 2008 | Conecta tu cuenta o pega una API key | +| 2012 | API key (alternativa) | +| 2015 | Créala en | +| 2015 | cloud.nan.builders | +| 2015 | › API Keys | +| 2019 | Plan | +| 2020 | Plan | +| 2022 | nan_community | +| 2023 | nan_member | +| 2024 | GLM Premium | +| 2031 | Avanzado · URL, facturación y endpoint de consumo | +| 2032 | Base URL | +| 2035 | Día de inicio de facturación | +| 2035 | Solo para GLM 5.3 (cuenta por periodo de Stripe) cuando usas la API key. | +| 2039 | Endpoint de consumo · URL | +| 2039 | Se llama con tu key como Bearer y debe devolver JSON. | +| 2043 | JSON Pointer · usado | +| 2043 | p. ej. /usage/used_tokens | +| 2046 | JSON Pointer · límite | +| 2046 | p. ej. /usage/quota_tokens | +| 2050 | Etiqueta | +| 2051 | Consumo | +| 2058 | Pasarela local · uso agregado | +| 2058 | 9Router | +| 2060 | Mostrar en el widget | +| 2065 | Quorb lee el uso y el gasto que | +| 2065 | 9Router | +| 2065 | ya registra en tu equipo: peticiones, tokens y coste por proveedor del periodo. Además del orbe agregado, cada proveedor que haya visto (Claude, Kiro, GLM…) aparece como su propia cuenta. | +| 2068 | Cómo conectar 9Router | +| 2070 | Conecta tu dashboard local | +| 2074 | Periodo y desglose | +| 2075 | Periodo de uso | +| 2075 | Rango que muestra el orbe y su historial. | +| 2077 | Hoy | +| 2078 | Últimas 24 h | +| 2079 | Últimos 7 días | +| 2080 | Últimos 30 días | +| 2081 | Últimos 60 días | +| 2082 | Todo | +| 2086 | Mostrar cada sub-proveedor como su propia cuenta | +| 2090 | Gasto mínimo por sub-proveedor (USD) | +| 2090 | No muestra un sub-orbe hasta que su gasto en el periodo supere esta cifra. 0 = cualquiera que gaste. | +| 2099 | Presupuesto (USD) | +| 2099 | Opcional: pinta la barra de gasto del periodo frente al tope que pongas. | +| 2112 | Avanzado · dirección del servidor | +| 2113 | Base URL | +| 2113 | Dónde escucha 9Router (por defecto http://localhost:20128). | +| 2123 | Suborbes ocultos de 9Router | +| 2127 | · mostrar | +| 2131 | Los ocultaste desde su panel en la tira. Pulsa para volver a mostrarlos. | +| 2143 | Pago por uso · Admin key | +| 2143 | Anthropic API | +| 2143 | OpenAI API | +| 2144 | Mostrar gasto del mes | +| 2145 | Cómo conectar la cuenta | +| 2147 | Admin key | +| 2150 | Admin API key (sk-ant-admin…) | +| 2150 | Admin key | +| 2155 | Las APIs de coste no admiten OAuth: hace falta una | +| 2155 | Admin key | +| 2155 | (solo lectura de la organización). | +| 2157 | Créala en | +| 2157 | Organization › Admin keys | +| 2163 | Presupuesto mensual (opcional) | +| 2164 | USD al mes | +| 2181 | Listo | +| 2183 | Guardando… | +| 2183 | Guardar cambios | +| 2192 | Anillos | +| 2194 | Vista previa a ${…} px | +| 2202 | Tamaño del anillo | +| 2202 | Restablecer ( | +| 2202 | px) | +| 2207 | Color de acento | +| 2210 | Usar ${…} | +| 2212 | Elegir cualquier color (RGB) | +| 2217 | Colores resultantes | +| 2226 | Es el del anillo exterior y la primera barra; los otros dos tonos salen solos. Los avisos y el exceso conservan sus colores de alerta. | +| 2227 | Restablecer rojo egdev | +| 2230 | Opacidad de la tira y el panel | +| 2234 | Círculos pequeños | +| 2234 | Un solo anillo por suscripción: el límite más cercano. | +| 2235 | Mostrar resets en los círculos | +| 2235 | El saldo de resets siempre sigue disponible en el panel de detalle. | +| 2239 | Tira | +| 2241 | Borde | +| 2249 | También puedes arrastrarla desde su asa: se pega al borde más cercano. | +| 2251 | Posición en el borde | +| 2255 | Ocultar sola | +| 2255 | Deja una franja en el borde y vuelve al acercar el ratón o cuando hay un aviso. | +| 2256 | Siempre encima | +| 2256 | Por encima del resto de ventanas. | +| 2260 | Personalización no aplicada | +| 2262 | Reintentar | +| 2271 | Español | +| 2272 | English | +| 2287 | Cuándo avisar | +| 2289 | Activar avisos | +| 2290 | Notificación del sistema | +| 2290 | Además del efecto en pantalla. | +| 2293 | 1 · Al superar el límite | +| 2294 | Avisar al llegar a | +| 2294 | Una vez por ventana (sesión de 5 h, semana, mes…). | +| 2300 | 2 · Al reiniciarse | +| 2302 | Avisar cuando el límite superado se reinicia | +| 2302 | «Claude · Sesión 5 h: disponible de nuevo» | +| 2310 | Efecto propio | +| 2312 | Un vídeo WebM (VP9) con canal alfa o una imagen animada GIF, WebP o APNG con transparencia. Se muestra centrado encima de todo, sin bloquear el ratón; el vídeo dura lo que dure (máx. 20 s) y las imágenes 4,5 s. | +| 2315 | Tamaño | +| 2317 | Pequeño | +| 2318 | Mediano | +| 2319 | Pantalla completa | +| 2323 | Con sonido | +| 2323 | Solo para vídeos. | +| 2330 | Atajos globales | +| 2332 | Activar atajos | +| 2332 | Funcionan aunque Quorb no tenga el foco. | +| 2338 | Atajos de teclado | +| 2341 | Acción | +| 2342 | Atajo | +| 2343 | Acciones | +| 2347 | Mostrar u ocultar la tira | +| 2348 | Abrir el más cargado | +| 2349 | Actualizar ahora | +| 2358 | Pulsa un atajo o «Cambiar» y teclea la combinación. Se aplican al guardar. | +| 2367 | Proyecto | +| 2368 | Quorb es open source (MIT). Si te ahorra un susto con los límites, una estrella en GitHub ayuda a que lo encuentre más gente. | +| 2370 | GitHub | +| 2371 | Pedir un proveedor | +| 2372 | egdev.es | +| 2383 | Cerrar | +| 2385 | Guardando… | +| 2385 | Guardar cambios | ## src/Widget.tsx @@ -1075,7 +1111,7 @@ look like text. | Line | Text | |---:|---| | 35 | viewBox="${…}" | -| 129 | API | +| 132 | API | ## src/components/RingGauge.tsx From eccbcee033e67e0aae9dcb072db73309dfb795e1 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:12:14 +0200 Subject: [PATCH 3/9] fix(providers): host oficial y segunda ventana del plan en MiniMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verificado contra la documentación oficial tras revisar la spec. **Host.** El curl de la FAQ oficial usa `https://www.minimax.io/v1/token_plan/remains`, con `www.`, no `api.minimax.io`. El default pasa a `www.minimax.io` y el comentario de las regiones China (`www.minimaxi.com`) queda alineado. **Segunda ventana.** El Token Plan tiene dos: una de 5 horas (`current_interval_*`) y otra semanal (`current_weekly_*`), ambas citadas en el FAQ. El parser solo leía la de 5 h, así que el anillo se mantenía tranquilo mientras la semana iba al límite. Ahora emite las dos ventanas con reinicio y duración propias, y el mensaje del popup resume la peor. **Semántica de los contadores.** Estos campos cambiaron de significado al migrar de endpoint: en `/coding_plan/remains`, `current_interval_usage_count` era lo que quedaba (MiniMax-AI/MiniMax-M2#99); en `/v1/token_plan/remains` es el consumo (MiniMax-AI/cli#104, «`current_interval_usage_count` is used count, not remaining»). Aquí se usa la lectura del endpoint actual. El comentario en el parser deja constancia de las dos, porque volver a confundirlas invierte el anillo en silencio. **Nombre de modelo.** No aplica: el plan es una barra de uso compartida y no expone contadores por modelo, así que el proveedor no depende de si la línea es M3 o M3.1 Flash. --- src-tauri/src/config.rs | 7 +- src-tauri/src/providers/minimax.rs | 111 +++++++++++++++++++++++------ src/lib/types.ts | 2 +- 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 17e1ead..b9e4e3c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -427,8 +427,9 @@ pub struct ApiCostConfig { pub monthly_budget_usd: Option, } -/// MiniMax. `base_url` cubre las dos regiones: global `api.minimax.io` y -/// China `api.minimaxi.com`. Cualquier otro host equivalente sirve igual. +/// MiniMax. `base_url` cubre las dos regiones: global `www.minimax.io` y +/// China `www.minimaxi.com` (el host que usa la documentación oficial del +/// Token Plan). Cualquier otro host equivalente sirve igual. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default, rename_all = "camelCase")] pub struct MiniMaxConfig { @@ -438,7 +439,7 @@ pub struct MiniMaxConfig { impl Default for MiniMaxConfig { fn default() -> Self { - Self { enabled: false, base_url: "https://api.minimax.io".into() } + Self { enabled: false, base_url: "https://www.minimax.io".into() } } } diff --git a/src-tauri/src/providers/minimax.rs b/src-tauri/src/providers/minimax.rs index 1dd788b..fb17546 100644 --- a/src-tauri/src/providers/minimax.rs +++ b/src-tauri/src/providers/minimax.rs @@ -106,22 +106,59 @@ fn timestamp(v: Option<&Value>) -> Option { num(v).map(|n| n as i64) } +/// Las dos ventanas del Token Plan. Los nombres de campo son los del endpoint +/// `/v1/token_plan/remains`. +struct Window { + id: &'static str, + label: &'static str, + used: &'static str, + total: &'static str, + start: &'static str, + end: &'static str, +} + +const WINDOWS: [Window; 2] = [ + Window { + id: "token_plan_5h", + label: "Plan · 5 horas", + used: "current_interval_usage_count", + total: "current_interval_total_count", + start: "current_interval_start_time", + end: "current_interval_end_time", + }, + Window { + id: "token_plan_weekly", + label: "Plan · semanal", + used: "current_weekly_usage_count", + total: "current_weekly_total_count", + start: "current_weekly_start_time", + end: "current_weekly_end_time", + }, +]; + pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { let mut meters = vec![]; - // El porcentaje sale de `usage / total`, nunca de `remains`: según - // MiniMax-AI/cli#104, `current_interval_usage_count` es el **consumo**, y - // `current_interval_remains_count` es lo que queda (y además no está en - // todos los payloads). Confundir ambos invierte el anillo. - let used = num(plan.get("current_interval_usage_count")); - let total = num(plan.get("current_interval_total_count")); - if let Some(limit) = total.filter(|t| *t > 0.0) { + // El Token Plan tiene **dos ventanas**: una de 5 horas (`current_interval_*`) + // y otra semanal (`current_weekly_*`). Se emiten las dos: con una sola, el + // anillo miente cuando la de 5 h va sobrada y la semana va al límite. + // + // El porcentaje sale de `usage / total`, nunca de `remains`. La lectura de + // estos campos depende del endpoint: en el viejo `/coding_plan/remains`, + // `current_interval_usage_count` era lo que **quedaba** (MiniMax-AI/MiniMax-M2#99), + // y al migrar a `/v1/token_plan/remains` pasó a ser el **consumo** + // (MiniMax-AI/cli#104, «`current_interval_usage_count` is used count, not + // remaining»). Aquí se usa la del endpoint actual; equivocarse invierte el anillo. + for window in WINDOWS { + let used = num(plan.get(window.used)); + let total = num(plan.get(window.total)); + let Some(limit) = total.filter(|t| *t > 0.0) else { continue }; let spent = used.unwrap_or(0.0); - let start = timestamp(plan.get("current_interval_start_time")); - let end = timestamp(plan.get("current_interval_end_time")); + let start = timestamp(plan.get(window.start)); + let end = timestamp(plan.get(window.end)); meters.push(Meter { - id: "token_plan".into(), - label: "Uso del plan".into(), + id: window.id.into(), + label: window.label.into(), percent: Some(spent / limit * 100.0), used: Some(spent), limit: Some(limit), @@ -147,10 +184,21 @@ pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { }); } - let message = match (meters.first().and_then(|m| m.limit), available) { - (Some(total), Some(balance)) => Some(format!("Plan {:.0}/{:.0} · saldo {balance:.2} $", used.unwrap_or(0.0), total)), - (Some(total), None) => Some(format!("Plan {:.0}/{:.0}", used.unwrap_or(0.0), total)), - _ => None, + // El mensaje resume la ventana **peor**: con dos ventanas, decir solo la de + // 5 h hides que la semana está al límite. + let worst = meters + .iter() + .filter(|m| m.percent.is_some()) + .max_by(|a, b| a.percent.partial_cmp(&b.percent).unwrap_or(std::cmp::Ordering::Equal)); + let plan = match worst { + Some(m) => Some(format!("{} {:.0}/{:.0}", m.label, m.used.unwrap_or(0.0), m.limit.unwrap_or(0.0))), + None => None, + }; + let message = match (plan, available) { + (Some(plan), Some(balance)) => Some(format!("{plan} · saldo {balance:.2} $")), + (Some(plan), None) => Some(plan), + (None, Some(balance)) => Some(format!("Saldo {balance:.2} $")), + (None, None) => None, }; Parsed { meters, message } } @@ -177,13 +225,26 @@ mod tests { "current_interval_remains_count": 7500, })), None); let meter = &p.meters[0]; - assert_eq!(meter.id, "token_plan"); + assert_eq!(meter.id, "token_plan_5h"); assert_eq!(meter.percent, Some(25.0)); assert_eq!(meter.used, Some(2500.0)); assert_eq!(meter.limit, Some(10000.0)); // El anillo debe ser el consumo, no lo que queda. assert_ne!(meter.percent, Some(75.0)); - assert_eq!(p.meters.len(), 1, "sin saldo no puede inventarse un segundo medidor"); + assert_eq!(p.meters.len(), 1, "sin ventana semanal ni saldo solo se emite la de 5 h"); + } + + #[test] + fn emits_both_token_plan_windows_independently() { + let p = parse(&ok(json!({ + "current_interval_usage_count": 2500, "current_interval_total_count": 10000, + "current_weekly_usage_count": 9000, "current_weekly_total_count": 10000, + })), None); + assert_eq!(p.meters.iter().map(|m| m.id.as_str()).collect::>(), ["token_plan_5h", "token_plan_weekly"]); + assert_eq!(p.meters[0].percent, Some(25.0)); + // Con la semana al 90 % y la ventana de 5 h al 25 %, el anillo tiene que + // mirar a la peor: el anillo es único y solo puede mentir si elige mal. + assert_eq!(p.meters[1].percent, Some(90.0)); } #[test] @@ -211,7 +272,7 @@ mod tests { assert_eq!(p.meters[1].percent, None, "un saldo no es un porcentaje consumido"); assert_eq!(p.meters[1].used, Some(12.50)); assert_eq!(p.meters[1].unit.as_deref(), Some("USD")); - assert_eq!(p.message.as_deref(), Some("Plan 1/4 · saldo 12.50 $")); + assert_eq!(p.message.as_deref(), Some("Plan · 5 horas 1/4 · saldo 12.50 $")); } #[test] @@ -222,7 +283,7 @@ mod tests { assert_eq!(p.meters.len(), 1, "el fallo del saldo no puede degradar el anillo del plan"); assert_eq!(p.meters[0].percent, Some(50.0)); } - assert_eq!(parse(&plan, None).message.as_deref(), Some("Plan 3/6")); + assert_eq!(parse(&plan, None).message.as_deref(), Some("Plan · 5 horas 3/6")); } #[test] @@ -240,11 +301,17 @@ mod tests { let p = parse(&ok(json!({ "current_interval_usage_count": 5, "current_interval_total_count": 10, + "current_weekly_usage_count": 1, + "current_weekly_total_count": 10, "current_interval_start_time": 1_735_689_600, // 2025-01-01T00:00:00Z - "current_interval_end_time": 1_738_368_000, // 2025-02-01T00:00:00Z + "current_interval_end_time": 1_735_707_600, // +5 h + "current_weekly_start_time": 1_735_516_800, // 2024-12-30T00:00:00Z + "current_weekly_end_time": 1_736_121_600, // 2025-01-06T00:00:00Z })), None); - assert_eq!(p.meters[0].window_seconds, Some(2_678_400)); - assert_eq!(p.meters[0].resets_at.as_deref(), Some("2025-02-01T00:00:00+00:00")); + assert_eq!(p.meters[0].window_seconds, Some(18_000)); + assert_eq!(p.meters[0].resets_at.as_deref(), Some("2025-01-01T05:00:00+00:00")); + assert_eq!(p.meters[1].window_seconds, Some(604_800)); + assert_eq!(p.meters[1].resets_at.as_deref(), Some("2025-01-06T00:00:00+00:00")); // Sin marcas de tiempo el medidor sigue siendo válido, solo pierde el reinicio. let sin_ventana = parse(&ok(json!({"current_interval_usage_count": 5, "current_interval_total_count": 10})), None); assert!(sin_ventana.meters[0].resets_at.is_none() && sin_ventana.meters[0].window_seconds.is_none()); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0959b87..35f72a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,7 +55,7 @@ export interface ApiCostConfig { monthlyBudgetUsd: number | null; } -/** MiniMax. `baseUrl` cubre las dos regiones: global `api.minimax.io` y China `api.minimaxi.com`. */ +/** MiniMax. `baseUrl` cubre las dos regiones: global `www.minimax.io` y China `www.minimaxi.com`. */ export interface MiniMaxConfig { enabled: boolean; baseUrl: string; From cb3510cb7f6df16a147309f92162c9d2278fac54 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:12:16 +0200 Subject: [PATCH 4/9] docs(i18n): regenerar el inventario tras el ajuste del host --- docs/i18n-inventory.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 882ee20..c5c9ea5 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `f48be44591a0a96dc39642b19c29b3c5f149f4ab` and `src-tauri/src/` = `cb7730b73b07845cd07378c5be18e89c10ef4e17`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `30a05d97abb5e0171fecdf3112f629ba3e51b021` and `src-tauri/src/` = `d0c9fa853188eac497d0c6f9e92672f680953ffc`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact lines. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -18,8 +18,8 @@ look like text. | Area | Distinct texts | Sites | |---|---:|---:| | React (`src/`) | 588 | 717 | -| Rust (`src-tauri/src/`) | 302 | 358 | -| Total | 859 | 1075 | +| Rust (`src-tauri/src/`) | 303 | 359 | +| Total | 860 | 1076 | ## src-tauri/src/channel.rs @@ -50,7 +50,7 @@ look like text. | Line | Text | |---:|---| | 281 | codex login | -| 584 | Cuenta {} | +| 585 | Cuenta {} | ## src-tauri/src/detail_geometry.rs @@ -283,11 +283,12 @@ look like text. | 85 | MiniMax devolvió una respuesta con base_resp vacío | | 93 | MiniMax rechazó la consulta (status_code {code}) | | 93 | MiniMax: {m} | -| 124 | Uso del plan | -| 140 | Saldo PAYG | -| 144 | USD | -| 151 | Plan {:.0}/{:.0} · saldo {balance:.2} $ | -| 152 | Plan {:.0}/{:.0} | +| 123 | Plan · 5 horas | +| 131 | Plan · semanal | +| 177 | Saldo PAYG | +| 181 | USD | +| 198 | {plan} · saldo {balance:.2} $ | +| 200 | Saldo {balance:.2} $ | ## src-tauri/src/providers/mod.rs From e059ed2150494413dc408b4f4143d4ce350d965e Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:53:47 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(providers):=20saldo=20no=20disponible,?= =?UTF-8?q?=20saturaci=C3=B3n=20y=20host=20de=20MiniMax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responde a la review del PR #172. **Host (bloqueante).** Verificado empíricamente contra la API: `api.` y `www.` de ambas regiones responden el mismo JSON con el sobre `base_resp`, así que los dos sirven y ninguno devuelve HTML. Se vuelve al default `api.minimax.io` por semántica —es el host de la API, no el sitio web— y el comentario en `config.rs` deja constancia de la verificación y de que el `curl` oficial usa `www.`, que también responde. El mismo sondeo confirma que `query_balance` es real: responde `{"base_resp":{"status_code":1004,"status_msg":"not authorized"}}` en vez de un 404, y `token_plan/remains` devuelve **HTTP 200 con `status_code` 1004**, que es justo el caso que justifica el chequeo estricto del sobre. **Saldo no disponible.** `parse` recibe si hay API key configurada, para que el usuario distinga «no configuré el saldo» de «lo configuré y falló». Sin la clave no se dice nada; con la clave y el saldo caído, el mensaje acaba en «· saldo no disponible». Test que fija los tres casos. **Saturación a 100.** El anillo ya saturaba en `ProviderGauge` y `RingGauge`, pero el valor crudo escapaba por `formatPercent` (que no satura), por `history.rs` —que lo persiste— y por el payload de alertas. El clamp va en `parse`: `used` y `limit` conservan el dato crudo y solo el porcentaje se satura. Test que fija un payload con `usage > total`. **Formato.** Corregida la indentación de la línea `minimax:` en el `Default` de `config.rs` (las vecinas van a 12 espacios) y añadidos los newlines finales que faltaban. No se corre `cargo fmt` porque el repo no lo cumple de base: son más de 400 archivos con el mismo patrón de líneas largas, incluido el resto de proveedores, y formatear solo este lo haría inconsistente con los demás. --- src-tauri/src/config.rs | 16 +++++-- src-tauri/src/providers/minimax.rs | 76 +++++++++++++++++++++++------- src/assets/logos/minimax.svg | 2 +- src/lib/types.ts | 2 +- 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b9e4e3c..d735168 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -296,7 +296,7 @@ impl Default for AppConfig { anthropic_api: ApiCostConfig::default(), openai_api: ApiCostConfig::default(), nan: NanConfig::default(), - minimax: MiniMaxConfig::default(), + minimax: MiniMaxConfig::default(), nine_router: NineRouterConfig::default(), accounts: vec![], } @@ -427,9 +427,15 @@ pub struct ApiCostConfig { pub monthly_budget_usd: Option, } -/// MiniMax. `base_url` cubre las dos regiones: global `www.minimax.io` y -/// China `www.minimaxi.com` (el host que usa la documentación oficial del -/// Token Plan). Cualquier otro host equivalente sirve igual. +/// MiniMax. `base_url` cubre las dos regiones: global `api.minimax.io` y +/// China `api.minimaxi.com`. +/// +/// Verificado contra la API en los cuatro hosts: `api.` y `www.` de ambas +/// regiones responden el mismo JSON con el sobre `base_resp`, así que cualquiera +/// de los dos sirve. Se usa `api.` por semántica (es el host de la API, no el +/// sitio web). La documentación oficial del Token Plan muestra `www.minimax.io` +/// en su ejemplo de `curl`, y ese host también responde, por si alguien prefiere +/// copiarlo tal cual desde Ajustes. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default, rename_all = "camelCase")] pub struct MiniMaxConfig { @@ -439,7 +445,7 @@ pub struct MiniMaxConfig { impl Default for MiniMaxConfig { fn default() -> Self { - Self { enabled: false, base_url: "https://www.minimax.io".into() } + Self { enabled: false, base_url: "https://api.minimax.io".into() } } } diff --git a/src-tauri/src/providers/minimax.rs b/src-tauri/src/providers/minimax.rs index fb17546..16f7536 100644 --- a/src-tauri/src/providers/minimax.rs +++ b/src-tauri/src/providers/minimax.rs @@ -40,13 +40,16 @@ pub async fn fetch(cfg: &MiniMaxConfig, who: Who<'_>, http: &reqwest::Client) -> // El saldo PAYG va con otra clave y es un endpoint que no está en la // documentación pública oficial: si falla, el informe del plan sigue válido. - let balance = match who.secret(secrets::MINIMAX_API_KEY) { - Some(api_key) => balance(http, &base, &api_key).await, + // Guardamos si había clave para poder distinguir «no configuré el saldo» de + // «lo configuré y falló»: sin eso el usuario no tiene forma de saberlo. + let payg_key = who.secret(secrets::MINIMAX_API_KEY); + let balance = match payg_key.as_deref() { + Some(api_key) => balance(http, &base, api_key).await, None => None, }; let mut report = report; - let parsed = parse(&plan, balance.as_ref()); + let parsed = parse(&plan, balance.as_ref(), payg_key.is_some()); report.meters = parsed.meters; report.message = parsed.message; report @@ -136,7 +139,7 @@ const WINDOWS: [Window; 2] = [ }, ]; -pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { +pub(crate) fn parse(plan: &Value, balance: Option<&Value>, payg_configured: bool) -> Parsed { let mut meters = vec![]; // El Token Plan tiene **dos ventanas**: una de 5 horas (`current_interval_*`) @@ -159,7 +162,10 @@ pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { meters.push(Meter { id: window.id.into(), label: window.label.into(), - percent: Some(spent / limit * 100.0), + // Saturado aquí, y no solo en el anillo: un payload con + // `usage > total` (ciclo mal cerrado) no debe colarse al popup ni + // quedar persistido en el historial para siempre. + percent: Some((spent / limit * 100.0).clamp(0.0, 100.0)), used: Some(spent), limit: Some(limit), unit: None, @@ -194,10 +200,18 @@ pub(crate) fn parse(plan: &Value, balance: Option<&Value>) -> Parsed { Some(m) => Some(format!("{} {:.0}/{:.0}", m.label, m.used.unwrap_or(0.0), m.limit.unwrap_or(0.0))), None => None, }; - let message = match (plan, available) { - (Some(plan), Some(balance)) => Some(format!("{plan} · saldo {balance:.2} $")), + // Con la API key puesta y el saldo sin llegar, el usuario no puede + // distinguir «no lo configuré» de «lo configuré y falla». Decírselo es la + // diferencia entre depurar una clave ycreer que MiniMax no publishes saldo. + let saldo = match available { + Some(balance) => Some(format!("saldo {balance:.2} $")), + None if payg_configured => Some("saldo no disponible".to_string()), + None => None, + }; + let message = match (plan, saldo) { + (Some(plan), Some(saldo)) => Some(format!("{plan} · {saldo}")), (Some(plan), None) => Some(plan), - (None, Some(balance)) => Some(format!("Saldo {balance:.2} $")), + (None, Some(saldo)) => Some(saldo), (None, None) => None, }; Parsed { meters, message } @@ -223,7 +237,7 @@ mod tests { "current_interval_usage_count": 2500, "current_interval_total_count": 10000, "current_interval_remains_count": 7500, - })), None); + })), None, false); let meter = &p.meters[0]; assert_eq!(meter.id, "token_plan_5h"); assert_eq!(meter.percent, Some(25.0)); @@ -239,7 +253,7 @@ mod tests { let p = parse(&ok(json!({ "current_interval_usage_count": 2500, "current_interval_total_count": 10000, "current_weekly_usage_count": 9000, "current_weekly_total_count": 10000, - })), None); + })), None, false); assert_eq!(p.meters.iter().map(|m| m.id.as_str()).collect::>(), ["token_plan_5h", "token_plan_weekly"]); assert_eq!(p.meters[0].percent, Some(25.0)); // Con la semana al 90 % y la ventana de 5 h al 25 %, el anillo tiene que @@ -247,6 +261,31 @@ mod tests { assert_eq!(p.meters[1].percent, Some(90.0)); } + #[test] + fn distinguishes_a_missing_balance_from_a_configured_one_that_failed() { + let plan = ok(json!({"current_interval_usage_count": 3, "current_interval_total_count": 6})); + // Sin API key: el usuario no pidió saldo, no hay nada que señalar. + assert_eq!(parse(&plan, None, false).message.as_deref(), Some("Plan · 5 horas 3/6")); + // Con API key y saldo caído: tiene que verse, o el usuario no puede + // distinguir «no lo configuré» de «lo configuré y falló». + assert_eq!(parse(&plan, None, true).message.as_deref(), Some("Plan · 5 horas 3/6 · saldo no disponible")); + // Sin ventanas de plan tampoco se pierde el aviso. + assert_eq!(parse(&ok(json!({})), None, true).message.as_deref(), Some("saldo no disponible")); + } + + #[test] + fn saturates_a_percentage_that_exceeds_the_total() { + // Ciclo mal cerrado o payload sucio: usage > total no puede pintar un + // anillo por encima de 100 ni quedar persistido así en el historial. + let p = parse(&ok(json!({ + "current_interval_usage_count": 1500, "current_interval_total_count": 1000, + })), None, false); + assert_eq!(p.meters[0].percent, Some(100.0)); + // El valor crudo se conserva: used/limit siguen diciendo la verdad. + assert_eq!(p.meters[0].used, Some(1500.0)); + assert_eq!(p.meters[0].limit, Some(1000.0)); + } + #[test] fn rejects_a_200_carrying_a_failed_base_resp() { let v = json!({"base_resp": {"status_code": 1004, "status_msg": "invalid api key"}}); @@ -266,6 +305,7 @@ mod tests { let p = parse( &ok(json!({"current_interval_usage_count": 1, "current_interval_total_count": 4})), Some(&ok(json!({"available_amount": "12.50", "cash_balance": "10.00", "voucher_balance": "2.50"}))), + true, ); assert_eq!(p.meters.len(), 2); assert_eq!(p.meters[1].id, "payg_balance"); @@ -279,20 +319,20 @@ mod tests { fn a_missing_or_unusable_balance_leaves_the_plan_report_intact() { let plan = ok(json!({"current_interval_usage_count": 3, "current_interval_total_count": 6})); for balance in [None, Some(&json!({})), Some(&ok(json!({"available_amount": null})))] { - let p = parse(&plan, balance); + let p = parse(&plan, balance, false); assert_eq!(p.meters.len(), 1, "el fallo del saldo no puede degradar el anillo del plan"); assert_eq!(p.meters[0].percent, Some(50.0)); } - assert_eq!(parse(&plan, None).message.as_deref(), Some("Plan · 5 horas 3/6")); + assert_eq!(parse(&plan, None, false).message.as_deref(), Some("Plan · 5 horas 3/6")); } #[test] fn reads_amounts_as_strings_or_numbers_and_never_divides_by_zero() { - let as_string = parse(&ok(json!({"current_interval_usage_count": "40", "current_interval_total_count": "80"})), None); + let as_string = parse(&ok(json!({"current_interval_usage_count": "40", "current_interval_total_count": "80"})), None, false); assert_eq!(as_string.meters[0].percent, Some(50.0)); - let sin_total = parse(&ok(json!({"current_interval_usage_count": 40, "current_interval_total_count": 0})), None); + let sin_total = parse(&ok(json!({"current_interval_usage_count": 40, "current_interval_total_count": 0})), None, false); assert!(sin_total.meters.is_empty(), "un total de 0 no produce un anillo indefinido"); - let sin_datos = parse(&ok(json!({})), None); + let sin_datos = parse(&ok(json!({})), None, false); assert!(sin_datos.meters.is_empty() && sin_datos.message.is_none()); } @@ -307,13 +347,13 @@ mod tests { "current_interval_end_time": 1_735_707_600, // +5 h "current_weekly_start_time": 1_735_516_800, // 2024-12-30T00:00:00Z "current_weekly_end_time": 1_736_121_600, // 2025-01-06T00:00:00Z - })), None); + })), None, false); assert_eq!(p.meters[0].window_seconds, Some(18_000)); assert_eq!(p.meters[0].resets_at.as_deref(), Some("2025-01-01T05:00:00+00:00")); assert_eq!(p.meters[1].window_seconds, Some(604_800)); assert_eq!(p.meters[1].resets_at.as_deref(), Some("2025-01-06T00:00:00+00:00")); // Sin marcas de tiempo el medidor sigue siendo válido, solo pierde el reinicio. - let sin_ventana = parse(&ok(json!({"current_interval_usage_count": 5, "current_interval_total_count": 10})), None); + let sin_ventana = parse(&ok(json!({"current_interval_usage_count": 5, "current_interval_total_count": 10})), None, false); assert!(sin_ventana.meters[0].resets_at.is_none() && sin_ventana.meters[0].window_seconds.is_none()); } -} \ No newline at end of file +} diff --git a/src/assets/logos/minimax.svg b/src/assets/logos/minimax.svg index 57bf258..97345b8 100644 --- a/src/assets/logos/minimax.svg +++ b/src/assets/logos/minimax.svg @@ -6,4 +6,4 @@ stroke-linecap="round" stroke-linejoin="round" /> - \ No newline at end of file + diff --git a/src/lib/types.ts b/src/lib/types.ts index 35f72a8..0959b87 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,7 +55,7 @@ export interface ApiCostConfig { monthlyBudgetUsd: number | null; } -/** MiniMax. `baseUrl` cubre las dos regiones: global `www.minimax.io` y China `www.minimaxi.com`. */ +/** MiniMax. `baseUrl` cubre las dos regiones: global `api.minimax.io` y China `api.minimaxi.com`. */ export interface MiniMaxConfig { enabled: boolean; baseUrl: string; From dff4c4190c5827f3017253f0d71e9d803cd755e7 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:53:49 +0200 Subject: [PATCH 6/9] docs(i18n): regenerar el inventario tras los ajustes de la review --- docs/i18n-inventory.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index c5c9ea5..93ec62b 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `30a05d97abb5e0171fecdf3112f629ba3e51b021` and `src-tauri/src/` = `d0c9fa853188eac497d0c6f9e92672f680953ffc`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `2e2775d022abe764f1244e117978426280b065ed` and `src-tauri/src/` = `a7901b6461592e2d67fb6bed04078888ab544756`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact lines. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -50,7 +50,7 @@ look like text. | Line | Text | |---:|---| | 281 | codex login | -| 585 | Cuenta {} | +| 591 | Cuenta {} | ## src-tauri/src/detail_geometry.rs @@ -279,16 +279,16 @@ look like text. | 30 | Subscription Key de MiniMax rechazada. | | 31 | HTTP {code} en /v1/token_plan/remains | | 35 | Respuesta inesperada: {e} | -| 82 | MiniMax devolvió una respuesta sin base_resp | -| 85 | MiniMax devolvió una respuesta con base_resp vacío | -| 93 | MiniMax rechazó la consulta (status_code {code}) | -| 93 | MiniMax: {m} | -| 123 | Plan · 5 horas | -| 131 | Plan · semanal | -| 177 | Saldo PAYG | -| 181 | USD | -| 198 | {plan} · saldo {balance:.2} $ | -| 200 | Saldo {balance:.2} $ | +| 85 | MiniMax devolvió una respuesta sin base_resp | +| 88 | MiniMax devolvió una respuesta con base_resp vacío | +| 96 | MiniMax rechazó la consulta (status_code {code}) | +| 96 | MiniMax: {m} | +| 126 | Plan · 5 horas | +| 134 | Plan · semanal | +| 183 | Saldo PAYG | +| 187 | USD | +| 207 | saldo {balance:.2} $ | +| 208 | saldo no disponible | ## src-tauri/src/providers/mod.rs From 358695b4b00184db2ce884b08df0c5eac2f2da18 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:56:38 +0200 Subject: [PATCH 7/9] fix(providers): leer el payload real de MiniMax, no uno supuesto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El parser anterior leía `current_interval_usage_count`, `current_interval_total_count` y las marcas de tiempo **en la raíz** de la respuesta. Con un payload real esos campos no existen ahí: el parser habría devuelto **cero medidores en todas las llamadas**, y todos los tests pasaban igual porque los fixtures eran inventados. Evidencia — `GET https://api.minimax.io/v1/token_plan/remains` con una Subscription Key válida (`sk-cp-…`, región global): ``` raíz: model_remains, base_resp raíz tiene current_interval_usage_count? False raíz tiene current_interval_total_count? False raíz tiene start_time? False raíz tiene base_resp? True modelos: ['general', 'video'] ``` Tres diferencias que el parser anterior no contemplaba: **1. Los datos viven en `model_remains[]`, y hay uno por categoría de modelo.** `general` y `video` consumen cuotas distintas: no es una barra compartida como supuse en la descripción del PR. El id del medidor lleva ahora el modelo (`token_plan_general_5h`) porque el popup y el historial los tienen que distinguir. **2. El campo fiable es `current_*_remaining_percent`, un porcentaje restante.** Los contadores `_usage_count` / `_total_count` existen pero no sirven: en `general` vienen a `usage=0, total=0` mientras el porcentaje restante dice 78. Dividir uno por otro daría 0 % con la cuota ya consumida. El porcentaje ahora sale de `100 - remaining_percent`, saturado. **3. Los tiempos van en milisegundos.** `end_time = 1792699200000` es `2026-10-22T20:00:00Z`. Interpretado como segundos caería en el año 58.000. Los tests se reescriben contra este payload real (recortado a los campos que usa el parser) e incluyen los que rompen cada una de las tres suposiciones: si el parser volviera a usar los contadores a cero, a leer la raíz o a tratar los tiempos como segundos, fallan. --- src-tauri/src/providers/minimax.rs | 305 +++++++++++++++-------------- 1 file changed, 154 insertions(+), 151 deletions(-) diff --git a/src-tauri/src/providers/minimax.rs b/src-tauri/src/providers/minimax.rs index 16f7536..1b444c9 100644 --- a/src-tauri/src/providers/minimax.rs +++ b/src-tauri/src/providers/minimax.rs @@ -44,12 +44,21 @@ pub async fn fetch(cfg: &MiniMaxConfig, who: Who<'_>, http: &reqwest::Client) -> // «lo configuré y falló»: sin eso el usuario no tiene forma de saberlo. let payg_key = who.secret(secrets::MINIMAX_API_KEY); let balance = match payg_key.as_deref() { - Some(api_key) => balance(http, &base, api_key).await, + Some(api_key) => balance(http, base, api_key).await, None => None, }; let mut report = report; let parsed = parse(&plan, balance.as_ref(), payg_key.is_some()); + // Un informe sin medidores y sin mensaje no dice nada: sería un orbe en cero + // que el usuario lee como «no he consumido». Si MiniMax renombra los campos, + // esto lo delata en vez de fallar en silencio. + if parsed.meters.is_empty() && parsed.message.is_none() { + return report.fail( + Status::Warning, + "MiniMax respondió bien pero su formato no coincide con el que se espera; puede haber cambiado el contrato.", + ); + } report.meters = parsed.meters; report.message = parsed.message; report @@ -105,73 +114,81 @@ fn num(v: Option<&Value>) -> Option { v.and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.trim().parse().ok()))) } -fn timestamp(v: Option<&Value>) -> Option { - num(v).map(|n| n as i64) -} -/// Las dos ventanas del Token Plan. Los nombres de campo son los del endpoint -/// `/v1/token_plan/remains`. +/// Las dos ventanas del Token Plan. +/// +/// Campos **del payload real** (GET `/v1/token_plan/remains`), no de documentación: +/// los contadores vienen dentro de `model_remains[]`, uno por categoría de modelo +/// (`general`, `video`…), y el campo fiable es `current_*_remaining_percent`, un +/// porcentaje **restante**. Los contadores `_usage_count` / `_total_count` no sirven: +/// en `general` vienen a 0 mientras el porcentaje restante dice 81, así que dividir +/// uno por otro daría 0 % con el 19 % consumido. struct Window { id: &'static str, label: &'static str, - used: &'static str, - total: &'static str, + remaining: &'static str, start: &'static str, end: &'static str, } const WINDOWS: [Window; 2] = [ Window { - id: "token_plan_5h", - label: "Plan · 5 horas", - used: "current_interval_usage_count", - total: "current_interval_total_count", - start: "current_interval_start_time", - end: "current_interval_end_time", + id: "5h", + label: "5 h", + remaining: "current_interval_remaining_percent", + start: "start_time", + end: "end_time", }, Window { - id: "token_plan_weekly", - label: "Plan · semanal", - used: "current_weekly_usage_count", - total: "current_weekly_total_count", - start: "current_weekly_start_time", - end: "current_weekly_end_time", + id: "weekly", + label: "semanal", + remaining: "current_weekly_remaining_percent", + start: "weekly_start_time", + end: "weekly_end_time", }, ]; +/// MiniMax manda los tiempos en **milisegundos** desde epoch. Con segundos, una +/// ventana de 2026 caería en el año 58.000. +fn millis(v: Option<&Value>) -> Option { + num(v).map(|n| (n / 1000.0) as i64) +} + pub(crate) fn parse(plan: &Value, balance: Option<&Value>, payg_configured: bool) -> Parsed { let mut meters = vec![]; - // El Token Plan tiene **dos ventanas**: una de 5 horas (`current_interval_*`) - // y otra semanal (`current_weekly_*`). Se emiten las dos: con una sola, el - // anillo miente cuando la de 5 h va sobrada y la semana va al límite. + // El payload real trae `model_remains[]`: **una entrada por categoría de + // modelo**, no una barra compartida. Por eso el id del medidor lleva el + // nombre del modelo: `general` y `video` consumen cuotas distintas y hay que + // poder distinguirlos en el popup y en el historial. // - // El porcentaje sale de `usage / total`, nunca de `remains`. La lectura de - // estos campos depende del endpoint: en el viejo `/coding_plan/remains`, - // `current_interval_usage_count` era lo que **quedaba** (MiniMax-AI/MiniMax-M2#99), - // y al migrar a `/v1/token_plan/remains` pasó a ser el **consumo** - // (MiniMax-AI/cli#104, «`current_interval_usage_count` is used count, not - // remaining»). Aquí se usa la del endpoint actual; equivocarse invierte el anillo. - for window in WINDOWS { - let used = num(plan.get(window.used)); - let total = num(plan.get(window.total)); - let Some(limit) = total.filter(|t| *t > 0.0) else { continue }; - let spent = used.unwrap_or(0.0); - let start = timestamp(plan.get(window.start)); - let end = timestamp(plan.get(window.end)); - meters.push(Meter { - id: window.id.into(), - label: window.label.into(), - // Saturado aquí, y no solo en el anillo: un payload con - // `usage > total` (ciclo mal cerrado) no debe colarse al popup ni - // quedar persistido en el historial para siempre. - percent: Some((spent / limit * 100.0).clamp(0.0, 100.0)), - used: Some(spent), - limit: Some(limit), - unit: None, - resets_at: end.and_then(|at| chrono::DateTime::from_timestamp(at, 0)).map(|at| at.to_rfc3339()), - window_seconds: start.zip(end).map(|(s, e)| e - s), - }); + // El porcentaje sale de `100 - current_*_remaining_percent`, que es un + // **restante**. Los contadores `_usage_count` / `_total_count` existen pero no + // son fiables (en `general` vienen a 0 con el 81 % restante), así que + // dividirlos daría un anillo en cero con la cuota realmente consumida. + for entry in plan.get("model_remains").and_then(Value::as_array).into_iter().flatten() { + let Some(model) = entry.get("model_name").and_then(Value::as_str) else { continue }; + let slug: String = model + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }) + .collect(); + for window in WINDOWS { + let Some(remaining) = num(entry.get(window.remaining)) else { continue }; + let start = millis(entry.get(window.start)); + let end = millis(entry.get(window.end)); + meters.push(Meter { + id: format!("token_plan_{slug}_{}", window.id), + label: format!("{model} · {label}", label = window.label), + percent: Some((100.0 - remaining).clamp(0.0, 100.0)), + // El payload no trae totales en unidades utilizables, así que no + // se inventan `used`/`limit`: solo el porcentaje es verificable. + used: None, + limit: None, + unit: None, + resets_at: end.and_then(|at| chrono::DateTime::from_timestamp(at, 0)).map(|at| at.to_rfc3339()), + window_seconds: start.zip(end).map(|(s, e)| e - s), + }); + } } // El saldo PAYG no es un porcentaje: no hay «total» contra el que medirlo. @@ -190,19 +207,17 @@ pub(crate) fn parse(plan: &Value, balance: Option<&Value>, payg_configured: bool }); } - // El mensaje resume la ventana **peor**: con dos ventanas, decir solo la de - // 5 h hides que la semana está al límite. + // El mensaje resume el **peor** medidor del plan, no el primero: con varias + // categorías de modelo y dos ventanas cada una, decir solo el primero esconde + // el que está al límite. let worst = meters .iter() .filter(|m| m.percent.is_some()) .max_by(|a, b| a.percent.partial_cmp(&b.percent).unwrap_or(std::cmp::Ordering::Equal)); - let plan = match worst { - Some(m) => Some(format!("{} {:.0}/{:.0}", m.label, m.used.unwrap_or(0.0), m.limit.unwrap_or(0.0))), - None => None, - }; + let plan = worst.map(|m| format!("{} {:.0}%", m.label, m.percent.unwrap_or(0.0))); // Con la API key puesta y el saldo sin llegar, el usuario no puede // distinguir «no lo configuré» de «lo configuré y falla». Decírselo es la - // diferencia entre depurar una clave ycreer que MiniMax no publishes saldo. + // diferencia entre depurar una clave y pensar que MiniMax no publica el saldo. let saldo = match available { Some(balance) => Some(format!("saldo {balance:.2} $")), None if payg_configured => Some("saldo no disponible".to_string()), @@ -222,8 +237,8 @@ mod tests { use super::*; use serde_json::json; - /// Envuelve el payload **en el mismo nivel** que `base_resp`, como lo hace - /// MiniMax: los campos del plan son hermanos del sobre, no hijos suyos. + /// Envuelve un cuerpo **en el mismo nivel** que `base_resp`, como lo hace + /// MiniMax: `model_remains` y el sobre son hermanos. fn ok(mut body: Value) -> Value { body.as_object_mut() .expect("el fixture debe ser un objeto") @@ -231,59 +246,74 @@ mod tests { body } - #[test] - fn derives_the_percentage_from_usage_over_total_never_from_remains() { - let p = parse(&ok(json!({ - "current_interval_usage_count": 2500, - "current_interval_total_count": 10000, - "current_interval_remains_count": 7500, - })), None, false); - let meter = &p.meters[0]; - assert_eq!(meter.id, "token_plan_5h"); - assert_eq!(meter.percent, Some(25.0)); - assert_eq!(meter.used, Some(2500.0)); - assert_eq!(meter.limit, Some(10000.0)); - // El anillo debe ser el consumo, no lo que queda. - assert_ne!(meter.percent, Some(75.0)); - assert_eq!(p.meters.len(), 1, "sin ventana semanal ni saldo solo se emite la de 5 h"); + /// **Payload real** de `GET /v1/token_plan/remains` con una Subscription Key + /// válida,recortado a los campos que el parser usa. Reproduce los tres rasgos + /// que rompían el parser anterior: los datos viven en `model_remains[]`, hay + /// una entrada por categoría de modelo, y los contadores vienen a 0 mientras + /// el porcentaje restante dice otra cosa. + fn real() -> Value { + json!({ + "model_remains": [ + { + "start_time": 1_792_681_200_000i64, "end_time": 1_792_699_200_000i64, + "remains_time": 4_587_190, + "current_interval_total_count": 0, "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, "current_weekly_usage_count": 0, + "weekly_start_time": 1_792_368_000_000i64, "weekly_end_time": 1_792_972_800_000i64, + "current_interval_status": 1, "current_interval_remaining_percent": 81, + "current_weekly_status": 1, "current_weekly_remaining_percent": 95 + }, + { + "start_time": 1_792_627_200_000i64, "end_time": 1_792_713_600_000i64, + "current_interval_total_count": 3, "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 21, "current_weekly_usage_count": 0, + "current_interval_remaining_percent": 100, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + }) } #[test] - fn emits_both_token_plan_windows_independently() { - let p = parse(&ok(json!({ - "current_interval_usage_count": 2500, "current_interval_total_count": 10000, - "current_weekly_usage_count": 9000, "current_weekly_total_count": 10000, - })), None, false); - assert_eq!(p.meters.iter().map(|m| m.id.as_str()).collect::>(), ["token_plan_5h", "token_plan_weekly"]); - assert_eq!(p.meters[0].percent, Some(25.0)); - // Con la semana al 90 % y la ventana de 5 h al 25 %, el anillo tiene que - // mirar a la peor: el anillo es único y solo puede mentir si elige mal. - assert_eq!(p.meters[1].percent, Some(90.0)); + fn lee_el_porcentaje_del_payload_real_y_no_de_los_contadores_a_cero() { + let p = parse(&real(), None, false); + // Los contadores de `general` valen 0/0; si el parser los usara daría 0 %. + let general = p.meters.iter().find(|m| m.id == "token_plan_general_5h").expect("medidor de general"); + assert_eq!(general.percent, Some(19.0), "81 % restante = 19 % consumido"); + assert_eq!(general.used, None, "el payload no da totales en unidades utilizables"); + assert_eq!(general.limit, None); + // 5 h y semanal, por cada categoría. + assert_eq!( + p.meters.iter().map(|m| m.id.as_str()).collect::>(), + ["token_plan_general_5h", "token_plan_general_weekly", "token_plan_video_5h", "token_plan_video_weekly"] + ); + assert_eq!(p.meters.iter().find(|m| m.id == "token_plan_general_weekly").unwrap().percent, Some(5.0)); + assert_eq!(p.meters.iter().find(|m| m.id == "token_plan_video_5h").unwrap().percent, Some(0.0)); + // El peor medidor manda en el mensaje: general 5 h al 19 %, no video al 0 %. + assert_eq!(p.message.as_deref(), Some("general · 5 h 19%")); } #[test] - fn distinguishes_a_missing_balance_from_a_configured_one_that_failed() { - let plan = ok(json!({"current_interval_usage_count": 3, "current_interval_total_count": 6})); - // Sin API key: el usuario no pidió saldo, no hay nada que señalar. - assert_eq!(parse(&plan, None, false).message.as_deref(), Some("Plan · 5 horas 3/6")); - // Con API key y saldo caído: tiene que verse, o el usuario no puede - // distinguir «no lo configuré» de «lo configuré y falló». - assert_eq!(parse(&plan, None, true).message.as_deref(), Some("Plan · 5 horas 3/6 · saldo no disponible")); - // Sin ventanas de plan tampoco se pierde el aviso. - assert_eq!(parse(&ok(json!({})), None, true).message.as_deref(), Some("saldo no disponible")); + fn interpreta_los_tiempos_como_milisegundos() { + let p = parse(&real(), None, false); + let general = &p.meters[0]; + assert_eq!(general.resets_at.as_deref(), Some("2026-10-22T20:00:00+00:00")); + assert_eq!(general.window_seconds, Some(18_000), "la ventana de 5 h son 5 horas, no 5 millones"); + assert_eq!(p.meters[1].resets_at.as_deref(), Some("2026-10-26T00:00:00+00:00")); } #[test] - fn saturates_a_percentage_that_exceeds_the_total() { - // Ciclo mal cerrado o payload sucio: usage > total no puede pintar un - // anillo por encima de 100 ni quedar persistido así en el historial. - let p = parse(&ok(json!({ - "current_interval_usage_count": 1500, "current_interval_total_count": 1000, - })), None, false); - assert_eq!(p.meters[0].percent, Some(100.0)); - // El valor crudo se conserva: used/limit siguen diciendo la verdad. - assert_eq!(p.meters[0].used, Some(1500.0)); - assert_eq!(p.meters[0].limit, Some(1000.0)); + fn un_solo_modelo_no_inventa_los_demas() { + let p = parse(&real(), None, false); + // 4 medidores: 2 categorías x 2 ventanas. Ni uno de más. + assert_eq!(p.meters.len(), 4); + let vacio = parse(&json!({"model_remains": [], "base_resp": {"status_code": 0}}), None, false); + assert!(vacio.meters.is_empty()); + let sin_array = parse(&ok(json!({})), None, false); + assert!(sin_array.meters.is_empty() && sin_array.message.is_none()); } #[test] @@ -292,68 +322,41 @@ mod tests { assert_eq!(envelope(&v).unwrap_err(), "MiniMax: invalid api key"); let sin_mensaje = json!({"base_resp": {"status_code": 1008}}); assert_eq!(envelope(&sin_mensaje).unwrap_err(), "MiniMax rechazó la consulta (status_code 1008)"); - assert!(envelope(&ok(json!({}))).is_ok()); - // Sin sobre no se afirma un éxito que no se pudo verificar. - assert!(envelope(&json!({"current_interval_total_count": 10})).is_err()); + assert!(envelope(&real()).is_ok(), "una respuesta real de éxito trae base_resp con status_code 0"); + assert!(envelope(&json!({"model_remains": []})).is_err(), "sin sobre no se afirma un éxito sin verificar"); assert!(envelope(&json!({"base_resp": null})).is_err()); - // Un sobre sin código no da veredicto en ninguna dirección. - assert!(envelope(&json!({"base_resp": {}})).is_ok()); + assert!(envelope(&json!({"base_resp": {}})).is_ok(), "sin código no hay veredicto en ninguna dirección"); } #[test] - fn payg_balance_is_a_value_meter_and_never_touches_the_ring() { + fn saldo_payg_es_un_valor_y_nunca_una_anillo() { let p = parse( - &ok(json!({"current_interval_usage_count": 1, "current_interval_total_count": 4})), + &real(), Some(&ok(json!({"available_amount": "12.50", "cash_balance": "10.00", "voucher_balance": "2.50"}))), true, ); - assert_eq!(p.meters.len(), 2); - assert_eq!(p.meters[1].id, "payg_balance"); - assert_eq!(p.meters[1].percent, None, "un saldo no es un porcentaje consumido"); - assert_eq!(p.meters[1].used, Some(12.50)); - assert_eq!(p.meters[1].unit.as_deref(), Some("USD")); - assert_eq!(p.message.as_deref(), Some("Plan · 5 horas 1/4 · saldo 12.50 $")); - } - - #[test] - fn a_missing_or_unusable_balance_leaves_the_plan_report_intact() { - let plan = ok(json!({"current_interval_usage_count": 3, "current_interval_total_count": 6})); - for balance in [None, Some(&json!({})), Some(&ok(json!({"available_amount": null})))] { - let p = parse(&plan, balance, false); - assert_eq!(p.meters.len(), 1, "el fallo del saldo no puede degradar el anillo del plan"); - assert_eq!(p.meters[0].percent, Some(50.0)); - } - assert_eq!(parse(&plan, None, false).message.as_deref(), Some("Plan · 5 horas 3/6")); + let balance = p.meters.iter().find(|m| m.id == "payg_balance").expect("medidor de saldo"); + assert_eq!(balance.percent, None, "un saldo no es un porcentaje consumido"); + assert_eq!(balance.used, Some(12.50)); + assert_eq!(balance.unit.as_deref(), Some("USD")); + assert_eq!(p.message.as_deref(), Some("general · 5 h 19% · saldo 12.50 $")); } #[test] - fn reads_amounts_as_strings_or_numbers_and_never_divides_by_zero() { - let as_string = parse(&ok(json!({"current_interval_usage_count": "40", "current_interval_total_count": "80"})), None, false); - assert_eq!(as_string.meters[0].percent, Some(50.0)); - let sin_total = parse(&ok(json!({"current_interval_usage_count": 40, "current_interval_total_count": 0})), None, false); - assert!(sin_total.meters.is_empty(), "un total de 0 no produce un anillo indefinido"); - let sin_datos = parse(&ok(json!({})), None, false); - assert!(sin_datos.meters.is_empty() && sin_datos.message.is_none()); + fn distingue_saldo_sin_configurar_de_configurado_y_fallido() { + assert_eq!(parse(&real(), None, false).message.as_deref(), Some("general · 5 h 19%")); + assert_eq!(parse(&real(), None, true).message.as_deref(), Some("general · 5 h 19% · saldo no disponible")); + assert_eq!(parse(&ok(json!({"model_remains": []})), None, true).message.as_deref(), Some("saldo no disponible")); } #[test] - fn carries_the_interval_reset_and_window_when_minimax_sends_them() { - let p = parse(&ok(json!({ - "current_interval_usage_count": 5, - "current_interval_total_count": 10, - "current_weekly_usage_count": 1, - "current_weekly_total_count": 10, - "current_interval_start_time": 1_735_689_600, // 2025-01-01T00:00:00Z - "current_interval_end_time": 1_735_707_600, // +5 h - "current_weekly_start_time": 1_735_516_800, // 2024-12-30T00:00:00Z - "current_weekly_end_time": 1_736_121_600, // 2025-01-06T00:00:00Z - })), None, false); - assert_eq!(p.meters[0].window_seconds, Some(18_000)); - assert_eq!(p.meters[0].resets_at.as_deref(), Some("2025-01-01T05:00:00+00:00")); - assert_eq!(p.meters[1].window_seconds, Some(604_800)); - assert_eq!(p.meters[1].resets_at.as_deref(), Some("2025-01-06T00:00:00+00:00")); - // Sin marcas de tiempo el medidor sigue siendo válido, solo pierde el reinicio. - let sin_ventana = parse(&ok(json!({"current_interval_usage_count": 5, "current_interval_total_count": 10})), None, false); - assert!(sin_ventana.meters[0].resets_at.is_none() && sin_ventana.meters[0].window_seconds.is_none()); + fn satura_un_porcentaje_que_supera_el_rango() { + // Porcentaje restante negativo o > 100: el payload viene sucio. + let sucio = json!({"model_remains": [ + {"model_name": "general", "current_interval_remaining_percent": -20, "current_weekly_remaining_percent": 140} + ], "base_resp": {"status_code": 0}}); + let p = parse(&sucio, None, false); + assert_eq!(p.meters[0].percent, Some(100.0), "restante negativo satura a 100 % consumido"); + assert_eq!(p.meters[1].percent, Some(0.0), "restante > 100 no produce un negativo"); } } From bbb4db333fd1c048b8ce39b2349c6383dc4ea738 Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:56:46 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(settings):=20los=20tres=20fallos=20de?= =?UTF-8?q?=20integraci=C3=B3n=20de=20MiniMax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **1. `CONFIG_KEY` sin `minimax`.** En `setAccountVisibility`, para un proveedor principal sin entrada en `CONFIG_KEY`: ```ts if (!Object.prototype.hasOwnProperty.call(CONFIG_KEY, id)) return cfg; ``` El toggle «Mostrar en el widget» **descartaba el cambio en silencio**: la UI lo reflejaba y al reiniciar volvía al valor anterior. Añadido a `CONFIG_KEY` y al tipo `MainKey`. **2. `minimax.baseUrl` no tenía campo en ninguna parte de la UI**, pero el hint de la Subscription Key prometía «cambia también el host en Ajustes avanzados». Era una promesa falsa: el usuario de la región de China tenía que editar el JSON a mano. Añadido el campo en un bloque «Avanzado», con el aviso de que las claves son por región —lo verificado en el sondeo de hosts, donde una clave global da `2049 invalid api key` en `api.minimaxi.com`— y los errores concretos a los que apuntar si el host no es el de la región. **3. Guard de medidores vacíos.** Si MiniMax renombra los campos, `parse` devolvía cero medidores y sin mensaje: un orbe en cero que el usuario lee como «no he consumido», que es justo lo que la descripción dice querer evitar. Ahora es un `Status::Warning` explícito. Mismo patrón que `claude.rs`, `codex.rs`, `cursor.rs`, `gemini.rs` y `nan.rs`. --- src/Settings.tsx | 16 +++++++++++++++- src/lib/settings-visibility.ts | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Settings.tsx b/src/Settings.tsx index 0379244..6183689 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1959,7 +1959,7 @@ export function Settings() { label="Subscription Key" configured={!!secrets.minimax_subscription_key} onSaved={loadSecrets} - hint={<>Del panel M/Token en minimax.io. En la región de China, en minimaxi.com (cambia también el host en Ajustes avanzados).} + hint={<>Del panel M/Token en minimax.io. En la región de China, en minimaxi.com (el host por región se cambia en Ajustes avanzados).} />
  • @@ -1974,6 +1974,20 @@ export function Settings() {
  • +
    + Avanzado · URL del API + + set('minimax', { baseUrl: e.target.value })} + /> + +
    )} {tab === 'openrouter' && ( diff --git a/src/lib/settings-visibility.ts b/src/lib/settings-visibility.ts index d1ea83e..c6924a7 100644 --- a/src/lib/settings-visibility.ts +++ b/src/lib/settings-visibility.ts @@ -1,12 +1,12 @@ import type { AppConfig } from './types'; import { isNineRouterId } from './visibility'; -type MainKey = 'claude' | 'codex' | 'gemini' | 'cursor' | 'copilot' | 'nan' | 'openrouter' | 'anthropicApi' | 'openaiApi'; +type MainKey = 'claude' | 'codex' | 'gemini' | 'cursor' | 'copilot' | 'nan' | 'openrouter' | 'minimax' | 'anthropicApi' | 'openaiApi'; /** Clave de configuración de cada cuenta principal; el id del informe puede ser distinto. */ export const CONFIG_KEY: Readonly> = { claude: 'claude', codex: 'codex', gemini: 'gemini', cursor: 'cursor', - copilot: 'copilot', nan: 'nan', openrouter: 'openrouter', + copilot: 'copilot', nan: 'nan', openrouter: 'openrouter', minimax: 'minimax', anthropic_api: 'anthropicApi', openai_api: 'openaiApi', }; From d492efaab7bd0e7ae7ae243b5a64d3f207fc7e7c Mon Sep 17 00:00:00 2001 From: RafaelDeLinares <86169310+Rafaeldelinares@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:56:54 +0200 Subject: [PATCH 9/9] fix(providers): clippy y typos de la review de #172 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `needless_borrow` en la llamada a `balance(http, &base, …)`: `base` ya es `&str`. - `timestamp()` queda sin uso tras pasar los tiempos a milisegundos, y se elimina. - Typos en comentarios: «hides» → «esconde», «ycreer», y «no publishes saldo» → «no publica el saldo». `cargo clippy --lib --tests` queda en **0 avisos** atribuibles a `minimax.rs`. --- docs/i18n-inventory.md | 331 +++++++++++++++++++++-------------------- 1 file changed, 167 insertions(+), 164 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 93ec62b..738f237 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `2e2775d022abe764f1244e117978426280b065ed` and `src-tauri/src/` = `a7901b6461592e2d67fb6bed04078888ab544756`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `87a618afd56fd64dcf0e329e20991f3808ff0a37` and `src-tauri/src/` = `210ed4f47e1041f3ef18253a37b170d8beb5efa8`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact lines. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -17,9 +17,9 @@ look like text. | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 588 | 717 | +| React (`src/`) | 590 | 720 | | Rust (`src-tauri/src/`) | 303 | 359 | -| Total | 860 | 1076 | +| Total | 862 | 1079 | ## src-tauri/src/channel.rs @@ -279,16 +279,16 @@ look like text. | 30 | Subscription Key de MiniMax rechazada. | | 31 | HTTP {code} en /v1/token_plan/remains | | 35 | Respuesta inesperada: {e} | -| 85 | MiniMax devolvió una respuesta sin base_resp | -| 88 | MiniMax devolvió una respuesta con base_resp vacío | -| 96 | MiniMax rechazó la consulta (status_code {code}) | -| 96 | MiniMax: {m} | -| 126 | Plan · 5 horas | -| 134 | Plan · semanal | -| 183 | Saldo PAYG | -| 187 | USD | -| 207 | saldo {balance:.2} $ | -| 208 | saldo no disponible | +| 59 | MiniMax respondió bien pero su formato no coincide con el que se espera; puede haber cambiado el contrato. | +| 94 | MiniMax devolvió una respuesta sin base_resp | +| 97 | MiniMax devolvió una respuesta con base_resp vacío | +| 105 | MiniMax rechazó la consulta (status_code {code}) | +| 105 | MiniMax: {m} | +| 137 | 5 h | +| 200 | Saldo PAYG | +| 204 | USD | +| 222 | saldo {balance:.2} $ | +| 223 | saldo no disponible | ## src-tauri/src/providers/mod.rs @@ -833,160 +833,163 @@ look like text. | 1962 | minimax.io | | 1962 | . En la región de China, en | | 1962 | minimaxi.com | -| 1962 | (cambia también el host en Ajustes avanzados). | +| 1962 | (el host por región se cambia en Ajustes avanzados). | | 1966 | API key de pago por uso (opcional) | | 1969 | API key PAYG | | 1972 | Sin ella solo se muestra la cuota del plan; el saldo es opcional y su fallo no lo degrade. | -| 1980 | Pago por uso · API oficial | -| 1980 | OpenRouter | -| 1981 | Mostrar en el widget | -| 1982 | Gasto del mes y de hoy, límite de la key (si tiene) y saldo de la cuenta. | -| 1983 | Cómo conectar la cuenta | -| 1985 | API key | -| 1988 | API key | -| 1991 | Créala en | -| 1991 | openrouter.ai › Keys | -| 2000 | Suscripción · cuenta o API key | -| 2000 | NaN Builders | -| 2001 | Mostrar en el widget | -| 2003 | Con la cuenta conectada ves lo mismo que en tu dashboard: tokens usados frente a la cuota de cada modelo, cuándo se reinicia y la serie diaria. Sin cuenta, la API key usa | -| 2004 | /v1/usage | -| 2004 | y las cuotas publicadas. | -| 2004 | Docs | -| 2006 | Cómo conectar la cuenta | -| 2008 | Conecta tu cuenta o pega una API key | -| 2012 | API key (alternativa) | -| 2015 | Créala en | -| 2015 | cloud.nan.builders | -| 2015 | › API Keys | -| 2019 | Plan | -| 2020 | Plan | -| 2022 | nan_community | -| 2023 | nan_member | -| 2024 | GLM Premium | -| 2031 | Avanzado · URL, facturación y endpoint de consumo | -| 2032 | Base URL | -| 2035 | Día de inicio de facturación | -| 2035 | Solo para GLM 5.3 (cuenta por periodo de Stripe) cuando usas la API key. | -| 2039 | Endpoint de consumo · URL | -| 2039 | Se llama con tu key como Bearer y debe devolver JSON. | -| 2043 | JSON Pointer · usado | -| 2043 | p. ej. /usage/used_tokens | -| 2046 | JSON Pointer · límite | -| 2046 | p. ej. /usage/quota_tokens | -| 2050 | Etiqueta | -| 2051 | Consumo | -| 2058 | Pasarela local · uso agregado | -| 2058 | 9Router | -| 2060 | Mostrar en el widget | -| 2065 | Quorb lee el uso y el gasto que | -| 2065 | 9Router | -| 2065 | ya registra en tu equipo: peticiones, tokens y coste por proveedor del periodo. Además del orbe agregado, cada proveedor que haya visto (Claude, Kiro, GLM…) aparece como su propia cuenta. | -| 2068 | Cómo conectar 9Router | -| 2070 | Conecta tu dashboard local | -| 2074 | Periodo y desglose | -| 2075 | Periodo de uso | -| 2075 | Rango que muestra el orbe y su historial. | -| 2077 | Hoy | -| 2078 | Últimas 24 h | -| 2079 | Últimos 7 días | -| 2080 | Últimos 30 días | -| 2081 | Últimos 60 días | -| 2082 | Todo | -| 2086 | Mostrar cada sub-proveedor como su propia cuenta | -| 2090 | Gasto mínimo por sub-proveedor (USD) | -| 2090 | No muestra un sub-orbe hasta que su gasto en el periodo supere esta cifra. 0 = cualquiera que gaste. | -| 2099 | Presupuesto (USD) | -| 2099 | Opcional: pinta la barra de gasto del periodo frente al tope que pongas. | -| 2112 | Avanzado · dirección del servidor | -| 2113 | Base URL | -| 2113 | Dónde escucha 9Router (por defecto http://localhost:20128). | -| 2123 | Suborbes ocultos de 9Router | -| 2127 | · mostrar | -| 2131 | Los ocultaste desde su panel en la tira. Pulsa para volver a mostrarlos. | -| 2143 | Pago por uso · Admin key | -| 2143 | Anthropic API | -| 2143 | OpenAI API | -| 2144 | Mostrar gasto del mes | -| 2145 | Cómo conectar la cuenta | -| 2147 | Admin key | -| 2150 | Admin API key (sk-ant-admin…) | -| 2150 | Admin key | -| 2155 | Las APIs de coste no admiten OAuth: hace falta una | -| 2155 | Admin key | -| 2155 | (solo lectura de la organización). | -| 2157 | Créala en | -| 2157 | Organization › Admin keys | -| 2163 | Presupuesto mensual (opcional) | -| 2164 | USD al mes | -| 2181 | Listo | -| 2183 | Guardando… | -| 2183 | Guardar cambios | -| 2192 | Anillos | -| 2194 | Vista previa a ${…} px | -| 2202 | Tamaño del anillo | -| 2202 | Restablecer ( | -| 2202 | px) | -| 2207 | Color de acento | -| 2210 | Usar ${…} | -| 2212 | Elegir cualquier color (RGB) | -| 2217 | Colores resultantes | -| 2226 | Es el del anillo exterior y la primera barra; los otros dos tonos salen solos. Los avisos y el exceso conservan sus colores de alerta. | -| 2227 | Restablecer rojo egdev | -| 2230 | Opacidad de la tira y el panel | -| 2234 | Círculos pequeños | -| 2234 | Un solo anillo por suscripción: el límite más cercano. | -| 2235 | Mostrar resets en los círculos | -| 2235 | El saldo de resets siempre sigue disponible en el panel de detalle. | -| 2239 | Tira | -| 2241 | Borde | -| 2249 | También puedes arrastrarla desde su asa: se pega al borde más cercano. | -| 2251 | Posición en el borde | -| 2255 | Ocultar sola | -| 2255 | Deja una franja en el borde y vuelve al acercar el ratón o cuando hay un aviso. | -| 2256 | Siempre encima | -| 2256 | Por encima del resto de ventanas. | -| 2260 | Personalización no aplicada | -| 2262 | Reintentar | -| 2271 | Español | -| 2272 | English | -| 2287 | Cuándo avisar | -| 2289 | Activar avisos | -| 2290 | Notificación del sistema | -| 2290 | Además del efecto en pantalla. | -| 2293 | 1 · Al superar el límite | -| 2294 | Avisar al llegar a | -| 2294 | Una vez por ventana (sesión de 5 h, semana, mes…). | -| 2300 | 2 · Al reiniciarse | -| 2302 | Avisar cuando el límite superado se reinicia | -| 2302 | «Claude · Sesión 5 h: disponible de nuevo» | -| 2310 | Efecto propio | -| 2312 | Un vídeo WebM (VP9) con canal alfa o una imagen animada GIF, WebP o APNG con transparencia. Se muestra centrado encima de todo, sin bloquear el ratón; el vídeo dura lo que dure (máx. 20 s) y las imágenes 4,5 s. | -| 2315 | Tamaño | -| 2317 | Pequeño | -| 2318 | Mediano | -| 2319 | Pantalla completa | -| 2323 | Con sonido | -| 2323 | Solo para vídeos. | -| 2330 | Atajos globales | -| 2332 | Activar atajos | -| 2332 | Funcionan aunque Quorb no tenga el foco. | -| 2338 | Atajos de teclado | -| 2341 | Acción | -| 2342 | Atajo | -| 2343 | Acciones | -| 2347 | Mostrar u ocultar la tira | -| 2348 | Abrir el más cargado | -| 2349 | Actualizar ahora | -| 2358 | Pulsa un atajo o «Cambiar» y teclea la combinación. Se aplican al guardar. | -| 2367 | Proyecto | -| 2368 | Quorb es open source (MIT). Si te ahorra un susto con los límites, una estrella en GitHub ayuda a que lo encuentre más gente. | -| 2370 | GitHub | -| 2371 | Pedir un proveedor | -| 2372 | egdev.es | -| 2383 | Cerrar | -| 2385 | Guardando… | -| 2385 | Guardar cambios | +| 1978 | Avanzado · URL del API | +| 1980 | Base URL | +| 1981 | Las claves son por región: global en api.minimax.io, China en api.minimaxi.com. Si el anillo sale con error 1004 o 2049, prueba con la de tu región. | +| 1994 | Pago por uso · API oficial | +| 1994 | OpenRouter | +| 1995 | Mostrar en el widget | +| 1996 | Gasto del mes y de hoy, límite de la key (si tiene) y saldo de la cuenta. | +| 1997 | Cómo conectar la cuenta | +| 1999 | API key | +| 2002 | API key | +| 2005 | Créala en | +| 2005 | openrouter.ai › Keys | +| 2014 | Suscripción · cuenta o API key | +| 2014 | NaN Builders | +| 2015 | Mostrar en el widget | +| 2017 | Con la cuenta conectada ves lo mismo que en tu dashboard: tokens usados frente a la cuota de cada modelo, cuándo se reinicia y la serie diaria. Sin cuenta, la API key usa | +| 2018 | /v1/usage | +| 2018 | y las cuotas publicadas. | +| 2018 | Docs | +| 2020 | Cómo conectar la cuenta | +| 2022 | Conecta tu cuenta o pega una API key | +| 2026 | API key (alternativa) | +| 2029 | Créala en | +| 2029 | cloud.nan.builders | +| 2029 | › API Keys | +| 2033 | Plan | +| 2034 | Plan | +| 2036 | nan_community | +| 2037 | nan_member | +| 2038 | GLM Premium | +| 2045 | Avanzado · URL, facturación y endpoint de consumo | +| 2046 | Base URL | +| 2049 | Día de inicio de facturación | +| 2049 | Solo para GLM 5.3 (cuenta por periodo de Stripe) cuando usas la API key. | +| 2053 | Endpoint de consumo · URL | +| 2053 | Se llama con tu key como Bearer y debe devolver JSON. | +| 2057 | JSON Pointer · usado | +| 2057 | p. ej. /usage/used_tokens | +| 2060 | JSON Pointer · límite | +| 2060 | p. ej. /usage/quota_tokens | +| 2064 | Etiqueta | +| 2065 | Consumo | +| 2072 | Pasarela local · uso agregado | +| 2072 | 9Router | +| 2074 | Mostrar en el widget | +| 2079 | Quorb lee el uso y el gasto que | +| 2079 | 9Router | +| 2079 | ya registra en tu equipo: peticiones, tokens y coste por proveedor del periodo. Además del orbe agregado, cada proveedor que haya visto (Claude, Kiro, GLM…) aparece como su propia cuenta. | +| 2082 | Cómo conectar 9Router | +| 2084 | Conecta tu dashboard local | +| 2088 | Periodo y desglose | +| 2089 | Periodo de uso | +| 2089 | Rango que muestra el orbe y su historial. | +| 2091 | Hoy | +| 2092 | Últimas 24 h | +| 2093 | Últimos 7 días | +| 2094 | Últimos 30 días | +| 2095 | Últimos 60 días | +| 2096 | Todo | +| 2100 | Mostrar cada sub-proveedor como su propia cuenta | +| 2104 | Gasto mínimo por sub-proveedor (USD) | +| 2104 | No muestra un sub-orbe hasta que su gasto en el periodo supere esta cifra. 0 = cualquiera que gaste. | +| 2113 | Presupuesto (USD) | +| 2113 | Opcional: pinta la barra de gasto del periodo frente al tope que pongas. | +| 2126 | Avanzado · dirección del servidor | +| 2127 | Base URL | +| 2127 | Dónde escucha 9Router (por defecto http://localhost:20128). | +| 2137 | Suborbes ocultos de 9Router | +| 2141 | · mostrar | +| 2145 | Los ocultaste desde su panel en la tira. Pulsa para volver a mostrarlos. | +| 2157 | Pago por uso · Admin key | +| 2157 | Anthropic API | +| 2157 | OpenAI API | +| 2158 | Mostrar gasto del mes | +| 2159 | Cómo conectar la cuenta | +| 2161 | Admin key | +| 2164 | Admin API key (sk-ant-admin…) | +| 2164 | Admin key | +| 2169 | Las APIs de coste no admiten OAuth: hace falta una | +| 2169 | Admin key | +| 2169 | (solo lectura de la organización). | +| 2171 | Créala en | +| 2171 | Organization › Admin keys | +| 2177 | Presupuesto mensual (opcional) | +| 2178 | USD al mes | +| 2195 | Listo | +| 2197 | Guardando… | +| 2197 | Guardar cambios | +| 2206 | Anillos | +| 2208 | Vista previa a ${…} px | +| 2216 | Tamaño del anillo | +| 2216 | Restablecer ( | +| 2216 | px) | +| 2221 | Color de acento | +| 2224 | Usar ${…} | +| 2226 | Elegir cualquier color (RGB) | +| 2231 | Colores resultantes | +| 2240 | Es el del anillo exterior y la primera barra; los otros dos tonos salen solos. Los avisos y el exceso conservan sus colores de alerta. | +| 2241 | Restablecer rojo egdev | +| 2244 | Opacidad de la tira y el panel | +| 2248 | Círculos pequeños | +| 2248 | Un solo anillo por suscripción: el límite más cercano. | +| 2249 | Mostrar resets en los círculos | +| 2249 | El saldo de resets siempre sigue disponible en el panel de detalle. | +| 2253 | Tira | +| 2255 | Borde | +| 2263 | También puedes arrastrarla desde su asa: se pega al borde más cercano. | +| 2265 | Posición en el borde | +| 2269 | Ocultar sola | +| 2269 | Deja una franja en el borde y vuelve al acercar el ratón o cuando hay un aviso. | +| 2270 | Siempre encima | +| 2270 | Por encima del resto de ventanas. | +| 2274 | Personalización no aplicada | +| 2276 | Reintentar | +| 2285 | Español | +| 2286 | English | +| 2301 | Cuándo avisar | +| 2303 | Activar avisos | +| 2304 | Notificación del sistema | +| 2304 | Además del efecto en pantalla. | +| 2307 | 1 · Al superar el límite | +| 2308 | Avisar al llegar a | +| 2308 | Una vez por ventana (sesión de 5 h, semana, mes…). | +| 2314 | 2 · Al reiniciarse | +| 2316 | Avisar cuando el límite superado se reinicia | +| 2316 | «Claude · Sesión 5 h: disponible de nuevo» | +| 2324 | Efecto propio | +| 2326 | Un vídeo WebM (VP9) con canal alfa o una imagen animada GIF, WebP o APNG con transparencia. Se muestra centrado encima de todo, sin bloquear el ratón; el vídeo dura lo que dure (máx. 20 s) y las imágenes 4,5 s. | +| 2329 | Tamaño | +| 2331 | Pequeño | +| 2332 | Mediano | +| 2333 | Pantalla completa | +| 2337 | Con sonido | +| 2337 | Solo para vídeos. | +| 2344 | Atajos globales | +| 2346 | Activar atajos | +| 2346 | Funcionan aunque Quorb no tenga el foco. | +| 2352 | Atajos de teclado | +| 2355 | Acción | +| 2356 | Atajo | +| 2357 | Acciones | +| 2361 | Mostrar u ocultar la tira | +| 2362 | Abrir el más cargado | +| 2363 | Actualizar ahora | +| 2372 | Pulsa un atajo o «Cambiar» y teclea la combinación. Se aplican al guardar. | +| 2381 | Proyecto | +| 2382 | Quorb es open source (MIT). Si te ahorra un susto con los límites, una estrella en GitHub ayuda a que lo encuentre más gente. | +| 2384 | GitHub | +| 2385 | Pedir un proveedor | +| 2386 | egdev.es | +| 2397 | Cerrar | +| 2399 | Guardando… | +| 2399 | Guardar cambios | ## src/Widget.tsx