diff --git a/Cargo.toml b/Cargo.toml index 5ccd66a4..c2570005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ features = [ "Win32_Graphics_DirectComposition", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", + "Win32_Security_Credentials", "Win32_Storage_FileSystem", ] diff --git a/README.md b/README.md index 8a62fce5..15efeac5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ In the default theme, left-click a provider tray icon to show or hide the widget | Grok Build | Run `grok login` in the Grok Build CLI, then enable Grok in **Providers**. The signed-in session is detected automatically. | | GitHub Copilot | Sign in with the Copilot CLI (`copilot`, then `/login`) or the GitHub CLI (`gh auth login`), then enable Copilot in **Providers**. The stored login is detected automatically. | +For OpenRouter, create a Management API key at `https://openrouter.ai/settings/management-keys`, +then paste it in **Settings → Providers → OpenRouter → Connection** and press **Test** to verify +the connection. The key is stored securely in Windows Credential Manager and never written to +`settings.json`. The environment variable `OPENROUTER_MANAGEMENT_KEY` still works as a fallback +if you prefer not to use the credential store. + For OpenCode Go, set `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`, or create `%APPDATA%\opencode-go\config.json`: ```json diff --git a/src/app_settings.rs b/src/app_settings.rs index d2e1d7f1..fcc12627 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -23,6 +23,27 @@ pub const POLL_1_HOUR: u32 = POLL_1_HOUR_SECONDS * 1_000; // SetTimer clamps longer intervals to USER_TIMER_MAXIMUM (i32::MAX ms). pub const MAX_POLL_MINUTES: u32 = i32::MAX as u32 / POLL_1_MIN; +/// Period used for the OpenRouter budget amount and its monitoring window. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BudgetPeriod { + Daily, + Weekly, + #[default] + Monthly, +} + +impl BudgetPeriod { + /// Days covered by one period, given the days in the current calendar month. + pub fn days(self, month_days: u32) -> f64 { + match self { + BudgetPeriod::Daily => 1.0, + BudgetPeriod::Weekly => 7.0, + BudgetPeriod::Monthly => f64::from(month_days), + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SettingsFile { #[serde(default)] @@ -66,6 +87,30 @@ pub struct SettingsFile { show_grok: bool, #[serde(default)] show_copilot: bool, + #[serde(default)] + show_openrouter: bool, + /// Personal spending cap (USD). When set, OpenRouter's gauge measures spend + /// against this cap, converted from `openrouter_budget_period` to the + /// `openrouter_monitor_period` window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openrouter_budget_amount: Option, + /// Period the budget amount is defined for (the "rate"). + #[serde(default)] + pub openrouter_budget_period: BudgetPeriod, + /// Period the OpenRouter gauge displays and measures spend for. + #[serde(default)] + pub openrouter_monitor_period: BudgetPeriod, + /// Whether the OpenRouter display shows the cap next to the spend. + #[serde(default)] + pub openrouter_show_cap: bool, + /// Credits manually entered as added in the current month (USD). When set, + /// the balance line shows what is left of this deposit, not the lifetime + /// account balance (the OpenRouter API has no per-month deposit endpoint). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openrouter_monthly_deposit: Option, + /// Whether the balance line also prints the monthly deposit next to it. + #[serde(default)] + pub openrouter_show_deposit: bool, #[serde(default = "default_true")] pub custom_theme_enabled: bool, /// Show what is left of each allowance instead of what has been spent, so @@ -137,6 +182,13 @@ impl Default for SettingsFile { show_cursor: false, show_grok: false, show_copilot: false, + show_openrouter: false, + openrouter_budget_amount: None, + openrouter_budget_period: BudgetPeriod::Monthly, + openrouter_monitor_period: BudgetPeriod::Monthly, + openrouter_show_cap: false, + openrouter_monthly_deposit: None, + openrouter_show_deposit: false, custom_theme_enabled: true, usage_countdown: false, active_theme_path: None, @@ -235,6 +287,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor, ProviderId::Grok => self.show_grok, ProviderId::Copilot => self.show_copilot, + ProviderId::OpenRouter => self.show_openrouter, } } @@ -283,6 +336,7 @@ impl SettingsFile { ProviderId::Cursor => self.show_cursor = enabled, ProviderId::Grok => self.show_grok = enabled, ProviderId::Copilot => self.show_copilot = enabled, + ProviderId::OpenRouter => self.show_openrouter = enabled, } } diff --git a/src/context_menu.rs b/src/context_menu.rs index bf87f113..cf0b3eab 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -424,6 +424,13 @@ pub fn classic_context_menu() -> ContextMenuDocument { provider: Provider::Copilot, }, ), + ContextMenuItem::action( + "provider-openrouter", + "OpenRouter", + Action::ToggleProvider { + provider: Provider::OpenRouter, + }, + ), ], ); let languages = std::iter::once(ContextMenuItem::action( diff --git a/src/icons/brand-marks.ttf b/src/icons/brand-marks.ttf index f05fc762..148bd32d 100644 Binary files a/src/icons/brand-marks.ttf and b/src/icons/brand-marks.ttf differ diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index df432b1e..53c5077d 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Collect usage from GitHub" = "Nutzungsdaten von GitHub abrufen" "Collect usage from Google" = "Nutzungsdaten von Google abrufen" "Collect usage from OpenAI" = "Nutzungsdaten von OpenAI abrufen" +"Collect usage from OpenRouter" = "Nutzungsdaten von OpenRouter abrufen" +"Show cap in the monitor" = "Limit im Monitor anzeigen" +"Monthly credits deposit" = "Monatliche Guthaben-Einzahlung" +"Show credits deposit in the monitor" = "Guthaben-Einzahlung im Monitor anzeigen" +"0 uses the accumulated account balance" = "0 nutzt das kumulierte Kontoguthaben" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter zählt Tagesausgaben in UTC: Der Tag wechselt um 00:00 UTC, was in Ihrer Zeitzone {time} entspricht; danach beginnt der Tageswert neu." +"Budget" = "Budget" +"Cap amount" = "Limitbetrag" +"Cap period" = "Limitzeitraum" +"Monitor period" = "Überwachungszeitraum" +"0 turns the spending cap off" = "0 deaktiviert die Ausgabenobergrenze" +"Daily" = "Täglich" +"Weekly" = "Wöchentlich" +"Monthly" = "Monatlich" "Collect usage from xAI" = "Nutzungsdaten von xAI abrufen" "Colour" = "Farbe" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Konten" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Designs verwenden das Standardkonto, sofern sie kein Konto angeben. Eigene Designs können mehrere Konten anzeigen." +"Default account" = "Standardkonto" +"Account name" = "Kontoname" +"Remove" = "Entfernen" +"Config directory" = "Konfigurationsverzeichnis" +"Custom credentials file" = "Eigene Anmeldedatendatei" +"Optional; overrides the config directory" = "Optional; überschreibt das Konfigurationsverzeichnis" +"Browse..." = "Durchsuchen..." +"Select credentials file" = "Anmeldedatendatei auswählen" +"Theme binding" = "Design-Bindung" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Verwendet CLAUDE_CONFIG_DIR, wenn gesetzt; sonst werden CLI-, Desktop- oder WSL-Anmeldedaten erkannt." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Verwendet CODEX_HOME, wenn gesetzt; sonst ~/.codex/auth.json." +"Monitoring disabled" = "Überwachung deaktiviert" +"Last known usage" = "Zuletzt bekannter Verbrauch" +"Session" = "Sitzung" +"Credits" = "Guthaben" +"Resets in" = "Zurücksetzung in" +"Waiting for usage" = "Warte auf Verbrauchsdaten" +"Add account" = "Konto hinzufügen" +"Connection" = "Verbindung" +"Management key" = "Management-Schlüssel" +"Test" = "Testen" +"Remove stored key" = "Gespeicherten Schlüssel entfernen" +"Credential stored in Windows Credential Manager" = "Anmeldedaten in der Windows-Anmeldeinformationsverwaltung gespeichert" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Kein Schlüssel konfiguriert. Erstellen Sie einen Management API key auf OpenRouter und fügen Sie ihn hier ein." +"Get OpenRouter management key" = "OpenRouter-Verwaltungsschlüssel abrufen" +"Testing..." = "Teste läuft..." +"Connected" = "Verbunden" +"BALANCE" = "GUTHABEN" +"USED" = "VERWENDET" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Fügen Sie einen Management-API-Schlüssel unter Einstellungen → Anbieter → OpenRouter → Verbindung hinzu und aktualisieren Sie diesen Monitor." diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 2273418e..6be2e4a6 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot Usage Monitor" "Collect usage from GitHub" = "Collect usage from GitHub" "Collect usage from Google" = "Collect usage from Google" "Collect usage from OpenAI" = "Collect usage from OpenAI" +"Collect usage from OpenRouter" = "Collect usage from OpenRouter" +"Show cap in the monitor" = "Show cap in the monitor" +"Monthly credits deposit" = "Monthly credits deposit" +"Show credits deposit in the monitor" = "Show credits deposit in the monitor" +"0 uses the accumulated account balance" = "0 uses the accumulated account balance" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." +"Budget" = "Budget" +"Cap amount" = "Cap amount" +"Cap period" = "Cap period" +"Monitor period" = "Monitor period" +"0 turns the spending cap off" = "0 turns the spending cap off" +"Daily" = "Daily" +"Weekly" = "Weekly" +"Monthly" = "Monthly" "Collect usage from xAI" = "Collect usage from xAI" "Colour" = "Colour" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot Usage Monitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Accounts" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Themes use the default account unless they specify an account. Custom themes can display multiple accounts." +"Default account" = "Default account" +"Account name" = "Account name" +"Remove" = "Remove" +"Config directory" = "Config directory" +"Custom credentials file" = "Custom credentials file" +"Optional; overrides the config directory" = "Optional; overrides the config directory" +"Browse..." = "Browse..." +"Select credentials file" = "Select credentials file" +"Theme binding" = "Theme binding" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." +"Monitoring disabled" = "Monitoring disabled" +"Last known usage" = "Last known usage" +"Session" = "Session" +"Credits" = "Credits" +"Resets in" = "Resets in" +"Waiting for usage" = "Waiting for usage" +"Add account" = "Add account" +"Connection" = "Connection" +"Management key" = "Management key" +"Test" = "Test" +"Remove stored key" = "Remove stored key" +"Credential stored in Windows Credential Manager" = "Credential stored in Windows Credential Manager" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "No key configured. Create a Management API key on OpenRouter and paste it here." +"Get OpenRouter management key" = "Get OpenRouter management key" +"Testing..." = "Testing..." +"Connected" = "Connected" +"BALANCE" = "BALANCE" +"USED" = "USED" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 41ac2ed6..6f9dd0be 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor de uso de Copilot" "Collect usage from GitHub" = "Recopilar uso de GitHub" "Collect usage from Google" = "Recopilar uso de Google" "Collect usage from OpenAI" = "Recopilar uso de OpenAI" +"Collect usage from OpenRouter" = "Recopilar uso de OpenRouter" +"Show cap in the monitor" = "Mostrar límite en el monitor" +"Monthly credits deposit" = "Depósito de créditos (mensual)" +"Show credits deposit in the monitor" = "Mostrar depósito de créditos en el monitor" +"0 uses the accumulated account balance" = "0 usa el saldo acumulado de la cuenta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter cuenta el gasto diario en UTC: el día cambia a las 00:00 UTC, que en tu zona horaria es {time}, así que el valor diario se reinicia a esa hora." +"Budget" = "Presupuesto" +"Cap amount" = "Monto del límite" +"Cap period" = "Período del límite" +"Monitor period" = "Período monitorizado" +"0 turns the spending cap off" = "0 desactiva el límite de gasto" +"Daily" = "Diario" +"Weekly" = "Semanal" +"Monthly" = "Mensual" "Collect usage from xAI" = "Recopilar uso de xAI" "Colour" = "Color" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Monitor de uso de Copilot" "Lock in taskbar" = "Fijar en la barra" "Keep the widget docked; never float automatically" = "Mantener el widget acoplado; no flotar automáticamente" "Locked" = "Fijado" +"Accounts" = "Cuentas" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Los temas usan la cuenta predeterminada a menos que especifiquen una cuenta. Los temas personalizados pueden mostrar varias cuentas." +"Default account" = "Cuenta predeterminada" +"Account name" = "Nombre de la cuenta" +"Remove" = "Quitar" +"Config directory" = "Directorio de configuración" +"Custom credentials file" = "Archivo de credenciales personalizado" +"Optional; overrides the config directory" = "Opcional; reemplaza el directorio de configuración" +"Browse..." = "Examinar..." +"Select credentials file" = "Seleccionar archivo de credenciales" +"Theme binding" = "Vinculación de tema" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Usa CLAUDE_CONFIG_DIR cuando está definido; de lo contrario, detecta credenciales de la CLI, el Escritorio o WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Usa CODEX_HOME cuando está definido; de lo contrario, ~/.codex/auth.json." +"Monitoring disabled" = "Supervisión desactivada" +"Last known usage" = "Último consumo conocido" +"Session" = "Sesión" +"Credits" = "Créditos" +"Resets in" = "Se restablece en" +"Waiting for usage" = "Esperando datos de uso" +"Add account" = "Agregar cuenta" +"Connection" = "Conexión" +"Management key" = "Clave de administración" +"Test" = "Probar" +"Remove stored key" = "Eliminar clave almacenada" +"Credential stored in Windows Credential Manager" = "Credencial almacenada en el Administrador de credenciales de Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Sin clave configurada. Crea una Management API key en OpenRouter y pégala aquí." +"Get OpenRouter management key" = "Obtener clave de gestión de OpenRouter" +"Testing..." = "Probando..." +"Connected" = "Conectado" +"BALANCE" = "SALDO" +"USED" = "USADO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Añade una Management API key en Ajustes → Proveedores → OpenRouter → Conexión y actualiza este monitor." diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 34545dcf..f358b12e 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -131,6 +131,20 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Collect usage from GitHub" = "Collecter l'utilisation depuis GitHub" "Collect usage from Google" = "Collecter l'utilisation depuis Google" "Collect usage from OpenAI" = "Collecter l'utilisation depuis OpenAI" +"Collect usage from OpenRouter" = "Collecter l'utilisation depuis OpenRouter" +"Show cap in the monitor" = "Afficher le plafond dans le moniteur" +"Monthly credits deposit" = "Dépôt de crédits (mensuel)" +"Show credits deposit in the monitor" = "Afficher le dépôt de crédits dans le moniteur" +"0 uses the accumulated account balance" = "0 utilise le solde cumulé du compte" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter compte les dépenses quotidiennes en UTC : le jour bascule à 00:00 UTC, soit {time} dans votre fuseau, le montant quotidien repart donc à ce moment-là." +"Budget" = "Budget" +"Cap amount" = "Montant du plafond" +"Cap period" = "Période du plafond" +"Monitor period" = "Période surveillée" +"0 turns the spending cap off" = "0 désactive le plafond de dépenses" +"Daily" = "Quotidien" +"Weekly" = "Hebdomadaire" +"Monthly" = "Mensuel" "Collect usage from xAI" = "Collecter l'utilisation depuis xAI" "Colour" = "Couleur" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Comptes" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Les thèmes utilisent le compte par défaut sauf s'ils spécifient un compte. Les thèmes personnalisés peuvent afficher plusieurs comptes." +"Default account" = "Compte par défaut" +"Account name" = "Nom du compte" +"Remove" = "Supprimer" +"Config directory" = "Répertoire de configuration" +"Custom credentials file" = "Fichier d'identifiants personnalisé" +"Optional; overrides the config directory" = "Facultatif ; remplace le répertoire de configuration" +"Browse..." = "Parcourir..." +"Select credentials file" = "Sélectionner le fichier d'identifiants" +"Theme binding" = "Liaison de thème" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Utilise CLAUDE_CONFIG_DIR lorsqu'il est défini ; sinon, détecte les identifiants de la CLI, du Bureau ou de WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Utilise CODEX_HOME lorsqu'il est défini ; sinon ~/.codex/auth.json." +"Monitoring disabled" = "Surveillance désactivée" +"Last known usage" = "Dernière consommation connue" +"Session" = "Session" +"Credits" = "Crédits" +"Resets in" = "Réinitialisation dans" +"Waiting for usage" = "En attente des données d'utilisation" +"Add account" = "Ajouter un compte" +"Connection" = "Connexion" +"Management key" = "Clé de gestion" +"Test" = "Tester" +"Remove stored key" = "Supprimer la clé enregistrée" +"Credential stored in Windows Credential Manager" = "Identifiant stocké dans le Gestionnaire d'informations d'identification Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Aucune clé configurée. Créez une Management API key sur OpenRouter et collez-la ici." +"Get OpenRouter management key" = "Obtenir la clé de gestion OpenRouter" +"Testing..." = "Test en cours..." +"Connected" = "Connecté" +"BALANCE" = "SOLDE" +"USED" = "UTILISÉ" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Ajoutez une Management API key dans Paramètres → Fournisseurs → OpenRouter → Connexion, puis actualisez ce moniteur." diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 6d6de298..a237ab0d 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量モニター" "Collect usage from GitHub" = "GitHub から使用量を取得" "Collect usage from Google" = "Google から使用量を取得" "Collect usage from OpenAI" = "OpenAI から使用量を取得" +"Collect usage from OpenRouter" = "OpenRouter から使用量を取得" +"Show cap in the monitor" = "モニターに上限を表示" +"Monthly credits deposit" = "クレジット入金(月次)" +"Show credits deposit in the monitor" = "モニターにクレジット入金を表示" +"0 uses the accumulated account balance" = "0 の場合は累計アカウント残高を使用" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter は日次の利用額を UTC で集計します。日付は 00:00 UTC(お使いのタイムゾーンでは {time})に切り替わるため、その時刻に日次値がリセットされます。" +"Budget" = "予算" +"Cap amount" = "上限額" +"Cap period" = "上限期間" +"Monitor period" = "監視期間" +"0 turns the spending cap off" = "0 で支出上限を無効化" +"Daily" = "日次" +"Weekly" = "週次" +"Monthly" = "月次" "Collect usage from xAI" = "xAI から使用量を取得" "Colour" = "色" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量モニター" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "アカウント" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "テーマはアカウントを指定しない限り既定のアカウントを使用します。カスタムテーマでは複数のアカウントを表示できます。" +"Default account" = "既定のアカウント" +"Account name" = "アカウント名" +"Remove" = "削除" +"Config directory" = "設定ディレクトリ" +"Custom credentials file" = "カスタム認証情報ファイル" +"Optional; overrides the config directory" = "任意。設定ディレクトリを上書きします" +"Browse..." = "参照..." +"Select credentials file" = "認証情報ファイルを選択" +"Theme binding" = "テーマの紐付け" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "CLAUDE_CONFIG_DIR が設定されていれば使用します。それ以外は CLI、デスクトップ、WSL の認証情報を検出します。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "CODEX_HOME が設定されていれば使用します。それ以外は ~/.codex/auth.json を使用します。" +"Monitoring disabled" = "監視は無効です" +"Last known usage" = "最後に確認した使用量" +"Session" = "セッション" +"Credits" = "クレジット" +"Resets in" = "リセットまで" +"Waiting for usage" = "使用状況を待機中" +"Add account" = "アカウントを追加" +"Connection" = "接続" +"Management key" = "マネジメントキー" +"Test" = "テスト" +"Remove stored key" = "保存済みキーを削除" +"Credential stored in Windows Credential Manager" = "Windows資格情報マネージャーに認証情報が保存されています" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "キーが設定されていません。OpenRouterでManagement APIキーを作成してここに貼り付けてください。" +"Get OpenRouter management key" = "OpenRouter の管理キーを取得" +"Testing..." = "テスト中..." +"Connected" = "接続済み" +"BALANCE" = "残高" +"USED" = "使用済み" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "設定 → プロバイダー → OpenRouter → 接続 で Management API key を追加し、このモニターを更新してください。" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 6d73478c..57793850 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 사용량 모니터" "Collect usage from GitHub" = "GitHub에서 사용량 수집" "Collect usage from Google" = "Google에서 사용량 수집" "Collect usage from OpenAI" = "OpenAI에서 사용량 수집" +"Collect usage from OpenRouter" = "OpenRouter에서 사용량 수집" +"Show cap in the monitor" = "모니터에 상한 표시" +"Monthly credits deposit" = "월간 크레딧 충전" +"Show credits deposit in the monitor" = "모니터에 크레딧 충전 표시" +"0 uses the accumulated account balance" = "0이면 누적 계정 잔액 사용" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter는 일일 사용량을 UTC로 계산합니다. 하루는 00:00 UTC(사용자의 시간대 기준 {time})에 바뀌므로 그 시점에 일일 값이 초기화됩니다." +"Budget" = "예산" +"Cap amount" = "한도 금액" +"Cap period" = "한도 기간" +"Monitor period" = "모니터링 기간" +"0 turns the spending cap off" = "0이면 지출 상한 해제" +"Daily" = "일간" +"Weekly" = "주간" +"Monthly" = "월간" "Collect usage from xAI" = "xAI에서 사용량 수집" "Colour" = "색상" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 사용량 모니터" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "계정" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "테마는 계정을 지정하지 않는 한 기본 계정을 사용합니다. 사용자 지정 테마는 여러 계정을 표시할 수 있습니다." +"Default account" = "기본 계정" +"Account name" = "계정 이름" +"Remove" = "제거" +"Config directory" = "구성 디렉터리" +"Custom credentials file" = "사용자 지정 자격 증명 파일" +"Optional; overrides the config directory" = "선택 사항. 구성 디렉터리를 재정의합니다" +"Browse..." = "찾아보기..." +"Select credentials file" = "자격 증명 파일 선택" +"Theme binding" = "테마 바인딩" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "CLAUDE_CONFIG_DIR이 설정되어 있으면 사용하며, 그렇지 않으면 CLI, 데스크톱 또는 WSL 자격 증명을 감지합니다." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "CODEX_HOME이 설정되어 있으면 사용하며, 그렇지 않으면 ~/.codex/auth.json을 사용합니다." +"Monitoring disabled" = "모니터링 비활성화됨" +"Last known usage" = "마지막으로 알려진 사용량" +"Session" = "세션" +"Credits" = "크레딧" +"Resets in" = "재설정까지" +"Waiting for usage" = "사용 데이터 대기 중" +"Add account" = "계정 추가" +"Connection" = "연결" +"Management key" = "관리 키" +"Test" = "테스트" +"Remove stored key" = "저장된 키 제거" +"Credential stored in Windows Credential Manager" = "Windows 자격 증명 관리자에 자격 증명이 저장되었습니다" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "키가 구성되지 않았습니다. OpenRouter에서 Management API 키를 만들고 여기에 붙여 넣으세요." +"Get OpenRouter management key" = "OpenRouter 관리 키 가져오기" +"Testing..." = "테스트 중..." +"Connected" = "연결됨" +"BALANCE" = "잔액" +"USED" = "사용됨" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "설정 → 공급자 → OpenRouter → 연결에서 Management API key를 추가한 뒤 이 모니터를 새로 고치세요." diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 50550807..55b70f8f 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Collect usage from GitHub" = "Gebruiksgegevens ophalen bij GitHub" "Collect usage from Google" = "Gebruiksgegevens ophalen bij Google" "Collect usage from OpenAI" = "Gebruiksgegevens ophalen bij OpenAI" +"Collect usage from OpenRouter" = "Gebruiksgegevens ophalen bij OpenRouter" +"Show cap in the monitor" = "Limiet in de monitor tonen" +"Monthly credits deposit" = "Maandelijkse kredietstorting" +"Show credits deposit in the monitor" = "Kredietstorting in de monitor tonen" +"0 uses the accumulated account balance" = "0 gebruikt het cumulatieve accountsaldo" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter rekent daguitgaven in UTC: de dag wisselt om 00:00 UTC (in uw tijdzone {time}), dus de dagwaarde begint dan opnieuw." +"Budget" = "Budget" +"Cap amount" = "Plafondbedrag" +"Cap period" = "Plafondperiode" +"Monitor period" = "Meetperiode" +"0 turns the spending cap off" = "0 schakelt de bestedingslimiet uit" +"Daily" = "Dagelijks" +"Weekly" = "Wekelijks" +"Monthly" = "Maandelijks" "Collect usage from xAI" = "Gebruiksgegevens ophalen bij xAI" "Colour" = "Kleur" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Accounts" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Thema's gebruiken het standaardaccount tenzij ze een account opgeven. Aangepaste thema's kunnen meerdere accounts tonen." +"Default account" = "Standaardaccount" +"Account name" = "Accountnaam" +"Remove" = "Verwijderen" +"Config directory" = "Configuratiemap" +"Custom credentials file" = "Aangepast inloggegevensbestand" +"Optional; overrides the config directory" = "Optioneel; overschrijft de configuratiemap" +"Browse..." = "Bladeren..." +"Select credentials file" = "Inloggegevensbestand selecteren" +"Theme binding" = "Themabinding" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Gebruikt CLAUDE_CONFIG_DIR indien ingesteld; anders worden CLI-, Desktop- of WSL-inloggegevens gedetecteerd." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Gebruikt CODEX_HOME indien ingesteld; anders ~/.codex/auth.json." +"Monitoring disabled" = "Bewaking uitgeschakeld" +"Last known usage" = "Laatst bekende verbruik" +"Session" = "Sessie" +"Credits" = "Tegoed" +"Resets in" = "Reset over" +"Waiting for usage" = "Wachten op verbruiksgegevens" +"Add account" = "Account toevoegen" +"Connection" = "Verbinding" +"Management key" = "Beheersleutel" +"Test" = "Testen" +"Remove stored key" = "Opgeslagen sleutel verwijderen" +"Credential stored in Windows Credential Manager" = "Referentie opgeslagen in Windows Referentiebeheer" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Geen sleutel geconfigureerd. Maak een Management API key aan op OpenRouter en plak deze hier." +"Get OpenRouter management key" = "OpenRouter-beheersleutel ophalen" +"Testing..." = "Testen..." +"Connected" = "Verbonden" +"BALANCE" = "SALDO" +"USED" = "GEBRUIKT" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Voeg een Management API key toe via Instellingen → Providers → OpenRouter → Verbinding en ververs deze monitor." diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index c743ce77..b30a0656 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor zużycia Copilot" "Collect usage from GitHub" = "Zbieraj dane o zużyciu z GitHuba" "Collect usage from Google" = "Zbieraj dane o zużyciu z Google" "Collect usage from OpenAI" = "Zbieraj dane o zużyciu z OpenAI" +"Collect usage from OpenRouter" = "Zbieraj dane o zużyciu z OpenRouter" +"Show cap in the monitor" = "Pokaż limit w monitorze" +"Monthly credits deposit" = "Miesięczna wpłata kredytów" +"Show credits deposit in the monitor" = "Pokaż wpłatę kredytów w monitorze" +"0 uses the accumulated account balance" = "0 oznacza skumulowane saldo konta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter liczy dzienne wydatki w UTC: doba zmienia się o 00:00 UTC (w Twojej strefie czasowej {time}), więc dzienna wartość zaczyna się wtedy od nowa." +"Budget" = "Budżet" +"Cap amount" = "Kwota limitu" +"Cap period" = "Okres limitu" +"Monitor period" = "Okres monitorowania" +"0 turns the spending cap off" = "0 wyłącza limit wydatków" +"Daily" = "Dziennie" +"Weekly" = "Tygodniowo" +"Monthly" = "Miesięcznie" "Collect usage from xAI" = "Zbieraj dane o zużyciu z xAI" "Colour" = "Kolor" "Column" = "Kolumna" @@ -457,3 +471,35 @@ copilot_window_title = "Monitor zużycia Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Konta" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Motywy używają konta domyślnego, chyba że określą konto. Motywy niestandardowe mogą wyświetlać wiele kont." +"Default account" = "Konto domyślne" +"Account name" = "Nazwa konta" +"Remove" = "Usuń" +"Config directory" = "Katalog konfiguracji" +"Custom credentials file" = "Niestandardowy plik poświadczeń" +"Optional; overrides the config directory" = "Opcjonalne; zastępuje katalog konfiguracji" +"Browse..." = "Przeglądaj..." +"Select credentials file" = "Wybierz plik poświadczeń" +"Theme binding" = "Powiązanie motywu" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Używa CLAUDE_CONFIG_DIR, jeśli jest ustawiona; w przeciwnym razie wykrywa poświadczenia CLI, Pulpitu lub WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Używa CODEX_HOME, jeśli jest ustawiona; w przeciwnym razie ~/.codex/auth.json." +"Monitoring disabled" = "Monitorowanie wyłączone" +"Last known usage" = "Ostatnie znane zużycie" +"Session" = "Sesja" +"Credits" = "Kredyty" +"Resets in" = "Reset za" +"Waiting for usage" = "Oczekiwanie na dane użycia" +"Add account" = "Dodaj konto" +"Connection" = "Połączenie" +"Management key" = "Klucz zarządzania" +"Test" = "Testuj" +"Remove stored key" = "Usuń zapisany klucz" +"Credential stored in Windows Credential Manager" = "Poświadczenia zapisane w Menedżerze poświadczeń systemu Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Brak skonfigurowanego klucza. Utwórz klucz Management API na OpenRouter i wklej go tutaj." +"Get OpenRouter management key" = "Pobierz klucz zarządzania OpenRouter" +"Testing..." = "Testowanie..." +"Connected" = "Połączono" +"BALANCE" = "SALDO" +"USED" = "UŻYTO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Dodaj Management API key w Ustawienia → Dostawcy → OpenRouter → Połączenie i odśwież ten monitor." diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 7719834b..a4745031 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -131,6 +131,20 @@ copilot_window_title = "Monitor de uso do Copilot" "Collect usage from GitHub" = "Coletar uso do GitHub" "Collect usage from Google" = "Coletar uso do Google" "Collect usage from OpenAI" = "Coletar uso da OpenAI" +"Collect usage from OpenRouter" = "Coletar uso do OpenRouter" +"Show cap in the monitor" = "Mostrar teto no monitor" +"Monthly credits deposit" = "Depósito de créditos (mensal)" +"Show credits deposit in the monitor" = "Mostrar depósito de créditos no monitor" +"0 uses the accumulated account balance" = "0 usa o saldo acumulado da conta" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "O OpenRouter conta o gasto diário em UTC: o dia vira à meia-noite UTC, que no seu fuso é {time}, então o valor diário zera nesse horário." +"Budget" = "Orçamento" +"Cap amount" = "Valor do teto" +"Cap period" = "Período do teto" +"Monitor period" = "Período monitorado" +"0 turns the spending cap off" = "0 desativa o teto de gastos" +"Daily" = "Diário" +"Weekly" = "Semanal" +"Monthly" = "Mensal" "Collect usage from xAI" = "Coletar uso da xAI" "Colour" = "Cor" "Column" = "Column" @@ -207,7 +221,7 @@ copilot_window_title = "Monitor de uso do Copilot" "False" = "Falso" "Fill" = "Preenchimento" "Fit" = "Ajuste" -"Floating" = "Floating" +"Floating" = "Flutuante" "Font" = "Fonte" "Format" = "Formato" "Freeform" = "Freeform" @@ -432,7 +446,7 @@ copilot_window_title = "Monitor de uso do Copilot" "Use asset" = "Usar recurso" "Use Builder" = "Usar Construtor" "Use the mouse wheel over the canvas to zoom" = "Use the mouse wheel over the canvas to zoom" -"Used" = "Used" +"Used" = "Usado" "Uses the same safe action language as layer mouse events." = "Uses the same safe action language as layer mouse events." "Valid actions" = "Ações válidas" "Valid expression" = "Expressão válida" @@ -454,6 +468,38 @@ copilot_window_title = "Monitor de uso do Copilot" "X" = "X" "Y offset" = "Deslocamento Y" "Y" = "Y" -"Lock in taskbar" = "Lock in taskbar" -"Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" -"Locked" = "Locked" +"Lock in taskbar" = "Fixar na barra de tarefas" +"Keep the widget docked; never float automatically" = "Manter o widget acoplado; nunca flutuar automaticamente" +"Locked" = "Fixo" +"Accounts" = "Contas" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Os temas usam a conta padrão, a menos que especifiquem uma conta. Temas personalizados podem exibir várias contas." +"Default account" = "Conta padrão" +"Account name" = "Nome da conta" +"Remove" = "Remover" +"Config directory" = "Diretório de configuração" +"Custom credentials file" = "Arquivo de credenciais personalizado" +"Optional; overrides the config directory" = "Opcional; substitui o diretório de configuração" +"Browse..." = "Procurar..." +"Select credentials file" = "Selecionar arquivo de credenciais" +"Theme binding" = "Vinculação de tema" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Usa CLAUDE_CONFIG_DIR quando definido; caso contrário, detecta credenciais da CLI, do Desktop ou do WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Usa CODEX_HOME quando definido; caso contrário, ~/.codex/auth.json." +"Monitoring disabled" = "Monitoramento desativado" +"Last known usage" = "Último consumo conhecido" +"Session" = "Sessão" +"Credits" = "Créditos" +"Resets in" = "Reinicia em" +"Waiting for usage" = "Aguardando dados de uso" +"Add account" = "Adicionar conta" +"Connection" = "Conexão" +"Management key" = "Chave de gerenciamento" +"Test" = "Testar" +"Remove stored key" = "Remover chave armazenada" +"Credential stored in Windows Credential Manager" = "Credencial armazenada no Gerenciador de Credenciais do Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Nenhuma chave configurada. Crie uma Management API key no OpenRouter e cole aqui." +"Get OpenRouter management key" = "Obter chave de gestão do OpenRouter" +"Testing..." = "Testando..." +"Connected" = "Conectado" +"BALANCE" = "SALDO" +"USED" = "USADO" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Adicione uma Management API key em Configurações → Provedores → OpenRouter → Conexão e atualize este monitor." diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 19d8aa49..952a1501 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -131,6 +131,20 @@ copilot_window_title = "Монитор использования Copilot" "Collect usage from GitHub" = "Получать данные об использовании GitHub" "Collect usage from Google" = "Получать данные об использовании Google" "Collect usage from OpenAI" = "Получать данные об использовании OpenAI" +"Collect usage from OpenRouter" = "Получать данные об использовании OpenRouter" +"Show cap in the monitor" = "Показывать лимит в мониторе" +"Monthly credits deposit" = "Пополнение кредитов (ежемесячно)" +"Show credits deposit in the monitor" = "Показывать пополнение кредитов в мониторе" +"0 uses the accumulated account balance" = "0 — использовать накопленный баланс аккаунта" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter считает дневные расходы в UTC: сутки меняются в 00:00 UTC (в вашем часовом поясе это {time}), поэтому дневное значение начинается заново в это время." +"Budget" = "Бюджет" +"Cap amount" = "Сумма лимита" +"Cap period" = "Период лимита" +"Monitor period" = "Период мониторинга" +"0 turns the spending cap off" = "0 отключает лимит расходов" +"Daily" = "Ежедневно" +"Weekly" = "Еженедельно" +"Monthly" = "Ежемесячно" "Collect usage from xAI" = "Получать данные об использовании xAI" "Colour" = "Цвет" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Монитор использования Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Аккаунты" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Темы используют аккаунт по умолчанию, если не указан другой. Пользовательские темы могут показывать несколько аккаунтов." +"Default account" = "Аккаунт по умолчанию" +"Account name" = "Имя аккаунта" +"Remove" = "Удалить" +"Config directory" = "Каталог конфигурации" +"Custom credentials file" = "Пользовательский файл учётных данных" +"Optional; overrides the config directory" = "Необязательно; заменяет каталог конфигурации" +"Browse..." = "Обзор..." +"Select credentials file" = "Выбрать файл учётных данных" +"Theme binding" = "Привязка темы" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Использует CLAUDE_CONFIG_DIR, если задан; иначе определяет учётные данные CLI, Desktop или WSL." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Использует CODEX_HOME, если задан; иначе ~/.codex/auth.json." +"Monitoring disabled" = "Отслеживание отключено" +"Last known usage" = "Последнее известное потребление" +"Session" = "Сессия" +"Credits" = "Кредиты" +"Resets in" = "Сброс через" +"Waiting for usage" = "Ожидание данных об использовании" +"Add account" = "Добавить аккаунт" +"Connection" = "Подключение" +"Management key" = "Ключ управления" +"Test" = "Тест" +"Remove stored key" = "Удалить сохранённый ключ" +"Credential stored in Windows Credential Manager" = "Учётные данные сохранены в Диспетчере учётных данных Windows" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Ключ не настроен. Создайте Management API key на OpenRouter и вставьте его сюда." +"Get OpenRouter management key" = "Получить ключ управления OpenRouter" +"Testing..." = "Тестирование..." +"Connected" = "Подключено" +"BALANCE" = "БАЛАНС" +"USED" = "ИСПОЛЬЗОВАНО" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Добавьте Management API key в Настройки → Провайдеры → OpenRouter → Подключение и обновите этот монитор." diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index c2a936ee..a5d76a69 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -133,6 +133,20 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Collect usage from GitHub" = "ดึงข้อมูลการใช้งานจาก GitHub" "Collect usage from Google" = "ดึงข้อมูลการใช้งานจาก Google" "Collect usage from OpenAI" = "ดึงข้อมูลการใช้งานจาก OpenAI" +"Collect usage from OpenRouter" = "ดึงข้อมูลการใช้งานจาก OpenRouter" +"Show cap in the monitor" = "แสดงเพดานในตัวตรวจสอบ" +"Monthly credits deposit" = "เติมเครดิต (รายเดือน)" +"Show credits deposit in the monitor" = "แสดงการเติมเครดิตในตัวตรวจสอบ" +"0 uses the accumulated account balance" = "0 หมายถึงใช้ยอดคงเหลือสะสมในบัญชี" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter คำนวณการใช้จ่ายรายวันเป็น UTC: วันจะเปลี่ยนเมื่อ 00:00 UTC (ตามเขตเวลาของคุณคือ {time}) ดังนั้นค่ายอดรายวันจะเริ่มใหม่ ณ เวลานั้น" +"Budget" = "งบประมาณ" +"Cap amount" = "จำนวนเพดาน" +"Cap period" = "ช่วงเพดาน" +"Monitor period" = "ช่วงที่ตรวจสอบ" +"0 turns the spending cap off" = "0 หมายถึงปิดเพดานการใช้จ่าย" +"Daily" = "รายวัน" +"Weekly" = "รายสัปดาห์" +"Monthly" = "รายเดือน" "Collect usage from xAI" = "ดึงข้อมูลการใช้งานจาก xAI" "Colour" = "สี" "Column" = "คอลัมน์" @@ -459,3 +473,35 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "บัญชี" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "ธีมจะใช้บัญชีเริ่มต้นเว้นแต่จะระบุบัญชี ธีมที่กำหนดเองสามารถแสดงหลายบัญชีได้" +"Default account" = "บัญชีเริ่มต้น" +"Account name" = "ชื่อบัญชี" +"Remove" = "ลบ" +"Config directory" = "ไดเรกทอรีการกำหนดค่า" +"Custom credentials file" = "ไฟล์ข้อมูลรับรองที่กำหนดเอง" +"Optional; overrides the config directory" = "ไม่บังคับ; แทนที่ไดเรกทอรีการกำหนดค่า" +"Browse..." = "เรียกดู..." +"Select credentials file" = "เลือกไฟล์ข้อมูลรับรอง" +"Theme binding" = "การผูกธีม" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "ใช้ CLAUDE_CONFIG_DIR เมื่อมีการตั้งค่า; มิฉะนั้นจะตรวจหาข้อมูลรับรองของ CLI, Desktop หรือ WSL" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "ใช้ CODEX_HOME เมื่อมีการตั้งค่า; มิฉะนั้นใช้ ~/.codex/auth.json" +"Monitoring disabled" = "ปิดการเฝ้าติดตาม" +"Last known usage" = "การใช้งานล่าสุดที่ทราบ" +"Session" = "เซสชัน" +"Credits" = "เครดิต" +"Resets in" = "รีเซ็ตใน" +"Waiting for usage" = "กำลังรอข้อมูลการใช้งาน" +"Add account" = "เพิ่มบัญชี" +"Connection" = "การเชื่อมต่อ" +"Management key" = "คีย์การจัดการ" +"Test" = "ทดสอบ" +"Remove stored key" = "ลบคีย์ที่บันทึกไว้" +"Credential stored in Windows Credential Manager" = "ข้อมูลรับรองถูกจัดเก็บใน Windows Credential Manager" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "ยังไม่ได้ตั้งค่าคีย์ สร้าง Management API key บน OpenRouter แล้ววางที่นี่" +"Get OpenRouter management key" = "รับคีย์การจัดการ OpenRouter" +"Testing..." = "กำลังทดสอบ..." +"Connected" = "เชื่อมต่อแล้ว" +"BALANCE" = "ยอดเงิน" +"USED" = "ใช้แล้ว" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "เพิ่ม Management API key ใน การตั้งค่า → ผู้ให้บริการ → OpenRouter → การเชื่อมต่อ แล้วรีเฟรชมอนิเตอร์นี้" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index cf8706c8..caca868f 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -131,6 +131,20 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Collect usage from GitHub" = "GitHub kullanımını topla" "Collect usage from Google" = "Google kullanımını topla" "Collect usage from OpenAI" = "OpenAI kullanımını topla" +"Collect usage from OpenRouter" = "OpenRouter kullanımını topla" +"Show cap in the monitor" = "Üst limiti izleyicide göster" +"Monthly credits deposit" = "Aylık kredi yüklemesi" +"Show credits deposit in the monitor" = "İzleyicide kredi yüklemesini göster" +"0 uses the accumulated account balance" = "0 birikmiş hesap bakiyesini kullanır" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter günlük harcamayı UTC ile hesaplar: gün 00:00 UTC'de (saat diliminizde {time}) değişir, bu yüzden günlük değer o saatte sıfırlanır." +"Budget" = "Bütçe" +"Cap amount" = "Limit tutarı" +"Cap period" = "Limit dönemi" +"Monitor period" = "İzleme dönemi" +"0 turns the spending cap off" = "0 harcama üst limitini kapatır" +"Daily" = "Günlük" +"Weekly" = "Haftalık" +"Monthly" = "Aylık" "Collect usage from xAI" = "xAI kullanımını topla" "Colour" = "Renk" "Column" = "Column" @@ -457,3 +471,35 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "Hesaplar" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "Temalar bir hesap belirtmediği sürece varsayılan hesabı kullanır. Özel temalar birden fazla hesap gösterebilir." +"Default account" = "Varsayılan hesap" +"Account name" = "Hesap adı" +"Remove" = "Kaldır" +"Config directory" = "Yapılandırma dizini" +"Custom credentials file" = "Özel kimlik bilgileri dosyası" +"Optional; overrides the config directory" = "İsteğe bağlı; yapılandırma dizinini geçersiz kılar" +"Browse..." = "Göz at..." +"Select credentials file" = "Kimlik bilgileri dosyasını seç" +"Theme binding" = "Tema bağlantısı" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "Ayarlandığında CLAUDE_CONFIG_DIR kullanılır; aksi halde CLI, Masaüstü veya WSL kimlik bilgileri algılanır." +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "Ayarlandığında CODEX_HOME kullanılır; aksi halde ~/.codex/auth.json." +"Monitoring disabled" = "İzleme devre dışı" +"Last known usage" = "Bilinen son kullanım" +"Session" = "Oturum" +"Credits" = "Krediler" +"Resets in" = "Sıfırlanma" +"Waiting for usage" = "Kullanım verileri bekleniyor" +"Add account" = "Hesap ekle" +"Connection" = "Bağlantı" +"Management key" = "Yönetim anahtarı" +"Test" = "Test et" +"Remove stored key" = "Kayıtlı anahtarı kaldır" +"Credential stored in Windows Credential Manager" = "Kimlik bilgisi Windows Kimlik Bilgisi Yöneticisi'nde depolandı" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "Anahtar yapılandırılmamış. OpenRouter'da bir Management API key oluşturun ve buraya yapıştırın." +"Get OpenRouter management key" = "OpenRouter yönetim anahtarını al" +"Testing..." = "Test ediliyor..." +"Connected" = "Bağlandı" +"BALANCE" = "BAKİYE" +"USED" = "KULLANILAN" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "Ayarlar → Sağlayıcılar → OpenRouter → Bağlantı bölümünde bir Management API key ekleyin ve bu monitörü yenileyin." diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 82c94804..31e259aa 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量监控" "Collect usage from GitHub" = "从 GitHub 获取使用量" "Collect usage from Google" = "从 Google 获取使用量" "Collect usage from OpenAI" = "从 OpenAI 获取使用量" +"Collect usage from OpenRouter" = "从 OpenRouter 获取使用量" +"Show cap in the monitor" = "在监视器中显示上限" +"Monthly credits deposit" = "每月充值额度" +"Show credits deposit in the monitor" = "在监视器中显示充值额度" +"0 uses the accumulated account balance" = "0 表示使用账户累计余额" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter 以 UTC 计算每日用量:每天于 00:00 UTC(您的时区为 {time})换日,因此每日数值会在该时重置。" +"Budget" = "预算" +"Cap amount" = "上限金额" +"Cap period" = "上限周期" +"Monitor period" = "监控周期" +"0 turns the spending cap off" = "0 表示关闭支出上限" +"Daily" = "每日" +"Weekly" = "每周" +"Monthly" = "每月" "Collect usage from xAI" = "从 xAI 获取使用量" "Colour" = "颜色" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量监控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "账户" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "除非指定账户,否则主题使用默认账户。自定义主题可显示多个账户。" +"Default account" = "默认账户" +"Account name" = "账户名称" +"Remove" = "移除" +"Config directory" = "配置目录" +"Custom credentials file" = "自定义凭据文件" +"Optional; overrides the config directory" = "可选;覆盖配置目录" +"Browse..." = "浏览..." +"Select credentials file" = "选择凭据文件" +"Theme binding" = "主题绑定" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "若已设置则使用 CLAUDE_CONFIG_DIR;否则自动检测 CLI、桌面版或 WSL 凭据。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "若已设置则使用 CODEX_HOME;否则使用 ~/.codex/auth.json。" +"Monitoring disabled" = "监控已禁用" +"Last known usage" = "上次已知用量" +"Session" = "会话" +"Credits" = "额度" +"Resets in" = "重置于" +"Waiting for usage" = "正在等待用量数据" +"Add account" = "添加账户" +"Connection" = "连接" +"Management key" = "管理密钥" +"Test" = "测试" +"Remove stored key" = "删除已存储的密钥" +"Credential stored in Windows Credential Manager" = "凭据已存储在 Windows 凭据管理器中" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "未配置密钥。请在 OpenRouter 上创建 Management API key 并粘贴到此处。" +"Get OpenRouter management key" = "获取 OpenRouter 管理密钥" +"Testing..." = "测试中..." +"Connected" = "已连接" +"BALANCE" = "余额" +"USED" = "已使用" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "请在 设置 → 提供商 → OpenRouter → 连接 中添加 Management API key,然后刷新此监视器。" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index b0644822..20a1014c 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -133,6 +133,20 @@ copilot_window_title = "Copilot 使用量監控" "Collect usage from GitHub" = "從 GitHub 取得使用量" "Collect usage from Google" = "從 Google 取得使用量" "Collect usage from OpenAI" = "從 OpenAI 取得使用量" +"Collect usage from OpenRouter" = "從 OpenRouter 取得使用量" +"Show cap in the monitor" = "在監視器中顯示上限" +"Monthly credits deposit" = "每月儲值額度" +"Show credits deposit in the monitor" = "在監視器中顯示儲值額度" +"0 uses the accumulated account balance" = "0 表示使用帳戶累計餘額" +"OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then." = "OpenRouter 以 UTC 計算每日用量:每天於 00:00 UTC(您的時區為 {time})換日,因此每日數值會在該時重置。" +"Budget" = "預算" +"Cap amount" = "上限金額" +"Cap period" = "上限週期" +"Monitor period" = "監控週期" +"0 turns the spending cap off" = "0 表示關閉支出上限" +"Daily" = "每日" +"Weekly" = "每週" +"Monthly" = "每月" "Collect usage from xAI" = "從 xAI 取得使用量" "Colour" = "色彩" "Column" = "Column" @@ -459,3 +473,35 @@ copilot_window_title = "Copilot 使用量監控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Accounts" = "帳戶" +"Themes use the default account unless they specify an account. Custom themes can display multiple accounts." = "除非指定帳戶,否則主題使用預設帳戶。自訂主題可顯示多個帳戶。" +"Default account" = "預設帳戶" +"Account name" = "帳戶名稱" +"Remove" = "移除" +"Config directory" = "設定目錄" +"Custom credentials file" = "自訂認證檔案" +"Optional; overrides the config directory" = "選填;覆寫設定目錄" +"Browse..." = "瀏覽..." +"Select credentials file" = "選擇認證檔案" +"Theme binding" = "主題綁定" +"Uses CLAUDE_CONFIG_DIR when set; otherwise detects CLI, Desktop or WSL credentials." = "若已設定則使用 CLAUDE_CONFIG_DIR;否則自動偵測 CLI、桌面版或 WSL 認證。" +"Uses CODEX_HOME when set; otherwise ~/.codex/auth.json." = "若已設定則使用 CODEX_HOME;否則使用 ~/.codex/auth.json。" +"Monitoring disabled" = "監控已停用" +"Last known usage" = "上次已知用量" +"Session" = "工作階段" +"Credits" = "額度" +"Resets in" = "重設於" +"Waiting for usage" = "正在等待用量資料" +"Add account" = "新增帳戶" +"Connection" = "連線" +"Management key" = "管理金鑰" +"Test" = "測試" +"Remove stored key" = "刪除已儲存的金鑰" +"Credential stored in Windows Credential Manager" = "憑證已儲存在 Windows 認證管理員中" +"No key configured. Create a Management API key on OpenRouter and paste it here." = "未設定金鑰。請在 OpenRouter 上建立 Management API key 並貼到此處。" +"Get OpenRouter management key" = "取得 OpenRouter 管理金鑰" +"Testing..." = "測試中..." +"Connected" = "已連線" +"BALANCE" = "餘額" +"USED" = "已使用" +"Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor." = "請在 設定 → 提供者 → OpenRouter → 連線 中新增 Management API key,然後重新整理此監視器。" diff --git a/src/localization/mod.rs b/src/localization/mod.rs index bd23eab4..5e22452c 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -100,6 +100,10 @@ impl LanguageId { strings.copilot_token_expired_title, strings.copilot_token_expired_body, ), + ProviderId::OpenRouter => ( + strings.token_expired_title, + self.text("Add a Management API key in Settings → Providers → OpenRouter → Connection, then refresh this monitor."), + ), } } diff --git a/src/main.rs b/src/main.rs index 818c93ba..3ad3ffc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod models; mod native_interop; mod poller; mod providers; +mod secret_store; mod studio_app; mod theme; mod theme_engine; diff --git a/src/poller.rs b/src/poller.rs index a6ac2e0b..e51f2d6b 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -283,6 +283,7 @@ mod copilot; mod cursor; mod grok; mod opencode; +pub(crate) mod openrouter; mod retry_after; mod windows_credentials; @@ -294,7 +295,7 @@ struct ProviderPoller { credential_watch: fn(bool) -> CredentialWatchSnapshot, } -const PROVIDER_POLLERS: [ProviderPoller; 7] = [ +const PROVIDER_POLLERS: [ProviderPoller; 8] = [ ProviderPoller { id: ProviderId::Claude, poll: claude::poll_claude_code, @@ -330,6 +331,11 @@ const PROVIDER_POLLERS: [ProviderPoller; 7] = [ poll: copilot::poll_copilot, credential_watch: copilot::credential_watch_snapshot, }, + ProviderPoller { + id: ProviderId::OpenRouter, + poll: openrouter::poll_openrouter, + credential_watch: openrouter::credential_watch_snapshot, + }, ]; fn provider_poller(provider: ProviderId) -> Option<&'static ProviderPoller> { diff --git a/src/poller/openrouter.rs b/src/poller/openrouter.rs new file mode 100644 index 00000000..b2fc9804 --- /dev/null +++ b/src/poller/openrouter.rs @@ -0,0 +1,1079 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use super::{build_agent, PollError}; +use crate::app_settings::BudgetPeriod; +use crate::diagnose; +use crate::models::{CreditsSection, UsageData, UsageLimit, UsageSection}; + +/// OpenRouter management endpoints. A management key reads account-level data; +/// an optional API key additionally exposes the key's own usage via `/key`. +const BASE_URL: &str = "https://openrouter.ai/api/v1"; +const MANAGEMENT_KEY_ENV: &str = "OPENROUTER_MANAGEMENT_KEY"; +const API_KEY_ENV: &str = "OPENROUTER_API_KEY"; + +/// Windows Credential Manager target for the OpenRouter management key. +pub const MANAGEMENT_KEY_TARGET: &str = "ClaudeCodeUsageMonitor/OpenRouter/management-key"; +const SECONDS_PER_DAY: i64 = 86_400; + +/// `GET /credits` -> account balance and lifetime usage. +#[derive(Deserialize)] +struct CreditsResponse { + data: CreditsData, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CreditsData { + #[serde(default)] + total_credits: f64, + #[serde(default)] + total_usage: f64, +} + +/// `GET /activity` -> one entry per completed UTC day (today excluded). +#[derive(Deserialize)] +struct ActivityResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct ActivityEntry { + #[serde(default)] + date: String, + #[serde(default)] + usage: f64, + #[serde(default)] + prompt_tokens: u64, + #[serde(default)] + completion_tokens: u64, + #[serde(default)] + reasoning_tokens: u64, + #[serde(default)] + cached_tokens: u64, + #[serde(default)] + requests: u64, +} + +/// `GET /key` -> per-window spend for the key that made the request. +#[derive(Deserialize)] +struct KeyResponse { + data: KeyData, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct KeyData { + /// Only today's per-key spend is used now: `/activity` (management key) + /// already reports the account-wide weekly/monthly windows. + #[serde(default)] + usage_daily: f64, +} + +struct Credentials { + management_key: String, + api_key: Option, +} + +/// Cheap connectivity probe for the settings UI: validates a management key and +/// returns `(total_credits, total_usage)`. +pub fn probe_management_key(key: &str) -> Result<(f64, f64), PollError> { + let response = fetch_credits(key)?; + Ok((response.data.total_credits, response.data.total_usage)) +} + +pub(super) fn poll_openrouter() -> Result { + let credentials = read_credentials().ok_or_else(|| { + diagnose::log("OpenRouter usage poll failed: no management key found"); + PollError::NoCredentials + })?; + poll_account(&credentials) +} + +/// Fingerprints only: secrets are hashed so a change can be detected without +/// ever logging the key itself. +pub(super) fn credential_watch_snapshot(_all_sources: bool) -> Vec { + vec![ + secret_signature(MANAGEMENT_KEY_ENV), + secret_signature(API_KEY_ENV), + ] +} + +fn poll_account(credentials: &Credentials) -> Result { + let credits = fetch_credits(&credentials.management_key)?; + let activity = fetch_activity(&credentials.management_key)?; + let key = match &credentials.api_key { + Some(api_key) => match fetch_key(api_key) { + Ok(key) => Some(key), + Err(error) => { + // The management key already answered; a key without the + // right scope (or a revoked key) must not blank the account. + diagnose::log(format!( + "OpenRouter /key poll failed; continuing without daily spend: {error:?}" + )); + None + } + }, + None => None, + }; + let now = SystemTime::now(); + let days = today_days(now); + // Analytics reports live spend for every window; /activity only has + // completed days and /key's usage_daily lags. A single day-granularity query + // spanning the wider of the week/month windows feeds all three. + let (week_start, _) = week_bounds(days); + let (month_start, _) = month_bounds(days); + let analytics = fetch_daily_usage( + &credentials.management_key, + week_start.min(month_start), + days, + ); + Ok(usage_from_account( + &credits.data, + &activity.data, + key.as_ref().map(|key| &key.data), + budget_config(), + days, + analytics.as_deref(), + )) +} + +/// OpenRouter budget configuration from the app's settings file. +#[derive(Clone, Copy, Debug)] +struct BudgetConfig { + amount: Option, + budget_period: BudgetPeriod, + monitor_period: BudgetPeriod, + show_cap: bool, + month_deposit: Option, + show_deposit: bool, +} + +impl Default for BudgetConfig { + fn default() -> Self { + Self { + amount: None, + budget_period: BudgetPeriod::Monthly, + monitor_period: BudgetPeriod::Monthly, + show_cap: false, + month_deposit: None, + show_deposit: false, + } + } +} + +/// Cache of the `/credits` total captured on the first poll of a UTC day, used +/// to derive live "today" spend. +fn day_baseline_path() -> std::path::PathBuf { + crate::app_settings::settings_path().with_file_name("openrouter-day.json") +} + +fn live_today_spend(total_usage: f64, today_days: i64) -> f64 { + let path = day_baseline_path(); + let stored = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()); + let same_day = stored + .as_ref() + .and_then(|value| value.get("day")) + .and_then(serde_json::Value::as_i64) + == Some(today_days); + if same_day { + let base = stored + .and_then(|value| value.get("usage").and_then(serde_json::Value::as_f64)) + .unwrap_or(total_usage); + (total_usage - base).max(0.0) + } else { + let snapshot = serde_json::json!({ "day": today_days, "usage": total_usage }); + let _ = std::fs::write(&path, snapshot.to_string()); + 0.0 + } +} + +fn budget_config() -> BudgetConfig { + let Ok(text) = std::fs::read_to_string(crate::app_settings::settings_path()) else { + return BudgetConfig::default(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return BudgetConfig::default(); + }; + BudgetConfig { + amount: value + .get("openrouter_budget_amount") + .and_then(serde_json::Value::as_f64) + .filter(|amount| amount.is_finite() && *amount > 0.0), + budget_period: value + .get("openrouter_budget_period") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .unwrap_or_default(), + monitor_period: value + .get("openrouter_monitor_period") + .and_then(|raw| serde_json::from_value(raw.clone()).ok()) + .unwrap_or_default(), + show_cap: value + .get("openrouter_show_cap") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + month_deposit: value + .get("openrouter_monthly_deposit") + .and_then(serde_json::Value::as_f64) + .filter(|deposit| deposit.is_finite() && *deposit > 0.0), + show_deposit: value + .get("openrouter_show_deposit") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + } +} + +fn fetch_credits(management_key: &str) -> Result { + get_json(&format!("{BASE_URL}/credits"), management_key, "credits") +} + +fn fetch_activity(management_key: &str) -> Result { + get_json(&format!("{BASE_URL}/activity"), management_key, "activity") +} + +fn fetch_key(api_key: &str) -> Result { + get_json(&format!("{BASE_URL}/key"), api_key, "key") +} + +/// Live per-day spend (UTC day index -> USD) from the analytics API. +/// +/// One day-granularity query spanning the wider of the week/month windows feeds +/// today, the current week and the current month, all in real time. Failures +/// degrade to `None` so a degraded analytics endpoint falls back to /activity. +fn fetch_daily_usage(management_key: &str, start_days: i64, today: i64) -> Option> { + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + let seconds = now % SECONDS_PER_DAY as u64; + let (start_year, start_month, start_day) = civil_from_days(start_days); + let (end_year, end_month, end_day) = civil_from_days(today); + let body = serde_json::json!({ + "metrics": ["total_usage"], + "granularity": "day", + "time_range": { + "start": format!("{start_year:04}-{start_month:02}-{start_day:02}T00:00:00Z"), + "end": format!( + "{end_year:04}-{end_month:02}-{end_day:02}T{:02}:{:02}:{:02}Z", + seconds / 3600, + (seconds % 3600) / 60, + seconds % 60 + ), + }, + }); + let response: serde_json::Value = post_json( + &format!("{BASE_URL}/analytics/query"), + management_key, + &body, + "/analytics/query", + ) + .ok()?; + let rows = response.get("data")?.get("data")?.as_array()?; + Some( + rows.iter() + .filter_map(|row| { + let day = date_days(row.get("date__day")?.as_str()?)?; + let usage = row.get("total_usage").and_then(serde_json::Value::as_f64)?; + Some((day, usage)) + }) + .collect(), + ) +} + +fn post_json Deserialize<'de>>( + url: &str, + token: &str, + body: &serde_json::Value, + endpoint: &str, +) -> Result { + let mut response = match build_agent()? + .post(url) + .header("Authorization", &format!("Bearer {token}")) + .header("Accept", "application/json") + .send_json(body) + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(error) => { + diagnose::log_error(&format!("OpenRouter {endpoint} request failed"), error); + return Err(PollError::RequestFailed); + } + }; + response.body_mut().read_json::().map_err(|error| { + diagnose::log_error( + &format!("OpenRouter {endpoint} response is not valid JSON"), + error, + ); + PollError::RequestFailed + }) +} + +fn get_json Deserialize<'de>>( + url: &str, + token: &str, + endpoint: &str, +) -> Result { + let mut response = match build_agent()? + .get(url) + .header("Authorization", &format!("Bearer {token}")) + .header("Accept", "application/json") + .call() + .and_then(super::check_http_status) + { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return Err(PollError::AuthRequired), + Err(error) => { + diagnose::log_error(&format!("OpenRouter /{endpoint} request failed"), error); + return Err(PollError::RequestFailed); + } + }; + + response.body_mut().read_json::().map_err(|error| { + diagnose::log_error( + &format!("OpenRouter /{endpoint} response is not valid JSON"), + error, + ); + PollError::RequestFailed + }) +} + +/// Map OpenRouter account data into the shared usage model. +/// +/// OpenRouter has no five-hour window, so nothing claims the `session` slot. +/// The balance lands in `credits` (currency), while calendar-week and +/// calendar-month spend become the `weekly`/`monthly` gauges, normalised +/// against the account's total credits so the percentages stay meaningful. +/// Token counts and today's spend reach themes through opt-in `limits`. +fn usage_from_account( + credits: &CreditsData, + activity: &[ActivityEntry], + key: Option<&KeyData>, + budget: BudgetConfig, + today_days: i64, + analytics: Option<&[(i64, f64)]>, +) -> UsageData { + let total_credits = credits.total_credits.max(0.0); + let balance = (credits.total_credits - credits.total_usage).max(0.0); + + let (week_start, next_week) = week_bounds(today_days); + let (month_start, next_month) = month_bounds(today_days); + let month_days = (next_month - month_start).max(1) as u32; + + // Analytics gives live spend for every window. Without it, fall back to the + // completed-day activity sums plus a live today (credits delta or /key). + let (today_spend, week_spend, month_spend) = match analytics { + Some(rows) => ( + sum_days(rows, today_days, today_days + 1), + sum_days(rows, week_start, next_week), + sum_days(rows, month_start, next_month), + ), + None => { + let live_today = live_today_spend(credits.total_usage, today_days); + let today = live_today.max(key.map(|key| key.usage_daily.max(0.0)).unwrap_or(0.0)); + ( + today, + sum_usage_between(activity, week_start, next_week) + today, + sum_usage_between(activity, month_start, next_month) + today, + ) + } + }; + + // The budget amount is a rate per `budget_period`; convert it to whichever + // window is monitored, so a monthly cap can be watched day by day. + let daily_rate = budget + .amount + .filter(|amount| *amount > 0.0) + .map(|amount| amount / budget.budget_period.days(month_days)); + let cap_for = |period: BudgetPeriod| match daily_rate { + Some(rate) => rate * period.days(month_days), + None => total_credits, + }; + let spend_for = |period: BudgetPeriod| match period { + BudgetPeriod::Daily => today_spend, + BudgetPeriod::Weekly => week_spend, + BudgetPeriod::Monthly => month_spend, + }; + + let (monthly_percentage, weekly_percentage, credits_section) = match daily_rate { + Some(_) => { + let monitor_spend = spend_for(budget.monitor_period); + let monitor_cap = cap_for(budget.monitor_period); + ( + share_of_total(month_spend, cap_for(BudgetPeriod::Monthly)), + share_of_total(week_spend, cap_for(BudgetPeriod::Weekly)), + (monitor_cap > 0.0).then_some(CreditsSection { + percentage: share_of_total(monitor_spend, monitor_cap), + // Not clamped: themes derive `spent` from `total - remaining`, + // so this keeps the real spend visible when over the cap (the + // gauge percentage still clamps at 100). + remaining: monitor_cap - monitor_spend, + total: monitor_cap, + }), + ) + } + None => ( + share_of_total(month_spend, total_credits), + share_of_total(week_spend, total_credits), + (total_credits > 0.0).then_some(CreditsSection { + percentage: share_of_total(credits.total_usage, credits.total_credits), + remaining: balance, + total: total_credits, + }), + ), + }; + + let tokens = token_totals(activity); + + let mut limits = vec![UsageLimit { + key: "tokens".into(), + kind: "tokens".into(), + label: "Tokens".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "prompt_tokens": tokens.prompt, + "completion_tokens": tokens.completion, + "reasoning_tokens": tokens.reasoning, + "cached_tokens": tokens.cached, + "requests": tokens.requests, + })), + is_active: tokens.prompt + tokens.completion + tokens.reasoning + tokens.cached > 0, + usage: UsageSection { + available: true, + percentage: 0.0, + resets_at: days_to_system_time(next_month), + }, + }]; + + // Line 1 (daily) and line 2 (weekly or monthly) for the two-line OpenRouter + // display. Dollar amounts ride in the scope so themes can print them. + let long_period = budget.monitor_period; + let long_spend = spend_for(long_period); + let long_cap = cap_for(long_period); + let day_cap = cap_for(BudgetPeriod::Daily); + // A zero/absent amount turns the cap off: the gauge still falls back to the + // credit total, but the printed cap is hidden and shows 0. + let display_cap_for = |period: BudgetPeriod| { + if daily_rate.is_some() { + cap_for(period) + } else { + 0.0 + } + }; + let cap_visible = daily_rate.is_some() && budget.show_cap; + let long_label = match long_period { + BudgetPeriod::Daily => "1d", + BudgetPeriod::Monthly => "30d", + BudgetPeriod::Weekly => "7d", + }; + let long_reset = match long_period { + BudgetPeriod::Daily => today_days + 1, + BudgetPeriod::Monthly => next_month, + BudgetPeriod::Weekly => next_week, + }; + limits.push(UsageLimit { + key: "daily".into(), + kind: "daily".into(), + label: "1d".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "usage_usd": today_spend, + "cap_usd": display_cap_for(BudgetPeriod::Daily), + })), + is_active: true, + usage: UsageSection { + available: true, + percentage: share_of_total(today_spend, day_cap), + resets_at: days_to_system_time(today_days + 1), + }, + }); + limits.push(UsageLimit { + key: "long".into(), + kind: "window".into(), + label: long_label.into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "usage_usd": long_spend, + "cap_usd": display_cap_for(long_period), + "show_cap": if cap_visible { 1.0 } else { 0.0 }, + })), + is_active: true, + usage: UsageSection { + available: true, + percentage: share_of_total(long_spend, long_cap), + resets_at: days_to_system_time(long_reset), + }, + }); + + // Line 1: account balance. The bar uses the fraction still available, so it + // shrinks as spend grows (a fuel-gauge read of the remaining credits). + // The printed number is always the real account balance + // (total_credits - total_usage). A manually entered monthly deposit only + // re-scales the bar denominator; when unset the lifetime total is used. + let (acct_balance, acct_total) = (balance, budget.month_deposit.unwrap_or(total_credits)); + // With the toggle on, the balance line prints the deposit next to the + // balance; when no deposit is set it falls back to the accumulated account + // total (acct_total below). + let deposit_visible = budget.show_deposit; + let remaining_pct = if acct_total > 0.0 { + (acct_balance / acct_total * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + limits.push(UsageLimit { + key: "account".into(), + kind: "account".into(), + label: "Balance".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ + "balance_usd": acct_balance, + "total_usd": acct_total, + "remaining_pct": remaining_pct, + "show_deposit": if deposit_visible { 1.0 } else { 0.0 }, + })), + is_active: acct_total > 0.0, + usage: UsageSection { + available: acct_total > 0.0, + percentage: remaining_pct, + resets_at: None, + }, + }); + + if key.is_some() { + // `/key`'s `usage_daily` lags (often 0 while spending), so the "today" + // limit reuses the live value derived from the credits delta instead of + // the per-key counter. The limit only exists when a key is configured. + let daily = today_spend; + limits.push(UsageLimit { + key: "today".into(), + kind: "daily".into(), + label: "Today".into(), + model: None, + model_id: None, + scope: Some(serde_json::json!({ "usage_usd": daily })), + is_active: daily > 0.0, + usage: UsageSection { + available: true, + percentage: share_of_total(daily, total_credits), + resets_at: days_to_system_time(today_days + 1), + }, + }); + } + + UsageData { + limits, + // No five-hour window: the short slot stays empty. + session: UsageSection::default(), + weekly: UsageSection { + available: true, + percentage: weekly_percentage, + resets_at: days_to_system_time(next_week), + }, + weekly_label: Some("7d".into()), + monthly: Some(UsageSection { + available: true, + percentage: monthly_percentage, + resets_at: days_to_system_time(next_month), + }), + credits: credits_section, + stale: false, + ..Default::default() + } +} + +fn share_of_total(amount: f64, total: f64) -> f64 { + if !amount.is_finite() || total <= 0.0 || !total.is_finite() { + return 0.0; + } + (amount / total * 100.0).clamp(0.0, 100.0) +} + +#[derive(Default)] +struct TokenTotals { + prompt: u64, + completion: u64, + reasoning: u64, + cached: u64, + requests: u64, +} + +fn token_totals(activity: &[ActivityEntry]) -> TokenTotals { + activity + .iter() + .fold(TokenTotals::default(), |mut totals, entry| { + totals.prompt = totals.prompt.saturating_add(entry.prompt_tokens); + totals.completion = totals.completion.saturating_add(entry.completion_tokens); + totals.reasoning = totals.reasoning.saturating_add(entry.reasoning_tokens); + totals.cached = totals.cached.saturating_add(entry.cached_tokens); + totals.requests = totals.requests.saturating_add(entry.requests); + totals + }) +} + +fn sum_days(rows: &[(i64, f64)], start: i64, end: i64) -> f64 { + rows.iter() + .filter(|(day, usage)| *day >= start && *day < end && usage.is_finite() && *usage >= 0.0) + .map(|(_, usage)| *usage) + .sum() +} + +fn sum_usage_between(activity: &[ActivityEntry], start: i64, end: i64) -> f64 { + activity + .iter() + .filter_map(|entry| { + let days = date_days(&entry.date)?; + (days >= start && days < end && entry.usage.is_finite() && entry.usage >= 0.0) + .then_some(entry.usage) + }) + .sum() +} + +/// Monday-based week boundaries in days since 1970-01-01 (a Thursday). +fn week_bounds(today_days: i64) -> (i64, i64) { + let weekday = (today_days + 3).rem_euclid(7); + let start = today_days - weekday; + (start, start + 7) +} + +/// Calendar-month boundaries: first day of this month and of the next. +fn month_bounds(today_days: i64) -> (i64, i64) { + let (year, month, _) = civil_from_days(today_days); + let (next_year, next_month) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + ( + days_from_civil(year, month, 1).unwrap_or(today_days), + days_from_civil(next_year, next_month, 1).unwrap_or(today_days + 31), + ) +} + +fn days_to_system_time(days: i64) -> Option { + let seconds = u64::try_from(days.max(0)) + .ok()? + .checked_mul(SECONDS_PER_DAY as u64)?; + UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds)) +} + +fn today_days(now: SystemTime) -> i64 { + now.duration_since(UNIX_EPOCH) + .map(|duration| (duration.as_secs() / SECONDS_PER_DAY as u64) as i64) + .unwrap_or(0) +} + +/// Days since 1970-01-01 for a UTC date string. +/// +/// OpenRouter's `/activity` returns timestamps like `2026-10-21 00:00:00`; +/// earlier payloads used a bare `YYYY-MM-DD`. Only the leading date matters. +fn date_days(value: &str) -> Option { + let head = value.get(..10)?; + let bytes = head.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + let year = parse_digits(&bytes[0..4])? as i64; + let month = parse_digits(&bytes[5..7])?; + let day = parse_digits(&bytes[8..10])?; + days_from_civil(year, month, day) +} + +fn parse_digits(bytes: &[u8]) -> Option { + if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + bytes.iter().try_fold(0_u32, |value, byte| { + value.checked_mul(10)?.checked_add(u32::from(byte - b'0')) + }) +} + +fn days_from_civil(year: i64, month: u32, day: u32) -> Option { + if !(1970..=9999).contains(&year) + || !(1..=12).contains(&month) + || day == 0 + || day > days_in_month(year, month) + { + return None; + } + let mut days: i64 = 0; + for y in 1970..year { + days += if is_leap(y) { 366 } else { 365 }; + } + let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for m in 1..month { + days += i64::from(month_days[m as usize]); + if m == 2 && is_leap(year) { + days += 1; + } + } + Some(days + i64::from(day) - 1) +} + +fn days_in_month(year: i64, month: u32) -> u32 { + match month { + 2 if is_leap(year) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + _ => 0, + } +} + +fn is_leap(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// Howard Hinnant's civil-from-days algorithm, inverted for `days_from_civil`. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (year + i64::from(month <= 2), month, day) +} + +fn read_credentials() -> Option { + let management_key = resolve_management_key()?; + let api_key = non_empty_environment(API_KEY_ENV).filter(|key| is_header_safe_token(key)); + Some(Credentials { + management_key, + api_key, + }) +} + +/// Resolve the management key by trying the credential store first, then the +/// environment variable as a fallback. A value that fails token validation is +/// logged (without the value) and the next source is tried. +fn resolve_management_key() -> Option { + // Source 1: Windows Credential Manager. + match crate::secret_store::read_secret(MANAGEMENT_KEY_TARGET) { + Ok(Some(value)) => { + if is_header_safe_token(&value) { + return Some(value); + } + diagnose::log( + "OpenRouter management key from credential store contains characters \ + unsafe for a header; falling back to environment variable", + ); + } + Ok(None) => {} + Err(err) => { + diagnose::log(format!( + "OpenRouter credential store read failed; \ + falling back to environment variable: {err}" + )); + } + } + + // Source 2: environment variable. + let value = non_empty_environment(MANAGEMENT_KEY_ENV)?; + if is_header_safe_token(&value) { + return Some(value); + } + diagnose::log( + "OpenRouter management key from environment variable contains characters \ + unsafe for a header", + ); + None +} + +/// Tokens go into a header, so anything that could split it is unusable. +fn is_header_safe_token(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_graphic()) +} + +fn non_empty_environment(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn secret_signature(name: &str) -> String { + match non_empty_environment(name) { + Some(value) => { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{name}|present|{}|{:x}", value.len(), hasher.finish()) + } + None => format!("{name}|missing"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TODAY_2026_10_22: i64 = 20_748; // 2026-10-22 is a Thursday. + + fn credits(total_credits: f64, total_usage: f64) -> CreditsData { + CreditsData { + total_credits, + total_usage, + } + } + + #[test] + fn balance_maps_to_credits_and_week_month_spend_to_gauges() { + // 2026-10-19 (Mon) and 2026-10-20 fall inside this calendar week; + // 2026-09-30 is last month. + let activity = [ + ActivityEntry { + date: "2026-10-19".into(), + usage: 1.5, + prompt_tokens: 100, + completion_tokens: 200, + requests: 3, + ..Default::default() + }, + ActivityEntry { + date: "2026-10-20".into(), + usage: 2.5, + prompt_tokens: 50, + completion_tokens: 60, + reasoning_tokens: 10, + cached_tokens: 5, + requests: 1, + ..Default::default() + }, + ActivityEntry { + date: "2026-09-30".into(), + usage: 10.0, + prompt_tokens: 1, + completion_tokens: 1, + requests: 1, + ..Default::default() + }, + ]; + + let data = usage_from_account( + &credits(100.0, 20.0), + &activity, + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + + let credits = data.credits.expect("credits present"); + assert_eq!(credits.remaining, 80.0); + assert_eq!(credits.total, 100.0); + assert_eq!(credits.percentage, 20.0); + + // This week spends 4.0 of 100 credits; the month includes 2026-10-19/20 + // but not 2026-09-30. + assert_eq!(data.weekly.percentage, 4.0); + assert_eq!(data.monthly.as_ref().unwrap().percentage, 4.0); + assert!(data.weekly.available); + // No five-hour window. + assert!(!data.session.available); + assert_eq!(data.weekly_label.as_deref(), Some("7d")); + + // Tokens are aggregated across the whole activity window. + let tokens = data + .limits + .iter() + .find(|limit| limit.key == "tokens") + .unwrap(); + let scope = tokens.scope.as_ref().unwrap(); + assert_eq!(scope["prompt_tokens"], 151); + assert_eq!(scope["completion_tokens"], 261); + assert_eq!(scope["reasoning_tokens"], 10); + assert_eq!(scope["cached_tokens"], 5); + assert_eq!(scope["requests"], 5); + assert!(tokens.is_active); + } + + #[test] + fn monthly_budget_overrides_the_gauge_denominator() { + let activity = [ActivityEntry { + date: "2026-10-20 00:00:00".into(), + usage: 5.0, + ..Default::default() + }]; + let data = usage_from_account( + &credits(140.0, 0.0), + &activity, + None, + BudgetConfig { + amount: Some(20.0), + budget_period: BudgetPeriod::Monthly, + monitor_period: BudgetPeriod::Monthly, + show_cap: true, + month_deposit: None, + show_deposit: true, + }, + TODAY_2026_10_22, + None, + ); + // Monthly gauge: 5 / 20 = 25% instead of 5 / 140. + assert!((data.monthly.as_ref().unwrap().percentage - 25.0).abs() < 1e-9); + let credits = data.credits.expect("budget credits present"); + assert!((credits.percentage - 25.0).abs() < 1e-9); + assert!((credits.total - 20.0).abs() < 1e-9); + assert!((credits.remaining - 15.0).abs() < 1e-9); + } + + #[test] + fn key_endpoint_adds_today_spend_without_overriding_activity_windows() { + let key = KeyData { + usage_daily: 0.5, + ..Default::default() + }; + // Window spend is account-wide, from /activity, not from /key. + let activity = [ActivityEntry { + date: "2026-10-20 00:00:00".into(), // same week and month + usage: 4.0, + ..Default::default() + }]; + let data = usage_from_account( + &credits(200.0, 0.0), + &activity, + Some(&key), + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + + // Window spend includes /activity (4.0) plus today's /key spend (0.5). + assert!((data.weekly.percentage - 2.25).abs() < 1e-9); // 4.5 / 200 * 100 + assert!((data.monthly.as_ref().unwrap().percentage - 2.25).abs() < 1e-9); + + let today = data + .limits + .iter() + .find(|limit| limit.key == "today") + .expect("today limit present"); + assert_eq!(today.scope.as_ref().unwrap()["usage_usd"], 0.5); + assert!((today.usage.percentage - 0.25).abs() < 1e-9); // 0.5 / 200 * 100 + assert!(today.is_active); + } + + #[test] + fn zero_credits_suppress_the_balance_and_clamp_percentages() { + let data = usage_from_account( + &credits(0.0, 0.0), + &[], + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + assert!(data.credits.is_none()); + assert_eq!(data.weekly.percentage, 0.0); + assert_eq!(data.monthly.as_ref().unwrap().percentage, 0.0); + assert!(!data.session.available); + } + + #[test] + fn weekly_and_monthly_resets_land_on_utc_boundaries() { + let data = usage_from_account( + &credits(10.0, 0.0), + &[], + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + // 2026-10-22 is Thursday; the next Monday is 2026-10-26. + let expected_monday = days_to_system_time(date_days("2026-10-26").unwrap()); + assert_eq!(data.weekly.resets_at, expected_monday); + let expected_month = days_to_system_time(date_days("2026-11-01").unwrap()); + assert_eq!(data.monthly.as_ref().unwrap().resets_at, expected_month); + } + + #[test] + fn activity_dates_outside_the_windows_are_ignored_for_spend() { + let activity = [ + ActivityEntry { + date: "2026-10-12".into(), // last week (Monday 2026-10-12) + usage: 9.0, + ..Default::default() + }, + ActivityEntry { + date: "2026-10-21".into(), // this week + usage: 1.0, + ..Default::default() + }, + ]; + let data = usage_from_account( + &credits(10.0, 0.0), + &activity, + None, + BudgetConfig::default(), + TODAY_2026_10_22, + None, + ); + assert_eq!(data.weekly.percentage, 10.0); // only 1.0 / 10 + } + + #[test] + fn civil_date_helpers_round_trip() { + for (days, civil) in [ + (0_i64, (1970_i64, 1_u32, 1_u32)), + (date_days("2000-03-01").unwrap(), (2000, 3, 1)), + (date_days("2024-02-29").unwrap(), (2024, 2, 29)), + (date_days("2026-10-22").unwrap(), (2026, 10, 22)), + (date_days("2026-12-31").unwrap(), (2026, 12, 31)), + ] { + assert_eq!(civil_from_days(days), civil); + assert_eq!(days_from_civil(civil.0, civil.1, civil.2), Some(days)); + } + } + + #[test] + fn week_and_month_bounds_are_monday_and_first_of_month() { + assert_eq!(week_bounds(TODAY_2026_10_22), (20_745, 20_752)); // Mon 10-19 .. Mon 10-26 + assert_eq!( + month_bounds(TODAY_2026_10_22), + ( + date_days("2026-10-01").unwrap(), + date_days("2026-11-01").unwrap() + ) + ); + // December rolls into the next year. + let dec31 = date_days("2026-12-31").unwrap(); + assert_eq!( + month_bounds(dec31), + ( + date_days("2026-12-01").unwrap(), + date_days("2027-01-01").unwrap() + ) + ); + } + + #[test] + fn malformed_activity_dates_are_skipped() { + assert_eq!(date_days("2026-10-22"), Some(TODAY_2026_10_22)); + // Real /activity timestamps carry a time; only the date prefix counts. + assert_eq!(date_days("2026-10-21 00:00:00"), date_days("2026-10-21")); + for invalid in [ + "", + "2026-13-01", + "2026-00-10", + "2026-02-30", + "not-a-date", + "2026/10/22", + ] { + assert_eq!(date_days(invalid), None, "{invalid}"); + } + } + + #[test] + fn header_unsafe_tokens_are_refused() { + assert!(is_header_safe_token("sk-or-v1-fixture")); + assert!(!is_header_safe_token("")); + assert!(!is_header_safe_token("sk-or\r\nX-Test: injected")); + assert!(!is_header_safe_token("two words")); + } +} diff --git a/src/poller/tests.rs b/src/poller/tests.rs index e29c58b9..45765e98 100644 --- a/src/poller/tests.rs +++ b/src/poller/tests.rs @@ -131,6 +131,7 @@ fn claude_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -154,6 +155,7 @@ fn codex_failure_does_not_block_claude_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("claude data should keep the poll successful"); @@ -177,6 +179,7 @@ fn returns_first_error_when_no_enabled_provider_succeeds() { ProviderId::Cursor => Err(PollError::NoCredentials), ProviderId::Grok => Err(PollError::NoCredentials), ProviderId::Copilot => Err(PollError::NoCredentials), + ProviderId::OpenRouter => Err(PollError::NoCredentials), }, ) .expect_err("all-provider failure should return an error"); @@ -297,6 +300,7 @@ fn antigravity_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("codex data should keep the poll successful"); @@ -320,6 +324,7 @@ fn opencode_failure_does_not_block_codex_when_both_are_enabled() { ProviderId::Cursor => unreachable!("Cursor is disabled"), ProviderId::Grok => unreachable!("Grok is disabled"), ProviderId::Copilot => unreachable!("Copilot is disabled"), + ProviderId::OpenRouter => unreachable!("OpenRouter is disabled"), }, ) .expect("Codex data should keep the poll successful"); diff --git a/src/providers.rs b/src/providers.rs index aa685b6b..9d07c88a 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -16,6 +16,7 @@ pub enum ProviderId { Cursor = 4, Grok = 5, Copilot = 6, + OpenRouter = 7, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -34,7 +35,7 @@ pub struct ProviderDescriptor { pub default_enabled: bool, } -pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ +pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 8] = [ ProviderDescriptor { id: ProviderId::Claude, key: "claude", @@ -98,10 +99,19 @@ pub const PROVIDER_DESCRIPTORS: [ProviderDescriptor; 7] = [ native_menu_command_id: 66, default_enabled: false, }, + ProviderDescriptor { + id: ProviderId::OpenRouter, + key: "openrouter", + cache_key: "openrouter", + display_name: "OpenRouter", + settings_description: "Collect usage from OpenRouter", + native_menu_command_id: 69, + default_enabled: false, + }, ]; impl ProviderId { - pub const ALL: [Self; 7] = [ + pub const ALL: [Self; 8] = [ Self::Claude, Self::Codex, Self::Antigravity, @@ -109,6 +119,7 @@ impl ProviderId { Self::Cursor, Self::Grok, Self::Copilot, + Self::OpenRouter, ]; pub const fn descriptor(self) -> &'static ProviderDescriptor { @@ -256,4 +267,62 @@ mod tests { ); } } + + #[test] + fn provider_menu_command_ids_are_unique_and_do_not_collide_with_static_commands() { + use crate::window::{ + IDM_DASHBOARD, IDM_FREQ_15MIN, IDM_FREQ_1HOUR, IDM_FREQ_1MIN, IDM_FREQ_5MIN, + IDM_LANG_FIRST, IDM_LANG_SYSTEM, IDM_LOCK_TASKBAR, IDM_PROVIDER_ANTIGRAVITY_EXTERNAL, + IDM_PROVIDER_ANTIGRAVITY_GEMINI, IDM_START_WITH_WINDOWS, IDM_VERSION_ACTION, + }; + + let ids: Vec = PROVIDER_DESCRIPTORS + .iter() + .map(|d| d.native_menu_command_id) + .collect(); + + // All ids must be pairwise unique. + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!( + ids[i], ids[j], + "providers '{}' and '{}' share native_menu_command_id {}", + PROVIDER_DESCRIPTORS[i].key, PROVIDER_DESCRIPTORS[j].key, ids[i] + ); + } + } + + // No id may collide with a reserved static command id. + let reserved: &[u16] = &[ + 1, // WM_COMMAND refresh (posted directly) + 2, // WM_COMMAND exit (posted directly) + IDM_FREQ_1MIN, + IDM_FREQ_5MIN, + IDM_FREQ_15MIN, + IDM_FREQ_1HOUR, + IDM_START_WITH_WINDOWS, + IDM_LOCK_TASKBAR, + IDM_VERSION_ACTION, + IDM_PROVIDER_ANTIGRAVITY_GEMINI, + IDM_PROVIDER_ANTIGRAVITY_EXTERNAL, + IDM_DASHBOARD, + IDM_LANG_SYSTEM, + ]; + + for &id in &ids { + for &res in reserved { + assert_ne!( + id, res, + "provider native_menu_command_id {} collides with a reserved static command id", + id + ); + } + assert!( + id < IDM_LANG_FIRST, + "provider native_menu_command_id {} falls in the language id range (>= {})", + id, + IDM_LANG_FIRST + ); + } + } } diff --git a/src/secret_store.rs b/src/secret_store.rs new file mode 100644 index 00000000..1c39d6ae --- /dev/null +++ b/src/secret_store.rs @@ -0,0 +1,220 @@ +//! Windows Credential Manager integration. +//! +//! Secrets are stored as generic credentials in the per-user vault, protected +//! by DPAPI. The blob is the raw UTF-8 encoding of the secret value. + +use std::fmt; + +use windows::Win32::Foundation::ERROR_NOT_FOUND; +use windows::Win32::Security::Credentials::{ + CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS, + CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, +}; + +/// Error returned by Windows Credential Manager operations. +/// +/// Contains the operation name and Win32 error code; the secret value is +/// never included. +#[derive(Debug)] +pub struct SecretError { + operation: &'static str, + code: u32, +} + +impl fmt::Display for SecretError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "credential store {} failed: Win32 error {:#010x}", + self.operation, self.code + ) + } +} + +impl std::error::Error for SecretError {} + +impl SecretError { + fn new(operation: &'static str, code: u32) -> Self { + Self { operation, code } + } +} + +fn to_wide_null(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Store `secret` under `target` in the Windows Credential Manager. +/// +/// Uses `CRED_TYPE_GENERIC` and `CRED_PERSIST_LOCAL_MACHINE`. An existing +/// credential with the same target is silently overwritten. +/// +/// Returns an error if `secret` is empty or contains only whitespace. +pub fn write_secret(target: &str, secret: &str) -> Result<(), SecretError> { + if secret.trim().is_empty() { + return Err(SecretError::new("write", 87)); // ERROR_INVALID_PARAMETER + } + + let target_wide = to_wide_null(target); + let blob = secret.as_bytes(); + + let cred = CREDENTIALW { + Flags: CRED_FLAGS(0), + Type: CRED_TYPE_GENERIC, + TargetName: windows::core::PWSTR(target_wide.as_ptr() as *mut u16), + Comment: windows::core::PWSTR::null(), + LastWritten: Default::default(), + CredentialBlobSize: blob.len() as u32, + CredentialBlob: blob.as_ptr() as *mut u8, + Persist: CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: windows::core::PWSTR::null(), + UserName: windows::core::PWSTR::null(), + }; + + unsafe { CredWriteW(&cred, 0) }.map_err(|e| SecretError::new("write", e.code().0 as u32)) +} + +/// Retrieve the secret stored under `target` from the Windows Credential Manager. +/// +/// Returns `Ok(None)` when the credential does not exist. +pub fn read_secret(target: &str) -> Result, SecretError> { + let target_wide = to_wide_null(target); + let mut pcred: *mut CREDENTIALW = std::ptr::null_mut(); + + let result = unsafe { + CredReadW( + windows::core::PCWSTR(target_wide.as_ptr()), + CRED_TYPE_GENERIC, + Some(0), + &mut pcred, + ) + }; + + match result { + Err(e) if e.code() == ERROR_NOT_FOUND.to_hresult() => Ok(None), + Err(e) => Err(SecretError::new("read", e.code().0 as u32)), + Ok(()) => { + // Safety: CredReadW returned Ok so pcred points to a valid CREDENTIALW + // allocated by the system. CredFree must be called regardless of outcome. + let decoded = unsafe { + let cred = &*pcred; + let blob = if cred.CredentialBlobSize == 0 { + &[][..] + } else { + std::slice::from_raw_parts( + cred.CredentialBlob, + cred.CredentialBlobSize as usize, + ) + }; + let value = std::str::from_utf8(blob) + .map(str::to_owned) + .map_err(|_| SecretError::new("read", 13)); // ERROR_INVALID_DATA + CredFree(pcred as *const _); + value + }; + Ok(Some(decoded?)) + } + } +} + +/// Remove the secret stored under `target` from the Windows Credential Manager. +/// +/// A missing credential is treated as success (`Ok(())`). +pub fn delete_secret(target: &str) -> Result<(), SecretError> { + let target_wide = to_wide_null(target); + + let result = unsafe { + CredDeleteW( + windows::core::PCWSTR(target_wide.as_ptr()), + CRED_TYPE_GENERIC, + Some(0), + ) + }; + + match result { + Err(e) if e.code() == ERROR_NOT_FOUND.to_hresult() => Ok(()), + Err(e) => Err(SecretError::new("delete", e.code().0 as u32)), + Ok(()) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The credential vault is a shared OS resource and does not enjoy six + /// concurrent readers/writers, so the tests take turns. + static VAULT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn vault_guard() -> std::sync::MutexGuard<'static, ()> { + VAULT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn test_target(name: &str) -> String { + format!( + "ClaudeCodeUsageMonitor/test/{}/{}", + std::process::id(), + name + ) + } + + #[test] + fn write_then_read_returns_same_value() { + let _guard = vault_guard(); + let target = test_target("write_read"); + write_secret(&target, "test-value-abc").unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some("test-value-abc")); + delete_secret(&target).unwrap(); + } + + #[test] + fn read_unknown_target_returns_none() { + let _guard = vault_guard(); + let target = test_target("read_unknown"); + let result = read_secret(&target).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn write_delete_read_returns_none() { + let _guard = vault_guard(); + let target = test_target("write_delete_read"); + write_secret(&target, "ephemeral-secret").unwrap(); + delete_secret(&target).unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn write_twice_overwrites() { + let _guard = vault_guard(); + let target = test_target("overwrite"); + write_secret(&target, "first-value").unwrap(); + write_secret(&target, "second-value").unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some("second-value")); + delete_secret(&target).unwrap(); + } + + #[test] + fn delete_unknown_target_is_ok() { + let _guard = vault_guard(); + let target = test_target("delete_unknown"); + assert!(delete_secret(&target).is_ok()); + } + + #[test] + fn non_ascii_secret_round_trips() { + let _guard = vault_guard(); + let target = test_target("non_ascii"); + let secret = "日本語テスト🔑"; + write_secret(&target, secret).unwrap(); + let result = read_secret(&target).unwrap(); + assert_eq!(result.as_deref(), Some(secret)); + delete_secret(&target).unwrap(); + } +} diff --git a/src/studio_app.rs b/src/studio_app.rs index 9372142e..e1fe5626 100644 --- a/src/studio_app.rs +++ b/src/studio_app.rs @@ -622,6 +622,13 @@ struct StudioApp { context_menu_dirty: bool, context_menu_selection: Option>, delete_context_menu_confirmation: Option<(PathBuf, String)>, + openrouter_key_buffer: String, + openrouter_connection_status: Option, + openrouter_testing: bool, + openrouter_test_pending: Option< + std::sync::Arc>>>, + >, + openrouter_test_label: Option, } mod studio_assets; diff --git a/src/studio_app/studio_core.rs b/src/studio_app/studio_core.rs index 997f5a92..6bf8eee0 100644 --- a/src/studio_app/studio_core.rs +++ b/src/studio_app/studio_core.rs @@ -172,6 +172,11 @@ impl StudioApp { context_menu_dirty: false, context_menu_selection: None, delete_context_menu_confirmation: None, + openrouter_key_buffer: String::new(), + openrouter_connection_status: None, + openrouter_testing: false, + openrouter_test_pending: None, + openrouter_test_label: None, } } diff --git a/src/studio_app/studio_settings.rs b/src/studio_app/studio_settings.rs index fec8218a..6eecbcc3 100644 --- a/src/studio_app/studio_settings.rs +++ b/src/studio_app/studio_settings.rs @@ -1,3 +1,5 @@ +use std::sync::{Arc, Mutex}; + use super::*; use crate::providers::ProviderId; @@ -5,6 +7,7 @@ impl StudioApp { pub(super) fn settings_page(&mut self, ui: &mut egui::Ui) { let language = self.language(); let mut changed = false; + let mut budget_changed = false; let mut requested_theme = None; let mut open_theme_studio = false; let themes = self.available_themes(); @@ -97,6 +100,283 @@ impl StudioApp { ); }); } + if descriptor.id == ProviderId::OpenRouter { + let provider_enabled = self.settings.provider_enabled(descriptor.id); + let key_stored = crate::secret_store::read_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ) + .ok() + .flatten() + .is_some(); + let mut connection_refresh = false; + egui::CollapsingHeader::new(language.text("Connection")) + .default_open(provider_enabled && !key_stored) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(language.text("Management key")); + ui.add( + egui::TextEdit::singleline( + &mut self.openrouter_key_buffer, + ) + .password(true) + .desired_width(200.0), + ); + if ui.button(language.text("Save")).clicked() { + match crate::secret_store::write_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + self.openrouter_key_buffer.trim(), + ) { + Ok(()) => { + self.openrouter_key_buffer.clear(); + connection_refresh = true; + self.openrouter_connection_status = Some( + language + .text("Credential stored in Windows Credential Manager") + .to_string(), + ); + } + Err(err) => { + self.openrouter_connection_status = + Some(err.to_string()); + } + } + } + if ui.button(language.text("Test")).clicked() { + let test_key = + if !self.openrouter_key_buffer.trim().is_empty() { + self.openrouter_key_buffer.trim().to_string() + } else { + crate::secret_store::read_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ) + .ok() + .flatten() + .unwrap_or_default() + }; + if !test_key.is_empty() { + let result_slot: Arc< + Mutex< + Option< + Result< + (f64, f64), + crate::poller::PollError, + >, + >, + >, + > = Arc::new(Mutex::new(None)); + self.openrouter_test_pending = + Some(result_slot.clone()); + self.openrouter_testing = true; + self.openrouter_test_label = None; + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + let outcome = crate::poller::openrouter::probe_management_key(&test_key); + if let Ok(mut guard) = result_slot.lock() { + *guard = Some(outcome); + } + ctx.request_repaint(); + }); + } + } + if ui.button(language.text("Remove stored key")).clicked() { + let _ = crate::secret_store::delete_secret( + crate::poller::openrouter::MANAGEMENT_KEY_TARGET, + ); + connection_refresh = true; + self.openrouter_connection_status = None; + self.openrouter_test_label = None; + } + }); + + // Poll test result each frame. Clone the Arc so the + // temporary borrow of openrouter_test_pending ends + // before we write to it. + if self.openrouter_testing { + if let Some(arc) = self.openrouter_test_pending.clone() { + if let Ok(mut guard) = arc.try_lock() { + if let Some(result) = guard.take() { + drop(guard); + self.openrouter_testing = false; + self.openrouter_test_pending = None; + self.openrouter_test_label = + Some(match result { + Ok((credits, usage)) => format!( + "{} · {} ${:.2} · ${:.2} {}", + language.text("Connected"), + language.text("BALANCE"), + credits, + usage, + language.text("USED"), + ), + Err(err) => err.message(language), + }); + } + } + } + } + + if self.openrouter_testing { + ui.weak(language.text("Testing...")); + } else if let Some(ref label) = self.openrouter_test_label.clone() + { + ui.label(label); + } + + if key_stored { + ui.weak(language.text( + "Credential stored in Windows Credential Manager", + )); + } else { + ui.weak(language.text( + "No key configured. Create a Management API key on OpenRouter and paste it here.", + )); + } + + if ui + .button(language.text("Get OpenRouter management key")) + .clicked() + { + crate::native_interop::open_web_url( + None, + "https://openrouter.ai/settings/management-keys", + ); + } + }); + if connection_refresh { + self.request_refresh(); + } + + let period_key = |period: app_settings::BudgetPeriod| match period { + app_settings::BudgetPeriod::Daily => "Daily", + app_settings::BudgetPeriod::Weekly => "Weekly", + app_settings::BudgetPeriod::Monthly => "Monthly", + }; + egui::CollapsingHeader::new(language.text("Budget")) + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(language.text("Cap amount")); + let mut amount = + self.settings.openrouter_budget_amount.unwrap_or(0.0); + let response = NumberField::new(&mut amount) + .range(0.0..=1_000_000.0) + .speed(1.0) + .prefix("$") + .show(ui, 110.0); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.changed() { + self.settings.openrouter_budget_amount = + (amount > 0.0).then_some(amount); + changed = true; + } + // Enter (or ending a drag / leaving the field) recalculates + // the displayed cap right away with the current periods. + if response.drag_stopped() + || response.lost_focus() + || (enter && response.changed()) + { + budget_changed = true; + } + ui.weak(language.text("0 turns the spending cap off")); + }); + for (label, field) in [ + ("Cap period", &mut self.settings.openrouter_budget_period), + ("Monitor period", &mut self.settings.openrouter_monitor_period), + ] { + ui.horizontal(|ui| { + ui.label(language.text(label)); + let selected = language.text(period_key(*field)); + let salt = if label == "Cap period" { + "openrouter_budget_period" + } else { + "openrouter_monitor_period" + }; + let mut picked = false; + Dropdown::from_id_salt(salt) + .width(150.0) + .selected_text(selected) + .show_ui(ui, |ui| { + for period in [ + app_settings::BudgetPeriod::Daily, + app_settings::BudgetPeriod::Weekly, + app_settings::BudgetPeriod::Monthly, + ] { + picked |= dropdown_selectable_value( + ui, + field, + period, + language.text(period_key(period)), + ) + .changed(); + } + }); + if picked { + changed = true; + budget_changed = true; + } + }); + } + if ui + .checkbox( + &mut self.settings.openrouter_show_cap, + language.text("Show cap in the monitor"), + ) + .changed() + { + changed = true; + budget_changed = true; + } + ui.horizontal(|ui| { + ui.label(language.text("Monthly credits deposit")); + let mut deposit = + self.settings.openrouter_monthly_deposit.unwrap_or(0.0); + let response = NumberField::new(&mut deposit) + .range(0.0..=1_000_000.0) + .speed(1.0) + .prefix("$") + .show(ui, 110.0); + let enter = + ui.input(|input| input.key_pressed(egui::Key::Enter)); + if response.changed() { + self.settings.openrouter_monthly_deposit = + (deposit > 0.0).then_some(deposit); + changed = true; + } + if response.drag_stopped() + || response.lost_focus() + || (enter && response.changed()) + { + budget_changed = true; + } + ui.weak(language.text("0 uses the accumulated account balance")); + }); + if ui + .checkbox( + &mut self.settings.openrouter_show_deposit, + language.text("Show credits deposit in the monitor"), + ) + .changed() + { + changed = true; + budget_changed = true; + } + ui.add_space(4.0); + let reset_time = crate::theme_engine::local_time_of_utc_midnight(); + ui.add( + egui::Label::new( + egui::RichText::new( + language + .text( + "OpenRouter counts daily spend in UTC: the day rolls over at 00:00 UTC, which is {time} on this device, so the daily value starts over then.", + ) + .replace("{time}", &reset_time), + ) + .weak(), + ) + .wrap(), + ); + }); + } } }); section(ui, language.text("Display"), |ui| { @@ -198,6 +478,9 @@ impl StudioApp { self.preview_dirty = true; self.save_settings(); } + if budget_changed { + self.request_refresh(); + } if let Some(path) = requested_theme { self.request_activate_theme(path); } diff --git a/src/studio_app/tests.rs b/src/studio_app/tests.rs index 76b1e836..8257deb7 100644 --- a/src/studio_app/tests.rs +++ b/src/studio_app/tests.rs @@ -487,6 +487,11 @@ fn app_with_surfaces(surfaces: Vec) -> StudioApp { context_menu_dirty: false, context_menu_selection: None, delete_context_menu_confirmation: None, + openrouter_key_buffer: String::new(), + openrouter_connection_status: None, + openrouter_testing: false, + openrouter_test_pending: None, + openrouter_test_label: None, } } diff --git a/src/theme_engine.rs b/src/theme_engine.rs index d09e4bcf..0b0f1ef7 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1640,6 +1640,57 @@ impl DataContext { // that has been spent so warning thresholds keep working, while // `display` follows the countdown setting. self.insert_limits(name, usage, countdown); + // Daily / long-window spend for the two-line OpenRouter display. Always + // bound (0 when absent) so themes validate without live usage. + self.insert( + &format!("{name}.daily.display"), + limit_display(usage, "daily", countdown), + ); + self.insert( + &format!("{name}.daily.spent_usd"), + limit_scope_value(usage, "daily", "usage_usd"), + ); + self.insert( + &format!("{name}.daily.cap_usd"), + limit_scope_value(usage, "daily", "cap_usd"), + ); + self.insert( + &format!("{name}.long.display"), + limit_display(usage, "long", countdown), + ); + self.insert( + &format!("{name}.long.spent_usd"), + limit_scope_value(usage, "long", "usage_usd"), + ); + self.insert( + &format!("{name}.long.cap_usd"), + limit_scope_value(usage, "long", "cap_usd"), + ); + self.insert( + &format!("{name}.long.show_cap"), + limit_scope_value(usage, "long", "show_cap"), + ); + self.insert_string( + &format!("{name}.long.label"), + limit_label(usage, "long").as_deref().unwrap_or("30d"), + ); + // Account balance (line 1). Always bound so themes validate without usage. + self.insert( + &format!("{name}.account.remaining_pct"), + limit_scope_value(usage, "account", "remaining_pct"), + ); + self.insert( + &format!("{name}.account.balance_usd"), + limit_scope_value(usage, "account", "balance_usd"), + ); + self.insert( + &format!("{name}.account.total_usd"), + limit_scope_value(usage, "account", "total_usd"), + ); + self.insert( + &format!("{name}.account.show_deposit"), + limit_scope_value(usage, "account", "show_deposit"), + ); let display = |percentage: f64| { if countdown { 100.0 - percentage @@ -1764,6 +1815,12 @@ impl DataContext { &format!("{name}.credits.balance"), credits.map(|credits| credits.remaining).unwrap_or(0.0), ); + self.insert( + &format!("{name}.credits.spent"), + credits + .map(|credits| (credits.total - credits.remaining).max(0.0)) + .unwrap_or(0.0), + ); self.insert( &format!("{name}.credits.total"), credits.map(|credits| credits.total).unwrap_or(0.0), @@ -2028,6 +2085,58 @@ fn set_mouse_property_expression( } = expression; } +fn limit_display(usage: Option<&crate::models::UsageData>, key: &str, countdown: bool) -> f64 { + let percentage = usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .map(|limit| limit.usage.percentage) + .unwrap_or(0.0); + if countdown { + 100.0 - percentage + } else { + percentage + } +} + +fn limit_scope_value(usage: Option<&crate::models::UsageData>, key: &str, field: &str) -> f64 { + usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .and_then(|limit| limit.scope.as_ref()) + .and_then(|scope| scope.get(field)) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0) +} + +fn limit_label(usage: Option<&crate::models::UsageData>, key: &str) -> Option { + usage + .and_then(|usage| usage.limits.iter().find(|limit| limit.key == key)) + .map(|limit| limit.label.clone()) + .filter(|label| !label.is_empty()) +} + +/// Local clock time matching OpenRouter's 00:00 UTC daily reset (e.g. "21:00" +/// for UTC-3). Keeps the settings hint correct on any device timezone. +pub fn local_time_of_utc_midnight() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_secs_f64()) + .unwrap_or(0.0); + let (Some(local), Some(utc)) = ( + theme_datetime::timestamp_parts(now, true), + theme_datetime::timestamp_parts(now, false), + ) else { + return "00:00".into(); + }; + let mut offset = i32::from(local.hour) * 60 + i32::from(local.minute) + - (i32::from(utc.hour) * 60 + i32::from(utc.minute)); + if offset > 12 * 60 { + offset -= 24 * 60; + } else if offset < -12 * 60 { + offset += 24 * 60; + } + let reset = ((offset % (24 * 60)) + 24 * 60) % (24 * 60); + format!("{:02}:{:02}", reset / 60, reset % 60) +} + pub fn validate_mouse_action_script( source: &str, theme: &ThemeDocument, diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index a8bc40b9..5af618b7 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -892,14 +892,12 @@ fn starter_theme_round_trips_and_validates() { .collect::>(); // Classic contains separate light and dark progress layers so the // 1.4.9 palette follows the taskbar mode without runtime recolouring: - // five providers over two windows in two modes, plus Grok and Copilot, - // which fill only the long-window row, plus a credit overlay on that row - // for the three providers that report credits, plus the two eight-segment - // Fable layers. - assert_eq!( - segments, - [vec![10; 5 * 2 * 2 + 2 * 2 + 3 * 2], vec![8; 2]].concat() - ); + // five providers over two windows in two modes, plus Grok, Copilot and + // OpenRouter, which fill only the long-window row, plus a credit overlay + // on that row for the four providers that report credits, plus the two + // eight-segment Fable layers. + // The Fable layers sit just before OpenRouter now that OpenRouter is last. + assert_eq!(segments, [vec![10; 30], vec![8; 2], vec![10; 4]].concat()); assert!(theme.surfaces[0] .children .iter() @@ -1833,7 +1831,7 @@ fn starter_adapts_width_segments_and_collapsed_provider_rows() { ), ( ThemeRuntime::from_providers(ProviderSet::from_enabled(ProviderId::ALL)), - 725, + 827, 2, ), ] { @@ -2027,7 +2025,7 @@ fn classic_vertical_stacks_enabled_providers_and_preserves_tray_roots() { for host_width in [46, 62, 100] { let runtime = ThemeRuntime::from_providers(providers).with_host_dimensions(host_width, 1440); - let width = (host_width - 2).min(62); + let width = (host_width - 2).min(92); let provider_height = |provider| match provider { ProviderId::Grok | ProviderId::Copilot => 42, _ => 64, @@ -2161,19 +2159,36 @@ fn compact_fluent_quad_widget_respects_usage_direction() { UsageData { session: section.clone(), weekly: section.clone(), - limits: (provider == ProviderId::Claude) - .then(|| UsageLimit { - key: "weekly_scoped_fable".into(), - kind: "weekly_scoped".into(), - label: "Fable".into(), - model: Some("Fable".into()), - model_id: None, - scope: None, - is_active: true, - usage: section.clone(), - }) - .into_iter() - .collect(), + limits: { + let mut limits: Vec = (provider == ProviderId::Claude) + .then(|| UsageLimit { + key: "weekly_scoped_fable".into(), + kind: "weekly_scoped".into(), + label: "Fable".into(), + model: Some("Fable".into()), + model_id: None, + scope: None, + is_active: true, + usage: section.clone(), + }) + .into_iter() + .collect(); + // The OpenRouter column draws one line per window. + for key in ["daily", "long", "account"] { + limits.push(UsageLimit { + key: key.into(), + kind: key.into(), + label: "1d".into(), + model: None, + model_id: None, + scope: (key == "account") + .then(|| serde_json::json!({ "remaining_pct": 25.0 })), + is_active: true, + usage: section.clone(), + }); + } + limits + }, credits: Some(CreditsSection { percentage: 25.0, remaining: 24.1, @@ -2199,7 +2214,9 @@ fn compact_fluent_quad_widget_respects_usage_direction() { ); for object in &theme.surfaces[0].children { match &object.content { - SceneContent::Progress { value, .. } => { + // The balance bar intentionally shows the remaining fraction, + // so it does not follow the global count-up/count-down flip. + SceneContent::Progress { value, .. } if !value.0.contains("remaining_pct") => { assert_eq!( evaluate(&value.0, &context).unwrap(), expected, diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index d0841ff3..a07d71d2 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -523,6 +523,7 @@ pub(super) fn format_usage_line(base: &str, context: &DataContext) -> Option S if let Some(value) = format_timestamp(value, format, context) { return value; } + if format.eq_ignore_ascii_case("amount") { + return if value.fract().abs() < 1e-9 { + format!("{value:.0}") + } else { + format!("{value:.2}") + }; + } if format.eq_ignore_ascii_case("duration_short") { let seconds = value.max(0.0).round() as u64; let days = seconds / 86_400; diff --git a/src/theme_engine/theme_limits.rs b/src/theme_engine/theme_limits.rs index 1277ee11..fbcb18e2 100644 --- a/src/theme_engine/theme_limits.rs +++ b/src/theme_engine/theme_limits.rs @@ -74,6 +74,15 @@ impl DataContext { .map(serde_json::Value::to_string) .unwrap_or_default(), ); + // Dollar amounts and display flags carried in the scope become direct + // bindings so themes can print them (e.g. `openrouter.limits.long.usage_usd`). + if let Some(scope) = limit.scope.as_ref().and_then(|value| value.as_object()) { + for (field, value) in scope { + if let Some(value) = value.as_f64() { + self.insert(&format!("{base}.{field}"), value); + } + } + } let unix = limit .usage .resets_at @@ -118,6 +127,7 @@ impl DataContext { | "cursor" | "grok" | "copilot" + | "openrouter" | "active" ) { return None; diff --git a/src/themes/classic-usage-widget.json b/src/themes/classic-usage-widget.json index 3cde50a2..213d50f4 100644 --- a/src/themes/classic-usage-widget.json +++ b/src/themes/classic-usage-widget.json @@ -19,7 +19,7 @@ "offset_x": 0, "offset_y": 0 }, - "width": "42 + providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 3 + (providers.opencode.enabled + providers.cursor.enabled) * 28 + providers.claude.enabled * claude.model.fable.available * 93", + "width": "42 + providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 3 + (providers.opencode.enabled + providers.cursor.enabled) * 28 + providers.claude.enabled * claude.model.fable.available * 93 + providers.openrouter.enabled * (48 - (1 - openrouter.long.show_cap) * 36)", "height": "46", "mouse_events": { "right_click": "show_context_menu(\"classic-v1\")" @@ -1807,6 +1807,430 @@ "color": "#5B4B8AFF" } } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter provider", + "render": "providers.openrouter.enabled", + "visibility": "100", + "parent": "provider-row", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + (110 - (1 - openrouter.long.show_cap) * 36)", + "height": "46", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "openrouter-account-segments-dark", + "name": "dark OpenRouter balance segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "5", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-dark", + "name": "dark OpenRouter balance with deposit", + "render": "(system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-dark", + "name": "dark OpenRouter balance only", + "render": "(system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-segments-light", + "name": "light OpenRouter balance segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "5", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-light", + "name": "light OpenRouter balance with deposit", + "render": "(1 - system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-light", + "name": "light OpenRouter balance only", + "render": "(1 - system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "5", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-dark", + "name": "dark OpenRouter window segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "28", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "dark OpenRouter window value with cap", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "dark OpenRouter window value", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-light", + "name": "light OpenRouter window segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "28", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "2", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-light", + "name": "light OpenRouter window value with cap", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "light OpenRouter window value", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "28", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } } ] }, diff --git a/src/themes/classic-vertical.json b/src/themes/classic-vertical.json index 58a1f80b..8a30a70d 100644 --- a/src/themes/classic-vertical.json +++ b/src/themes/classic-vertical.json @@ -19,7 +19,7 @@ "offset_x": 0, "offset_y": -52 }, - "width": "min(62, max(1, host.width - 2))", + "width": "min(92, max(1, host.width - 2))", "height": "max(1, providers.count * 64 - (providers.grok.enabled + providers.copilot.enabled) * 22)", "mouse_events": { "right_click": "show_context_menu(\"classic-v1\")" @@ -1225,6 +1225,314 @@ }, "corner_radius": "1" } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter", + "parent": "provider-column", + "render": "providers.openrouter.enabled", + "width": "canvas.width", + "height": "64" + }, + { + "id": "openrouter-title", + "name": "openrouter-title", + "parent": "openrouter-provider", + "render": "system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "OpenRouter", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-title-light", + "name": "openrouter-title-light", + "parent": "openrouter-provider", + "render": "1 - system.dark", + "x": "2", + "y": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "content": { + "type": "text", + "template": "OpenRouter", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-account-value-dark", + "name": "OpenRouter balance (dark)", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "18", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}·${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-account-value-light", + "name": "OpenRouter balance (light)", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "18", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}·${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-account-bar", + "name": "OpenRouter balance bar", + "render": "1", + "visibility": "100", + "parent": "openrouter-provider", + "y": "33", + "x": "4", + "width": "max(1, canvas.width - 8)", + "height": "3", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84A" + }, + "track": { + "color": "#80808040" + }, + "corner_radius": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "OpenRouter window with cap (dark)", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}·${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "OpenRouter window (dark)", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#A8B84AFF" + } + } + }, + { + "id": "openrouter-long-cap-light", + "name": "OpenRouter window with cap (light)", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}·${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "OpenRouter window (light)", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "y": "40", + "x": "2", + "width": "max(1, canvas.width - 4)", + "height": "14", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "min(10, (canvas.width - 4) / 5.5)", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "center", + "color": { + "color": "#4A5512FF" + } + } + }, + { + "id": "openrouter-long-bar", + "name": "OpenRouter window bar", + "render": "1", + "visibility": "100", + "parent": "openrouter-provider", + "y": "55", + "x": "4", + "width": "max(1, canvas.width - 8)", + "height": "3", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84A" + }, + "track": { + "color": "#80808040" + }, + "corner_radius": "1" + } } ] }, diff --git a/src/themes/compact-fluent-quad.json b/src/themes/compact-fluent-quad.json index 55250224..ff116940 100644 --- a/src/themes/compact-fluent-quad.json +++ b/src/themes/compact-fluent-quad.json @@ -21,7 +21,7 @@ "offset_x": 6, "offset_y": 0 }, - "width": "providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 6 + providers.opencode.enabled * 28 + providers.cursor.enabled * 18 + providers.claude.enabled * claude.model.fable.available * 96", + "width": "providers.count * (ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + 62) + max(0, providers.count - 1) * 6 + providers.opencode.enabled * 28 + providers.cursor.enabled * 18 + providers.claude.enabled * claude.model.fable.available * 96 + providers.openrouter.enabled * (48 - (1 - openrouter.long.show_cap) * 36)", "height": "46", "background": { "type": "none" @@ -2919,6 +2919,430 @@ "opacity": "1" } } + }, + { + "id": "openrouter-provider", + "name": "OpenRouter provider", + "render": "providers.openrouter.enabled", + "visibility": "100", + "parent": "provider-row", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4 + (110 - (1 - openrouter.long.show_cap) * 36)", + "height": "46", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "none" + } + }, + { + "id": "openrouter-account-segments-dark", + "name": "dark OpenRouter balance segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "11", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-dark", + "name": "dark OpenRouter balance with deposit", + "render": "(system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-dark", + "name": "dark OpenRouter balance only", + "render": "(system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-segments-light", + "name": "light OpenRouter balance segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "11", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.account.remaining_pct", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-account-dep-light", + "name": "light OpenRouter balance with deposit", + "render": "(1 - system.dark) && openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount} · ${openrouter.account.total_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-account-nodep-light", + "name": "light OpenRouter balance only", + "render": "(1 - system.dark) && !openrouter.account.show_deposit", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "${openrouter.account.balance_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-dark", + "name": "dark OpenRouter window segments", + "render": "system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#A8B84AFF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-dark", + "name": "dark OpenRouter window value with cap", + "render": "(system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-dark", + "name": "dark OpenRouter window value", + "render": "(system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#A8B84AFF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-segments-light", + "name": "light OpenRouter window segments", + "render": "1 - system.dark", + "visibility": "100", + "parent": "openrouter-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "openrouter.long.display", + "direction": "left_to_right", + "fill": { + "color": "#4A5512FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "openrouter-long-cap-light", + "name": "light OpenRouter window value with cap", + "render": "(1 - system.dark) && openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount} · ${openrouter.long.cap_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } + }, + { + "id": "openrouter-long-nocap-light", + "name": "light OpenRouter window value", + "render": "(1 - system.dark) && !openrouter.long.show_cap", + "visibility": "100", + "parent": "openrouter-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "110 - (1 - openrouter.long.show_cap) * 36", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{openrouter.long.label} ${openrouter.long.spent_usd:amount}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#4A5512FF", + "opacity": "1" + } + } } ] }, diff --git a/src/ui/components/number_field.rs b/src/ui/components/number_field.rs index 8cee4b5a..6bd83d50 100644 --- a/src/ui/components/number_field.rs +++ b/src/ui/components/number_field.rs @@ -31,6 +31,11 @@ impl<'a> NumberField<'a> { self } + pub(crate) fn prefix(mut self, prefix: &'a str) -> Self { + self.widget = self.widget.prefix(prefix); + self + } + /// Shows the field in a row whose height cannot change when keyboard /// editing replaces the drag button with a text field. pub(crate) fn show(self, ui: &mut egui::Ui, width: f32) -> egui::Response { diff --git a/src/ui/theme.rs b/src/ui/theme.rs index f9f7a449..9ca5fa7d 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -62,6 +62,7 @@ pub(crate) fn provider_mark_glyph(provider: ProviderId) -> char { ProviderId::Cursor => '\u{f005}', ProviderId::Grok => '\u{f006}', ProviderId::Copilot => '\u{f007}', + ProviderId::OpenRouter => '\u{f008}', } } diff --git a/src/window.rs b/src/window.rs index e9eff271..f95026d6 100644 --- a/src/window.rs +++ b/src/window.rs @@ -201,19 +201,19 @@ fn perform_update_action(hwnd: HWND) { const RETRY_BASE_MS: u32 = 30_000; // 30 seconds // Menu item IDs for update frequency -const IDM_FREQ_1MIN: u16 = 10; -const IDM_FREQ_5MIN: u16 = 11; -const IDM_FREQ_15MIN: u16 = 12; -const IDM_FREQ_1HOUR: u16 = 13; -const IDM_START_WITH_WINDOWS: u16 = 20; -const IDM_LOCK_TASKBAR: u16 = 21; -const IDM_VERSION_ACTION: u16 = 31; -const IDM_LANG_SYSTEM: u16 = 100; -const IDM_LANG_FIRST: u16 = 101; -const IDM_DASHBOARD: u16 = 71; +pub(crate) const IDM_FREQ_1MIN: u16 = 10; +pub(crate) const IDM_FREQ_5MIN: u16 = 11; +pub(crate) const IDM_FREQ_15MIN: u16 = 12; +pub(crate) const IDM_FREQ_1HOUR: u16 = 13; +pub(crate) const IDM_START_WITH_WINDOWS: u16 = 20; +pub(crate) const IDM_LOCK_TASKBAR: u16 = 21; +pub(crate) const IDM_VERSION_ACTION: u16 = 31; +pub(crate) const IDM_LANG_SYSTEM: u16 = 100; +pub(crate) const IDM_LANG_FIRST: u16 = 101; +pub(crate) const IDM_DASHBOARD: u16 = 71; // 62 stays the whole-provider Antigravity toggle used by saved custom menus. -pub(super) const IDM_PROVIDER_ANTIGRAVITY_GEMINI: u16 = 68; -pub(super) const IDM_PROVIDER_ANTIGRAVITY_EXTERNAL: u16 = 67; +pub(crate) const IDM_PROVIDER_ANTIGRAVITY_GEMINI: u16 = 68; +pub(crate) const IDM_PROVIDER_ANTIGRAVITY_EXTERNAL: u16 = 67; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -3337,9 +3337,10 @@ fn reload_external_settings(hwnd: HWND) { // previously ejected widget so the lock takes effect at once. redock_locked_widget(hwnd); } - if providers_changed { - request_poll(hwnd); - } + // The OpenRouter budget, cap and deposit are baked into the poll result, so + // any settings edit needs a re-poll - not just provider visibility changes. + // `request_poll` coalesces while a poll is in flight. + request_poll(hwnd); sync_tray_icon(hwnd); position_at_taskbar(); render_layered();