From 8d1d6b702debe6ad52bc7a0860dbf4c191be416a Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Thu, 29 Oct 2026 12:47:48 -0300 Subject: [PATCH 01/12] feat(providers): proveedor Antigravity con cuota por modelo del IDE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity no deja una sesión reutilizable en disco y su cuota es un grupo propio, distinto del de Gemini CLI. El proveedor consulta el servidor de lenguaje local que arranca el IDE (GetUserStatus, no oficial): token CSRF desde la línea de comandos del proceso lanzado con --app_data_dir antigravity y puerto HTTP desde language_server.log del arranque actual (mismo pid). La petición va solo a 127.0.0.1 y el token no se guarda. Un medidor por modelo agrupando las variantes de esfuerzo (High/Medium/ Low), el más gastado primero; plan desde userTier.description. La ventana (5 h o semanal) se deduce del tiempo hasta el reinicio. Con el IDE cerrado el informe queda en Warning con instrucciones. Config AntigravityConfig { enabled } (sin cuentas adicionales), pestaña en Ajustes, tipos TS, visibilidad, dataKey, i18n, monograma AG y filas en los README. --- README.es.md | 1 + README.md | 1 + src-tauri/src/config.rs | 11 + src-tauri/src/providers/antigravity.rs | 443 +++++++++++++++++++++++++ src-tauri/src/providers/mod.rs | 10 + src/Settings-providers-i18n.test.tsx | 2 + src/Settings.tsx | 19 ++ src/components/ProviderLogo.tsx | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/lib/order.ts | 1 + src/lib/settings-visibility.ts | 3 +- src/lib/types.ts | 1 + src/lib/widget-refresh.ts | 4 +- 14 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/providers/antigravity.rs diff --git a/README.es.md b/README.es.md index 74767f4..0b1ce81 100644 --- a/README.es.md +++ b/README.es.md @@ -112,6 +112,7 @@ React 19 y TypeScript gestionan el widget, los ajustes y los overlays. Tauri 2 y | Anthropic API / OpenAI API | Coste mensual | Admin API keys | | z.ai | GLM Coding Plan: cuota de 5 h y semanal de coding, mensual de web tools y saldo de resets | API key | | OpenCode Go | Go / Go Plus: cuotas de 5 h, semanal y del ciclo de facturación | API key de Go | +| Antigravity | Cuota del agente por modelo (ventanas de 5 h y semanales) | Sesión local del IDE abierto | Algunas integraciones usan endpoints no documentados, que pueden cambiar o dejar de funcionar. Copilot, OpenRouter y las APIs de coste usan APIs oficiales; NaN combina la API documentada con endpoints no documentados del dashboard, y z.ai lee endpoints de cuotas y resets no documentados. diff --git a/README.md b/README.md index 9ff94d5..c48b76b 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ React 19 and TypeScript handle the widget, settings and overlays. Tauri 2 and Ru | Anthropic API / OpenAI API | Monthly cost | Admin API keys | | z.ai | GLM Coding Plan: 5-hour and weekly coding quotas, monthly web tools, manual reset balance | API key | | OpenCode Go | Go / Go Plus: 5-hour, weekly and billing-cycle quotas | Go API key | +| Antigravity | Per-model agent quota (5-hour and weekly windows) | Local session of the open IDE | Some integrations use undocumented endpoints, which may change or stop working. Copilot, OpenRouter and the cost APIs use official APIs; NaN combines documented API access with undocumented dashboard endpoints, and z.ai reads undocumented quota and reset endpoints. diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d0bb173..9d9c55d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -94,6 +94,8 @@ pub struct AppConfig { pub nine_router: NineRouterConfig, #[serde(default)] pub opencode_go: OpenCodeGoConfig, + #[serde(default)] + pub antigravity: AntigravityConfig, /// Cuentas adicionales de un proveedor (la principal es la de arriba). pub accounts: Vec, } @@ -323,6 +325,7 @@ impl Default for AppConfig { minimax: MiniMaxConfig::default(), nine_router: NineRouterConfig::default(), opencode_go: OpenCodeGoConfig::default(), + antigravity: AntigravityConfig::default(), accounts: vec![], } } @@ -398,6 +401,14 @@ pub struct OpenCodeGoConfig { pub enabled: bool, } +/// Antigravity — cuota por modelo leída del servidor local del IDE abierto. +/// Sin cuentas adicionales: solo hay una sesión del IDE en la máquina. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct AntigravityConfig { + pub enabled: bool, +} + /// Suscripciones de consumo (Claude Pro/Max, ChatGPT Plus/Pro) leídas /// a partir de la sesión OAuth que ya guarda el CLI oficial. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] diff --git a/src-tauri/src/providers/antigravity.rs b/src-tauri/src/providers/antigravity.rs new file mode 100644 index 0000000..8dcf9b1 --- /dev/null +++ b/src-tauri/src/providers/antigravity.rs @@ -0,0 +1,443 @@ +//! Antigravity (IDE de Google) — cuota por modelo de su agente. +//! +//! Antigravity no guarda una sesión reutilizable en disco (como `oauth_creds.json` +//! de Gemini CLI) y su cuota es un grupo propio, distinto del de Gemini CLI. Lo que +//! sí hace es arrancar un servidor de lenguaje local que expone el estado del +//! usuario, el mismo que pinta el panel de cuotas del IDE (NO OFICIAL): +//! · `POST http://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUserStatus` +//! +//! Solo funciona con Antigravity abierto. Se descubre así: +//! · token CSRF → argumento `--csrf_token` del proceso `language_server` +//! lanzado con `--app_data_dir antigravity` (sin él responde 401); +//! · puerto HTTP → línea «listening on random port at N for HTTP» del log +//! `language_server.log`, que se reescribe en cada arranque. +//! +//! Nada sale de la máquina: la petición va a localhost y el token no se guarda. + +use super::{net_error, Who}; +use crate::config::AntigravityConfig; +use crate::model::{Kind, Meter, Status, UsageReport}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +const METHOD: &str = "/exa.language_server_pb.LanguageServerService/GetUserStatus"; +const NOT_RUNNING: &str = + "Abre Antigravity para leer su cuota (solo se consulta con el IDE abierto)."; + +/// Proceso del servidor de lenguaje de Antigravity. +#[derive(Debug, PartialEq)] +struct Server { + pid: u32, + csrf: String, +} + +pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, http: &reqwest::Client) -> UsageReport { + let report = who.report("Antigravity", Kind::Subscription); + // Listar procesos lanza un comando externo: fuera del hilo del runtime. + let server = tokio::task::spawn_blocking(find_server) + .await + .ok() + .flatten(); + let Some(server) = server else { + return report.fail(Status::Warning, NOT_RUNNING); + }; + let port = std::fs::read_to_string(log_path()) + .ok() + .and_then(|log| http_port(&log, server.pid)); + let Some(port) = port else { + return report.fail( + Status::Warning, + "Antigravity está arrancando: aún no publica el puerto de su servidor local.", + ); + }; + + let res = http + .post(format!("http://127.0.0.1:{port}{METHOD}")) + .header("X-Codeium-Csrf-Token", &server.csrf) + .header("Connect-Protocol-Version", "1") + .json(&json!({ + "metadata": { "ideName": "antigravity", "extensionName": "antigravity", "locale": "en" } + })) + .send() + .await; + let res = match res { + Ok(r) => r, + Err(e) => return report.fail(Status::Warning, format!("{NOT_RUNNING} ({})", net_error(e))), + }; + let v: Value = match res.status().as_u16() { + 200 => match res.json().await { + Ok(v) => v, + Err(e) => return report.fail(Status::Error, format!("Respuesta inesperada: {e}")), + }, + 401 | 403 => { + return report.fail( + Status::Warning, + "Antigravity ha rechazado la consulta local. Reinícialo e inténtalo de nuevo.", + ) + } + code => return report.fail(Status::Error, format!("HTTP {code} en GetUserStatus")), + }; + build_report(report, &v, chrono::Utc::now()) +} + +/// Informe final a partir de la respuesta de `GetUserStatus`. Puro, sin red. +fn build_report( + mut report: UsageReport, + v: &Value, + now: chrono::DateTime, +) -> UsageReport { + let status = v.get("userStatus").unwrap_or(v); + report.plan = plan_name(status); + report.meters = parse_models(status, now); + if report.meters.is_empty() { + report.status = Status::Warning; + report.message = Some( + "Antigravity no ha devuelto cuotas por modelo. ¿Has iniciado sesión en el IDE?".into(), + ); + } + report +} + +/// «Google AI Pro» (nivel de la suscripción) o, si falta, el plan interno («Pro»). +fn plan_name(status: &Value) -> Option { + let tier = status + .pointer("/userTier/description") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let plan = status + .pointer("/planStatus/planInfo/planName") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + tier.or(plan).map(str::to_string) +} + +/// Un medidor por modelo (el % gastado es 1 − remainingFraction). Las variantes de +/// esfuerzo («(High)», «(Low)»…) comparten cuota: nos quedamos con la más gastada. +fn parse_models(status: &Value, now: chrono::DateTime) -> Vec { + let mut by_model: BTreeMap = BTreeMap::new(); + for c in status + .pointer("/cascadeModelConfigData/clientModelConfigs") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(label) = c.get("label").and_then(Value::as_str) else { + continue; + }; + let Some(q) = c.get("quotaInfo") else { + continue; + }; + // Sin `remainingFraction` el modelo está agotado (proto3 omite el 0). + let remaining = q + .get("remainingFraction") + .and_then(Value::as_f64) + .unwrap_or(0.0); + if !remaining.is_finite() { + continue; + } + let name = base_label(label); + let resets_at = q + .get("resetTime") + .and_then(Value::as_str) + .filter(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()) + .map(str::to_string); + let meter = Meter { + id: format!("model:{}", slug(&name)), + label: name.clone(), + percent: Some(((1.0 - remaining) * 100.0).clamp(0.0, 100.0)), + used: None, + limit: None, + unit: None, + window_seconds: resets_at.as_deref().and_then(|r| window_for(r, now)), + resets_at, + }; + let keep = by_model + .get(&name) + .map(|m| m.percent < meter.percent) + .unwrap_or(true); + if keep { + by_model.insert(name, meter); + } + } + let mut out: Vec = by_model.into_values().collect(); + out.sort_by(|a, b| { + b.percent + .partial_cmp(&a.percent) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out +} + +/// «Claude Opus 5.5 (High)» → «Claude Opus 5.5». +fn base_label(label: &str) -> String { + let t = label.trim(); + match t.rfind(" (") { + Some(i) if t.ends_with(')') => t[..i].trim_end().to_string(), + _ => t.to_string(), + } +} + +/// «Claude Opus 5.5» → «claude_opus_5_5». +fn slug(name: &str) -> String { + let mut out = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + } else if !out.ends_with('_') { + out.push('_'); + } + } + out.trim_matches('_').to_string() +} + +/// Antigravity no dice la duración de la ventana. Las cuotas que conocemos son de +/// 5 h y semanales; deducimos cuál por el tiempo que falta para el reinicio. +fn window_for(reset: &str, now: chrono::DateTime) -> Option { + let reset = chrono::DateTime::parse_from_rfc3339(reset).ok()?; + let left = (reset.with_timezone(&chrono::Utc) - now).num_seconds(); + match left { + l if l <= 0 => None, + l if l <= 5 * 3600 => Some(5 * 3600), + l if l <= 7 * 86_400 => Some(7 * 86_400), + _ => None, + } +} + +/// `language_server.log` en la carpeta de datos de Antigravity del SO. +fn log_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(super::home) + .join("Antigravity") + .join("logs") + .join("language_server.log") +} + +/// Puerto HTTP que anunció en el log el proceso `pid`. Se ignora un log de un +/// arranque anterior (otro pid) para no consultar un puerto ajeno. +fn http_port(log: &str, pid: u32) -> Option { + let start = format!("Starting language server process with pid {pid}"); + let from = log.rfind(&start)?; + log[from..].lines().find_map(|l| { + let rest = l.split("listening on random port at ").nth(1)?; + let (port, kind) = rest.split_once(' ')?; + kind.trim_end() + .ends_with("for HTTP") + .then(|| port.parse().ok())? + }) +} + +/// Valor de `--flag valor` o `--flag=valor` en una línea de comandos. +fn arg_value<'a>(cmdline: &'a str, flag: &str) -> Option<&'a str> { + let mut it = cmdline.split_whitespace(); + while let Some(tok) = it.next() { + if tok == flag { + return it.next(); + } + if let Some(v) = tok.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) { + return Some(v); + } + } + None +} + +/// Servidor de Antigravity entre `(pid, línea de comandos)`. Windsurf y otros +/// IDEs de Codeium lanzan el mismo binario: se filtra por `--app_data_dir antigravity`. +fn pick_server<'a>(procs: impl IntoIterator) -> Option { + procs.into_iter().find_map(|(pid, cmd)| { + if !cmd.to_ascii_lowercase().contains("language_server") { + return None; + } + if !arg_value(cmd, "--app_data_dir").is_some_and(|d| d.eq_ignore_ascii_case("antigravity")) + { + return None; + } + let csrf = arg_value(cmd, "--csrf_token")? + .trim_matches('"') + .to_string(); + (!csrf.is_empty()).then_some(Server { pid, csrf }) + }) +} + +#[cfg(windows)] +fn find_server() -> Option { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_Process -Filter \"Name LIKE 'language_server%'\" | \ + Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + let v: Value = serde_json::from_slice(&out.stdout).ok()?; + // Un solo proceso llega como objeto; varios, como lista. + let list = match v { + Value::Array(a) => a, + other => vec![other], + }; + let procs: Vec<(u32, String)> = list + .iter() + .filter_map(|p| { + let pid = p.get("ProcessId")?.as_u64()? as u32; + Some((pid, p.get("CommandLine")?.as_str()?.to_string())) + }) + .collect(); + pick_server(procs.iter().map(|(p, c)| (*p, c.as_str()))) +} + +#[cfg(not(windows))] +fn find_server() -> Option { + let out = std::process::Command::new("ps") + .args(["-ww", "-eo", "pid=,args="]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let procs: Vec<(u32, &str)> = text + .lines() + .filter_map(|l| { + let (pid, cmd) = l.trim_start().split_once(' ')?; + Some((pid.parse().ok()?, cmd)) + }) + .collect(); + pick_server(procs) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn now() -> chrono::DateTime { + chrono::Utc + .with_ymd_and_hms(2026, 10, 29, 15, 37, 41) + .unwrap() + } + + const CMD: &str = r"C:\Users\u\AppData\Local\Programs\antigravity\resources\bin\language_server.exe --standalone --override_ide_name antigravity --https_server_port 0 --csrf_token abc-123 --app_data_dir antigravity --enable_sidecars"; + + #[test] + fn picks_antigravity_server_and_ignores_other_ides() { + let windsurf = "language_server_windows_x64.exe --csrf_token zzz --app_data_dir windsurf"; + let other = "node server.js --csrf_token nope"; + let got = pick_server([(1, other), (2, windsurf), (3, CMD)]); + assert_eq!( + got, + Some(Server { + pid: 3, + csrf: "abc-123".into() + }) + ); + assert_eq!(pick_server([(2, windsurf)]), None); + // `--flag=valor` también vale. + let eq = "language_server --app_data_dir=antigravity --csrf_token=t1"; + assert_eq!(pick_server([(9, eq)]).map(|s| s.csrf), Some("t1".into())); + // Sin token no hay servidor utilizable. + assert_eq!( + pick_server([(4, "language_server --app_data_dir antigravity")]), + None + ); + } + + #[test] + fn reads_http_port_of_the_current_process_only() { + let log = "\ +I1029 server.go:1631] Starting language server process with pid 111 +I1029 server.go:649] Language server listening on random port at 40000 for HTTPS (gRPC) +I1029 server.go:657] Language server listening on random port at 40001 for HTTP +I1029 server.go:1631] Starting language server process with pid 222 +I1029 server.go:649] Language server listening on random port at 51182 for HTTPS (gRPC) +I1029 server.go:657] Language server listening on random port at 51183 for HTTP\r +"; + assert_eq!(http_port(log, 222), Some(51183)); + assert_eq!(http_port(log, 111), Some(40001)); + assert_eq!(http_port(log, 333), None); + assert_eq!(http_port("", 222), None); + } + + #[test] + fn groups_effort_variants_and_sorts_by_usage() { + let v = json!({"userStatus": { + "userTier": {"id": "g1-pro-tier", "description": "Google AI Pro"}, + "planStatus": {"planInfo": {"planName": "Pro"}}, + "cascadeModelConfigData": {"clientModelConfigs": [ + {"label": "Gemini 4 Argon (High)", "quotaInfo": {"remainingFraction": 1, "resetTime": "2026-11-05T15:37:41Z"}}, + {"label": "Claude Opus 5.5 (Medium)", "quotaInfo": {"remainingFraction": 0.6, "resetTime": "2026-10-29T20:37:41Z"}}, + {"label": "Claude Opus 5.5 (High)", "quotaInfo": {"remainingFraction": 0.25, "resetTime": "2026-10-29T20:37:41Z"}}, + {"label": "Sin cuota"}, + {"label": "Gemini 3.8 Flash (Low)", "quotaInfo": {"resetTime": "2026-10-29T20:37:41Z"}} + ]} + }}); + let r = build_report( + UsageReport::new("antigravity", "Antigravity", Kind::Subscription), + &v, + now(), + ); + assert_eq!(r.status, Status::Ok); + assert_eq!(r.plan.as_deref(), Some("Google AI Pro")); + let ids: Vec<&str> = r.meters.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + [ + "model:gemini_3_8_flash", + "model:claude_opus_5_5", + "model:gemini_4_argon" + ] + ); + // Sin remainingFraction = agotado. + assert_eq!(r.meters[0].percent, Some(100.0)); + assert_eq!(r.meters[1].label, "Claude Opus 5.5"); + assert_eq!(r.meters[1].percent, Some(75.0)); + assert_eq!(r.meters[1].window_seconds, Some(5 * 3600)); + assert_eq!(r.meters[2].percent, Some(0.0)); + assert_eq!(r.meters[2].window_seconds, Some(7 * 86_400)); + assert_eq!( + r.meters[2].resets_at.as_deref(), + Some("2026-11-05T15:37:41Z") + ); + } + + #[test] + fn plan_falls_back_to_plan_name() { + let v = json!({"planStatus": {"planInfo": {"planName": "Pro"}}}); + assert_eq!(plan_name(&v).as_deref(), Some("Pro")); + assert_eq!(plan_name(&json!({})), None); + } + + #[test] + fn empty_response_is_a_warning() { + let r = build_report( + UsageReport::new("antigravity", "Antigravity", Kind::Subscription), + &json!({"userStatus": {}}), + now(), + ); + assert_eq!(r.status, Status::Warning); + assert!(r.meters.is_empty()); + assert!(r.message.is_some()); + } + + #[test] + fn invalid_reset_is_dropped_but_percent_kept() { + let v = json!({"cascadeModelConfigData": {"clientModelConfigs": [ + {"label": "GPT-OSS 120B (Medium)", "quotaInfo": {"remainingFraction": 0.5, "resetTime": "mañana"}} + ]}}); + let m = parse_models(&v, now()); + assert_eq!(m[0].id, "model:gpt_oss_120b"); + assert_eq!(m[0].percent, Some(50.0)); + assert_eq!(m[0].resets_at, None); + assert_eq!(m[0].window_seconds, None); + } + + #[test] + fn labels_and_slugs() { + assert_eq!(base_label("Claude Sonnet 5.5 (Low)"), "Claude Sonnet 5.5"); + assert_eq!(base_label("GPT-OSS 120B"), "GPT-OSS 120B"); + assert_eq!(slug("GPT-OSS 120B"), "gpt_oss_120b"); + assert_eq!(arg_value("a --x 1", "--x"), Some("1")); + assert_eq!(arg_value("a --xy 1", "--x"), None); + } +} diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index d85dc9a..1881019 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -2,6 +2,7 @@ //! devuelven `Err`: cualquier fallo se refleja en `status` + `message`. pub mod anthropic_api; +pub mod antigravity; pub mod claude; pub mod codex; pub mod copilot; @@ -148,6 +149,14 @@ pub async fn fetch_all(cfg: &AppConfig, http: &reqwest::Client) -> Vec Opt "openai_api" => openai_api::fetch(&cfg.openai_api, Who::main("openai_api"), http).await, "zai" => zai::fetch(&cfg.zai, Who::main("zai"), http).await, "opencode_go" => opencode_go::fetch(&cfg.opencode_go, Who::main("opencode_go"), http).await, + "antigravity" => antigravity::fetch(&cfg.antigravity, Who::main("antigravity"), http).await, _ => return None, }) } diff --git a/src/Settings-providers-i18n.test.tsx b/src/Settings-providers-i18n.test.tsx index d2f3ac1..eb9f218 100644 --- a/src/Settings-providers-i18n.test.tsx +++ b/src/Settings-providers-i18n.test.tsx @@ -53,6 +53,7 @@ const enabledOnly = (...ids: string[]) => { nineRouter: on('nine_router'), zai: on('zai'), opencodeGo: on('opencode_go'), + antigravity: on('antigravity'), accounts: [{ id: 'claude:b', provider: 'claude', label: 'Cuenta 2', enabled: true }], } as unknown as AppConfig; }; @@ -79,6 +80,7 @@ const descriptions: [es: string, en: string][] = [ ['Pasarela local · uso y gasto agregados', 'Local gateway · aggregated usage and spend'], ['GLM Coding Plan · cuotas de 5 h, semanales y web tools', 'GLM Coding Plan · 5 h, weekly quotas and web tools'], ['Go / Go Plus · cuotas de 5 h, semanal y ciclo de facturación', 'Go / Go Plus · 5 h, weekly and billing-cycle quotas'], + ['Cuota del agente por modelo · IDE abierto', 'Per-model agent quota · open IDE'], ]; describe('Providers list: tab descriptions and badges (SSR, i18n)', () => { diff --git a/src/Settings.tsx b/src/Settings.tsx index ca15629..0317eb7 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1119,6 +1119,7 @@ const PROVIDER_TABS = [ { id: 'codex', name: 'ChatGPT y Codex', nameKey: 'settings.providers.codexName', group: 'sub', descKey: 'settings.providers.desc.codex' }, { id: 'gemini', name: 'Gemini', group: 'sub', descKey: 'settings.providers.desc.gemini' }, { id: 'cursor', name: 'Cursor', group: 'sub', descKey: 'settings.providers.desc.cursor' }, + { id: 'antigravity', name: 'Antigravity', group: 'sub', descKey: 'settings.providers.desc.antigravity' }, { id: 'grok', name: 'Grok', group: 'sub', descKey: 'settings.providers.desc.grok' }, { id: 'copilot', name: 'GitHub Copilot', group: 'sub', descKey: 'settings.providers.desc.copilot' }, { id: 'minimax', name: 'MiniMax', group: 'sub', descKey: 'settings.providers.desc.minimax' }, @@ -1658,6 +1659,8 @@ function mainState(id: ProviderTab, cfg: AppConfig, s: SecretStatus): ProviderSt return on(cfg.grok.enabled); case 'cursor': return on(cfg.cursor.enabled); + case 'antigravity': + return on(cfg.antigravity.enabled); case 'copilot': return on(cfg.copilot.enabled, !!s.github_token && cfg.copilot.username.trim() !== ''); case 'nan': @@ -2336,6 +2339,22 @@ export function Settings() { ); })()} + {tab === 'antigravity' && ( +
+ setVisibility('antigravity', v)} /> +

Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial.

+
    +
  1. + Abre Antigravity con tu cuenta de Google + + Quorb consulta el servidor local que arranca el IDE en este equipo. Solo se lee con Antigravity abierto; no se guarda ningún token. + +
  2. + +
+
+ )} + {tab === 'cursor' && (
setVisibility('cursor', v)} /> diff --git a/src/components/ProviderLogo.tsx b/src/components/ProviderLogo.tsx index a94089a..50b571f 100644 --- a/src/components/ProviderLogo.tsx +++ b/src/components/ProviderLogo.tsx @@ -112,6 +112,7 @@ const MONOGRAM: Record = { minimax: { text: 'MM' }, nine_router: { text: '9R' }, opencode_go: { text: 'OC' }, + antigravity: { text: 'AG' }, }; /** Las APIs de pago por uso llevan una etiqueta «API» para distinguirlas de la suscripción. */ diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d38f5d4..d41e903 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -197,6 +197,7 @@ export const en = { nineRouter: 'Local gateway · aggregated usage and spend', zai: 'GLM Coding Plan · 5 h, weekly quotas and web tools', opencodeGo: 'Go / Go Plus · 5 h, weekly and billing-cycle quotas', + antigravity: 'Per-model agent quota · open IDE', }, badge: { on: 'Active', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 1c0acb5..969611f 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -209,6 +209,7 @@ export const es = { nineRouter: 'Pasarela local · uso y gasto agregados', zai: 'GLM Coding Plan · cuotas de 5 h, semanales y web tools', opencodeGo: 'Go / Go Plus · cuotas de 5 h, semanal y ciclo de facturación', + antigravity: 'Cuota del agente por modelo · IDE abierto', }, badge: { on: 'Activo', diff --git a/src/lib/order.ts b/src/lib/order.ts index 1935f73..2ddcbc2 100644 --- a/src/lib/order.ts +++ b/src/lib/order.ts @@ -36,6 +36,7 @@ export const OFFICIAL_USAGE_URL: Record = { copilot: 'https://github.com/settings/billing/premium_requests_usage', openrouter: 'https://openrouter.ai/activity', opencode_go: 'https://opencode.ai/console', + antigravity: 'https://antigravity.google/', }; /** URL del dashboard de 9Router a partir de su Base URL configurada (sin barra final). */ diff --git a/src/lib/settings-visibility.ts b/src/lib/settings-visibility.ts index d94eccc..c2a44b8 100644 --- a/src/lib/settings-visibility.ts +++ b/src/lib/settings-visibility.ts @@ -1,7 +1,7 @@ import type { AppConfig } from './types'; import { isNineRouterId } from './visibility'; -type MainKey = 'claude' | 'codex' | 'gemini' | 'cursor' | 'grok' | 'copilot' | 'nan' | 'openrouter' | 'minimax' | 'anthropicApi' | 'openaiApi' | 'zai' | 'opencodeGo'; +type MainKey = 'claude' | 'codex' | 'gemini' | 'cursor' | 'grok' | 'copilot' | 'nan' | 'openrouter' | 'minimax' | 'anthropicApi' | 'openaiApi' | 'zai' | 'opencodeGo' | 'antigravity'; /** Clave de configuración de cada cuenta principal; el id del informe puede ser distinto. */ export const CONFIG_KEY: Readonly> = { @@ -10,6 +10,7 @@ export const CONFIG_KEY: Readonly> = { anthropic_api: 'anthropicApi', openai_api: 'openaiApi', zai: 'zai', opencode_go: 'opencodeGo', + antigravity: 'antigravity', }; /** diff --git a/src/lib/types.ts b/src/lib/types.ts index e3354a2..bf17ce1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -162,6 +162,7 @@ export interface AppConfig { openaiApi: ApiCostConfig; zai: { enabled: boolean }; opencodeGo: { enabled: boolean }; + antigravity: { enabled: boolean }; nan: NanConfig; minimax: MiniMaxConfig; nineRouter: NineRouterConfig; diff --git a/src/lib/widget-refresh.ts b/src/lib/widget-refresh.ts index 490b9c5..51e0369 100644 --- a/src/lib/widget-refresh.ts +++ b/src/lib/widget-refresh.ts @@ -4,11 +4,11 @@ import type { AppConfig } from './types'; * Campos de la config que, si cambian, obligan a volver a pedir datos a los * proveedores. Mover la tira o cambiar colores NO está aquí: eso no debe disparar * peticiones. Incluye todos los proveedores, también 9Router (periodo, presupuesto, - * umbral de gasto, desglose de sub-proveedores y su `enabled`) y z.ai y OpenCode Go. + * umbral de gasto, desglose de sub-proveedores y su `enabled`) z.ai, OpenCode Go y Antigravity. */ export function dataKey(c: AppConfig | null): string { return c - ? JSON.stringify([c.claude, c.codex, c.gemini, c.cursor, c.grok, c.copilot, c.openrouter, c.anthropicApi, c.openaiApi, c.nan, c.minimax, c.nineRouter, c.zai, c.opencodeGo]) + ? JSON.stringify([c.claude, c.codex, c.gemini, c.cursor, c.grok, c.copilot, c.openrouter, c.anthropicApi, c.openaiApi, c.nan, c.minimax, c.nineRouter, c.zai, c.opencodeGo, c.antigravity]) : ''; } From b529aa8fa73287225e97eec5b1b69b8f72df122a Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Thu, 29 Oct 2026 12:47:51 -0300 Subject: [PATCH 02/12] chore(i18n): regenerar inventario con los textos de Antigravity --- docs/i18n-inventory.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 0a158b8..16f1e61 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/` = `fa75c860b6af6a7b493df730cf283c3baf3e6009` and `src-tauri/src/` = `c8f3801d92cdff92304b2258c4e8471b6fb2d5ca`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `1b8f28c1d210a1bb6583731e9628a7745c8ae35e` and `src-tauri/src/` = `f4d748fce2ab30223500bbe3ff7e2710fc11a41a`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -18,9 +18,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 842 | 985 | -| Rust (`src-tauri/src/`) | 348 | 422 | -| Total | 1155 | 1407 | +| React (`src/`) | 849 | 995 | +| Rust (`src-tauri/src/`) | 359 | 435 | +| Total | 1172 | 1430 | ## src-tauri/src/channel.rs @@ -140,6 +140,21 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Presupuesto mensual - Respuesta inesperada: {e} +## src-tauri/src/providers/antigravity.rs + +- Abre Antigravity para leer su cuota (solo se consulta con el IDE abierto). +- Antigravity +- Antigravity está arrancando: aún no publica el puerto de su servidor local. +- Antigravity ha rechazado la consulta local. Reinícialo e inténtalo de nuevo. +- Antigravity no ha devuelto cuotas por modelo. ¿Has iniciado sesión en el IDE? +- Get-CimInstance Win32_Process -Filter "Name LIKE 'language_server%'" \| Select-Object ProcessId,CommandLine \| ConvertTo-Json -Compress +- HTTP {code} en GetUserStatus +- Respuesta inesperada: {e} +- Starting language server process with pid {pid} +- for HTTP +- listening on random port at +- model:{} + ## src-tauri/src/providers/claude.rs - Claude @@ -526,11 +541,13 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - API key PAYG - API key de OpenCode Go - API key de pago por uso (opcional) +- Abre Antigravity con tu cuenta de Google - Admin API key (sk-ant-admin…) - Admin key - Admin key de esta organización - Al guardar se borran también sus claves del almacén seguro del sistema. - Anthropic API +- Antigravity - Arriba, para distinguirla de la principal en la tira (por ejemplo, «Trabajo»). - Avanzado · URL del API - Avanzado · URL, facturación y endpoint de consumo @@ -567,6 +584,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Créalo en - Cuenta adicional - Cuenta de NaN Cloud conectada +- Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. - Cuota de las últimas 5 h, cuota semanal y cuota del ciclo de facturación de la suscripción Go / Go Plus. - Cuota del plan y, si añades la segunda clave, tu saldo de pago por uso. Son - Cuotas de coding de las últimas 5 h y de la semana, y cuota mensual de web tools del Coding Plan. @@ -660,6 +678,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Puede ser de otra cuenta o de la misma con otro límite. - Quitar - Quorb +- Quorb consulta el servidor local que arranca el IDE en este equipo. Solo se lee con Antigravity abierto; no se guarda ningún token. - Quorb lee el uso y el gasto que - 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. - Rango que muestra el orbe y su historial. @@ -675,6 +694,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Suscripción · API oficial - Suscripción · GLM Coding Plan - Suscripción · Go / Go Plus +- Suscripción · IDE abierto - Suscripción · OAuth - Suscripción · cuenta o API key - Suscripción · sesión de Gemini CLI @@ -917,6 +937,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Pace {pace}× · you will not make it to the reset - Pace {pace}× · you would end at {pct} % - Paste the key here +- Per-model agent quota · open IDE - Period through - Personalization not applied - Pick any color (RGB) @@ -1134,6 +1155,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Copiar - Copiar resumen - Cuentas +- Cuota del agente por modelo · IDE abierto - Cuota del plan M/Token y saldo de pago por uso - Cuota del plan y tokens por día - Cuota diaria por modelo From c5e584b270b01c38d6e85fc914bda91a8f7011c9 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 07:44:57 -0300 Subject: [PATCH 03/12] fix(providers): deducir la ventana de Antigravity sin depender de un instante MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Una cuota semanal a la que le quedan menos de 5 h para el reinicio se clasificaba como ventana de 5 h, con ventana y ritmo incorrectos en sus últimas horas. Ahora la ventana sale de señales fiables: más de 5 h hasta el reinicio solo puede ser semanal; un reinicio ya visto conserva la ventana deducida entonces (memoria en proceso por medidor); un reinicio que cambió y cae dentro de 5 h es una ventana de 5 h; y los modelos con el mismo resetTime exacto comparten ventana. Sin ninguna señal, window_seconds queda en None. Tests del borde semanal con menos de 5 h, del aprendizaje de 5 h por reinicio cambiado, del grupo y de reinicios lejanos o pasados. Refs: feedback de review en egdev6/quorb#207 --- src-tauri/src/providers/antigravity.rs | 192 ++++++++++++++++++++++--- 1 file changed, 176 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/providers/antigravity.rs b/src-tauri/src/providers/antigravity.rs index 8dcf9b1..b64c49f 100644 --- a/src-tauri/src/providers/antigravity.rs +++ b/src-tauri/src/providers/antigravity.rs @@ -20,6 +20,23 @@ use crate::model::{Kind, Meter, Status, UsageReport}; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::path::PathBuf; +use std::sync::Mutex; + +const FIVE_HOURS: i64 = 5 * 3600; +const WEEK: i64 = 7 * 86_400; +/// Margen para el desfase entre el reloj de Antigravity y el nuestro. +const TOLERANCE: i64 = 120; + +/// Último reinicio visto por medidor y la ventana deducida para él. +#[derive(Clone, Debug, PartialEq)] +struct Seen { + reset: String, + window: Option, +} + +/// Memoria entre consultas (clave: id del medidor). Solo en memoria: al reiniciar +/// Quorb se vuelve a deducir desde cero. +static SEEN: Mutex> = Mutex::new(BTreeMap::new()); const METHOD: &str = "/exa.language_server_pb.LanguageServerService/GetUserStatus"; const NOT_RUNNING: &str = @@ -78,7 +95,8 @@ pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, http: &reqwest::Clien } code => return report.fail(Status::Error, format!("HTTP {code} en GetUserStatus")), }; - build_report(report, &v, chrono::Utc::now()) + let mut memory = SEEN.lock().unwrap_or_else(|e| e.into_inner()); + build_report(report, &v, chrono::Utc::now(), &mut memory) } /// Informe final a partir de la respuesta de `GetUserStatus`. Puro, sin red. @@ -86,10 +104,11 @@ fn build_report( mut report: UsageReport, v: &Value, now: chrono::DateTime, + memory: &mut BTreeMap, ) -> UsageReport { let status = v.get("userStatus").unwrap_or(v); report.plan = plan_name(status); - report.meters = parse_models(status, now); + report.meters = parse_models(status, now, memory); if report.meters.is_empty() { report.status = Status::Warning; report.message = Some( @@ -114,7 +133,11 @@ fn plan_name(status: &Value) -> Option { /// Un medidor por modelo (el % gastado es 1 − remainingFraction). Las variantes de /// esfuerzo («(High)», «(Low)»…) comparten cuota: nos quedamos con la más gastada. -fn parse_models(status: &Value, now: chrono::DateTime) -> Vec { +fn parse_models( + status: &Value, + now: chrono::DateTime, + memory: &mut BTreeMap, +) -> Vec { let mut by_model: BTreeMap = BTreeMap::new(); for c in status .pointer("/cascadeModelConfigData/clientModelConfigs") @@ -142,14 +165,18 @@ fn parse_models(status: &Value, now: chrono::DateTime) -> Vec) -> Vec = by_model.into_values().collect(); + settle_windows(&mut out, memory); out.sort_by(|a, b| { b.percent .partial_cmp(&a.percent) @@ -192,15 +220,49 @@ fn slug(name: &str) -> String { } /// Antigravity no dice la duración de la ventana. Las cuotas que conocemos son de -/// 5 h y semanales; deducimos cuál por el tiempo que falta para el reinicio. -fn window_for(reset: &str, now: chrono::DateTime) -> Option { - let reset = chrono::DateTime::parse_from_rfc3339(reset).ok()?; - let left = (reset.with_timezone(&chrono::Utc) - now).num_seconds(); - match left { - l if l <= 0 => None, - l if l <= 5 * 3600 => Some(5 * 3600), - l if l <= 7 * 86_400 => Some(7 * 86_400), - _ => None, +/// 5 h y semanales, y un solo instante no basta para distinguirlas: a una semanal +/// le llega un momento en que le quedan menos de 5 h. Señales que sí son fiables: +/// · faltan más de 5 h → solo puede ser semanal; +/// · ya vimos este mismo reinicio → se mantiene la ventana deducida entonces; +/// · el reinicio cambió y el nuevo cae dentro de 5 h → ventana de 5 h (el nuevo +/// reinicio de una semanal caería a ~7 días). +/// Sin ninguna de ellas, `None`: mejor sin ritmo que con uno equivocado. +fn window_for(prev: Option<&Seen>, reset: &str, now: chrono::DateTime) -> Option { + let at = chrono::DateTime::parse_from_rfc3339(reset).ok()?; + let left = (at.with_timezone(&chrono::Utc) - now).num_seconds(); + if left <= 0 { + return None; + } + if left > FIVE_HOURS + TOLERANCE { + return (left <= WEEK + TOLERANCE).then_some(WEEK); + } + match prev { + Some(p) if p.reset == reset => p.window, + Some(_) => Some(FIVE_HOURS), + None => None, + } +} + +/// Completa las ventanas desconocidas con las de los modelos del mismo grupo +/// (mismo `resetTime` exacto) y guarda lo deducido para la próxima consulta. +fn settle_windows(meters: &mut [Meter], memory: &mut BTreeMap) { + let known: BTreeMap = meters + .iter() + .filter_map(|m| Some((m.resets_at.clone()?, m.window_seconds?))) + .collect(); + for m in meters.iter_mut() { + if m.window_seconds.is_none() { + m.window_seconds = m.resets_at.as_ref().and_then(|r| known.get(r).copied()); + } + if let Some(reset) = &m.resets_at { + memory.insert( + m.id.clone(), + Seen { + reset: reset.clone(), + window: m.window_seconds, + }, + ); + } } } @@ -376,6 +438,7 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ UsageReport::new("antigravity", "Antigravity", Kind::Subscription), &v, now(), + &mut BTreeMap::new(), ); assert_eq!(r.status, Status::Ok); assert_eq!(r.plan.as_deref(), Some("Google AI Pro")); @@ -392,7 +455,8 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ assert_eq!(r.meters[0].percent, Some(100.0)); assert_eq!(r.meters[1].label, "Claude Opus 5.5"); assert_eq!(r.meters[1].percent, Some(75.0)); - assert_eq!(r.meters[1].window_seconds, Some(5 * 3600)); + // Primera consulta, faltan justo 5 h y sin historial: ventana desconocida. + assert_eq!(r.meters[1].window_seconds, None); assert_eq!(r.meters[2].percent, Some(0.0)); assert_eq!(r.meters[2].window_seconds, Some(7 * 86_400)); assert_eq!( @@ -414,6 +478,7 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ UsageReport::new("antigravity", "Antigravity", Kind::Subscription), &json!({"userStatus": {}}), now(), + &mut BTreeMap::new(), ); assert_eq!(r.status, Status::Warning); assert!(r.meters.is_empty()); @@ -425,13 +490,108 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ let v = json!({"cascadeModelConfigData": {"clientModelConfigs": [ {"label": "GPT-OSS 120B (Medium)", "quotaInfo": {"remainingFraction": 0.5, "resetTime": "mañana"}} ]}}); - let m = parse_models(&v, now()); + let m = parse_models(&v, now(), &mut BTreeMap::new()); assert_eq!(m[0].id, "model:gpt_oss_120b"); assert_eq!(m[0].percent, Some(50.0)); assert_eq!(m[0].resets_at, None); assert_eq!(m[0].window_seconds, None); } + /// Una sola cuota con su reinicio, como la devuelve GetUserStatus. + fn one(label: &str, reset: &str) -> Value { + json!({"cascadeModelConfigData": {"clientModelConfigs": [ + {"label": label, "quotaInfo": {"remainingFraction": 0.5, "resetTime": reset}} + ]}}) + } + + fn at(h: i64) -> chrono::DateTime { + now() + chrono::Duration::hours(h) + } + + #[test] + fn weekly_quota_near_reset_is_not_taken_for_five_hours() { + // Semanal con menos de 5 h para el reinicio y sin historial: sin señal fiable. + let reset = "2026-10-29T18:37:41Z"; // now() + 3 h + let m = parse_models( + &one("Gemini 4 Argon (High)", reset), + now(), + &mut BTreeMap::new(), + ); + assert_eq!(m[0].window_seconds, None); + + // La misma semanal vista antes con días por delante conserva su ventana + // hasta el reinicio, aunque ya le queden menos de 5 h. + let mut memory = BTreeMap::new(); + let m = parse_models(&one("Gemini 4 Argon (High)", reset), at(-48), &mut memory); + assert_eq!(m[0].window_seconds, Some(WEEK)); + let m = parse_models(&one("Gemini 4 Argon (High)", reset), now(), &mut memory); + assert_eq!(m[0].window_seconds, Some(WEEK)); + let m = parse_models(&one("Gemini 4 Argon (High)", reset), at(2), &mut memory); + assert_eq!(m[0].window_seconds, Some(WEEK)); + } + + #[test] + fn five_hour_window_needs_a_changed_reset() { + let mut memory = BTreeMap::new(); + // Cuota sin usar: el reinicio se desliza con el reloj (siempre ahora + 5 h). + let m = parse_models( + &one("Claude Opus 5.5", "2026-10-29T20:37:41Z"), + now(), + &mut memory, + ); + assert_eq!(m[0].window_seconds, None); + let m = parse_models( + &one("Claude Opus 5.5", "2026-10-29T20:42:41Z"), + now() + chrono::Duration::minutes(5), + &mut memory, + ); + assert_eq!(m[0].window_seconds, Some(FIVE_HOURS)); + // Ya en uso, el reinicio queda fijo y la ventana aprendida se conserva. + let m = parse_models( + &one("Claude Opus 5.5", "2026-10-29T20:42:41Z"), + at(4), + &mut memory, + ); + assert_eq!(m[0].window_seconds, Some(FIVE_HOURS)); + // Pasado el reinicio no se inventa ventana. + let m = parse_models( + &one("Claude Opus 5.5", "2026-10-29T20:42:41Z"), + at(6), + &mut memory, + ); + assert_eq!(m[0].window_seconds, None); + } + + #[test] + fn models_sharing_a_reset_share_the_window() { + let reset = "2026-10-29T18:37:41Z"; // now() + 3 h + let mut memory = BTreeMap::from([( + "model:gemini_4_argon".to_string(), + Seen { + reset: reset.into(), + window: Some(WEEK), + }, + )]); + let v = json!({"cascadeModelConfigData": {"clientModelConfigs": [ + {"label": "Gemini 4 Argon (High)", "quotaInfo": {"remainingFraction": 0.2, "resetTime": reset}}, + {"label": "Gemini 4 Nuevo", "quotaInfo": {"remainingFraction": 0.9, "resetTime": reset}}, + {"label": "Otro", "quotaInfo": {"remainingFraction": 0.9, "resetTime": "2026-10-29T17:00:00Z"}} + ]}}); + let m = parse_models(&v, now(), &mut memory); + let w = |id: &str| m.iter().find(|x| x.id == id).unwrap().window_seconds; + assert_eq!(w("model:gemini_4_nuevo"), Some(WEEK)); + assert_eq!(w("model:otro"), None); + // Lo deducido por el grupo queda guardado para la próxima consulta. + assert_eq!(memory["model:gemini_4_nuevo"].window, Some(WEEK)); + } + + #[test] + fn far_or_past_resets_have_no_window() { + assert_eq!(window_for(None, "2026-11-05T15:37:41Z", now()), Some(WEEK)); + assert_eq!(window_for(None, "2026-12-01T00:00:00Z", now()), None); + assert_eq!(window_for(None, "2026-10-29T10:00:00Z", now()), None); + } + #[test] fn labels_and_slugs() { assert_eq!(base_label("Claude Sonnet 5.5 (Low)"), "Claude Sonnet 5.5"); From 000ba3dbed8ea617792bbad989506f003ffe39ba Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 07:44:58 -0300 Subject: [PATCH 04/12] docs(providers): Antigravity solo verificado en Windows y endpoint no documentado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indica en los README y en la pestaña de Ajustes que por ahora solo se verificó en Windows, y añade Antigravity (GetUserStatus interno) al aviso de endpoints no documentados. Refs: feedback de review en egdev6/quorb#207 --- README.es.md | 4 ++-- README.md | 4 ++-- src/Settings.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.es.md b/README.es.md index 0b1ce81..9bb6dc5 100644 --- a/README.es.md +++ b/README.es.md @@ -112,9 +112,9 @@ React 19 y TypeScript gestionan el widget, los ajustes y los overlays. Tauri 2 y | Anthropic API / OpenAI API | Coste mensual | Admin API keys | | z.ai | GLM Coding Plan: cuota de 5 h y semanal de coding, mensual de web tools y saldo de resets | API key | | OpenCode Go | Go / Go Plus: cuotas de 5 h, semanal y del ciclo de facturación | API key de Go | -| Antigravity | Cuota del agente por modelo (ventanas de 5 h y semanales) | Sesión local del IDE abierto | +| Antigravity | Cuota del agente por modelo (ventanas de 5 h y semanales). Por ahora solo verificado en Windows | Sesión local del IDE abierto | -Algunas integraciones usan endpoints no documentados, que pueden cambiar o dejar de funcionar. Copilot, OpenRouter y las APIs de coste usan APIs oficiales; NaN combina la API documentada con endpoints no documentados del dashboard, y z.ai lee endpoints de cuotas y resets no documentados. +Algunas integraciones usan endpoints no documentados, que pueden cambiar o dejar de funcionar. Copilot, OpenRouter y las APIs de coste usan APIs oficiales; NaN combina la API documentada con endpoints no documentados del dashboard, z.ai lee endpoints de cuotas y resets no documentados, y Antigravity lee el endpoint interno `GetUserStatus` del servidor de lenguaje local del IDE. ### Funcionalidades diff --git a/README.md b/README.md index c48b76b..613c047 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ React 19 and TypeScript handle the widget, settings and overlays. Tauri 2 and Ru | Anthropic API / OpenAI API | Monthly cost | Admin API keys | | z.ai | GLM Coding Plan: 5-hour and weekly coding quotas, monthly web tools, manual reset balance | API key | | OpenCode Go | Go / Go Plus: 5-hour, weekly and billing-cycle quotas | Go API key | -| Antigravity | Per-model agent quota (5-hour and weekly windows) | Local session of the open IDE | +| Antigravity | Per-model agent quota (5-hour and weekly windows). Verified on Windows only for now | Local session of the open IDE | -Some integrations use undocumented endpoints, which may change or stop working. Copilot, OpenRouter and the cost APIs use official APIs; NaN combines documented API access with undocumented dashboard endpoints, and z.ai reads undocumented quota and reset endpoints. +Some integrations use undocumented endpoints, which may change or stop working. Copilot, OpenRouter and the cost APIs use official APIs; NaN combines documented API access with undocumented dashboard endpoints, z.ai reads undocumented quota and reset endpoints, and Antigravity reads the internal `GetUserStatus` endpoint of the IDE's local language server. ### Features diff --git a/src/Settings.tsx b/src/Settings.tsx index 0317eb7..094f162 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -2342,7 +2342,7 @@ export function Settings() { {tab === 'antigravity' && (
setVisibility('antigravity', v)} /> -

Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial.

+

Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. Por ahora solo verificado en Windows.

  1. Abre Antigravity con tu cuenta de Google From 78392f44f21dce4d50c661491e5f467928f7c95f Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 07:45:01 -0300 Subject: [PATCH 05/12] chore(i18n): regenerar inventario tras la nota de Windows de Antigravity --- docs/i18n-inventory.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 16f1e61..ed42f03 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/` = `1b8f28c1d210a1bb6583731e9628a7745c8ae35e` and `src-tauri/src/` = `f4d748fce2ab30223500bbe3ff7e2710fc11a41a`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `6c93358519b11292ee2df132c696607f05dceaca` and `src-tauri/src/` = `8dccbb3df1b886e10ba0c4dcbb6a0da42d49b0aa`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -584,7 +584,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Créalo en - Cuenta adicional - Cuenta de NaN Cloud conectada -- Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. +- Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. Por ahora solo verificado en Windows. - Cuota de las últimas 5 h, cuota semanal y cuota del ciclo de facturación de la suscripción Go / Go Plus. - Cuota del plan y, si añades la segunda clave, tu saldo de pago por uso. Son - Cuotas de coding de las últimas 5 h y de la semana, y cuota mensual de web tools del Coding Plan. From 626ca4d21c3e3a51bbb42426159c581848af4d87 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 07:51:23 -0300 Subject: [PATCH 06/12] chore(i18n): regenerar inventario tras integrar main --- docs/i18n-inventory.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 6640fb0..0bdd2be 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/` = `c24d6c20b3a7f4d7a413f8c957798e6eb4b7f8eb` and `src-tauri/src/` = `8dca82fa4e3ed90f594534a2e6a9cfcc2321c432`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `e56f0ac2d3105cd56c2520d64e3fb1db7fdfe3b4` and `src-tauri/src/` = `911fefcb3652f7a75b82aeb173a4e9218bb3b09e`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -18,9 +18,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 947 | 1054 | -| Rust (`src-tauri/src/`) | 409 | 488 | -| Total | 1320 | 1542 | +| React (`src/`) | 958 | 1066 | +| Rust (`src-tauri/src/`) | 420 | 501 | +| Total | 1341 | 1567 | ## src-tauri/src/channel.rs @@ -139,6 +139,21 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Presupuesto mensual - Respuesta inesperada: {e} +## src-tauri/src/providers/antigravity.rs + +- Abre Antigravity para leer su cuota (solo se consulta con el IDE abierto). +- Antigravity +- Antigravity está arrancando: aún no publica el puerto de su servidor local. +- Antigravity ha rechazado la consulta local. Reinícialo e inténtalo de nuevo. +- Antigravity no ha devuelto cuotas por modelo. ¿Has iniciado sesión en el IDE? +- Get-CimInstance Win32_Process -Filter "Name LIKE 'language_server%'" \| Select-Object ProcessId,CommandLine \| ConvertTo-Json -Compress +- HTTP {code} en GetUserStatus +- Respuesta inesperada: {e} +- Starting language server process with pid {pid} +- for HTTP +- listening on random port at +- model:{} + ## src-tauri/src/providers/claude.rs - Claude @@ -585,6 +600,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Admin API key (sk-ant-admin…) - Admin key - Anthropic API +- Antigravity - Avanzado · URL, facturación y endpoint de consumo - Avanzado · dirección del servidor - Base URL @@ -933,6 +949,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Notifications - Official usage ↗ - Once per window (5 h session, week, month…). +- Open Antigravity with your Google account - Open its panel and press "Renew session". Quorb refreshes itself as soon as it renews. - Open the login again - Open the most loaded @@ -946,6 +963,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Paste the key here - Paste this account token - Pay-as-you-go API key (optional) +- Per-model agent quota · open IDE - Period through - Personalization not applied - Pick any color (RGB) @@ -970,10 +988,12 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Provider-reported period end; does not confirm cancellation - Providers - Quorb is open source (MIT). If it saves you a scare with the limits, a star on GitHub helps more people find it. +- Quorb queries the local server the IDE starts on this computer. It is read only while Antigravity is open; no token is stored. - Quorb reads the session that the Cursor app stores on this computer (read-only). If you do not use the app here, paste a manual token: it takes priority over the app session. - Quorb signs in with its official login. Install it from {where}{needs}; Quorb detects it on its own when it finishes. - Quorb {version} available - Quota for the last 5 h, weekly quota and billing-cycle quota of the Go / Go Plus subscription. +- Quota of each Antigravity agent model (5-hour and weekly windows). It is a separate pool from Gemini CLI. Unofficial local endpoint. Verified on Windows only for now. - Read-only token - Refresh now - Remove @@ -1038,6 +1058,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Subscription · OAuth - Subscription · Official API - Subscription · app session +- Subscription · open IDE - Subscriptions - Subscriptions & APIs - SuperGrok · weekly CLI credit usage @@ -1151,6 +1172,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - API key de {name} - APIs y créditos - Abajo +- Abre Antigravity con tu cuenta de Google - Abre el CLI oficial, que renueva la sesión; el widget se actualiza solo al terminar - Abrir el más cargado - Acaba de empezar el mes @@ -1243,7 +1265,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Créalo en - Cuenta adicional - Cuentas +- Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. Por ahora solo verificado en Windows. - Cuota de las últimas 5 h, cuota semanal y cuota del ciclo de facturación de la suscripción Go / Go Plus. +- Cuota del agente por modelo · IDE abierto - Cuota del plan M/Token y saldo de pago por uso - Cuota del plan y tokens por día - Cuota del plan y, si añades la segunda clave, tu saldo de pago por uso. Son @@ -1377,6 +1401,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Pulsa un atajo o «Cambiar» y teclea la combinación. Se aplican al guardar. - Pulsa «Renovar sesión» en su panel. Quorb se actualiza solo en cuanto se renueve. - Quitar +- Quorb consulta el servidor local que arranca el IDE en este equipo. Solo se lee con Antigravity abierto; no se guarda ningún token. - 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. - Quorb inicia sesión con su login oficial. Instálalo desde {where}{needs}; Quorb lo detecta solo al terminar. - 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. @@ -1419,6 +1444,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Suscripción · API oficial - Suscripción · GLM Coding Plan - Suscripción · Go / Go Plus +- Suscripción · IDE abierto - Suscripción · OAuth - Suscripción · sesión de Gemini CLI - Suscripción · sesión de la app From 8ff9070e7ba44cb09c45ed4657cbbb1b3fd79e97 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 08:18:54 -0300 Subject: [PATCH 07/12] test(settings): cubrir el panel de Antigravity en los tests i18n de paneles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mismo patrón que el resto de paneles migrados en C1b (#217): español verbatim con locale es e inglés sin restos de español con locale en. Refs: coordinación C1b / egdev6/quorb#207 --- src/Settings-panels-i18n.test.tsx | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/Settings-panels-i18n.test.tsx b/src/Settings-panels-i18n.test.tsx index 609b75e..3697ce7 100644 --- a/src/Settings-panels-i18n.test.tsx +++ b/src/Settings-panels-i18n.test.tsx @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import type { CodexInstallController } from './lib/codex-install'; import type { AppConfig, Locale } from './lib/types'; import { + AntigravityPanel, CopilotPanel, CursorPanel, MiniMaxPanel, @@ -34,6 +35,7 @@ const cfg = { gemini: sub('gemini'), grok: sub('grok'), cursor: { enabled: true, credentialsPath: null }, + antigravity: { enabled: true }, copilot: { enabled: true, username: '', plan: 'pro', customAllowance: null }, minimax: { enabled: true, baseUrl: 'https://api.minimax.io' }, zai: { enabled: true }, @@ -67,6 +69,7 @@ const common = (locale: Locale) => ({ const subscription = (locale: Locale, p: 'claude' | 'codex' | 'gemini' | 'grok', cli: Record = {}) => renderToStaticMarkup(); const cursor = (locale: Locale) => renderToStaticMarkup(); +const antigravity = (locale: Locale) => renderToStaticMarkup(); const copilot = (locale: Locale, plan = 'pro') => renderToStaticMarkup(); const minimax = (locale: Locale) => renderToStaticMarkup(); @@ -252,6 +255,46 @@ describe('Subscription provider panels (SSR, i18n)', () => { ); }); + it('renders the Spanish literals of Antigravity copied verbatim from the current code', () => { + expectAll( + antigravity('es'), + [ + 'Suscripción · IDE abierto', + 'Antigravity', + 'Mostrar en el widget', + 'Cuota de cada modelo del agente de Antigravity (ventanas de 5 h y semanales). Es un grupo distinto del de Gemini CLI. Endpoint local no oficial. Por ahora solo verificado en Windows.', + 'aria-label="Cómo conectar la cuenta"', + 'Abre Antigravity con tu cuenta de Google', + 'Quorb consulta el servidor local que arranca el IDE en este equipo. Solo se lee con Antigravity abierto; no se guarda ningún token.', + 'Comprobando la conexión…', + ], + 'es antigravity', + ); + }); + + it('renders the English texts of Antigravity when the locale is en, with no Spanish left', () => { + const html = antigravity('en'); + expectAll( + html, + [ + 'Subscription · open IDE', + 'Antigravity', + 'Show in the widget', + 'Quota of each Antigravity agent model (5-hour and weekly windows). It is a separate pool from Gemini CLI. Unofficial local endpoint. Verified on Windows only for now.', + 'aria-label="How to connect the account"', + 'Open Antigravity with your Google account', + 'Quorb queries the local server the IDE starts on this computer. It is read only while Antigravity is open; no token is stored.', + 'Checking the connection…', + ], + 'en antigravity', + ); + expectNone( + html, + ['Suscripción', 'Mostrar en el widget', 'Cuota de cada', 'Cómo conectar', 'Abre Antigravity', 'Quorb consulta', 'no oficial', 'solo verificado', 'Comprobando'], + 'en antigravity', + ); + }); + it('renders the Spanish literals of GitHub Copilot copied verbatim from the current code', () => { const html = copilot('es'); expectAll( From a3d85c6ba012c54dc375b06cbec82f5873916baf Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 08:19:11 -0300 Subject: [PATCH 08/12] chore(i18n): regenerar inventario tras los tests del panel de Antigravity --- docs/i18n-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 0bdd2be..7f81d96 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/` = `e56f0ac2d3105cd56c2520d64e3fb1db7fdfe3b4` and `src-tauri/src/` = `911fefcb3652f7a75b82aeb173a4e9218bb3b09e`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `911fefcb3652f7a75b82aeb173a4e9218bb3b09e`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. From 13d7c136e8d4bf395d96e6fce6861022363cd5d8 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 09:32:29 -0300 Subject: [PATCH 09/12] fix(providers): no deducir 5 h de Antigravity tras una pausa entre consultas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un reinicio que cambia y cae dentro de 5 h solo demuestra una ventana de 5 h si la consulta anterior es reciente. Si el reinicio cambió, la ventana nueva empezó después de esa consulta; una semanal acabaría al menos 7 días después de ella y no podría quedarle ≤ 5 h antes de 7 días − 5 h. Ahora se guarda cuándo se vio cada reinicio y, tras una pausa mayor (Antigravity cerrado), la transición es ambigua y queda en None en vez de aprender 5 h. Tests: el salto semanal tras una pausa (27 oct → 5 nov) del review y la cota del intervalo a ambos lados del límite. Refs: feedback de review en egdev6/quorb#207 --- src-tauri/src/providers/antigravity.rs | 78 +++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/providers/antigravity.rs b/src-tauri/src/providers/antigravity.rs index b64c49f..3625a70 100644 --- a/src-tauri/src/providers/antigravity.rs +++ b/src-tauri/src/providers/antigravity.rs @@ -27,11 +27,12 @@ const WEEK: i64 = 7 * 86_400; /// Margen para el desfase entre el reloj de Antigravity y el nuestro. const TOLERANCE: i64 = 120; -/// Último reinicio visto por medidor y la ventana deducida para él. +/// Último reinicio visto por medidor, la ventana deducida para él y cuándo se vio. #[derive(Clone, Debug, PartialEq)] struct Seen { reset: String, window: Option, + seen_at: chrono::DateTime, } /// Memoria entre consultas (clave: id del medidor). Solo en memoria: al reiniciar @@ -188,7 +189,7 @@ fn parse_models( } } let mut out: Vec = by_model.into_values().collect(); - settle_windows(&mut out, memory); + settle_windows(&mut out, memory, now); out.sort_by(|a, b| { b.percent .partial_cmp(&a.percent) @@ -224,8 +225,11 @@ fn slug(name: &str) -> String { /// le llega un momento en que le quedan menos de 5 h. Señales que sí son fiables: /// · faltan más de 5 h → solo puede ser semanal; /// · ya vimos este mismo reinicio → se mantiene la ventana deducida entonces; -/// · el reinicio cambió y el nuevo cae dentro de 5 h → ventana de 5 h (el nuevo -/// reinicio de una semanal caería a ~7 días). +/// · el reinicio cambió, el nuevo cae dentro de 5 h y la consulta anterior es de +/// hace menos de 7 días − 5 h → ventana de 5 h. Si el reinicio cambió, la ventana +/// nueva empezó después de esa consulta; si fuera semanal acabaría al menos 7 días +/// después de ella, y no podrían quedarle ≤ 5 h antes de 7 días − 5 h. Tras una +/// pausa más larga (Antigravity cerrado) el cambio es ambiguo. /// Sin ninguna de ellas, `None`: mejor sin ritmo que con uno equivocado. fn window_for(prev: Option<&Seen>, reset: &str, now: chrono::DateTime) -> Option { let at = chrono::DateTime::parse_from_rfc3339(reset).ok()?; @@ -238,14 +242,20 @@ fn window_for(prev: Option<&Seen>, reset: &str, now: chrono::DateTime p.window, - Some(_) => Some(FIVE_HOURS), - None => None, + Some(p) if (now - p.seen_at).num_seconds() < WEEK - FIVE_HOURS - 2 * TOLERANCE => { + Some(FIVE_HOURS) + } + _ => None, } } /// Completa las ventanas desconocidas con las de los modelos del mismo grupo /// (mismo `resetTime` exacto) y guarda lo deducido para la próxima consulta. -fn settle_windows(meters: &mut [Meter], memory: &mut BTreeMap) { +fn settle_windows( + meters: &mut [Meter], + memory: &mut BTreeMap, + now: chrono::DateTime, +) { let known: BTreeMap = meters .iter() .filter_map(|m| Some((m.resets_at.clone()?, m.window_seconds?))) @@ -260,6 +270,7 @@ fn settle_windows(meters: &mut [Meter], memory: &mut BTreeMap) { Seen { reset: reset.clone(), window: m.window_seconds, + seen_at: now, }, ); } @@ -562,6 +573,58 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ assert_eq!(m[0].window_seconds, None); } + #[test] + fn weekly_reset_after_a_pause_is_not_taken_for_five_hours() { + let utc = |s: &str| { + chrono::DateTime::parse_from_rfc3339(s) + .unwrap() + .with_timezone(&chrono::Utc) + }; + let mut memory = BTreeMap::new(); + // 27 oct 15:37: semanal que reinicia el 29 oct 18:37 → se aprende WEEK. + let m = parse_models( + &one("Gemini 4 Argon", "2026-10-29T18:37:41Z"), + utc("2026-10-27T15:37:41Z"), + &mut memory, + ); + assert_eq!(m[0].window_seconds, Some(WEEK)); + // Antigravity cerrado hasta el 5 nov 15:37. El siguiente reinicio semanal es + // el 5 nov 18:37 (7 días después del anterior): cambió y quedan 3 h, pero tras + // la pausa no hay evidencia de que sea una ventana de 5 h. + let m = parse_models( + &one("Gemini 4 Argon", "2026-11-05T18:37:41Z"), + utc("2026-11-05T15:37:41Z"), + &mut memory, + ); + assert_eq!(m[0].window_seconds, None); + // Y no se aprende 5 h para las consultas siguientes del mismo reinicio. + let m = parse_models( + &one("Gemini 4 Argon", "2026-11-05T18:37:41Z"), + utc("2026-11-05T16:37:41Z"), + &mut memory, + ); + assert_eq!(m[0].window_seconds, None); + } + + #[test] + fn five_hour_inference_is_bounded_by_the_gap_since_last_seen() { + let bound = WEEK - FIVE_HOURS - 2 * TOLERANCE; + let prev = |gap: i64| Seen { + reset: "2026-10-01T00:00:00Z".into(), + window: Some(FIVE_HOURS), + seen_at: now() - chrono::Duration::seconds(gap), + }; + let reset = "2026-10-29T18:37:41Z"; // now() + 3 h, distinto del anterior + // Una semanal no puede haber cambiado de reinicio y quedarse en ≤ 5 h tan pronto. + assert_eq!( + window_for(Some(&prev(bound - 1)), reset, now()), + Some(FIVE_HOURS) + ); + // Con una pausa mayor ya podría ser el siguiente reinicio semanal: ambiguo. + assert_eq!(window_for(Some(&prev(bound)), reset, now()), None); + assert_eq!(window_for(Some(&prev(10 * 86_400)), reset, now()), None); + } + #[test] fn models_sharing_a_reset_share_the_window() { let reset = "2026-10-29T18:37:41Z"; // now() + 3 h @@ -570,6 +633,7 @@ I1029 server.go:657] Language server listening on random port at 51183 for HTTP\ Seen { reset: reset.into(), window: Some(WEEK), + seen_at: at(-1), }, )]); let v = json!({"cascadeModelConfigData": {"clientModelConfigs": [ From 65d0b94c30bb256b961108954ad0cb25705140a1 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 09:32:36 -0300 Subject: [PATCH 10/12] chore(i18n): regenerar inventario tras el ajuste de ventanas de Antigravity --- docs/i18n-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 7f81d96..bf82f19 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/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `911fefcb3652f7a75b82aeb173a4e9218bb3b09e`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `127bd31c1ed1a31a61d042f2360273da710a424b`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. From 1d7c4c822fce0b3603bd9f9663cb3bd462e2a9d1 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 10:10:40 -0300 Subject: [PATCH 11/12] fix(providers): aislar la consulta local de Antigravity de proxies y del log de otros procesos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: la consulta usaba el cliente HTTP compartido, que respeta HTTP_PROXY/ALL_PROXY; sin un NO_PROXY que excluya loopback, el token X-Codeium-Csrf-Token llegaba al proxy aunque la URL fuese 127.0.0.1. Ahora va con un cliente dedicado (local_client) con no_proxy(), sin seguir redirecciones y con timeout propio. P2: http_port buscaba «pid N» como subcadena (pid 123 coincidía con pid 1234) y, si el bloque de ese pid no tenía puerto HTTP, seguía por los arranques posteriores. Ahora compara el pid completo del marcador de arranque y solo mira su bloque, hasta el siguiente «Starting…». Regresiones (RED observado antes del arreglo): pid exacto, bloque delimitado, proxy configurado que no recibe la consulta y redirección que no se sigue, con servidores de prueba en loopback. Refs: feedback de review en egdev6/quorb#207 --- src-tauri/src/providers/antigravity.rs | 186 ++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/providers/antigravity.rs b/src-tauri/src/providers/antigravity.rs index 3625a70..51a2148 100644 --- a/src-tauri/src/providers/antigravity.rs +++ b/src-tauri/src/providers/antigravity.rs @@ -50,7 +50,9 @@ struct Server { csrf: String, } -pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, http: &reqwest::Client) -> UsageReport { +/// No usa el cliente HTTP compartido: ese respeta HTTP_PROXY/ALL_PROXY y el token +/// CSRF acabaría en el proxy. La consulta va con `local_client`. +pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, _http: &reqwest::Client) -> UsageReport { let report = who.report("Antigravity", Kind::Subscription); // Listar procesos lanza un comando externo: fuera del hilo del runtime. let server = tokio::task::spawn_blocking(find_server) @@ -70,15 +72,16 @@ pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, http: &reqwest::Clien ); }; - let res = http - .post(format!("http://127.0.0.1:{port}{METHOD}")) - .header("X-Codeium-Csrf-Token", &server.csrf) - .header("Connect-Protocol-Version", "1") - .json(&json!({ - "metadata": { "ideName": "antigravity", "extensionName": "antigravity", "locale": "en" } - })) - .send() - .await; + let client = match local_client(reqwest::Client::builder()) { + Ok(c) => c, + Err(e) => { + return report.fail( + Status::Error, + format!("No puedo preparar la consulta local: {e}"), + ) + } + }; + let res = post_status(&client, port, &server.csrf).await; let res = match res { Ok(r) => r, Err(e) => return report.fail(Status::Warning, format!("{NOT_RUNNING} ({})", net_error(e))), @@ -100,6 +103,34 @@ pub async fn fetch(_cfg: &AntigravityConfig, who: Who<'_>, http: &reqwest::Clien build_report(report, &v, chrono::Utc::now(), &mut memory) } +/// Cliente solo para la consulta local: el token CSRF no debe salir de la máquina. +/// Sin proxies (ni los del sistema ni los configurados en `builder`) y sin seguir +/// redirecciones, que podrían llevar el token a otro destino. +fn local_client(builder: reqwest::ClientBuilder) -> reqwest::Result { + builder + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)) + .build() +} + +/// `GetUserStatus` contra el servidor local del IDE. +async fn post_status( + client: &reqwest::Client, + port: u16, + csrf: &str, +) -> reqwest::Result { + client + .post(format!("http://127.0.0.1:{port}{METHOD}")) + .header("X-Codeium-Csrf-Token", csrf) + .header("Connect-Protocol-Version", "1") + .json(&json!({ + "metadata": { "ideName": "antigravity", "extensionName": "antigravity", "locale": "en" } + })) + .send() + .await +} + /// Informe final a partir de la respuesta de `GetUserStatus`. Puro, sin red. fn build_report( mut report: UsageReport, @@ -286,18 +317,24 @@ fn log_path() -> PathBuf { .join("language_server.log") } -/// Puerto HTTP que anunció en el log el proceso `pid`. Se ignora un log de un -/// arranque anterior (otro pid) para no consultar un puerto ajeno. +/// Puerto HTTP que anunció en el log el proceso `pid`. Solo se mira el bloque de +/// su último arranque (hasta el siguiente «Starting…»), con el pid completo: así +/// nunca se toma el puerto de otro proceso. fn http_port(log: &str, pid: u32) -> Option { - let start = format!("Starting language server process with pid {pid}"); - let from = log.rfind(&start)?; - log[from..].lines().find_map(|l| { - let rest = l.split("listening on random port at ").nth(1)?; - let (port, kind) = rest.split_once(' ')?; - kind.trim_end() - .ends_with("for HTTP") - .then(|| port.parse().ok())? - }) + const START: &str = "Starting language server process with pid "; + let start_pid = |l: &str| -> Option { l.split(START).nth(1)?.trim().parse().ok() }; + let lines: Vec<&str> = log.lines().collect(); + let from = lines.iter().rposition(|l| start_pid(l) == Some(pid))?; + lines[from + 1..] + .iter() + .take_while(|l| !l.contains(START)) + .find_map(|l| { + let rest = l.split("listening on random port at ").nth(1)?; + let (port, kind) = rest.split_once(' ')?; + kind.trim_end() + .ends_with("for HTTP") + .then(|| port.parse().ok())? + }) } /// Valor de `--flag valor` o `--flag=valor` en una línea de comandos. @@ -416,6 +453,113 @@ mod tests { ); } + #[test] + fn port_lookup_matches_the_whole_pid() { + let log = "I1029 server.go:1631] Starting language server process with pid 1234 +I1029 server.go:657] Language server listening on random port at 40001 for HTTP +"; + // «pid 123» no es «pid 1234». + assert_eq!(http_port(log, 123), None); + assert_eq!(http_port(log, 1234), Some(40001)); + } + + #[test] + fn port_lookup_stops_at_the_next_start() { + let log = "I1029 server.go:1631] Starting language server process with pid 222 +I1029 server.go:649] Language server listening on random port at 51182 for HTTPS (gRPC) +I1029 server.go:1631] Starting language server process with pid 333 +I1029 server.go:657] Language server listening on random port at 60001 for HTTP +"; + // El bloque de 222 no publica puerto HTTP: no se toma el de 333. + assert_eq!(http_port(log, 222), None); + assert_eq!(http_port(log, 333), Some(60001)); + } + + /// Servidor HTTP mínimo en loopback: atiende una conexión con `response` y + /// devuelve lo que recibió. `None` si nadie se conecta en `wait`. + fn one_shot(response: String) -> (u16, std::sync::mpsc::Receiver) { + use std::io::{Read, Write}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + if let Ok((mut s, _)) = l.accept() { + s.set_read_timeout(Some(std::time::Duration::from_millis(500))) + .ok(); + let mut buf = vec![0u8; 8192]; + let n = s.read(&mut buf).unwrap_or(0); + let _ = s.write_all(response.as_bytes()); + let _ = tx.send(String::from_utf8_lossy(&buf[..n]).into_owned()); + } + }); + (port, rx) + } + + fn block_on(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(f) + } + + const EMPTY_OK: &str = "HTTP/1.1 200 OK +Content-Length: 2 +Connection: close + +{}"; + + #[test] + fn csrf_token_never_goes_through_a_proxy() { + let (proxy, proxy_rx) = one_shot( + "HTTP/1.1 502 Bad Gateway +Content-Length: 0 + +" + .into(), + ); + let (server, server_rx) = one_shot(EMPTY_OK.into()); + // Mismo efecto que HTTP_PROXY/ALL_PROXY sin NO_PROXY para loopback. + let builder = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("http://127.0.0.1:{proxy}")).unwrap()); + let client = local_client(builder).unwrap(); + let res = block_on(post_status(&client, server, "token-ficticio")).unwrap(); + assert_eq!(res.status().as_u16(), 200); + let wait = std::time::Duration::from_secs(2); + assert!(server_rx + .recv_timeout(wait) + .unwrap() + .contains("token-ficticio")); + assert!( + proxy_rx + .recv_timeout(std::time::Duration::from_millis(300)) + .is_err(), + "el proxy recibió la consulta" + ); + } + + #[test] + fn local_query_does_not_follow_redirects() { + let (other, other_rx) = one_shot(EMPTY_OK.into()); + let (server, _server_rx) = one_shot(format!( + "HTTP/1.1 307 Temporary Redirect +Location: http://127.0.0.1:{other}/x +Content-Length: 0 +Connection: close + +" + )); + let client = local_client(reqwest::Client::builder()).unwrap(); + let res = block_on(post_status(&client, server, "token-ficticio")).unwrap(); + assert_eq!(res.status().as_u16(), 307); + assert!( + other_rx + .recv_timeout(std::time::Duration::from_millis(300)) + .is_err(), + "se siguió la redirección" + ); + } + #[test] fn reads_http_port_of_the_current_process_only() { let log = "\ From 164213c5da5e1ab271b29e1f5a52332064cf6092 Mon Sep 17 00:00:00 2001 From: Micaela Eichhorn Date: Fri, 30 Oct 2026 10:10:46 -0300 Subject: [PATCH 12/12] chore(i18n): regenerar inventario tras aislar la consulta de Antigravity --- docs/i18n-inventory.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index bf82f19..d940f2b 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/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `127bd31c1ed1a31a61d042f2360273da710a424b`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `c9ffea3ddacbdf22d2fa5509030ba29ded394734` and `src-tauri/src/` = `71fa2ce6c1ffbdf5a1f3fec0925dfed7a6a505ed`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -19,8 +19,8 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| | React (`src/`) | 958 | 1066 | -| Rust (`src-tauri/src/`) | 420 | 501 | -| Total | 1341 | 1567 | +| Rust (`src-tauri/src/`) | 421 | 502 | +| Total | 1342 | 1568 | ## src-tauri/src/channel.rs @@ -148,8 +148,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Antigravity no ha devuelto cuotas por modelo. ¿Has iniciado sesión en el IDE? - Get-CimInstance Win32_Process -Filter "Name LIKE 'language_server%'" \| Select-Object ProcessId,CommandLine \| ConvertTo-Json -Compress - HTTP {code} en GetUserStatus +- No puedo preparar la consulta local: {e} - Respuesta inesperada: {e} -- Starting language server process with pid {pid} +- Starting language server process with pid - for HTTP - listening on random port at - model:{}