diff --git a/CLAUDE.md b/CLAUDE.md index 23ef8532..325d587f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # onWatch -Go CLI for AI quota tracking. Polls 18 providers → SQLite → Material Design 3 dashboard. +Go CLI for AI quota tracking. Polls 19 providers → SQLite → Material Design 3 dashboard. ## Task -Background daemon (<50MB RAM) tracking: Anthropic, Synthetic, Z.ai, Copilot, Codex, MiniMax, Antigravity, Gemini, Cursor, Kimi Code, Grok, Moonshot, DeepSeek, OpenRouter, OpenCode Go, Ollama Cloud, Muse, Command Code. +Background daemon (<50MB RAM) tracking: Anthropic, Synthetic, Z.ai, Copilot, Codex, MiniMax, Antigravity, Gemini, Cursor, Kimi Code, Grok, Moonshot, DeepSeek, OpenRouter, OpenCode Go, Ollama Cloud, Muse, Command Code, Mistral. ## Code Map @@ -74,7 +74,7 @@ On `go.sum` changes, update `vendorHash` in `flake.nix` (run `nix build .#onwatc 5. Add to `internal/web/handlers.go` endpoints 6. Update dashboard JS in `internal/web/static/app.js` -**API Docs:** See `docs/` for provider-specific setup (COPILOT_SETUP.md, CODEX_SETUP.md, ANTIGRAVITY_SETUP.md, GEMINI_SETUP.md, CURSOR_SETUP.md, KIMI_SETUP.md, GROK_SETUP.md, MOONSHOT_SETUP.md, DEEPSEEK_SETUP.md, OPENCODE_SETUP.md, OLLAMA_SETUP.md, MUSE_SETUP.md, COMMANDCODE_SETUP.md) and WEBHOOK_SETUP.md for outbound HTTP notifications +**API Docs:** See `docs/` for provider-specific setup (COPILOT_SETUP.md, CODEX_SETUP.md, ANTIGRAVITY_SETUP.md, GEMINI_SETUP.md, CURSOR_SETUP.md, KIMI_SETUP.md, GROK_SETUP.md, MOONSHOT_SETUP.md, DEEPSEEK_SETUP.md, OPENCODE_SETUP.md, OLLAMA_SETUP.md, MUSE_SETUP.md, COMMANDCODE_SETUP.md, MISTRAL_SETUP.md) and WEBHOOK_SETUP.md for outbound HTTP notifications **Containers:** `IsDockerEnvironment()` in `config.go` detects Docker/K8s. Containers run foreground only. diff --git a/README.md b/README.md index 8c9385e5..167d7ad7 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ It is a thin client: it needs the onWatch daemon running (steps above) and finds - **Moonshot** -- Balance-based tracking for the Moonshot (Kimi) open-platform API. Available, Voucher, and Cash balance cards with drop-rate trends. Set `MOONSHOT_API_KEY`. See [Moonshot Setup](docs/MOONSHOT_SETUP.md). - **DeepSeek** -- Balance-based tracking for the DeepSeek platform API. Total, Granted, and Topped-Up balance cards with drop-rate trends. Set `DEEPSEEK_API_KEY`. See [DeepSeek Setup](docs/DEEPSEEK_SETUP.md). - **OpenCode Go** -- Subscription quota cards (5-Hour, Weekly, and Monthly when present) read from the plan's own meters via the OpenCode console API (set `OPENCODE_GO_API_KEY`, a service-account key) or, as a legacy fallback, scraped from the authenticated dashboard (`OPENCODE_GO_WORKSPACE_ID` + `OPENCODE_GO_AUTH_COOKIE`), with cycle history and deep insights. Separate from `OPENCODE_ENABLED`, which only feeds ChatGPT credentials into the Codex provider. See [OpenCode Setup](docs/OPENCODE_SETUP.md). +- **Mistral** (beta) - Separate included API and Vibe Code allowances plus pay-as-you-go charges, with browser-cookie import or manual authentication. See [Mistral Setup](docs/MISTRAL_SETUP.md). - **Ollama Cloud** (beta) -- Included monthly usage in USD from the ollama.com API with plan-derived caps, per-model request counts, extra-usage spend, cycle history and insights. Set `OLLAMA_API_KEY`. See [Ollama Setup](docs/OLLAMA_SETUP.md). - **Muse** -- Meta Muse coding-plan quota tracking (5-hour prompts + weekly usage) from the same subscription snapshot `muse /usage` shows, via one minimal probe per poll. Opt-in: set `MUSE_ENABLED=true` and onWatch uses the key `muse login` stored (macOS Keychain / login file), or set `META_API_KEY` directly. Tracking stays off until you opt in, because each poll spends a prompt from your own 5h window. See [Muse Setup](docs/MUSE_SETUP.md). - **Command Code** -- Credit balance plus the 5-hour and weekly rate-limit windows from the Command Code API, with billing-period cost, request and token totals. Auto-detected from the `cmd` CLI login (or the pi / OMP auth store); set `COMMAND_CODE_API_KEY` for Docker and headless hosts. Polling reads billing endpoints only, so it spends no credits. See [Command Code Setup](docs/COMMANDCODE_SETUP.md). @@ -366,6 +367,11 @@ Additional environment variables: | `OPENCODE_GO_API_KEY` | OpenCode console service-account key with usage read access (enables quota tracking; preferred)| | `OPENCODE_GO_WORKSPACE_ID` | Legacy scrape mode: OpenCode Go workspace ID (`wrk_...`) from the dashboard URL| | `OPENCODE_GO_AUTH_COOKIE` | Legacy scrape mode: OpenCode Go `auth` cookie value| +| `MISTRAL_ENABLED` | Enable Mistral subscription and pay-as-you-go tracking (default: false) | +| `MISTRAL_AUTH_COOKIE` | Manual Mistral Cookie header; keep private | +| `MISTRAL_BROWSER` | auto, chrome, firefox, safari (macOS), or edge | +| `MISTRAL_BROWSER_PROFILE` | Browser profile path/name; Firefox supports `::container=ID` | +| `MISTRAL_RETENTION` | How long Mistral rows are kept in SQLite (default: `2160h` = 90 days, `0` disables pruning) | | `OLLAMA_API_KEY` | Ollama Cloud API key from ollama.com/settings/keys (enables usage tracking)| | `OLLAMA_MONTHLY_LIMIT` | Ollama included usage cap in USD (0 = derive from plan)| | `OLLAMA_RESET_DAY` | Ollama reset day of month, 1-31 (0 = learn from the first observed reset, starting from the account anniversary)| diff --git a/cmd/onwatch/main.go b/cmd/onwatch/main.go index b1b8a3f0..e9ba5f69 100644 --- a/cmd/onwatch/main.go +++ b/cmd/onwatch/main.go @@ -1946,6 +1946,14 @@ func run() error { if kimiAg != nil { agentMgr.RegisterFactory("kimi", func() (agent.AgentRunner, error) { return kimiAg, nil }) } + if cfg.HasProvider("mistral") { + agentMgr.RegisterFactory("mistral", func() (agent.AgentRunner, error) { + mistralAg := agent.NewMistralAgent(db, cfg, logger) + mistralAg.SetNotifier(notifier) + mistralAg.SetPollingCheck(func() bool { return isPollingEnabled("mistral") }) + return mistralAg, nil + }) + } if opencodeAg != nil { agentMgr.RegisterFactory("opencode", func() (agent.AgentRunner, error) { return opencodeAg, nil }) } @@ -2003,7 +2011,7 @@ func run() error { // Start configured agents through the manager. startedAny := false - for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "grok", "kimi", "moonshot", "deepseek", "opencode", "ollama", "muse", "commandcode"} { + for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "grok", "kimi", "moonshot", "deepseek", "opencode", "mistral", "ollama", "muse", "commandcode"} { if !isPollingEnabled(providerKey) { continue } @@ -2615,6 +2623,11 @@ func printHelp() { fmt.Println(" MINIMAX_API_KEY MiniMax API key") fmt.Println(" MINIMAX_REGION MiniMax region: global or cn (default: global)") fmt.Println(" OPENROUTER_API_KEY OpenRouter API key") + fmt.Println(" MISTRAL_ENABLED Enable Mistral subscription and pay-as-you-go tracking") + fmt.Println(" MISTRAL_AUTH_COOKIE Manual Mistral Cookie header (keep private)") + fmt.Println(" MISTRAL_BROWSER auto, chrome, firefox, safari, or edge") + fmt.Println(" MISTRAL_BROWSER_PROFILE Browser profile path/name; optional ::container=ID") + fmt.Println(" MISTRAL_RETENTION How long Mistral history is kept (default 2160h; 0 disables)") fmt.Println(" OLLAMA_API_KEY Ollama Cloud API key (ollama.com/settings/keys)") fmt.Println(" OLLAMA_MONTHLY_LIMIT Ollama included usage cap in USD (0 = derive from plan)") fmt.Println(" OLLAMA_RESET_DAY Ollama reset day of month (1-31; 0 = account anniversary)") diff --git a/docs/MISTRAL_SETUP.md b/docs/MISTRAL_SETUP.md new file mode 100644 index 00000000..1f0fb8d8 --- /dev/null +++ b/docs/MISTRAL_SETUP.md @@ -0,0 +1,70 @@ +# Mistral Setup Guide + +Track your Mistral subscription allowance (Included API + Included Vibe Code) and pay-as-you-go spend in onWatch. + +Mistral has no public usage API, so onWatch reads your usage the way the browser does: it imports your signed-in session cookie and reads your subscription page directly. That's different from most other providers here, which just need an API key - Mistral needs a browser session instead. + +## Enable tracking + +1. Sign in at https://admin.mistral.ai/subscription in a supported browser. +2. In onWatch, go to Settings > Providers > Mistral and turn it on. +3. Pick an authentication method (below) and restart onWatch. + +### Automatic browser import (recommended) + +onWatch reads your session cookie straight from Chrome, Firefox, or another supported browser - no copy/pasting, and it re-imports automatically as your session refreshes. Set `MISTRAL_BROWSER` to your browser if it doesn't detect one automatically. + +### Manual cookie + +Settings > Providers > Mistral > Manual, then paste your Mistral Cookie header. No browser or keychain access needed - useful for Docker, headless servers, or if automatic import doesn't work for you. You'll need to re-paste it when the session expires. Never commit a cookie header to source control. + +## macOS permissions + +Automatic import needs two one-time permissions on macOS: + +1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. Right-click the onWatch tray icon and choose **Grant Browser Access...**, then pick your browser in the folder panel. A locally rebuilt binary will need re-granting. +2. **Keychain access.** You'll see a prompt that `security` wants to use **Chrome Safe Storage** - this decrypts the cookie file, it's not asking for your Mistral password. Enter your Mac login password and choose **Allow** (or **Allow Once** if you'd rather be asked again next time). + +Prefer to skip both prompts? Sign into Mistral in Firefox instead and select Firefox as your browser, or use manual cookie mode. + +## Other platforms + +- **Linux:** Chromium import needs an unlocked desktop keyring (D-Bus/GNOME/KWallet), which headless services usually don't have. Use Firefox or manual cookies instead. +- **Windows:** Chromium cookies are supported via DPAPI; app-bound encryption on newer Chrome builds can block this. Use Firefox or manual cookies if it fails. +- **Safari:** may require Full Disk Access, or point `MISTRAL_BROWSER_PROFILE` at a specific `Cookies.binarycookies` file. +- **Firefox containers:** append `::container=` to `MISTRAL_BROWSER_PROFILE` to pick a specific container. +- **Docker:** use manual cookies (`MISTRAL_AUTH_COOKIE`) rather than mounting a browser profile into the container. + +## Configuration reference + +```dotenv +MISTRAL_ENABLED=true +MISTRAL_BROWSER=chrome +# Optional profile directory/name; Firefox containers use ::container=ID +MISTRAL_BROWSER_PROFILE=Default +# Manual mode can instead supply MISTRAL_AUTH_COOKIE privately. +# How long stored Mistral history is kept; 0 disables pruning. +MISTRAL_RETENTION=2160h +``` + +Settings saved in onWatch override these environment variables. The cookie field is masked in settings responses. + +## What's tracked + +**Included API** and **Included Vibe Code** are tracked as separate allowances, each with currency, used amount, limit, remaining amount, percentage, and reset date. + +**Pay-as-you-go spend** is tracked separately from your subscription allowances. If you're not on PAYG, hide it from the dashboard: Settings > Providers > Mistral > **Show pay-as-you-go spend** > **Hide**. + +## Refresh behaviour + +onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything). + +## Testing + +Opt-in live check against your own signed-in session (may prompt for browser/keychain access): + +```sh +ONWATCH_MISTRAL_LIVE=1 GOFLAGS='-run=TestMistralLive -v' ./app.sh --test +``` + +The default test suite uses synthetic data only. A synthetic dashboard preview is also available with `ONWATCH_MISTRAL_PREVIEW=1 GOFLAGS='-run=TestMistralPreview -v' ./app.sh --test`. diff --git a/extensions/vscode/media/codepoints.json b/extensions/vscode/media/codepoints.json index 99be3b15..9455ef9f 100644 --- a/extensions/vscode/media/codepoints.json +++ b/extensions/vscode/media/codepoints.json @@ -10,11 +10,12 @@ "grok": 57353, "kimi": 57354, "minimax": 57355, - "moonshot": 57356, - "ollama": 57357, - "openai": 57358, - "opencode": 57359, - "openrouter": 57360, - "synthetic": 57361, - "zai": 57362 + "mistral": 57356, + "moonshot": 57357, + "ollama": 57358, + "openai": 57359, + "opencode": 57360, + "openrouter": 57361, + "synthetic": 57362, + "zai": 57363 } diff --git a/extensions/vscode/media/icons/mistral.svg b/extensions/vscode/media/icons/mistral.svg new file mode 100644 index 00000000..dea035a1 --- /dev/null +++ b/extensions/vscode/media/icons/mistral.svg @@ -0,0 +1 @@ + diff --git a/extensions/vscode/media/onwatch-icons.woff b/extensions/vscode/media/onwatch-icons.woff index d503d71c..1042612b 100644 Binary files a/extensions/vscode/media/onwatch-icons.woff and b/extensions/vscode/media/onwatch-icons.woff differ diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index cb8609cd..1f3c361a 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -316,53 +316,60 @@ "fontCharacter": "\\E00B" } }, + "onwatch-mistral": { + "description": "onWatch mistral mark", + "default": { + "fontPath": "./media/onwatch-icons.woff", + "fontCharacter": "\\E00C" + } + }, "onwatch-moonshot": { "description": "onWatch Moonshot mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E00C" + "fontCharacter": "\\E00D" } }, "onwatch-ollama": { "description": "onWatch ollama mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E00D" + "fontCharacter": "\\E00E" } }, "onwatch-openai": { "description": "onWatch OpenAI mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E00E" + "fontCharacter": "\\E00F" } }, "onwatch-opencode": { "description": "onWatch OpenCode mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E00F" + "fontCharacter": "\\E010" } }, "onwatch-openrouter": { "description": "onWatch OpenRouter mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E010" + "fontCharacter": "\\E011" } }, "onwatch-synthetic": { "description": "onWatch Synthetic mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E011" + "fontCharacter": "\\E012" } }, "onwatch-zai": { "description": "onWatch Z.ai mark", "default": { "fontPath": "./media/onwatch-icons.woff", - "fontCharacter": "\\E012" + "fontCharacter": "\\E013" } } } diff --git a/extensions/vscode/src/icons.ts b/extensions/vscode/src/icons.ts index fbd5e328..45339178 100644 --- a/extensions/vscode/src/icons.ts +++ b/extensions/vscode/src/icons.ts @@ -17,6 +17,7 @@ export const GLYPHS: ReadonlySet = new Set([ "ollama", "openai", "opencode", + "mistral", "openrouter", "synthetic", "zai", diff --git a/extensions/vscode/src/model.ts b/extensions/vscode/src/model.ts index f9993124..309d08f8 100644 --- a/extensions/vscode/src/model.ts +++ b/extensions/vscode/src/model.ts @@ -15,7 +15,8 @@ export interface QuotaMeter { status: Severity; used?: number; limit?: number; - /** "currency" when used/limit are US dollars. */ + /** "currency" when used/limit are monetary values. */ + currency?: string; format?: string; reset_at?: string; time_until_reset?: string; @@ -342,7 +343,10 @@ function formatCount(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1); } -function formatUsd(value: number): string { +function formatUsd(value: number, currency?: string): string { + if (currency && currency !== "USD") { + try { return new Intl.NumberFormat(undefined, {style: "currency", currency}).format(value); } catch { return `${value.toFixed(2)} ${currency}`; } + } return `$${value > 0 && value < 0.01 ? value.toFixed(3) : value.toFixed(2)}`; } @@ -363,7 +367,7 @@ function usedCell(quota: QuotaMeter): string { if (hasLimit) { cell += quota.format === "currency" - ? ` (${formatUsd(used)}/${formatUsd(limit)})` + ? ` (${formatUsd(used, quota.currency)}/${formatUsd(limit, quota.currency)})` : ` (${formatCount(used)}/${formatCount(limit)})`; } } diff --git a/extensions/vscode/test/model.test.ts b/extensions/vscode/test/model.test.ts index 90f1d696..74a8e436 100644 --- a/extensions/vscode/test/model.test.ts +++ b/extensions/vscode/test/model.test.ts @@ -294,6 +294,13 @@ describe("tooltip sections", () => { expect(formatProviderSection(fractional, NOW)).toContain("| 23% (2.3/10) |"); }); + it("preserves the currency reported by Mistral", () => { + const card = provider({id:"mistral",base_provider:"mistral",quotas:[quota({used:1.25,limit:12.75,format:"currency",currency:"EUR",status:"healthy"})]}); + const text = formatProviderSection(card, NOW); + expect(text).toContain("€1.25"); + expect(text).not.toContain("$1.25"); + }); + it("shows dollars for currency quotas and the daemon's reading when the cap is unknown", () => { const paid = provider({ id: "ollama", diff --git a/flake.nix b/flake.nix index 80e87755..67be74d0 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ inherit version; src = ./.; subPackages = [ "cmd/onwatch" ]; - vendorHash = "sha256-zagPclPZItTTUaMh+8Ph7k5ESqc3vETPNkhMQ493MoY="; + vendorHash = "sha256-/WAnFpl4zdWzrF1KL0xb0WZOwIGm5jLhHvZSYEbDlrQ="; ldflags = [ "-s" "-w" diff --git a/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral-symbolic.svg b/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral-symbolic.svg new file mode 100644 index 00000000..dea035a1 --- /dev/null +++ b/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral-symbolic.svg @@ -0,0 +1 @@ + diff --git a/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral.svg b/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral.svg new file mode 100644 index 00000000..dea035a1 --- /dev/null +++ b/gnome-extension/onwatch-menubar@onllm.dev/icons/mistral.svg @@ -0,0 +1 @@ + diff --git a/go.mod b/go.mod index d165f532..edb4e5d2 100644 --- a/go.mod +++ b/go.mod @@ -7,20 +7,23 @@ require ( github.com/aymanbagabas/go-pty v0.2.3 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-isatty v0.0.22 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - golang.org/x/crypto v0.51.0 - modernc.org/sqlite v1.44.3 + github.com/steipete/sweetcookie v0.0.2 + golang.org/x/crypto v0.53.0 + gopkg.in/ini.v1 v1.67.3 + modernc.org/sqlite v1.53.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -28,12 +31,11 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/u-root/u-root v0.16.0 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.36.8 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index dbe2f90e..c1e8f509 100644 --- a/go.sum +++ b/go.sum @@ -9,12 +9,15 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -35,8 +38,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -57,63 +60,75 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/steipete/sweetcookie v0.0.2 h1:+7KNO1x2PMzSfUtXYcE52JUvZUZO/aSGoV/0rBC5nzE= +github.com/steipete/sweetcookie v0.0.2/go.mod h1:gBr7LoqETFBqOxmtR60n0O+yEihC9Kkkg/85YmGT5qA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g= github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY= github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8= github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= -modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/agent/mistral_agent.go b/internal/agent/mistral_agent.go new file mode 100644 index 00000000..0d550977 --- /dev/null +++ b/internal/agent/mistral_agent.go @@ -0,0 +1,329 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +const mistralSubscriptionURL = "https://admin.mistral.ai/subscription" + +type mistralFetcher interface { + FetchSnapshot(context.Context, api.MistralSession) (*api.MistralSnapshot, error) +} +type MistralAgent struct { + client mistralFetcher + store *store.Store + tr *tracker.MistralTracker + cfg *config.Config + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool + read api.MistralCookieReader + session *api.MistralSession + source *api.MistralSource + imported time.Time + next time.Time + paused bool + failures int + importFailures int + lastPrune time.Time + sessionIdentity string + lastQuotas map[string]api.MistralQuota +} + +func NewMistralAgent(s *store.Store, cfg *config.Config, logger *slog.Logger) *MistralAgent { + if logger == nil { + logger = slog.Default() + } + cfgCopy := *cfg + return &MistralAgent{client: api.NewMistralClient(), store: s, cfg: &cfgCopy, logger: logger, tr: tracker.NewMistralTracker(s), sm: NewSessionManager(s, "mistral", 15*time.Minute, logger)} +} +func (a *MistralAgent) SetNotifier(n *notify.NotificationEngine) { + a.notifier = n + a.tr.SetOnReset(func(key string) { n.Check(notify.QuotaStatus{Provider: "mistral", QuotaKey: key, ResetOccurred: true}) }) +} +func (a *MistralAgent) SetPollingCheck(fn func() bool) { a.pollingCheck = fn } +func (a *MistralAgent) Run(ctx context.Context) error { + interval := a.cfg.PollInterval + if interval <= 0 { + interval = 120 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer func() { a.sm.Close() }() + if raw, e := a.store.GetSetting("mistral_source"); e == nil && raw != "" { + var selected struct { + Browser, Profile string + Source api.MistralSource + } + if json.Unmarshal([]byte(raw), &selected) == nil && selected.Browser == a.cfg.MistralBrowser && selected.Profile == a.cfg.MistralBrowserProfile { + a.source = &selected.Source + } + } + // Select the active history partition before polling, so a newly selected + // source never displays the previous source's values while reconnecting. + identity := "" + if a.source != nil { + identity = a.source.Identity() + } + if a.cfg.MistralAuthMode == "manual" || a.cfg.MistralAuthMode != "automatic" && a.cfg.MistralAuthCookie != "" { + identity = "" + if session, e := api.ManualMistralSession(a.cfg.MistralAuthCookie); e == nil { + identity = session.Source.Identity() + } + } + // Losing the startup WAL race must not disable the provider: a Run that + // returns is never retried. SaveMistral re-asserts the identity in the same + // transaction as the first successful snapshot. + if e := a.store.SetSetting("mistral_identity", identity); e != nil { + a.logger.Warn("Mistral identity selection deferred to first poll", "error", e) + } + a.poll(ctx) + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + a.poll(ctx) + } + } +} +func (a *MistralAgent) status(status string) { _ = a.store.SetSetting("mistral_status", status) } +func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, error) { + a.imported = time.Now() + if a.cfg.MistralAuthMode == "manual" || a.cfg.MistralAuthCookie != "" && a.cfg.MistralAuthMode != "automatic" { + session, e := api.ManualMistralSession(a.cfg.MistralAuthCookie) + if e != nil { + return nil, e + } + a.session = &session + return nil, nil + } + // Automatic import reads browser cookie stores, which prompts for macOS + // Keychain or Linux keyring access. Never do that for a provider the user + // has not enabled. + if !a.cfg.HasProvider("mistral") { + return nil, api.ErrMistralAuth + } + var sources []api.MistralSource + if a.source != nil { + sources = []api.MistralSource{*a.source} + } else { + var e error + sources, e = api.MistralSources(a.cfg.MistralBrowser, a.cfg.MistralBrowserProfile) + // Scan failures are diagnostics, not a verdict: report them and use + // whatever profiles were found. + if e != nil { + a.logger.Warn("Mistral profile discovery incomplete", "error", e) + } + if len(sources) == 0 { + if e != nil { + return nil, e + } + return nil, api.ErrMistralAuth + } + } + // Covers a prompted credential-store read per candidate source; each + // ImportMistralSessions call is separately bounded. + ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + a.logger.Info("Mistral credential discovery", "sources", len(sources)) + for _, source := range sources { + sessions, e := api.ImportMistralSessions(ctx, source, a.read) + if e != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + a.logger.Warn("Mistral source unreadable", "browser", source.Browser, "error", e) + continue + } + a.logger.Info("Mistral source read", "browser", source.Browser, "sessions", len(sessions)) + for _, session := range sessions { + if a.source != nil { + a.session = &session + return nil, nil + } + snap, e := a.client.FetchSnapshot(ctx, session) + if e != nil { + if !errors.Is(e, api.ErrMistralAuth) && !errors.Is(e, api.ErrMistralParse) { + return nil, e + } + // Cookies were read but Mistral would not serve this session. + a.logger.Warn("Mistral session not usable", "browser", source.Browser, "cookies", session.CookieNames(), "error", e) + continue + } + a.source = &session.Source + a.session = &session + data, e := json.Marshal(struct { + Browser, Profile string + Source api.MistralSource + }{a.cfg.MistralBrowser, a.cfg.MistralBrowserProfile, session.Source}) + if e != nil { + return nil, e + } + if e = a.store.SetSetting("mistral_source", string(data)); e != nil { + return nil, e + } + return snap, nil + } + } + return nil, api.ErrMistralAuth +} +func (a *MistralAgent) poll(ctx context.Context) { + if a.pollingCheck != nil && !a.pollingCheck() || time.Now().Before(a.next) { + return + } + var snap *api.MistralSnapshot + var e error + // Only re-read the browser when there is no usable session, or while paused + // and waiting for the user to sign in again (a.next bounds those retries to + // one per 10 minutes). Every read shells out to the platform credential + // store and can prompt for the user's login password, so a session that + // still yields cookies is reused until it expires or is rejected. + oldHeader := "" + if a.session != nil { + oldHeader = a.session.Header(mistralSubscriptionURL, time.Now()) + } + if a.session == nil || oldHeader == "" || a.paused { + snap, e = a.importSession(ctx) + if e != nil { + if ctx.Err() != nil { + return + } + if !errors.Is(e, api.ErrMistralAuth) && !errors.Is(e, api.ErrMistralParse) { + a.backoff(e) + return + } + a.status("reconnect") + // Without this the provider sits in "reconnect" with no way to tell + // a denied keychain prompt from a signed-out browser. + a.logger.Warn("Mistral credential import failed; sign in to Mistral in the selected browser or use manual cookies", "error", e, "browser", a.cfg.MistralBrowser, "profileSet", a.cfg.MistralBrowserProfile != "") + // Back off progressively. A retry re-reads the credential store, + // which can prompt for a password, so a setup that is simply not + // signed in must not keep asking every ten minutes. + a.importFailures = min(a.importFailures+1, 5) + a.next = time.Now().Add(min(10*time.Minute< 0 { + a.next = time.Now().Add(min(snap.RetryAfter, time.Hour)) + } + if e = a.store.SaveMistral(ctx, snap); e != nil { + a.logger.Error("Mistral storage failed") + return + } + if e = a.tr.Process(ctx, snap); e != nil { + a.logger.Error("Mistral tracking failed") + return + } + if a.sessionIdentity != snap.Identity { + a.sm.Close() + a.sm = NewSessionManager(a.store, "mistral:"+snap.Identity, 15*time.Minute, a.logger) + a.sessionIdentity = snap.Identity + a.lastQuotas = map[string]api.MistralQuota{} + } + values := []float64{} + for _, q := range snap.Quotas { + if old, ok := a.lastQuotas[q.Name]; ok && (q.Limit != old.Limit || q.Used < old.Used || q.PercentOnly != old.PercentOnly) { + a.sm.Close() + a.sm.hasPrev = false + } + a.lastQuotas[q.Name] = q + if !q.PercentOnly { + values = append(values, q.Used) + } + if a.notifier != nil { + a.notifier.Check(notify.QuotaStatus{Provider: "mistral", QuotaKey: q.Name, Utilization: q.Utilization, Limit: q.Limit}) + } + } + if len(values) == 2 { + a.sm.ReportPoll(values) + } + // Retention is configurable via MISTRAL_RETENTION; 0 disables pruning + // entirely. The <= guard also stops a negative value slipping past config + // validation and pruning the whole history with a future cutoff. + if a.cfg.MistralRetention > 0 && time.Since(a.lastPrune) > 24*time.Hour { + if a.store.PruneMistral(ctx, time.Now().Add(-a.cfg.MistralRetention)) == nil { + a.lastPrune = time.Now() + } + } +} + +// A failed optional endpoint does not invalidate a session that still returns +// allowances or billing. Keep polling the working data without reimporting +// cookies (and prompting for Keychain access) on every partial response. +func mistralSessionRejected(snap *api.MistralSnapshot, err error) bool { + if errors.Is(err, api.ErrMistralAuth) { + return true + } + return snap != nil && snap.AuthFailed && len(snap.Quotas) == 0 && + (snap.Billing == nil || snap.Billing.Amount == nil) +} + +func (a *MistralAgent) backoff(e error) { + + a.failures = min(a.failures+1, 6) + delay := time.Duration(1< delay { + delay = he.RetryAfter + } + a.next = time.Now().Add(min(delay, time.Hour)) + a.status("stale") + a.logger.Warn("Mistral polling failed", "error", e) +} diff --git a/internal/agent/mistral_agent_test.go b/internal/agent/mistral_agent_test.go new file mode 100644 index 00000000..4d408aae --- /dev/null +++ b/internal/agent/mistral_agent_test.go @@ -0,0 +1,276 @@ +package agent + +import ( + "context" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/steipete/sweetcookie" + "io" + "log/slog" + "runtime" + "runtime/debug" + "testing" + "time" +) + +type fakeMistralFetcher struct { + calls int + authFail bool + failure error +} + +func (f *fakeMistralFetcher) FetchSnapshot(_ context.Context, s api.MistralSession) (*api.MistralSnapshot, error) { + f.calls++ + if f.failure != nil { + return nil, f.failure + } + if f.authFail { + return nil, api.ErrMistralAuth + } + now := time.Now().UTC() + return &api.MistralSnapshot{Identity: s.Source.Identity(), CapturedAt: now, Status: "ok", Quotas: []api.MistralQuota{{Name: "api_included", Used: 2, Limit: 10, Utilization: 20, CapturedAt: now}}}, nil +} +func TestMistralPauseAndRecovery(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true}, nil) + defer a.sm.Close() + source := api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + a.source = &source + cookieValue := "expired-session" + imports := 0 + a.read = func(_ context.Context, o sweetcookie.Options) (sweetcookie.Result, error) { + imports++ + if o.Profiles[sweetcookie.BrowserChrome] != "synthetic" { + t.Fatal("source switched") + } + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: cookieValue, Domain: ".mistral.ai", Path: "/"}}}, nil + } + client := &fakeMistralFetcher{authFail: true} + a.client = client + a.poll(context.Background()) + if !a.paused || client.calls != 2 || imports != 2 { + t.Fatalf("paused=%v calls=%d imports=%d", a.paused, client.calls, imports) + } + a.poll(context.Background()) + if client.calls != 2 { + t.Fatal("network polled while paused") + } + a.next = time.Time{} + a.imported = time.Now().Add(-11 * time.Minute) + a.poll(context.Background()) + if client.calls != 2 { + t.Fatal("unchanged cookie retried") + } + cookieValue = "refreshed-session" + client.authFail = false + a.next = time.Time{} + a.imported = time.Now().Add(-11 * time.Minute) + a.poll(context.Background()) + if a.paused || client.calls != 3 { + t.Fatal("did not recover") + } + snap, e := db.LatestMistral(context.Background()) + if e != nil || snap == nil || snap.Identity != source.Identity() { + t.Fatalf("snapshot=%+v error=%v", snap, e) + } +} +func TestMistralManualMissingCookieDoesNotImport(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralAuthMode: "manual"}, nil) + defer a.sm.Close() + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + t.Fatal("manual mode read browser") + return sweetcookie.Result{}, nil + } + a.poll(context.Background()) + status, _ := db.GetSetting("mistral_status") + if status != "reconnect" { + t.Fatal(status) + } +} + +func TestMistralDiscoveryRateLimit(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true, MistralBrowser: "chrome", MistralBrowserProfile: t.TempDir()}, nil) + defer a.sm.Close() + a.client = &fakeMistralFetcher{failure: &api.MistralHTTPError{Status: 429, RetryAfter: time.Hour}} + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: "synthetic", Domain: ".mistral.ai", Path: "/"}}}, nil + } + a.poll(context.Background()) + status, _ := db.GetSetting("mistral_status") + if status != "stale" || time.Until(a.next) < 59*time.Minute { + t.Fatalf("rate limit misreported as login failure: %s delay=%v", status, time.Until(a.next)) + } +} + +func TestMistralPollingHeap(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + a := NewMistralAgent(db, &config.Config{MistralAuthCookie: "ory_session_test=synthetic"}, logger) + defer func() { a.sm.Close() }() + a.client = &fakeMistralFetcher{} + a.poll(context.Background()) + debug.FreeOSMemory() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + for i := 0; i < 300; i++ { + a.imported = time.Time{} + a.poll(context.Background()) + if _, e = db.LatestMistral(context.Background()); e != nil { + t.Fatal(e) + } + } + debug.FreeOSMemory() + runtime.ReadMemStats(&after) + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + t.Logf("300 synthetic polls/imports: retained Go heap=%d bytes, growth=%d bytes (not daemon RSS)", after.HeapAlloc, growth) + if growth > 4<<20 { + t.Fatalf("unexpected retained heap growth: %d", growth) + } +} + +// An unconfigured Mistral provider must never read browser cookie stores. +// Automatic import prompts for macOS Keychain / Linux keyring access, so it may +// only run once the user has actually enabled the provider. +func TestMistralUnconfiguredNeverReadsBrowsers(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.sm.Close() + source := api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + a.source = &source + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + t.Error("unconfigured Mistral read a browser cookie store") + return sweetcookie.Result{}, nil + } + client := &fakeMistralFetcher{} + a.client = client + a.poll(context.Background()) + if client.calls != 0 { + t.Fatalf("unconfigured Mistral contacted Mistral: calls=%d", client.calls) + } +} + +// The daemon starts every provider agent at once, so an early settings write +// can lose a WAL race and return SQLITE_BUSY. That must not be fatal: a failed +// Run is never retried, which would disable Mistral until the next restart. +// The identity is re-asserted by SaveMistral on the first successful poll. +func TestMistralStartupSettingFailureKeepsAgentAlive(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + a := NewMistralAgent(db, &config.Config{MistralEnabled: true}, slog.New(slog.NewTextHandler(io.Discard, nil))) + source := api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + a.source = &source + a.client = &fakeMistralFetcher{} + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{}, nil + } + // A closed store makes every write fail the way a contended one does. + db.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := a.Run(ctx); err != nil { + t.Fatalf("agent exited on a startup settings-write failure: %v", err) + } +} + +// A working session must never be re-read from the browser on a schedule. +// Every read shells out to the platform credential store (macOS `security`, +// Linux keyring), which prompts the user for their login password. A daemon +// that re-imports every cycle prompts indefinitely, and a denied or ignored +// prompt returns no cookies, which silently breaks polling. +func TestMistralValidSessionIsNotReimported(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true}, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.sm.Close() + source := api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + a.source = &source + imports := 0 + future := time.Now().Add(720 * time.Hour) + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + imports++ + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: "v", Domain: ".mistral.ai", Path: "/", Expires: &future}}}, nil + } + a.client = &fakeMistralFetcher{} + a.poll(context.Background()) + if imports != 1 { + t.Fatalf("first poll imports=%d, want 1", imports) + } + for i := 0; i < 5; i++ { + a.imported = time.Time{} // however much time has passed since the last read + a.next = time.Time{} + a.poll(context.Background()) + } + if imports != 1 { + t.Fatalf("a valid session was re-read from the browser %d times; each read can prompt for the user's password", imports) + } +} + +// Retention is configurable and 0 disables pruning entirely, matching +// ONWATCH_API_INTEGRATIONS_RETENTION. Pruning with a zero cutoff would delete +// the whole history, so the disabled case must short-circuit before the call. +func TestMistralRetentionDisabledDoesNotPrune(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + now := time.Now().UTC() + old := now.AddDate(0, 0, -400) + source := api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + // History is partitioned by identity, so the seeded rows have to share the + // identity the polled snapshot will pin. + for _, at := range []time.Time{old, now.Add(-time.Minute)} { + snap := &api.MistralSnapshot{Identity: source.Identity(), CapturedAt: at, Status: "ok", Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 1, Limit: 10, CapturedAt: at}, + }} + if e = db.SaveMistral(context.Background(), snap); e != nil { + t.Fatal(e) + } + } + a := NewMistralAgent(db, &config.Config{MistralEnabled: true, MistralRetention: 0}, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.sm.Close() + a.source = &source + future := time.Now().Add(720 * time.Hour) + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: "v", Domain: ".mistral.ai", Path: "/", Expires: &future}}}, nil + } + a.client = &fakeMistralFetcher{} + a.lastPrune = time.Time{} // due for a prune + a.poll(context.Background()) + + rows, e := db.MistralHistory(context.Background(), old.Add(-time.Hour), time.Now().Add(time.Hour), 200) + if e != nil { + t.Fatal(e) + } + if len(rows) < 3 { + t.Fatalf("retention 0 pruned history: %d snapshots left, want the 2 saved plus the polled one", len(rows)) + } +} diff --git a/internal/agent/mistral_recovery_test.go b/internal/agent/mistral_recovery_test.go new file mode 100644 index 00000000..2ee1775d --- /dev/null +++ b/internal/agent/mistral_recovery_test.go @@ -0,0 +1,73 @@ +package agent + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +type partialMistralFetcher struct{ calls int } + +func (f *partialMistralFetcher) FetchSnapshot(_ context.Context, s api.MistralSession) (*api.MistralSnapshot, error) { + f.calls++ + now := time.Now().UTC() + status := "partial" + if f.calls > 2 { + status = "ok" + } + return &api.MistralSnapshot{Identity: s.Source.Identity(), CapturedAt: now, Status: status, AuthFailed: f.calls <= 2, Quotas: []api.MistralQuota{{Name: "api_included", Used: float64(f.calls), Limit: 100, Utilization: float64(f.calls), CapturedAt: now}}}, nil +} +func TestMistralBillingAuthDoesNotFreezeQuotas(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true, MistralAuthMode: "manual", MistralAuthCookie: "ory_session_test=synthetic"}, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.sm.Close() + f := &partialMistralFetcher{} + a.client = f + a.poll(context.Background()) + if f.calls != 1 { + t.Fatalf("partial success should not reimport credentials, got %d requests", f.calls) + } + a.next = time.Time{} + a.poll(context.Background()) + if f.calls != 2 { + t.Fatal("allowances remain readable, but unchanged cookie suppresses every subsequent network poll after billing authentication failure") + } + a.next = time.Time{} + a.poll(context.Background()) + status, err := db.GetSetting("mistral_status") + if err != nil || f.calls != 3 || status != "ok" || a.paused { + t.Fatalf("did not recover: calls=%d status=%s paused=%v err=%v", f.calls, status, a.paused, err) + } + +} + +func TestMistralSessionRejected(t *testing.T) { + zero := 0.0 + for _, tc := range []struct { + name string + snapshot *api.MistralSnapshot + err error + want bool + }{ + {"full rejection", nil, api.ErrMistralAuth, true}, + {"empty auth failure", &api.MistralSnapshot{AuthFailed: true}, nil, true}, + {"billing still works", &api.MistralSnapshot{AuthFailed: true, Billing: &api.MistralBilling{Amount: &zero}}, nil, false}, + {"unavailable billing", &api.MistralSnapshot{AuthFailed: true, Billing: &api.MistralBilling{}}, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := mistralSessionRejected(tc.snapshot, tc.err); got != tc.want { + t.Fatalf("rejected=%v want %v", got, tc.want) + } + }) + } +} diff --git a/internal/api/mistral_client.go b/internal/api/mistral_client.go new file mode 100644 index 00000000..c607ec97 --- /dev/null +++ b/internal/api/mistral_client.go @@ -0,0 +1,139 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +var ErrMistralAuth = errors.New("mistral: reconnect in the selected browser or update manual cookies") + +type MistralHTTPError struct { + Status int + RetryAfter time.Duration +} + +func (e *MistralHTTPError) Error() string { return fmt.Sprintf("mistral: HTTP %d", e.Status) } + +type MistralClient struct{ http *http.Client } + +func NewMistralClient() *MistralClient { + return &MistralClient{http: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment, MaxIdleConns: 2, MaxIdleConnsPerHost: 1, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ForceAttemptHTTP2: true}}} +} +func (c *MistralClient) get(ctx context.Context, s MistralSession, url string) ([]byte, error) { + header := s.Header(url, time.Now()) + if header == "" { + return nil, fmt.Errorf("no cookie matched %s: %w", url, ErrMistralAuth) + } + req, e := http.NewRequestWithContext(ctx, "GET", url, nil) + if e != nil { + return nil, ErrMistralParse + } + req.Header.Set("Cookie", header) + req.Header.Set("User-Agent", "onWatch") + req.Header.Set("Accept", "text/html, application/json") + if strings.HasPrefix(url, "https://console.mistral.ai/") { + for _, cookie := range req.Cookies() { + if cookie.Name == "csrftoken" { + req.Header.Set("X-CSRFTOKEN", cookie.Value) + } + } + } + res, e := c.http.Do(req) + if e != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New("mistral: network request failed") + } + defer res.Body.Close() + if res.StatusCode == 401 || res.StatusCode == 403 { + return nil, fmt.Errorf("mistral rejected the session (HTTP %d): %w", res.StatusCode, ErrMistralAuth) + } + if res.StatusCode >= 300 && res.StatusCode < 400 { + return nil, fmt.Errorf("redirected to sign-in (HTTP %d): %w", res.StatusCode, ErrMistralAuth) + } + if res.StatusCode != 200 { + retry := time.Duration(0) + if seconds, e := strconv.Atoi(res.Header.Get("Retry-After")); e == nil { + retry = time.Duration(max(0, min(seconds, 3600))) * time.Second + } else if date, e := http.ParseTime(res.Header.Get("Retry-After")); e == nil { + retry = time.Until(date) + } + return nil, &MistralHTTPError{res.StatusCode, retry} + } + data, e := io.ReadAll(io.LimitReader(res.Body, (2<<20)+1)) + if e != nil || len(data) > 2<<20 { + return nil, fmt.Errorf("response from %s was unreadable or oversized: %w", url, ErrMistralParse) + } + return data, nil +} +func (c *MistralClient) FetchSnapshot(ctx context.Context, s MistralSession) (*MistralSnapshot, error) { + now := time.Now().UTC() + snap := &MistralSnapshot{Identity: s.Source.Identity(), CapturedAt: now, Quotas: []MistralQuota{}, Status: "ok"} + page, subErr := c.get(ctx, s, "https://admin.mistral.ai/subscription") + if subErr == nil { + snap.Quotas, subErr = ParseMistralSubscription(page, now) + } + hasVibe := false + for _, q := range snap.Quotas { + if q.Name == "vibe_included" { + hasVibe = true + } + } + if !hasVibe && !errors.Is(subErr, ErrMistralAuth) { + const vibeURL = "https://console.mistral.ai/api-ui/trpc/billing.vibeUsage?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D%7D" + if strings.Contains(s.Header(vibeURL, now), "csrftoken=") { + if data, e := c.get(ctx, s, vibeURL); e == nil { + if q, e := ParseMistralVibe(data, now); e == nil { + snap.Quotas = append(snap.Quotas, q) + subErr = nil + } + } + } + } + data, billErr := c.get(ctx, s, fmt.Sprintf("https://admin.mistral.ai/api/billing/v2/usage?month=%d&year=%d", now.Month(), now.Year())) + if billErr == nil { + snap.Billing, billErr = ParseMistralBilling(data, now) + } + for _, err := range []error{subErr, billErr} { + var httpErr *MistralHTTPError + if errors.As(err, &httpErr) { + snap.RetryAfter = max(snap.RetryAfter, httpErr.RetryAfter) + if httpErr.Status >= 500 { + snap.RetryAfter = max(snap.RetryAfter, 2*time.Minute) + } + } + if errors.Is(err, ErrMistralAuth) { + snap.AuthFailed = true + } + } + if subErr != nil && billErr != nil { + // Return the underlying error, not the bare sentinel: the wrapped + // message says whether the session was rejected, redirected to + // sign-in, or simply had no cookie that matched the request. + if errors.Is(subErr, ErrMistralAuth) { + return nil, subErr + } + if errors.Is(billErr, ErrMistralAuth) { + return nil, billErr + } + var he *MistralHTTPError + if errors.As(billErr, &he) && (he.Status == 429 || he.Status >= 500) { + return nil, billErr + } + return nil, subErr + } + if subErr != nil || billErr != nil || len(snap.Quotas) < 2 { + snap.Status = "partial" + } + if snap.Billing == nil { + snap.Billing = &MistralBilling{Status: "unavailable"} + } + return snap, nil +} diff --git a/internal/api/mistral_cookie.go b/internal/api/mistral_cookie.go new file mode 100644 index 00000000..f4a6a175 --- /dev/null +++ b/internal/api/mistral_cookie.go @@ -0,0 +1,369 @@ +package api + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/steipete/sweetcookie" + "gopkg.in/ini.v1" +) + +const ( + // Provider settings are stored at whatever length the dashboard sends, so + // the values are bounded where they are parsed instead. + maxMistralCookieHeaderLen = 8192 + maxMistralProfilePathLen = 1024 +) + +// MistralSource contains metadata only. Cookies are never persisted in automatic mode. +type MistralSource struct { + Browser string `json:"browser"` + Profile string `json:"profile"` + Container int `json:"container"` +} + +func (s MistralSource) Identity() string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Browser+"\x00"+s.Profile+"\x00"+strconv.Itoa(s.Container)))) +} + +type MistralSession struct { + Source MistralSource + Cookies []sweetcookie.Cookie +} +type MistralCookieReader func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) + +func MistralBrowserOrder(platform string) []string { + if platform == "darwin" { + return []string{"chrome", "firefox", "safari"} + } + return []string{"chrome", "firefox", "edge"} +} + +// MistralSources enumerates metadata without reading/decrypting cookie databases. +// Every returned profile is an explicit store, preventing library-level merging. +func MistralSources(browser, profile string) ([]MistralSource, error) { + home, e := os.UserHomeDir() + if e != nil { + return nil, e + } + if len(profile) > maxMistralProfilePathLen { + return nil, fmt.Errorf("mistral: browser profile exceeds %d bytes: %w", maxMistralProfilePathLen, ErrMistralParse) + } + order := MistralBrowserOrder(runtime.GOOS) + if browser != "" && browser != "auto" { + order = []string{browser} + } + var out []MistralSource + var scanErrs []error + for _, b := range order { + if b != "chrome" && b != "firefox" && b != "edge" && b != "safari" { + continue + } + if profile != "" { + p := profile + c := 0 + if i := strings.LastIndex(p, "::container="); i >= 0 { + var err error + c, err = strconv.Atoi(p[i+12:]) + if err != nil || c < 0 { + return nil, ErrMistralParse + } + p = p[:i] + } + if filepath.IsAbs(p) { + out = append(out, MistralSource{b, p, c}) + } else { + discovered, err := MistralSources(b, "") + if err != nil { + return nil, err + } + for _, candidate := range discovered { + base := filepath.Base(candidate.Profile) + if base == p || strings.HasSuffix(base, "."+p) { + candidate.Container = c + out = append(out, candidate) + } + } + } + continue + } + var root string + switch runtime.GOOS { + case "darwin": + switch b { + case "chrome": + root = filepath.Join(home, "Library/Application Support/Google/Chrome") + case "edge": + root = filepath.Join(home, "Library/Application Support/Microsoft Edge") + case "firefox": + root = filepath.Join(home, "Library/Application Support/Firefox") + case "safari": + // Only offer Safari when its store is really there: a blind + // entry masks the failure of every other browser. + safari := filepath.Join(home, "Library/Cookies/Cookies.binarycookies") + if _, err := os.Stat(safari); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + scanErrs = append(scanErrs, mistralScanError("safari", err)) + } + continue + } + out = append(out, MistralSource{b, safari, 0}) + continue + } + case "windows": + switch b { + case "chrome": + root = filepath.Join(os.Getenv("LOCALAPPDATA"), "Google/Chrome/User Data") + case "edge": + root = filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft/Edge/User Data") + case "firefox": + root = filepath.Join(os.Getenv("APPDATA"), "Mozilla/Firefox") + } + default: + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + base = filepath.Join(home, ".config") + } + switch b { + case "chrome": + root = filepath.Join(base, "google-chrome") + case "edge": + root = filepath.Join(base, "microsoft-edge") + case "firefox": + root = filepath.Join(home, ".mozilla/firefox") + } + } + if root == "" { + continue + } + var profiles []string + if b == "firefox" { + cfg, err := ini.Load(filepath.Join(root, "profiles.ini")) + if err == nil { + for _, section := range cfg.Sections() { + if !strings.HasPrefix(section.Name(), "Profile") { + continue + } + p := section.Key("Path").String() + if p == "" { + continue + } + if section.Key("IsRelative").String() == "1" { + p = filepath.Join(root, p) + } + profiles = append(profiles, p) + } + } + } else { + entries, err := os.ReadDir(root) + // A missing directory just means the browser is not installed. + // Anything else (notably a macOS privacy denial for another app's + // data) must be reported, or discovery fails with no explanation. + if err != nil && !errors.Is(err, fs.ErrNotExist) { + scanErrs = append(scanErrs, mistralScanError(b, err)) + } + for _, entry := range entries { + if entry.IsDir() && (entry.Name() == "Default" || strings.HasPrefix(entry.Name(), "Profile ")) { + profiles = append(profiles, filepath.Join(root, entry.Name())) + } + } + } + sort.Strings(profiles) + for _, p := range profiles { + out = append(out, MistralSource{b, p, -1}) + } + } + if len(out) > 32 { + out = out[:32] + } + // Sources and diagnostics are both returned: a browser that could not be + // scanned must not hide the ones that could. + return out, errors.Join(scanErrs...) +} + +// mistralScanError reports a browser profile that could not be scanned +// without the OS error's path, which would otherwise put the user's home +// directory into the daemon log. A permission failure stays detectable +// through fs.ErrPermission, so callers can still tell it apart. +func mistralScanError(browser string, err error) error { + if errors.Is(err, fs.ErrPermission) { + return fmt.Errorf("%s: profile folder not readable: %w", browser, fs.ErrPermission) + } + return fmt.Errorf("%s: profile folder not readable", browser) +} + +func ImportMistralSessions(ctx context.Context, source MistralSource, read MistralCookieReader) ([]MistralSession, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + restoreScope := read == nil + if read == nil { + read = sweetcookie.Get + } + // Generous but bounded: reading a Chromium store can raise a Keychain or + // keyring prompt, and the budget has to cover a person finding and typing + // their login password, not just the decryption itself. + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + b := sweetcookie.Browser(source.Browser) + if restoreScope && b == sweetcookie.BrowserSafari { + if _, e := readMistralSafariScopes(ctx, source.Profile); e != nil { + return nil, ErrMistralAuth + } + } + // Cookie names and hosts are stored in the clear; only values are + // encrypted. Checking them first means a profile that has never signed in + // to Mistral is skipped without touching the platform credential store, + // which is what raises a password prompt. + if scopes, e := readMistralScopes(ctx, mistralStorePath(source), b); e == nil && len(scopes) == 0 { + return nil, nil + } + result, err := read(ctx, sweetcookie.Options{URL: "https://admin.mistral.ai/subscription", Origins: []string{"https://admin.mistral.ai/api/billing/v2/usage", "https://console.mistral.ai/api-ui/trpc/billing.vibeUsage"}, Browsers: []sweetcookie.Browser{b}, Profiles: map[sweetcookie.Browser]string{b: source.Profile}, Timeout: 90 * time.Second}) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + return nil, ErrMistralAuth + } + if restoreScope { + result.Cookies, err = restoreMistralScopes(ctx, result.Cookies) + if err != nil { + return nil, err + } + } + // Never include library warnings: OS errors may contain private paths/data. + groups := map[int][]sweetcookie.Cookie{} + for _, c := range result.Cookies { + if !mistralCookieName(c.Name) || (c.Expires != nil && c.Expires.Before(time.Now())) { + continue + } + if source.Container >= 0 && c.Container.ID != source.Container { + continue + } + groups[c.Container.ID] = append(groups[c.Container.ID], c) + } + ids := make([]int, 0, len(groups)) + for id := range groups { + ids = append(ids, id) + } + sort.Ints(ids) + var sessions []MistralSession + for _, id := range ids { + s := source + s.Container = id + sessions = append(sessions, MistralSession{s, groups[id]}) + } + return sessions, nil +} + +// mistralStorePath resolves a source to the cookie database inside it. A +// profile may already point straight at the store file. +func mistralStorePath(source MistralSource) string { + if info, e := os.Stat(source.Profile); e != nil || !info.IsDir() { + return source.Profile + } + switch sweetcookie.Browser(source.Browser) { + case sweetcookie.BrowserFirefox: + return filepath.Join(source.Profile, "cookies.sqlite") + default: + return filepath.Join(source.Profile, "Cookies") + } +} + +// CookieNames lists the cookie names held by the session. Names only: values +// are credentials and must never reach a log. +func (s MistralSession) CookieNames() []string { + names := make([]string, 0, len(s.Cookies)) + for _, c := range s.Cookies { + names = append(names, c.Name+"@"+c.Domain) + } + sort.Strings(names) + return names +} + +func mistralCookieName(n string) bool { + return strings.HasPrefix(n, "ory_session_") || n == "csrftoken" +} +func (s MistralSession) Header(destination string, now time.Time) string { + u, e := url.Parse(destination) + if e != nil || u.Scheme != "https" || (u.Host != "admin.mistral.ai" && u.Host != "console.mistral.ai") { + return "" + } + req := &http.Request{Header: make(http.Header)} + for _, c := range s.Cookies { + if !mistralCookieName(c.Name) || (c.Expires != nil && !c.Expires.After(now)) { + continue + } + domain := strings.TrimPrefix(c.Domain, ".") + host := u.Hostname() + if host != domain && !(strings.HasPrefix(c.Domain, ".") && strings.HasSuffix(host, "."+domain)) { + continue + } + path := c.Path + if path == "" { + path = "/" + } + if u.Path != path && !(strings.HasPrefix(u.Path, path) && (strings.HasSuffix(path, "/") || len(u.Path) > len(path) && u.Path[len(path)] == '/')) { + continue + } + // RFC 6265 allows a cookie value to be wrapped in double quotes, and + // Ory session cookies are stored that way. Go rejects a raw '"' inside + // Cookie.Value, so hand it the unquoted value and let it re-apply the + // quoting on the wire; otherwise the session cookie is dropped and the + // request is silently unauthenticated. + value, quoted := c.Value, false + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + value, quoted = value[1:len(value)-1], true + } + cookie := &http.Cookie{Name: c.Name, Value: value, Quoted: quoted} + if cookie.Valid() != nil { + continue + } + req.AddCookie(cookie) + } + return req.Header.Get("Cookie") +} +func ManualMistralSession(header string) (MistralSession, error) { + s := MistralSession{Source: MistralSource{Browser: "manual", Profile: "manual"}} + header = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(header), "Cookie:")) + if len(header) > maxMistralCookieHeaderLen { + return s, fmt.Errorf("mistral: cookie header exceeds %d bytes: %w", maxMistralCookieHeaderLen, ErrMistralAuth) + } + if strings.ContainsAny(header, "\r\n") { + return s, ErrMistralAuth + } + req := http.Request{Header: http.Header{"Cookie": []string{header}}} + hasSession := false + for _, c := range req.Cookies() { + if !mistralCookieName(c.Name) { + continue + } + if strings.HasPrefix(c.Name, "ory_session_") { + hasSession = true + } + // Parsing strips the quoting; keep it so the value is sent back in the + // same form the browser would send it. + value := c.Value + if c.Quoted { + value = `"` + value + `"` + } + s.Cookies = append(s.Cookies, sweetcookie.Cookie{Name: c.Name, Value: value, Domain: ".mistral.ai", Path: "/", Secure: true}) + } + if !hasSession { + return s, ErrMistralAuth + } + s.Source.Profile = fmt.Sprintf("%x", sha256.Sum256([]byte(header))) + return s, nil +} diff --git a/internal/api/mistral_cookie_test.go b/internal/api/mistral_cookie_test.go new file mode 100644 index 00000000..fbf4eddd --- /dev/null +++ b/internal/api/mistral_cookie_test.go @@ -0,0 +1,298 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/steipete/sweetcookie" + _ "modernc.org/sqlite" +) + +func TestMistralCookieIsolation(t *testing.T) { + now := time.Now() + expired := now.Add(-time.Hour) + read := func(ctx context.Context, o sweetcookie.Options) (sweetcookie.Result, error) { + if len(o.Browsers) != 1 || o.Profiles[o.Browsers[0]] != "profile-a" { + t.Fatal("profile not isolated") + } + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{ + {Name: "ory_session_a", Value: "default", Domain: "admin.mistral.ai", Path: "/", Source: sweetcookie.Source{Profile: "profile-a"}}, + {Name: "ory_session_a", Value: "container", Domain: ".mistral.ai", Path: "/", Container: sweetcookie.Container{ID: 2}}, + {Name: "ory_session_old", Value: "old", Domain: ".mistral.ai", Path: "/", Expires: &expired}, + {Name: "tracking", Value: "private", Domain: ".mistral.ai", Path: "/"}, + }}, nil + } + sessions, e := ImportMistralSessions(context.Background(), MistralSource{"firefox", "profile-a", -1}, read) + if e != nil || len(sessions) != 2 { + t.Fatalf("count=%d err=%v", len(sessions), e) + } + if got := sessions[0].Header("https://admin.mistral.ai/subscription", now); got != "ory_session_a=default" { + t.Fatal(got) + } + if got := sessions[0].Header("https://console.mistral.ai/api-ui/trpc/billing.vibeUsage", now); got != "" { + t.Fatal("host-only cookie escaped origin") + } + if got := sessions[1].Header("https://evil.mistral.ai/", now); got != "" { + t.Fatal("invalid destination") + } + if got := sessions[1].Header("http://admin.mistral.ai/", now); got != "" { + t.Fatal("insecure destination") + } +} +func TestMistralImporterFailures(t *testing.T) { + for _, err := range []error{errors.New("permission denied"), errors.New("locked database"), errors.New("unsupported app-bound encryption")} { + _, e := ImportMistralSessions(context.Background(), MistralSource{"chrome", "Default", 0}, func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{}, err + }) + if !errors.Is(e, ErrMistralAuth) { + t.Fatal(e) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, e := ImportMistralSessions(ctx, MistralSource{}, func(ctx context.Context, _ sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{}, ctx.Err() + }) + if !errors.Is(e, context.Canceled) { + t.Fatal(e) + } +} + +type mistralTransport func(*http.Request) (*http.Response, error) + +func (f mistralTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestMistralHTTPFailures(t *testing.T) { + session, _ := ManualMistralSession("ory_session_test=secret; tracking=excluded") + for _, status := range []int{302, 401, 403, 429, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + c := NewMistralClient() + c.http.Transport = mistralTransport(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.Header.Get("Cookie"), "tracking") { + t.Fatal("extra cookie") + } + return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": []string{"123"}}, Body: http.NoBody}, nil + }) + _, err := c.get(context.Background(), session, "https://admin.mistral.ai/subscription") + if err == nil { + t.Fatal("missing error") + } + if status == 429 { + var he *MistralHTTPError + if !errors.As(err, &he) || he.RetryAfter != 123*time.Second { + t.Fatal(err) + } + } + if strings.Contains(err.Error(), "secret") { + t.Fatal("secret leaked") + } + }) + } +} + +func TestMistralBrowserOrdering(t *testing.T) { + for _, platform := range []string{"darwin", "linux", "windows"} { + order := MistralBrowserOrder(platform) + if len(order) != 3 || order[0] != "chrome" || order[1] != "firefox" { + t.Fatal(order) + } + if platform == "darwin" && order[2] != "safari" || platform != "darwin" && order[2] != "edge" { + t.Fatal(order) + } + } +} + +func TestMistralSyntheticFirefoxStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "cookies.sqlite") + db, e := sql.Open("sqlite", path) + if e != nil { + t.Fatal(e) + } + _, e = db.Exec(`CREATE TABLE moz_cookies(host TEXT,name TEXT,value TEXT,path TEXT,expiry INTEGER,isSecure INTEGER,isHttpOnly INTEGER,sameSite INTEGER,originAttributes TEXT)`) + if e != nil { + t.Fatal(e) + } + for _, row := range []struct { + value, attrs string + expiry int64 + }{{"default", "", time.Now().Add(time.Hour).Unix()}, {"container", "^userContextId=3", time.Now().Add(time.Hour).Unix()}, {"expired", "^userContextId=4", time.Now().Add(-time.Hour).Unix()}} { + _, e = db.Exec(`INSERT INTO moz_cookies VALUES(?,?,?,?,?,1,1,0,?)`, ".mistral.ai", "ory_session_test", row.value, "/", row.expiry, row.attrs) + if e != nil { + t.Fatal(e) + } + } + if e = db.Close(); e != nil { + t.Fatal(e) + } + sessions, e := ImportMistralSessions(context.Background(), MistralSource{"firefox", path, -1}, nil) + if e != nil || len(sessions) != 2 { + t.Fatalf("count=%d error=%v", len(sessions), e) + } + if sessions[0].Header("https://admin.mistral.ai/subscription", time.Now()) != "ory_session_test=default" || sessions[1].Source.Container != 3 { + t.Fatal("container isolation failed") + } +} + +func TestMistralPartialRateLimit(t *testing.T) { + s, _ := ManualMistralSession("ory_session_test=secret") + client := NewMistralClient() + client.http.Transport = mistralTransport(func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.URL.Path, "billing") { + return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"3600"}}, Body: http.NoBody}, nil + } + page := `

Included API usage

€1 €10

` + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(page))}, nil + }) + snap, e := client.FetchSnapshot(context.Background(), s) + if e != nil || len(snap.Quotas) != 1 || snap.RetryAfter != time.Hour { + t.Fatalf("snap=%+v e=%v", snap, e) + } +} + +// A profile that has never signed in to Mistral must not reach the platform +// credential store: that is what raises a macOS Keychain password prompt, and +// prompting for a profile that cannot possibly help is pure user annoyance. +func TestMistralProfileWithoutMistralCookiesIsNotDecrypted(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "Cookies")) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`CREATE TABLE cookies(host_key TEXT,name TEXT,path TEXT,value TEXT)`); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`INSERT INTO cookies VALUES('example.com','sid','/','x')`); err != nil { + t.Fatal(err) + } + db.Close() + + read := func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + t.Error("credential store was opened for a profile with no Mistral cookies") + return sweetcookie.Result{}, nil + } + sessions, err := ImportMistralSessions(context.Background(), MistralSource{Browser: "chrome", Profile: dir, Container: -1}, read) + if err != nil || len(sessions) != 0 { + t.Fatalf("sessions=%d err=%v", len(sessions), err) + } +} + +// Ory session cookies are stored as quoted values, which RFC 6265 permits +// (DQUOTE *cookie-octet DQUOTE). Go rejects a raw '"' inside Cookie.Value, so +// passing the stored value through verbatim silently drops the session cookie +// and leaves only the CSRF cookie - a request that looks authenticated but is +// redirected straight to sign-in. +func TestMistralHeaderKeepsQuotedSessionCookie(t *testing.T) { + exp := time.Now().Add(time.Hour) + s := MistralSession{Cookies: []sweetcookie.Cookie{ + {Name: "ory_session_abc", Value: `"MTc2NDU4+/=abc"`, Domain: ".mistral.ai", Path: "/", Expires: &exp}, + {Name: "csrftoken", Value: "plain", Domain: "admin.mistral.ai", Path: "/", Expires: &exp}, + }} + h := s.Header("https://admin.mistral.ai/subscription", time.Now()) + if !strings.Contains(h, `ory_session_abc="MTc2NDU4+/=abc"`) { + t.Fatalf("quoted session cookie dropped or mangled: %q", h) + } + if !strings.Contains(h, "csrftoken=plain") { + t.Fatalf("csrf cookie missing: %q", h) + } +} + +// Only names, sorted, ever leave a session this way - callers such as the +// agent's own logging pass this straight to slog, and a cookie value is a +// credential that must never reach a log line. +func TestMistralSessionCookieNames(t *testing.T) { + s := MistralSession{Cookies: []sweetcookie.Cookie{ + {Name: "csrftoken", Value: "secret-b", Domain: "admin.mistral.ai"}, + {Name: "ory_session_abc", Value: "secret-a", Domain: ".mistral.ai"}, + }} + names := s.CookieNames() + want := []string{"csrftoken@admin.mistral.ai", "ory_session_abc@.mistral.ai"} + if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] { + t.Fatalf("names=%v want=%v", names, want) + } + for _, n := range names { + if strings.Contains(n, "secret") { + t.Fatalf("cookie value leaked into name list: %v", names) + } + } +} + +// A pasted header keeps its quoting through the round trip. +func TestManualMistralSessionKeepsQuotedValue(t *testing.T) { + s, err := ManualMistralSession(`ory_session_abc="MTc2NDU4+/=abc"; csrftoken=plain`) + if err != nil { + t.Fatal(err) + } + h := s.Header("https://admin.mistral.ai/subscription", time.Now()) + if !strings.Contains(h, `ory_session_abc="MTc2NDU4+/=abc"`) { + t.Fatalf("manual quoted cookie lost: %q", h) + } +} + +// Settings values are persisted at whatever length arrives - the only backstop +// is the 64KB request-body cap - so the parse boundary enforces its own limits, +// matching the byte-length reject style used for ingested fields. +func TestMistralRejectsOversizedInput(t *testing.T) { + atLimit := "ory_session_a=" + strings.Repeat("x", maxMistralCookieHeaderLen-len("ory_session_a=")) + if _, err := ManualMistralSession(atLimit); err != nil { + t.Fatalf("header exactly at the limit was rejected: %v", err) + } + if _, err := ManualMistralSession(atLimit + "x"); err == nil { + t.Fatal("oversized cookie header was accepted") + } + + // Absolute paths are used as-is, without scanning the host's real browser + // profiles, so these cases stay independent of the machine running them. + atLimitPath := "/" + strings.Repeat("p", maxMistralProfilePathLen-1) + if _, err := MistralSources("chrome", atLimitPath+"p"); err == nil { + t.Fatal("oversized browser profile was accepted") + } + if _, err := MistralSources("chrome", atLimitPath); err != nil { + t.Fatalf("profile exactly at the limit was rejected: %v", err) + } +} + +// A browser profile that cannot be scanned is reported and logged, so the +// error must not carry the OS error's path: that would write the user's home +// directory into the daemon log. The permission cause stays detectable. +func TestMistralScanErrorOmitsPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions do not block listing on Windows") + } + if os.Getuid() == 0 { + t.Skip("root bypasses directory permissions") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + root := filepath.Join(home, ".config", "google-chrome") + if runtime.GOOS == "darwin" { + root = filepath.Join(home, "Library", "Application Support", "Google", "Chrome") + } + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + + _, err := MistralSources("chrome", "") + if err == nil { + t.Fatal("unreadable profile folder was not reported") + } + if !errors.Is(err, fs.ErrPermission) { + t.Fatalf("permission cause lost: %v", err) + } + if strings.Contains(err.Error(), home) { + t.Fatalf("error leaks a filesystem path: %v", err) + } +} diff --git a/internal/api/mistral_live_test.go b/internal/api/mistral_live_test.go new file mode 100644 index 00000000..aa620eb2 --- /dev/null +++ b/internal/api/mistral_live_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" +) + +// Explicit opt-in only. Default test/CI runs never access developer credentials. +func TestMistralLive(t *testing.T) { + if os.Getenv("ONWATCH_MISTRAL_LIVE") != "1" { + t.Skip("live check is opt-in") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + sources, e := MistralSources("chrome", "") + if e != nil { + t.Fatal(e) + } + client := NewMistralClient() + for _, source := range sources { + sessions, e := ImportMistralSessions(ctx, source, nil) + if e != nil { + continue + } + for _, session := range sessions { + snap, e := client.FetchSnapshot(ctx, session) + if e != nil { + continue + } + for _, q := range snap.Quotas { + fmt.Printf("MISTRAL_LIVE %s used=%.6f limit=%.2f currency=%s reset=%v\n", q.Name, q.Used, q.Limit, q.Currency, q.ResetsAt) + } + now := time.Now().UTC() + data, e := client.get(ctx, session, fmt.Sprintf("https://admin.mistral.ai/api/billing/v2/usage?month=%d&year=%d", now.Month(), now.Year())) + if e != nil { + t.Fatal(e) + } + // Report schema and numeric billing fields only; never cookies/account metadata. + var root map[string]any + if json.Unmarshal(data, &root) != nil { + t.Fatal("invalid billing JSON") + } + var sanitize func(any) any + sanitize = func(v any) any { + switch x := v.(type) { + case map[string]any: + out := map[string]any{} + for k, v := range x { + switch k { + case "currency", "price", "value_paid", "value", "vibe_usage", "billing_metric", "billing_group", "start_date", "end_date", "date": + out[k] = v + default: + out[k] = sanitize(v) + } + } + return out + case []any: + if len(x) > 0 { + return []any{sanitize(x[0])} + } + return []any{} + case float64: + return x + default: + return "" + } + } + safe, _ := json.Marshal(sanitize(root)) + fmt.Printf("MISTRAL_BILLING_SCHEMA %s\n", safe) + return + } + } + t.Fatal("automatic import could not retrieve a Mistral session") +} diff --git a/internal/api/mistral_parser.go b/internal/api/mistral_parser.go new file mode 100644 index 00000000..c9969145 --- /dev/null +++ b/internal/api/mistral_parser.go @@ -0,0 +1,365 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +var ErrMistralParse = errors.New("mistral: data unavailable or ambiguous") + +// ParseMistralSubscription reads model rows only, skipping byte-counted Flight +// text/binary payloads so rendered text cannot masquerade as structured data. +func ParseMistralSubscription(page []byte, now time.Time) ([]MistralQuota, error) { + var stream strings.Builder + marker := []byte("self.__next_f.push(") + for rest := page; ; { + i := bytes.Index(rest, marker) + if i < 0 { + break + } + rest = rest[i+len(marker):] + var row []json.RawMessage + d := json.NewDecoder(bytes.NewReader(rest)) + if d.Decode(&row) != nil { + continue + } + rest = rest[d.InputOffset():] + if len(row) < 2 || string(row[0]) != "1" { + continue + } + var chunk string + if json.Unmarshal(row[1], &chunk) == nil { + stream.WriteString(chunk) + } + } + quotas := map[string]MistralQuota{} + var collect func(any) error + collect = func(v any) error { + switch x := v.(type) { + case map[string]any: + if b, ok := x["budget"].(map[string]any); ok { + for _, pair := range [][2]string{{"api_budget", "api_included"}, {"vibe_budget", "vibe_included"}} { + raw, ok := b[pair[0]].(map[string]any) + if !ok { + continue + } + pct, pok := raw["usage_percentage"].(float64) + limit, lok := raw["initial_budget"].(float64) + currency, _ := raw["currency"].(string) + if !pok || !lok || pct < 0 || limit <= 0 || len(currency) != 3 || math.IsInf(limit*pct, 0) { + continue + } + q := MistralQuota{Name: pair[1], Used: limit * pct / 100, Limit: limit, Utilization: pct, Currency: strings.ToUpper(currency), CapturedAt: now} + if reset, ok := raw["reset_at"].(string); ok { + if t, e := time.Parse(time.RFC3339, reset); e == nil { + q.ResetsAt = &t + } + } + if old, ok := quotas[q.Name]; ok { + a, _ := json.Marshal(old) + b, _ := json.Marshal(q) + if !bytes.Equal(a, b) { + return ErrMistralParse + } + } + quotas[q.Name] = q + } + } + for _, v := range x { + if e := collect(v); e != nil { + return e + } + } + case []any: + for _, v := range x { + if e := collect(v); e != nil { + return e + } + } + } + return nil + } + data := []byte(stream.String()) + for len(data) > 0 { + end := bytes.IndexByte(data, '\n') + if end < 0 { + end = len(data) + } + colon := bytes.IndexByte(data[:end], ':') + if colon < 1 { + data = data[min(end+1, len(data)):] + continue + } + if _, err := strconv.ParseUint(string(data[:colon]), 16, 64); err != nil { + data = data[min(end+1, len(data)):] + continue + } + row := data[colon+1:] + if len(row) > 0 && strings.ContainsRune("TAOoUSsLlGgMmV", rune(row[0])) { + comma := bytes.IndexByte(row, ',') + if comma < 2 { + return nil, ErrMistralParse + } + n, e := strconv.ParseUint(string(row[1:comma]), 16, 32) + if e != nil || n > uint64(len(row)-comma-1) { + return nil, ErrMistralParse + } + data = row[comma+1+int(n):] + continue + } + var root any + if len(row) > 0 && (row[0] == '{' || row[0] == '[') { + if json.Unmarshal(data[colon+1:end], &root) != nil { + return nil, ErrMistralParse + } + if e := collect(root); e != nil { + return nil, e + } + } + data = data[min(end+1, len(data)):] + } + result := []MistralQuota{} + for _, key := range []string{"api_included", "vibe_included"} { + if q, ok := quotas[key]; ok { + result = append(result, q) + } + } + if len(result) == 0 { + q, e := parseMistralRendered(page, now) + if e != nil { + return nil, fmt.Errorf("no budget fields in the subscription page (%d bytes): %w", len(page), e) + } + return q, nil + } + return result, nil +} + +// ParseMistralBilling deliberately never uses `value` (included + paid usage), +// subscription fees, credits, or the ambiguous `vibe_usage` total as spend. +func ParseMistralBilling(data []byte, now time.Time) (*MistralBilling, error) { + var root map[string]any + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + if d.Decode(&root) != nil { + return nil, ErrMistralParse + } + var extra any + if d.Decode(&extra) != io.EOF { + return nil, ErrMistralParse + } + currency, _ := root["currency"].(string) + if len(currency) != 3 { + return nil, ErrMistralParse + } + start := time.Date(now.UTC().Year(), now.UTC().Month(), 1, 0, 0, 0, 0, time.UTC) + b := &MistralBilling{Currency: strings.ToUpper(currency), PeriodStart: start, PeriodEnd: start.AddDate(0, 1, 0), CapturedAt: now, Status: "ok"} + number := func(v any) (float64, bool) { + var s string + switch n := v.(type) { + case json.Number: + s = string(n) + case string: + s = n + default: + return 0, false + } + n, e := strconv.ParseFloat(s, 64) + return n, e == nil && !math.IsInf(n, 0) && !math.IsNaN(n) + } + for key, target := range map[string]*time.Time{"start_date": &b.PeriodStart, "end_date": &b.PeriodEnd} { + if raw, exists := root[key]; exists { + text, ok := raw.(string) + if !ok { + return nil, ErrMistralParse + } + parsed, e := time.Parse(time.RFC3339, text) + if e != nil { + parsed, e = time.Parse("2006-01-02", text) + } + if e != nil { + return nil, ErrMistralParse + } + *target = parsed + } + } + if !b.PeriodEnd.After(b.PeriodStart) { + return nil, ErrMistralParse + } + // This explicitly named monetary total excludes subscription fees and takes + // precedence over quantities. Generic 'total'/'cost' fields remain ambiguous. + if raw, exists := root["usage_charge_total"]; exists { + amount, ok := number(raw) + if !ok { + return nil, ErrMistralParse + } + b.Amount = &amount + return b, nil + } + prices := map[string]float64{} + if ps, ok := root["prices"].([]any); ok { + for _, v := range ps { + p, ok := v.(map[string]any) + if !ok { + return nil, ErrMistralParse + } + metric, _ := p["billing_metric"].(string) + group, _ := p["billing_group"].(string) + n, ok := number(p["price"]) + if !ok || n < 0 { + return nil, ErrMistralParse + } + key := metric + "\x00" + group + if old, exists := prices[key]; exists && old != n { + return nil, ErrMistralParse + } + prices[key] = n + } + } + total := 0.0 + count := 0 + var walk func(any) error + walk = func(v any) error { + switch x := v.(type) { + case map[string]any: + if _, exists := x["billing_metric"]; exists { + metric, _ := x["billing_metric"].(string) + group, _ := x["billing_group"].(string) + paid, ok := number(x["value_paid"]) + price, priced := prices[metric+"\x00"+group] + if !ok || !priced { + return ErrMistralParse + } + total += paid * price + count++ + return nil + } + for _, child := range x { + if e := walk(child); e != nil { + return e + } + } + case []any: + for _, child := range x { + if e := walk(child); e != nil { + return e + } + } + } + return nil + } + for _, key := range []string{"completion", "ocr", "connectors", "libraries_api", "fine_tuning", "audio"} { + if e := walk(root[key]); e != nil { + return nil, e + } + } + vibeCount := count + if v, exists := root["vibe"]; exists { + if e := walk(v); e != nil { + return nil, e + } + } + // A Vibe total alone does not establish whether any of it was paid overage. + if v, exists := root["vibe_usage"]; exists { + n, ok := number(v) + if !ok || n != 0 && count == vibeCount { + return nil, ErrMistralParse + } + } + if count == 0 || math.IsInf(total, 0) || math.IsNaN(total) { + return nil, ErrMistralParse + } + b.Amount = &total + return b, nil +} + +// ParseMistralVibe keeps percentage-only fallback data explicit. The console +// response does not establish a monetary allowance, so none is invented. +func ParseMistralVibe(data []byte, now time.Time) (MistralQuota, error) { + var rows []struct { + Result struct { + Data struct { + JSON struct { + Percentage *float64 `json:"usage_percentage"` + Reset string `json:"reset_at"` + } `json:"json"` + } `json:"data"` + } `json:"result"` + } + if json.Unmarshal(data, &rows) != nil || len(rows) != 1 { + return MistralQuota{}, ErrMistralParse + } + v := rows[0].Result.Data.JSON + if v.Percentage == nil || *v.Percentage < 0 || math.IsInf(*v.Percentage, 0) || math.IsNaN(*v.Percentage) { + return MistralQuota{}, ErrMistralParse + } + q := MistralQuota{Name: "vibe_included", Utilization: *v.Percentage, PercentOnly: true, CapturedAt: now} + if reset, e := time.Parse(time.RFC3339, v.Reset); e == nil { + q.ResetsAt = &reset + } + return q, nil +} + +var mistralScripts = regexp.MustCompile(`(?is)<(?:script|style)\b[^>]*>.*?`) +var mistralTags = regexp.MustCompile(`<[^>]+>`) + +// Capture the whole numeric token before validating it. Matching only its +// prefix would silently turn a grouped amount such as 1,275.00 into 1. +var mistralMoneyPattern = regexp.MustCompile(`([€£])\s*([0-9][0-9.,\x{00a0}\x{202f} ]*)`) +var mistralAmountPattern = regexp.MustCompile(`^(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)(?:\.[0-9]+)?$`) + +func parseMistralRendered(page []byte, now time.Time) ([]MistralQuota, error) { + text := html.UnescapeString(mistralTags.ReplaceAllString(mistralScripts.ReplaceAllString(string(page), " "), " ")) + lower := strings.ToLower(text) + result := []MistralQuota{} + for _, pair := range [][2]string{{"included api usage", "api_included"}, {"included vibe code usage", "vibe_included"}} { + start := strings.Index(lower, pair[0]) + if start < 0 { + continue + } + start += len(pair[0]) + end := len(text) + for _, boundary := range []string{"included api usage", "included vibe code usage", "pay-as-you-go"} { + if i := strings.Index(lower[start:], boundary); i >= 0 && start+i < end { + end = start + i + } + } + chunk := text[start:end] + matches := mistralMoneyPattern.FindAllStringSubmatch(chunk, -1) + if len(matches) != 2 || matches[0][1] != matches[1][1] { + continue + } + usedText, limitText := strings.TrimSpace(matches[0][2]), strings.TrimSpace(matches[1][2]) + if !mistralAmountPattern.MatchString(usedText) || !mistralAmountPattern.MatchString(limitText) { + continue + } + used, e1 := strconv.ParseFloat(strings.ReplaceAll(usedText, ",", ""), 64) + limit, e2 := strconv.ParseFloat(strings.ReplaceAll(limitText, ",", ""), 64) + if e1 != nil || e2 != nil || limit <= 0 || math.IsInf(used/limit*100, 0) { + continue + } + currency := "EUR" + if matches[0][1] == "£" { + currency = "GBP" + } + q := MistralQuota{Name: pair[1], Used: used, Limit: limit, Utilization: used / limit * 100, Currency: currency, CapturedAt: now} + if strings.Contains(strings.ToLower(chunk), "first day of each calendar month") { + utc := now.UTC() + reset := time.Date(utc.Year(), utc.Month()+1, 1, 0, 0, 0, 0, time.UTC) + q.ResetsAt = &reset + } + result = append(result, q) + } + if len(result) == 0 { + return nil, ErrMistralParse + } + return result, nil +} diff --git a/internal/api/mistral_rendered_test.go b/internal/api/mistral_rendered_test.go new file mode 100644 index 00000000..b9ba1b64 --- /dev/null +++ b/internal/api/mistral_rendered_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "testing" + "time" +) + +func TestMistralRenderedThousands(t *testing.T) { + for _, tc := range []struct { + name, amount string + valid bool + }{ + {"grouped", "1,275.00", true}, + {"ungrouped", "1275.00", true}, + {"bad grouping", "1,27.50", false}, + {"decimal comma", "1.275,00", false}, + {"space grouping", "1 275,00", false}, + {"nonbreaking space", "1\u00a0275,00", false}, + } { + t.Run(tc.name, func(t *testing.T) { + q, err := ParseMistralSubscription([]byte(`

Included API usage

€999.00

€`+tc.amount+`

`), time.Now()) + if !tc.valid { + if err == nil { + t.Fatalf("ambiguous amount accepted: %+v", q) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(q) != 1 || q[0].Limit != 1275 || q[0].Used != 999 { + t.Fatalf("formatted amount misread: %+v", q) + } + }) + } +} diff --git a/internal/api/mistral_scope.go b/internal/api/mistral_scope.go new file mode 100644 index 00000000..b2b5fcff --- /dev/null +++ b/internal/api/mistral_scope.go @@ -0,0 +1,175 @@ +package api + +import ( + "bytes" + "context" + "database/sql" + "encoding/binary" + "io" + "net/url" + "os" + "strconv" + "strings" + + "github.com/steipete/sweetcookie" +) + +// sweetcookie normalizes away leading domain dots. Recover scope from the +// original store's non-secret metadata; never broaden a host-only cookie. +// An unreadable/ambiguous scope fails closed. These handles are temporary and +// sequential, independent of the application's SQLite connection. +func restoreMistralScopes(ctx context.Context, cookies []sweetcookie.Cookie) ([]sweetcookie.Cookie, error) { + byStore := map[string][]sweetcookie.Cookie{} + for _, c := range cookies { + if mistralCookieName(c.Name) { + byStore[c.Source.StorePath] = append(byStore[c.Source.StorePath], c) + } + } + var out []sweetcookie.Cookie + for path, group := range byStore { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if path == "" { + return nil, ErrMistralAuth + } + scopes, e := readMistralScopes(ctx, path, group[0].Source.Browser) + if e != nil { + return nil, ErrMistralAuth + } + for _, c := range group { + key := mistralScopeKey(c.Domain, c.Name, c.Path, c.Container.ID) + domain, ok := scopes[key] + if !ok || domain == "" { + continue + } + c.Domain = domain + out = append(out, c) + } + } + return out, nil +} +func mistralScopeKey(domain, name, path string, container int) string { + return strings.TrimPrefix(domain, ".") + "\x00" + name + "\x00" + path + "\x00" + strconv.Itoa(container) +} +func addMistralScope(scopes map[string]string, domain, name, path string, container int) { + if !mistralCookieName(name) { + return + } + host := strings.TrimPrefix(domain, ".") + if host != "mistral.ai" && host != "admin.mistral.ai" && host != "console.mistral.ai" { + return + } + key := mistralScopeKey(domain, name, path, container) + if prior, ok := scopes[key]; ok && prior != domain { + scopes[key] = "" + } else { + scopes[key] = domain + } +} +func readMistralScopes(ctx context.Context, path string, browser sweetcookie.Browser) (map[string]string, error) { + if browser == sweetcookie.BrowserSafari { + return readMistralSafariScopes(ctx, path) + } + u := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"} + db, e := sql.Open("sqlite", u.String()) + if e != nil { + return nil, e + } + db.SetMaxOpenConns(1) + defer db.Close() + query := `SELECT host_key,name,path,'' FROM cookies WHERE host_key IN (?,?,?,?,?,?) AND (name LIKE 'ory_session_%' OR name='csrftoken') LIMIT 1000` + if browser == sweetcookie.BrowserFirefox { + query = `SELECT host,name,path,originAttributes FROM moz_cookies WHERE host IN (?,?,?,?,?,?) AND (name LIKE 'ory_session_%' OR name='csrftoken') LIMIT 1000` + } + rows, e := db.QueryContext(ctx, query, "mistral.ai", ".mistral.ai", "admin.mistral.ai", ".admin.mistral.ai", "console.mistral.ai", ".console.mistral.ai") + if e != nil { + return nil, e + } + defer rows.Close() + scopes := map[string]string{} + for rows.Next() { + var domain, name, path string + var attrs sql.NullString + if e = rows.Scan(&domain, &name, &path, &attrs); e != nil { + return nil, e + } + container := 0 + for _, v := range strings.Split(strings.TrimPrefix(attrs.String, "^"), "&") { + if id, ok := strings.CutPrefix(v, "userContextId="); ok { + container, _ = strconv.Atoi(id) + } + } + addMistralScope(scopes, domain, name, path, container) + } + return scopes, rows.Err() +} +func readMistralSafariScopes(ctx context.Context, path string) (map[string]string, error) { + f, e := os.Open(path) + if e != nil { + return nil, e + } + defer f.Close() + data, e := io.ReadAll(io.LimitReader(f, (8<<20)+1)) + if e != nil || len(data) > 8<<20 || len(data) < 8 || string(data[:4]) != "cook" { + return nil, ErrMistralAuth + } + n := int(binary.BigEndian.Uint32(data[4:8])) + if n > (len(data)-8)/4 { + return nil, ErrMistralAuth + } + pos := 8 + 4*n + scopes := map[string]string{} + for i := 0; i < n; i++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + size := int(binary.BigEndian.Uint32(data[8+i*4 : 12+i*4])) + if size < 8 || size > len(data)-pos { + return nil, ErrMistralAuth + } + page := data[pos : pos+size] + pos += size + count := int(binary.LittleEndian.Uint32(page[4:8])) + if count > (len(page)-8)/4 { + return nil, ErrMistralAuth + } + for j := 0; j < count; j++ { + off := int(binary.LittleEndian.Uint32(page[8+4*j : 12+4*j])) + if off < 0 || off > len(page)-32 { + return nil, ErrMistralAuth + } + record := page[off:] + length := int(binary.LittleEndian.Uint32(record[:4])) + if length < 32 || length > len(record) { + return nil, ErrMistralAuth + } + record = record[:length] + field := func(at int) (string, error) { + offset := int(binary.LittleEndian.Uint32(record[at : at+4])) + if offset < 0 || offset >= len(record) { + return "", ErrMistralAuth + } + end := bytes.IndexByte(record[offset:], 0) + if end < 0 { + return "", ErrMistralAuth + } + return string(record[offset : offset+end]), nil + } + domain, e := field(16) + if e != nil { + return nil, e + } + name, e := field(20) + if e != nil { + return nil, e + } + path, e := field(24) + if e != nil { + return nil, e + } + addMistralScope(scopes, domain, name, path, 0) + } + } + return scopes, nil +} diff --git a/internal/api/mistral_scope_test.go b/internal/api/mistral_scope_test.go new file mode 100644 index 00000000..46eef689 --- /dev/null +++ b/internal/api/mistral_scope_test.go @@ -0,0 +1,289 @@ +package api + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/steipete/sweetcookie" +) + +// Synthetic Cookies.binarycookies fixtures. +// +// Apple's format: a big-endian file header ("cook" magic, a page count, then +// one big-endian size per page) followed by the pages themselves, each of +// which is little-endian internally (a tag, a record count, an offset table, +// then the records). readMistralSafariScopes only reads the url/name/path +// fields (record offsets 16/20/24), so these builders leave every other +// header field zeroed. + +func buildSafariCookieRecord(domain, name, path string) []byte { + const header = 56 // through the path-offset field; the rest is unused by the parser + urlOff := uint32(header) + nameOff := urlOff + uint32(len(domain)) + 1 + pathOff := nameOff + uint32(len(name)) + 1 + total := int(pathOff) + len(path) + 1 + + rec := make([]byte, total) // zero-filled, so each string's trailing NUL is implicit + binary.LittleEndian.PutUint32(rec[0:4], uint32(total)) + binary.LittleEndian.PutUint32(rec[16:20], urlOff) + binary.LittleEndian.PutUint32(rec[20:24], nameOff) + binary.LittleEndian.PutUint32(rec[24:28], pathOff) + copy(rec[urlOff:], domain) + copy(rec[nameOff:], name) + copy(rec[pathOff:], path) + return rec +} + +func buildSafariPage(records [][]byte) []byte { + offsetTable := 8 + 4*len(records) + page := make([]byte, offsetTable) + binary.LittleEndian.PutUint32(page[4:8], uint32(len(records))) + pos := offsetTable + for i, rec := range records { + binary.LittleEndian.PutUint32(page[8+4*i:12+4*i], uint32(pos)) + page = append(page, rec...) + pos += len(rec) + } + return page +} + +func buildSafariCookiesFile(pages [][]byte) []byte { + var out bytes.Buffer + out.WriteString("cook") + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], uint32(len(pages))) + out.Write(buf[:]) + for _, p := range pages { + binary.BigEndian.PutUint32(buf[:], uint32(len(p))) + out.Write(buf[:]) + } + for _, p := range pages { + out.Write(p) + } + return out.Bytes() +} + +func writeSafariFixture(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Cookies.binarycookies") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestReadMistralSafariScopes_Valid(t *testing.T) { + page := buildSafariPage([][]byte{ + buildSafariCookieRecord(".mistral.ai", "ory_session_1", "/"), + buildSafariCookieRecord(".mistral.ai", "csrftoken", "/"), + buildSafariCookieRecord(".mistral.ai", "unrelated", "/"), // not an auth cookie name: dropped + buildSafariCookieRecord(".evil.com", "ory_session_2", "/"), // not a Mistral domain: dropped + }) + path := writeSafariFixture(t, buildSafariCookiesFile([][]byte{page})) + + scopes, err := readMistralSafariScopes(context.Background(), path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(scopes) != 2 { + t.Fatalf("scopes = %+v, want 2 entries", scopes) + } + if got := scopes[mistralScopeKey(".mistral.ai", "ory_session_1", "/", 0)]; got != ".mistral.ai" { + t.Fatalf("session scope = %q", got) + } + if got := scopes[mistralScopeKey(".mistral.ai", "csrftoken", "/", 0)]; got != ".mistral.ai" { + t.Fatalf("csrf scope = %q", got) + } +} + +func TestReadMistralSafariScopes_MultiplePages(t *testing.T) { + p1 := buildSafariPage([][]byte{buildSafariCookieRecord("admin.mistral.ai", "ory_session_a", "/")}) + p2 := buildSafariPage([][]byte{buildSafariCookieRecord("console.mistral.ai", "ory_session_b", "/api")}) + path := writeSafariFixture(t, buildSafariCookiesFile([][]byte{p1, p2})) + + scopes, err := readMistralSafariScopes(context.Background(), path) + if err != nil || len(scopes) != 2 { + t.Fatalf("scopes=%+v err=%v", scopes, err) + } +} + +func TestReadMistralSafariScopes_NoPages(t *testing.T) { + path := writeSafariFixture(t, buildSafariCookiesFile(nil)) + scopes, err := readMistralSafariScopes(context.Background(), path) + if err != nil || len(scopes) != 0 { + t.Fatalf("scopes=%+v err=%v", scopes, err) + } +} + +// Two raw domain spellings that normalize to the same host must blank the +// scope rather than silently pick one of them, so an ambiguous cookie gets +// dropped by restoreMistralScopes instead of guessed at. +func TestReadMistralSafariScopes_AmbiguousDomainDropped(t *testing.T) { + page := buildSafariPage([][]byte{ + buildSafariCookieRecord(".mistral.ai", "ory_session_x", "/"), + buildSafariCookieRecord("mistral.ai", "ory_session_x", "/"), + }) + path := writeSafariFixture(t, buildSafariCookiesFile([][]byte{page})) + + scopes, err := readMistralSafariScopes(context.Background(), path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + key := mistralScopeKey(".mistral.ai", "ory_session_x", "/", 0) + if got, ok := scopes[key]; !ok || got != "" { + t.Fatalf("ambiguous scope = %q, ok=%v, want blank", got, ok) + } +} + +func TestReadMistralSafariScopes_ContextCanceled(t *testing.T) { + page := buildSafariPage([][]byte{buildSafariCookieRecord(".mistral.ai", "ory_session_1", "/")}) + path := writeSafariFixture(t, buildSafariCookiesFile([][]byte{page})) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := readMistralSafariScopes(ctx, path); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestReadMistralSafariScopes_MissingFile(t *testing.T) { + if _, err := readMistralSafariScopes(context.Background(), filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected error for a missing file") + } +} + +// Every bounds check in the hand-rolled binary parser fails closed with +// ErrMistralAuth rather than risk an out-of-range panic on a malformed or +// truncated cookie file. +func TestReadMistralSafariScopes_Malformed(t *testing.T) { + validPage := buildSafariPage([][]byte{buildSafariCookieRecord(".mistral.ai", "ory_session_1", "/")}) + validFile := buildSafariCookiesFile([][]byte{validPage}) + + // File-header level corruption. + headerCases := map[string][]byte{ + "empty": {}, + "too short": []byte("cook"), + "bad magic": append([]byte("evil"), validFile[4:]...), + "page count overflow": func() []byte { + d := append([]byte(nil), validFile...) + binary.BigEndian.PutUint32(d[4:8], 1<<20) // claims far more pages than the file has room for + return d + }(), + } + for name, data := range headerCases { + t.Run(name, func(t *testing.T) { + path := writeSafariFixture(t, data) + if _, err := readMistralSafariScopes(context.Background(), path); !errors.Is(err, ErrMistralAuth) { + t.Fatalf("err = %v, want ErrMistralAuth", err) + } + }) + } + + // Page- and record-level corruption. Layout of the single-page, + // single-record validFile: an 8-byte file header + one 4-byte page-size + // entry (12 bytes total), then the page itself (4B tag + 4B count + 4B + // offset table = 12 bytes) with its one record starting right after. + const pageSizeAt = 8 + const pageStart = 12 + const recordStart = pageStart + 12 + + corrupt := func(patch func(d []byte)) []byte { + d := append([]byte(nil), validFile...) + patch(d) + return d + } + recordCases := map[string][]byte{ + "page size too small": corrupt(func(d []byte) { + binary.BigEndian.PutUint32(d[pageSizeAt:pageSizeAt+4], 4) + }), + "page size too large": corrupt(func(d []byte) { + binary.BigEndian.PutUint32(d[pageSizeAt:pageSizeAt+4], 1<<20) + }), + "record count overflow": corrupt(func(d []byte) { + binary.LittleEndian.PutUint32(d[pageStart+4:pageStart+8], 1<<20) + }), + "record offset out of bounds": corrupt(func(d []byte) { + binary.LittleEndian.PutUint32(d[pageStart+8:pageStart+12], 1<<20) + }), + "record length too small": corrupt(func(d []byte) { + binary.LittleEndian.PutUint32(d[recordStart:recordStart+4], 4) + }), + "record length too large": corrupt(func(d []byte) { + binary.LittleEndian.PutUint32(d[recordStart:recordStart+4], 1<<20) + }), + "domain field offset out of bounds": corrupt(func(d []byte) { + binary.LittleEndian.PutUint32(d[recordStart+16:recordStart+20], 1<<20) + }), + "unterminated string": corrupt(func(d []byte) { + // Point the domain offset at the record's last byte (its + // implicit NUL from the zero-filled builder) and clobber it, so + // no terminator exists between the offset and the record's end. + last := len(d) - 1 + binary.LittleEndian.PutUint32(d[recordStart+16:recordStart+20], uint32(last-recordStart)) + d[last] = 'x' + }), + } + for name, data := range recordCases { + t.Run(name, func(t *testing.T) { + path := writeSafariFixture(t, data) + if _, err := readMistralSafariScopes(context.Background(), path); !errors.Is(err, ErrMistralAuth) { + t.Fatalf("err = %v, want ErrMistralAuth", err) + } + }) + } +} + +func TestAddMistralScope(t *testing.T) { + scopes := map[string]string{} + + addMistralScope(scopes, "mistral.ai", "not_a_session_cookie", "/", 0) + if len(scopes) != 0 { + t.Fatalf("non-auth cookie name was recorded: %+v", scopes) + } + + addMistralScope(scopes, "example.com", "ory_session_1", "/", 0) + if len(scopes) != 0 { + t.Fatalf("cookie for an unrelated domain was recorded: %+v", scopes) + } + + addMistralScope(scopes, ".mistral.ai", "ory_session_1", "/", 0) + key := mistralScopeKey(".mistral.ai", "ory_session_1", "/", 0) + if got := scopes[key]; got != ".mistral.ai" { + t.Fatalf("scope not recorded: %+v", scopes) + } + + // Same normalized host, different raw domain spelling: ambiguous, must blank. + addMistralScope(scopes, "mistral.ai", "ory_session_1", "/", 0) + if got, ok := scopes[key]; !ok || got != "" { + t.Fatalf("conflicting domain was not blanked: %q ok=%v", got, ok) + } +} + +func TestRestoreMistralScopes_EmptyStorePathFailsClosed(t *testing.T) { + cookies := []sweetcookie.Cookie{{Name: "ory_session_1", Domain: ".mistral.ai", Path: "/", Source: sweetcookie.Source{StorePath: ""}}} + if _, err := restoreMistralScopes(context.Background(), cookies); !errors.Is(err, ErrMistralAuth) { + t.Fatalf("err = %v, want ErrMistralAuth", err) + } +} + +func TestRestoreMistralScopes_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cookies := []sweetcookie.Cookie{{Name: "ory_session_1", Domain: ".mistral.ai", Path: "/", Source: sweetcookie.Source{StorePath: "/tmp/does-not-matter"}}} + if _, err := restoreMistralScopes(ctx, cookies); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestRestoreMistralScopes_NoAuthCookies(t *testing.T) { + cookies := []sweetcookie.Cookie{{Name: "tracking", Domain: ".mistral.ai", Path: "/"}} + out, err := restoreMistralScopes(context.Background(), cookies) + if err != nil || len(out) != 0 { + t.Fatalf("out=%+v err=%v", out, err) + } +} diff --git a/internal/api/mistral_test.go b/internal/api/mistral_test.go new file mode 100644 index 00000000..660b1ab7 --- /dev/null +++ b/internal/api/mistral_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "encoding/json" + "testing" + "time" +) + +func TestMistralSubscription(t *testing.T) { + page := `` + q, err := ParseMistralSubscription([]byte(page), time.Now()) + if err != nil || len(q) != 2 { + t.Fatalf("quotas=%v err=%v", q, err) + } + if q[0].Currency != "EUR" || q[0].Limit != 10 || q[0].ResetsAt == nil || q[1].ResetsAt != nil { + t.Fatalf("incorrect quotas: %+v", q) + } +} + +func TestMistralBillingPaidOnly(t *testing.T) { + for _, tc := range []struct { + name, body string + want float64 + valid bool + }{ + {"paid", `{"currency":"EUR","completion":{"models":{"x":{"input":[{"value":100,"value_paid":2,"billing_metric":"tokens","billing_group":"x"}]}}},"prices":[{"billing_metric":"tokens","billing_group":"x","price":"0.5"}]}`, 1, true}, + {"missing paid", `{"currency":"EUR","completion":{"models":{"x":{"input":[{"value":100,"billing_metric":"tokens","billing_group":"x"}]}}},"prices":[{"billing_metric":"tokens","billing_group":"x","price":"0.5"}]}`, 0, false}, + {"missing price", `{"currency":"USD","completion":{"models":{"x":{"input":[{"value_paid":2,"billing_metric":"tokens","billing_group":"x"}]}}}}`, 0, false}, + {"zero", `{"currency":"GBP","completion":{"models":{"x":{"input":[{"value_paid":0,"billing_metric":"tokens","billing_group":"x"}]}}},"prices":[{"billing_metric":"tokens","billing_group":"x","price":"0.5"}]}`, 0, true}, + {"signed adjustment", `{"currency":"EUR","completion":{"models":{"x":{"input":[{"value_paid":-2,"billing_metric":"tokens","billing_group":"x"}]}}},"prices":[{"billing_metric":"tokens","billing_group":"x","price":"0.5"}]}`, -1, true}, + {"ambiguous vibe", `{"currency":"EUR","vibe_usage":9}`, 0, false}, + {"empty", `{}`, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + b, err := ParseMistralBilling([]byte(tc.body), time.Now()) + if (err == nil) != tc.valid { + t.Fatalf("billing=%+v err=%v", b, err) + } + if tc.valid && (b.Amount == nil || *b.Amount != tc.want) { + t.Fatalf("billing=%+v", b) + } + }) + } +} + +func TestMistralVibeFallback(t *testing.T) { + q, e := ParseMistralVibe([]byte(`[{"result":{"data":{"json":{"usage_percentage":25,"reset_at":"2026-10-01T00:00:00Z"}}}}]`), time.Now()) + if e != nil || q.Name != "vibe_included" || q.Utilization != 25 || !q.PercentOnly || q.Limit != 0 { + t.Fatalf("q=%+v err=%v", q, e) + } +} + +func TestMistralRenderedFallback(t *testing.T) { + page := `

Included API usage

Resets on the first day of each calendar month

€0
€10.00

Included Vibe Code usage

€100.00
€100.00

PAY-AS-YOU-GO

€99` + now := time.Date(2026, 12, 31, 12, 0, 0, 0, time.UTC) + q, e := ParseMistralSubscription([]byte(page), now) + if e != nil || len(q) != 2 || q[0].Used != 0 || q[1].Utilization != 100 || q[0].ResetsAt.Year() != 2027 || q[1].ResetsAt != nil { + t.Fatalf("%+v %v", q, e) + } +} + +func TestMistralExplicitBillingAndVibe(t *testing.T) { + for _, tc := range []struct { + body string + amount float64 + }{ + {`{"currency":"EUR","usage_charge_total":"1.25","vibe_usage":8,"start_date":"2026-09-01","end_date":"2026-10-01"}`, 1.25}, + {`{"currency":"GBP","vibe":{"input":[{"value_paid":3,"billing_metric":"vibe","billing_group":"overage"}]},"vibe_usage":8,"prices":[{"price":"0.5","billing_metric":"vibe","billing_group":"overage"}]}`, 1.5}, + } { + b, e := ParseMistralBilling([]byte(tc.body), time.Date(2026, 9, 23, 0, 0, 0, 0, time.UTC)) + if e != nil || b.Amount == nil || *b.Amount != tc.amount { + t.Fatalf("%+v %v", b, e) + } + } +} + +func TestMistralUnknownAmounts(t *testing.T) { + data, e := json.Marshal(MistralQuota{Name: "vibe_included", PercentOnly: true, Utilization: 10}) + if e != nil || !bytes.Contains(data, []byte(`"used":null`)) || !bytes.Contains(data, []byte(`"limit":null`)) { + t.Fatalf("unknown amounts serialized as zero: %s %v", data, e) + } +} diff --git a/internal/api/mistral_types.go b/internal/api/mistral_types.go new file mode 100644 index 00000000..2dd5157a --- /dev/null +++ b/internal/api/mistral_types.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/json" + "time" +) + +type MistralQuota struct { + PercentOnly bool `json:"percentOnly,omitempty"` + Name string `json:"name"` + Used float64 `json:"used"` + Limit float64 `json:"limit"` + Utilization float64 `json:"utilization"` + Currency string `json:"currency"` + ResetsAt *time.Time `json:"resetsAt,omitempty"` + CapturedAt time.Time `json:"capturedAt"` +} + +type MistralBilling struct { + Amount *float64 `json:"amount"` + Currency string `json:"currency"` + PeriodStart time.Time `json:"periodStart"` + PeriodEnd time.Time `json:"periodEnd"` + CapturedAt time.Time `json:"capturedAt"` + Status string `json:"status"` +} + +type MistralSnapshot struct { + RetryAfter time.Duration `json:"-"` + AuthFailed bool `json:"-"` + ID int64 `json:"id"` + Identity string `json:"identity"` + CapturedAt time.Time `json:"capturedAt"` + Quotas []MistralQuota `json:"quotas"` + Billing *MistralBilling `json:"billing"` + Status string `json:"status"` +} + +// Percentage-only console data must not serialize unknown monetary amounts as zero. +func (q MistralQuota) MarshalJSON() ([]byte, error) { + type quota MistralQuota + var used, limit *float64 + if !q.PercentOnly { + used = &q.Used + limit = &q.Limit + } + return json.Marshal(struct { + quota + Used *float64 `json:"used"` + Limit *float64 `json:"limit"` + }{quota(q), used, limit}) +} diff --git a/internal/api/testdata/mistral/subscription.html b/internal/api/testdata/mistral/subscription.html new file mode 100644 index 00000000..0cbab588 --- /dev/null +++ b/internal/api/testdata/mistral/subscription.html @@ -0,0 +1,2 @@ + + diff --git a/internal/config/config.go b/internal/config/config.go index c84e2416..28588f52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,14 @@ const ( // Config holds all application configuration. type Config struct { + MistralEnabled bool + MistralDisabled bool + MistralAuthCookie string + MistralAuthMode string + MistralBrowser string + MistralBrowserProfile string + MistralRetention time.Duration // MISTRAL_RETENTION (example: 2160h, 0 disables pruning) + // Synthetic provider configuration SyntheticAPIKey string // SYNTHETIC_API_KEY @@ -370,6 +378,19 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) { cfg.CodexShowAvailable = "usage" } // OpenCode feeds the Codex provider using ChatGPT OAuth stored by OpenCode. + cfg.MistralEnabled = os.Getenv("MISTRAL_ENABLED") == "true" + cfg.MistralDisabled = os.Getenv("MISTRAL_ENABLED") == "false" + cfg.MistralAuthCookie = strings.TrimSpace(os.Getenv("MISTRAL_AUTH_COOKIE")) + cfg.MistralBrowser = strings.TrimSpace(os.Getenv("MISTRAL_BROWSER")) + cfg.MistralBrowserProfile = strings.TrimSpace(os.Getenv("MISTRAL_BROWSER_PROFILE")) + cfg.MistralRetention = 90 * 24 * time.Hour + if env := strings.TrimSpace(os.Getenv("MISTRAL_RETENTION")); env != "" { + if env == "0" { + cfg.MistralRetention = 0 + } else if v, err := time.ParseDuration(env); err == nil { + cfg.MistralRetention = v + } + } cfg.OpenCodeEnabled = os.Getenv("OPENCODE_ENABLED") == "true" cfg.OpenCodeGoWorkspaceID = strings.TrimSpace(os.Getenv("OPENCODE_GO_WORKSPACE_ID")) cfg.OpenCodeGoAuthCookie = strings.TrimSpace(os.Getenv("OPENCODE_GO_AUTH_COOKIE")) @@ -747,6 +768,9 @@ func (c *Config) Validate() error { if c.APIIntegrationsRetention < 0 { return fmt.Errorf("API integrations retention must be non-negative") } + if c.MistralRetention < 0 { + return fmt.Errorf("Mistral retention must be non-negative") + } return nil } @@ -808,6 +832,9 @@ func (c *Config) AvailableProviders() []string { if c.CommandCodeAPIKey != "" || c.CommandCodeEnabled { providers = append(providers, "commandcode") } + if c.HasProvider("mistral") { + providers = append(providers, "mistral") + } return providers } @@ -842,6 +869,8 @@ func (c *Config) HasProvider(name string) bool { return c.GrokToken != "" || c.GrokEnabled case "kimi": return c.KimiToken != "" || c.KimiEnabled + case "mistral": + return !c.MistralDisabled && (c.MistralEnabled || c.MistralAuthCookie != "") case "opencode": return c.OpenCodeGoConfigured() case "ollama": @@ -917,6 +946,9 @@ func (c *Config) HasMultipleProviders() bool { if c.CommandCodeAPIKey != "" || c.CommandCodeEnabled { count++ } + if c.HasProvider("mistral") { + count++ + } return count > 1 } @@ -968,6 +1000,7 @@ func (c *Config) String() string { fmt.Fprintf(&sb, " APIIntegrationsEnabled: %v,\n", c.APIIntegrationsEnabled) fmt.Fprintf(&sb, " APIIntegrationsDir: %s,\n", c.APIIntegrationsDir) fmt.Fprintf(&sb, " APIIntegrationsRetention: %v,\n", c.APIIntegrationsRetention) + fmt.Fprintf(&sb, " MistralRetention: %v,\n", c.MistralRetention) // Redact Cursor token cursorDisplay := redactAPIKey(c.CursorToken, "") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8045e74f..d4f7023c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1547,3 +1547,55 @@ func TestOpenRotatingLogFile_CreatesMissingParentDir(t *testing.T) { t.Fatalf("log file not created: %v", err) } } + +func TestConfig_MistralRetention_Default(t *testing.T) { + os.Clearenv() + defer os.Clearenv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.MistralRetention != 90*24*time.Hour { + t.Errorf("MistralRetention = %v, want %v", cfg.MistralRetention, 90*24*time.Hour) + } +} + +func TestConfig_MistralRetention_LoadsFromEnv(t *testing.T) { + os.Clearenv() + os.Setenv("MISTRAL_RETENTION", "168h") + defer os.Clearenv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.MistralRetention != 168*time.Hour { + t.Errorf("MistralRetention = %v, want %v", cfg.MistralRetention, 168*time.Hour) + } +} + +func TestConfig_MistralRetention_Disabled(t *testing.T) { + os.Clearenv() + os.Setenv("MISTRAL_RETENTION", "0") + defer os.Clearenv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.MistralRetention != 0 { + t.Errorf("MistralRetention = %v, want 0", cfg.MistralRetention) + } +} + +// A negative duration parses fine, so Validate has to reject it. +func TestConfig_MistralRetention_RejectsNegative(t *testing.T) { + os.Clearenv() + os.Setenv("MISTRAL_RETENTION", "-5h") + defer os.Clearenv() + + if _, err := Load(); err == nil { + t.Fatal("negative MISTRAL_RETENTION was accepted") + } +} diff --git a/internal/menubar/browser_access.go b/internal/menubar/browser_access.go new file mode 100644 index 00000000..fa4ad102 --- /dev/null +++ b/internal/menubar/browser_access.go @@ -0,0 +1,51 @@ +package menubar + +import ( + "os" + "path/filepath" + "runtime" +) + +// browserDataRoots lists the per-browser data directories the Mistral cookie +// importer has to enumerate to find profiles. These live inside other +// applications' data directories, which recent macOS releases gate behind an +// explicit per-application grant. +func browserDataRoots(home string) []string { + switch runtime.GOOS { + case "darwin": + return []string{ + filepath.Join(home, "Library/Application Support/Google/Chrome"), + filepath.Join(home, "Library/Application Support/Microsoft Edge"), + filepath.Join(home, "Library/Application Support/Firefox"), + } + case "windows": + return []string{ + filepath.Join(home, "AppData/Local/Google/Chrome/User Data"), + filepath.Join(home, "AppData/Local/Microsoft/Edge/User Data"), + filepath.Join(home, "AppData/Roaming/Mozilla/Firefox"), + } + default: + return []string{ + filepath.Join(home, ".config/google-chrome"), + filepath.Join(home, ".config/microsoft-edge"), + filepath.Join(home, ".mozilla/firefox"), + } + } +} + +// blockedBrowserRoot returns the first root that exists but cannot be +// enumerated. A directory that is present and unreadable is the signature of a +// privacy denial rather than a browser that is simply not installed, and it is +// the folder the user has to grant access to. An empty string means nothing is +// blocked. +func blockedBrowserRoot(roots []string) string { + for _, root := range roots { + if _, err := os.Stat(root); err != nil { + continue + } + if _, err := os.ReadDir(root); err != nil { + return root + } + } + return "" +} diff --git a/internal/menubar/browser_access_darwin.go b/internal/menubar/browser_access_darwin.go new file mode 100644 index 00000000..37161484 --- /dev/null +++ b/internal/menubar/browser_access_darwin.go @@ -0,0 +1,43 @@ +//go:build menubar && darwin && cgo + +package menubar + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc +#cgo LDFLAGS: -framework Cocoa + +#include + +int onwatch_request_folder_access(const char* path, const char* message); +*/ +import "C" + +import "unsafe" + +// Outcomes reported by the native panel, kept in sync with the codes in +// browser_access_darwin.m. +const ( + accessCancelled = 0 + accessGranted = 1 + accessNoApp = 2 + accessTimeout = 3 +) + +// requestFolderAccess asks the user to confirm access to one folder. macOS +// records the resulting grant against this binary, so the daemon shares it. +func requestFolderAccess(path, message string) (bool, string) { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + cMessage := C.CString(message) + defer C.free(unsafe.Pointer(cMessage)) + switch int(C.onwatch_request_folder_access(cPath, cMessage)) { + case accessGranted: + return true, "granted" + case accessNoApp: + return false, "no application context for a panel" + case accessTimeout: + return false, "panel was not answered" + default: + return false, "cancelled" + } +} diff --git a/internal/menubar/browser_access_darwin.m b/internal/menubar/browser_access_darwin.m new file mode 100644 index 00000000..9f1ed575 --- /dev/null +++ b/internal/menubar/browser_access_darwin.m @@ -0,0 +1,96 @@ +//go:build menubar && darwin && cgo + +#import + +// Result codes shared with the Go side. +#define ONWATCH_ACCESS_CANCELLED 0 +#define ONWATCH_ACCESS_GRANTED 1 +#define ONWATCH_ACCESS_NO_APP 2 +#define ONWATCH_ACCESS_TIMEOUT 3 + +// Presents the system folder panel so the user can grant this binary access to +// one browser data directory. Choosing a folder is what makes macOS record the +// per-application grant; nothing here can grant it silently. +// +// The panel is shown with beginWithCompletionHandler rather than runModal: a +// menubar-only app runs as an accessory, and a modal session started from a +// dispatch_sync block returns immediately without ever drawing the panel. +int onwatch_request_folder_access(const char *path, const char *message) { + if (NSApp == nil) { + return ONWATCH_ACCESS_NO_APP; + } + __block int outcome = ONWATCH_ACCESS_CANCELLED; + __block NSOpenPanel *panel = nil; + __block BOOL finished = NO; + __block NSApplicationActivationPolicy previous; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + NSString *dir = path ? [NSString stringWithUTF8String:path] : nil; + NSString *msg = message ? [NSString stringWithUTF8String:message] : @""; + + dispatch_async(dispatch_get_main_queue(), ^{ + // An accessory app cannot reliably own a key window. Become a regular app + // for the lifetime of the panel, then drop back so no dock icon lingers. + previous = [NSApp activationPolicy]; + if (previous != NSApplicationActivationPolicyRegular) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + } + [NSApp activateIgnoringOtherApps:YES]; + + panel = [NSOpenPanel openPanel]; + // Keep the asynchronous panel alive until its completion handler runs, + // and keep it visible if focus returns to the browser or the tray. + panel.releasedWhenClosed = NO; + panel.hidesOnDeactivate = NO; + panel.collectionBehavior = NSWindowCollectionBehaviorMoveToActiveSpace | + NSWindowCollectionBehaviorFullScreenAuxiliary; + panel.canChooseDirectories = YES; + panel.canChooseFiles = NO; + panel.allowsMultipleSelection = NO; + panel.canCreateDirectories = NO; + panel.showsHiddenFiles = YES; + panel.message = msg; + panel.prompt = @"Grant Access"; + panel.title = @"Grant onWatch access"; + if (dir.length > 0) { + panel.directoryURL = [NSURL fileURLWithPath:dir isDirectory:YES]; + } + panel.level = NSModalPanelWindowLevel; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + if (finished) { + return; + } + finished = YES; + outcome = (result == NSModalResponseOK) ? ONWATCH_ACCESS_GRANTED + : ONWATCH_ACCESS_CANCELLED; + [panel orderOut:nil]; + panel = nil; + if (previous != NSApplicationActivationPolicyRegular) { + [NSApp setActivationPolicy:previous]; + } + dispatch_semaphore_signal(done); + }]; + // Present first, then bring the actual system panel forward. Ordering an + // unpresented panel does not reliably bring the remote picker onscreen. + [panel makeKeyAndOrderFront:nil]; + [panel orderFrontRegardless]; + }); + + // Bounded so a panel the user never answers cannot wedge the menu handler. + if (dispatch_semaphore_wait( + done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(300 * NSEC_PER_SEC))) != 0) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (finished) { + return; + } + finished = YES; + [panel cancel:nil]; + [panel orderOut:nil]; + panel = nil; + if (previous != NSApplicationActivationPolicyRegular) { + [NSApp setActivationPolicy:previous]; + } + }); + return ONWATCH_ACCESS_TIMEOUT; + } + return outcome; +} diff --git a/internal/menubar/browser_access_stub.go b/internal/menubar/browser_access_stub.go new file mode 100644 index 00000000..424cc261 --- /dev/null +++ b/internal/menubar/browser_access_stub.go @@ -0,0 +1,9 @@ +//go:build !menubar || !darwin || !cgo + +package menubar + +// requestFolderAccess is macOS-only: no other supported platform gates reading +// another application's data directory behind a per-application grant. +func requestFolderAccess(string, string) (bool, string) { + return false, "unsupported on this platform" +} diff --git a/internal/menubar/browser_access_test.go b/internal/menubar/browser_access_test.go new file mode 100644 index 00000000..1adf8e20 --- /dev/null +++ b/internal/menubar/browser_access_test.go @@ -0,0 +1,57 @@ +package menubar + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestBlockedBrowserRootSkipsMissingAndReadable(t *testing.T) { + home := t.TempDir() + readable := filepath.Join(home, "readable") + if err := os.MkdirAll(filepath.Join(readable, "Default"), 0o755); err != nil { + t.Fatal(err) + } + missing := filepath.Join(home, "not-installed") + if got := blockedBrowserRoot([]string{missing, readable}); got != "" { + t.Fatalf("a missing or readable root must not ask for a grant, got %q", got) + } +} + +func TestBlockedBrowserRootReportsUnreadable(t *testing.T) { + // Windows ignores chmod 0000 on directories, and os.Getuid returns -1 + // there, so the root check alone would let this run and fail. + if runtime.GOOS == "windows" { + t.Skip("directory permissions do not block listing on Windows") + } + if os.Getuid() == 0 { + t.Skip("root bypasses directory permissions") + } + home := t.TempDir() + blocked := filepath.Join(home, "blocked") + if err := os.MkdirAll(blocked, 0o755); err != nil { + t.Fatal(err) + } + // A directory that exists but cannot be enumerated is exactly what a macOS + // app-data denial looks like to the importer. + if err := os.Chmod(blocked, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) + if got := blockedBrowserRoot([]string{blocked}); got != blocked { + t.Fatalf("blockedBrowserRoot()=%q, want %q", got, blocked) + } +} + +func TestBrowserDataRootsCoverChromiumAndFirefox(t *testing.T) { + roots := browserDataRoots("/Users/example") + if len(roots) == 0 { + t.Skip("no browser data roots on this platform") + } + for _, r := range roots { + if !filepath.IsAbs(r) { + t.Fatalf("root %q is not absolute", r) + } + } +} diff --git a/internal/menubar/companion.go b/internal/menubar/companion.go index 8286cf4b..046b78fe 100644 --- a/internal/menubar/companion.go +++ b/internal/menubar/companion.go @@ -7,6 +7,8 @@ import ( "fmt" "log/slog" "net/http" + "os" + "path/filepath" "sync" "time" @@ -30,6 +32,21 @@ type trayController struct { menuMu sync.Mutex providerItems []*systray.MenuItem + + mistralMu sync.RWMutex + mistralEnabled bool +} + +func (c *trayController) setMistralEnabled(enabled bool) { + c.mistralMu.Lock() + c.mistralEnabled = enabled + c.mistralMu.Unlock() +} + +func (c *trayController) isMistralEnabled() bool { + c.mistralMu.RLock() + defer c.mistralMu.RUnlock() + return c.mistralEnabled } func runCompanion(cfg *Config) error { @@ -92,6 +109,11 @@ func (c *trayController) onReady() { quickViewItem := systray.AddMenuItem("Open Quick View", "Show the onWatch quick view") dashboardItem := systray.AddMenuItem("Open Dashboard", "Open the local onWatch dashboard") refreshItem := systray.AddMenuItem("Refresh Now", "Fetch the latest quota data") + // Only offered while a browser data directory is actually unreadable. The + // daemon cannot present this itself: it has no user interface, and the + // grant is only created when a person chooses the folder. + grantItem := systray.AddMenuItem("Grant Browser Access...", "Allow onWatch to read browser cookies for Mistral") + grantItem.Hide() systray.AddSeparator() quitItem := systray.AddMenuItem("Quit Menubar", "Quit the menubar companion") @@ -103,6 +125,7 @@ func (c *trayController) onReady() { logger.Info("Menubar ready and visible") go c.watchMenu(quickViewItem, dashboardItem, refreshItem, quitItem) + go c.watchBrowserAccess(grantItem) go c.watchProviderItems() go c.refreshLoop() } @@ -116,6 +139,56 @@ func (c *trayController) onExit() { slog.Default().Info("Menubar shutting down") } +// watchBrowserAccess shows the grant action only while a browser data +// directory exists but cannot be read, and re-checks after each attempt so the +// item disappears once access has been granted. +func (c *trayController) watchBrowserAccess(grantItem *systray.MenuItem) { + logger := slog.Default() + previous := "\x00" + refresh := func() string { + if !c.isMistralEnabled() { + grantItem.Hide() + return "" + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + blocked := blockedBrowserRoot(browserDataRoots(home)) + if blocked == "" { + grantItem.Hide() + } else { + grantItem.Show() + } + if blocked != previous { + previous = blocked + logger.Info("Browser access check", "blocked", filepath.Base(blocked), "actionVisible", blocked != "") + } + return blocked + } + blocked := refresh() + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + blocked = refresh() + case _, ok := <-grantItem.ClickedCh: + if !ok { + return + } + if blocked == "" { + if blocked = refresh(); blocked == "" { + continue + } + } + granted, detail := requestFolderAccess(blocked, "Select this folder to let onWatch read your browser cookies for Mistral. onWatch reads only Mistral session cookies from it.") + logger.Info("Browser access grant requested", "folder", filepath.Base(blocked), "chosen", granted, "detail", detail) + blocked = refresh() + } + } +} + func (c *trayController) watchMenu(quickViewItem, dashboardItem, refreshItem, quitItem *systray.MenuItem) { for { select { @@ -208,6 +281,7 @@ func (c *trayController) refreshStatus() { c.updateMenu(nil) return } + c.setMistralEnabled(snapshot.MistralEnabled) settings, err := c.fetchPreferences() if err != nil { diff --git a/internal/menubar/config.go b/internal/menubar/config.go index afd60c44..eee66670 100644 --- a/internal/menubar/config.go +++ b/internal/menubar/config.go @@ -86,6 +86,10 @@ type Snapshot struct { UpdatedAgo string `json:"updated_ago"` Aggregate Aggregate `json:"aggregate"` Providers []ProviderCard `json:"providers"` + // MistralEnabled reflects config, not poll state: a provider card only + // appears in Providers once it has quota data, which would hide anything + // gated on it until after the first successful poll. + MistralEnabled bool `json:"mistral_enabled"` } // Aggregate summarizes the overall health across all visible providers. @@ -100,16 +104,17 @@ type Aggregate struct { // ProviderCard is the top-level card rendered for each provider. type ProviderCard struct { - ID string `json:"id"` - BaseProvider string `json:"base_provider"` - Label string `json:"label"` - Subtitle string `json:"subtitle,omitempty"` - Status string `json:"status"` - HighestPercent float64 `json:"highest_percent"` - UpdatedAt string `json:"updated_at,omitempty"` - Quotas []QuotaMeter `json:"quotas"` - Trends []TrendSeries `json:"trends,omitempty"` - Promo *ProviderPromo `json:"promo,omitempty"` + ID string `json:"id"` + BaseProvider string `json:"base_provider"` + Label string `json:"label"` + Subtitle string `json:"subtitle,omitempty"` + ConnectionStatus string `json:"connection_status,omitempty"` + Status string `json:"status"` + HighestPercent float64 `json:"highest_percent"` + UpdatedAt string `json:"updated_at,omitempty"` + Quotas []QuotaMeter `json:"quotas"` + Trends []TrendSeries `json:"trends,omitempty"` + Promo *ProviderPromo `json:"promo,omitempty"` } // ProviderPromo carries promo metadata for a provider card. @@ -125,6 +130,7 @@ type ProviderPromo struct { // QuotaMeter represents one circular quota meter inside a provider card. type QuotaMeter struct { + Currency string `json:"currency,omitempty"` Key string `json:"key"` Label string `json:"label"` DisplayValue string `json:"display_value"` @@ -132,7 +138,7 @@ type QuotaMeter struct { Status string `json:"status"` Used float64 `json:"used,omitempty"` Limit float64 `json:"limit,omitempty"` - Format string `json:"format,omitempty"` // "currency" when Used/Limit are USD + Format string `json:"format,omitempty"` // "currency" when Used/Limit are monetary values ResetAt string `json:"reset_at,omitempty"` TimeUntilReset string `json:"time_until_reset,omitempty"` ProjectedValue float64 `json:"projected_value,omitempty"` diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 05d1a67b..88aed88c 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -2,6 +2,7 @@ package metrics import ( + "context" "net/http" "runtime" "runtime/debug" @@ -22,7 +23,9 @@ const defaultAccountID = "default" // Metrics holds all Prometheus metrics for onWatch. type Metrics struct { - reg *prometheus.Registry + reg *prometheus.Registry + billingSpend *prometheus.GaugeVec + billingUpdated *prometheus.GaugeVec // scrapeMu serializes Scrape() so concurrent callers (HTTP handler, tests, // signal handlers) cannot race the Reset()+repopulate sequence and observe @@ -62,7 +65,9 @@ func New() *Metrics { reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) m := &Metrics{ - reg: reg, + reg: reg, + billingSpend: prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "onwatch_billing_spend", Help: "Last reported pay-as-you-go charges in the billing currency."}, []string{"provider", "account_id", "currency"}), + billingUpdated: prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "onwatch_billing_updated_timestamp_seconds", Help: "Timestamp of last successful billing result."}, []string{"provider", "account_id"}), quotaUtilization: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "onwatch_quota_utilization_percent", @@ -150,7 +155,7 @@ func New() *Metrics { } reg.MustRegister( - m.quotaUtilization, + m.billingSpend, m.billingUpdated, m.quotaUtilization, m.quotaResetTimestamp, m.creditsBalance, m.agentHealthy, @@ -239,6 +244,8 @@ func (m *Metrics) Scrape(s *store.Store, pollInterval time.Duration) { staleThreshold := pollInterval * 2 + m.billingSpend.Reset() + m.billingUpdated.Reset() m.quotaUtilization.Reset() m.quotaResetTimestamp.Reset() m.creditsBalance.Reset() @@ -248,6 +255,7 @@ func (m *Metrics) Scrape(s *store.Store, pollInterval time.Duration) { m.apiIntegrationSpendUSD.Reset() m.accountInfo.Reset() + m.scrapeMistral(s, staleThreshold) m.scrapeAnthropic(s, staleThreshold) m.scrapeCodex(s, staleThreshold) m.scrapeCopilot(s, staleThreshold) @@ -738,3 +746,35 @@ func (m *Metrics) recordLastCycleAge(provider, accountID string, capturedAt time m.agentLastCycleAge.With(lbls).Set(ageSeconds) } + +func (m *Metrics) scrapeMistral(s *store.Store, staleThreshold time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + snap, err := s.LatestMistral(ctx) + if err != nil { + m.scrapeErrorsTotal.WithLabelValues("mistral", "query_failed").Inc() + return + } + if snap == nil { + return + } + oldest := snap.CapturedAt + for _, q := range snap.Quotas { + labels := prometheus.Labels{"provider": "mistral", "quota_type": q.Name, "account_id": snap.Identity} + m.quotaUtilization.With(labels).Set(q.Utilization) + if q.ResetsAt != nil { + m.quotaResetTimestamp.With(labels).Set(float64(q.ResetsAt.Unix())) + } + if q.CapturedAt.Before(oldest) { + oldest = q.CapturedAt + } + } + m.recordLastCycleAge("mistral", snap.Identity, oldest, staleThreshold) + if status, _ := s.GetSetting("mistral_status"); status == "reconnect" || status == "stale" { + m.agentHealthy.WithLabelValues("mistral", snap.Identity).Set(0) + } + if b := snap.Billing; b != nil && b.Amount != nil { + m.billingSpend.WithLabelValues("mistral", snap.Identity, b.Currency).Set(*b.Amount) + m.billingUpdated.WithLabelValues("mistral", snap.Identity).Set(float64(b.CapturedAt.Unix())) + } +} diff --git a/internal/metrics/mistral_test.go b/internal/metrics/mistral_test.go new file mode 100644 index 00000000..3c4d594d --- /dev/null +++ b/internal/metrics/mistral_test.go @@ -0,0 +1,47 @@ +package metrics + +import ( + "context" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" + "testing" + "time" +) + +func TestMistralMetrics(t *testing.T) { + s, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer s.Close() + now := time.Now().UTC() + amount := 2.5 + if e = s.SaveMistral(context.Background(), &api.MistralSnapshot{Identity: "source", CapturedAt: now, Status: "ok", Quotas: []api.MistralQuota{{Name: "api_included", Utilization: 25, CapturedAt: now}}, Billing: &api.MistralBilling{Amount: &amount, Currency: "EUR", CapturedAt: now}}); e != nil { + t.Fatal(e) + } + m := New() + m.Scrape(s, time.Minute) + families, e := m.Gather().Gather() + if e != nil { + t.Fatal(e) + } + assertGaugeValue(t, families, "onwatch_billing_spend", map[string]string{"provider": "mistral", "account_id": "source", "currency": "EUR"}, 2.5) + if e = s.SetSetting("mistral_status", "reconnect"); e != nil { + t.Fatal(e) + } + m.Scrape(s, time.Minute) + families, e = m.Gather().Gather() + if e != nil { + t.Fatal(e) + } + assertGaugeValue(t, families, "onwatch_agent_healthy", map[string]string{"provider": "mistral", "account_id": "source"}, 0) + if e = s.SetSetting("mistral_identity", "different"); e != nil { + t.Fatal(e) + } + m.Scrape(s, time.Minute) + families, e = m.Gather().Gather() + if e != nil { + t.Fatal(e) + } + assertMetricFamilyMissing(t, families, "onwatch_billing_spend") +} diff --git a/internal/store/connection_pragmas_test.go b/internal/store/connection_pragmas_test.go new file mode 100644 index 00000000..2b581427 --- /dev/null +++ b/internal/store/connection_pragmas_test.go @@ -0,0 +1,44 @@ +package store + +import ( + "context" + "database/sql" + "path/filepath" + "testing" +) + +// The file-backed pool keeps two connections. Pragmas applied with db.Exec +// reach only whichever connection runs them, so every per-connection setting +// has to hold on a second connection too: without foreign_keys ON DELETE +// CASCADE silently does nothing, and without busy_timeout a contended write +// fails with SQLITE_BUSY at once instead of waiting. +func TestConnectionPragmasApplyToEveryConnection(t *testing.T) { + s, err := New(filepath.Join(t.TempDir(), "p.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + + // Holding the first connection open forces the pool to open a second. + var conns []*sql.Conn + for i := 0; i < 2; i++ { + c, err := s.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer c.Close() + conns = append(conns, c) + } + for i, conn := range conns { + for pragma, want := range map[string]int{"foreign_keys": 1, "busy_timeout": 5000, "cache_size": -500} { + var got int + if err := conn.QueryRowContext(ctx, "PRAGMA "+pragma).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("connection %d: %s=%d, want %d", i+1, pragma, got, want) + } + } + } +} diff --git a/internal/store/mistral_store.go b/internal/store/mistral_store.go new file mode 100644 index 00000000..12796965 --- /dev/null +++ b/internal/store/mistral_store.go @@ -0,0 +1,365 @@ +package store + +import ( + "context" + "database/sql" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +// The Mistral tables are declared with every other provider's in store.go. +const mistralActiveIdentity = `(SELECT value FROM settings WHERE key='mistral_identity')` + +// Hot-path reads. LatestMistral runs on every dashboard refresh, menubar +// refresh and metrics scrape, so each statement is ordered to let SQLite walk +// an index backwards and stop at the first row. idx_mistral_snapshots is +// (identity, captured_at) and carries the rowid, so ordering by captured_at +// then id matches it; ordering by id alone forced a sort of every snapshot the +// identity had - about 28ms a call at 90 days, growing with retention. +const ( + mistralLatestSnapshotSQL = `SELECT id,identity,captured_at,status FROM mistral_snapshots WHERE identity=` + mistralActiveIdentity + ` ORDER BY captured_at DESC, id DESC LIMIT 1` + // Driven from the billing table, which only has rows when pay-as-you-go + // data exists, instead of from every snapshot of the identity. + mistralLatestBillingSQL = `SELECT b.amount,b.currency,b.period_start,b.period_end,b.captured_at,b.status FROM mistral_billing b WHERE (SELECT identity FROM mistral_snapshots WHERE id=b.snapshot_id)=? ORDER BY b.snapshot_id DESC LIMIT 1` + mistralHistoryWindowSQL = `SELECT id,identity,captured_at,status FROM mistral_snapshots WHERE identity=` + mistralActiveIdentity + ` AND captured_at BETWEEN ? AND ? ORDER BY captured_at DESC, id DESC LIMIT ?` +) + +// mistralQuotaFromRow rebuilds a quota from its typed columns. NULL amounts mean +// Mistral reported only a percentage, which the API type marks as PercentOnly +// so the unknown amounts are never shown as zero. +func mistralQuotaFromRow(name string, used, limit sql.NullFloat64, utilization float64, currency string, resetsAt sql.NullString) api.MistralQuota { + q := api.MistralQuota{Name: name, Utilization: utilization, Currency: currency} + if used.Valid && limit.Valid { + q.Used, q.Limit = used.Float64, limit.Float64 + } else { + q.PercentOnly = true + } + if resetsAt.Valid && resetsAt.String != "" { + if t, e := time.Parse(time.RFC3339Nano, resetsAt.String); e == nil { + q.ResetsAt = &t + } + } + return q +} + +func mistralBillingFromRow(amount float64, currency, periodStart, periodEnd, capturedAt, status string) *api.MistralBilling { + b := &api.MistralBilling{Amount: &amount, Currency: currency, Status: status} + b.PeriodStart, _ = time.Parse(time.RFC3339Nano, periodStart) + b.PeriodEnd, _ = time.Parse(time.RFC3339Nano, periodEnd) + b.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + return b +} + +func (s *Store) SaveMistral(ctx context.Context, snap *api.MistralSnapshot) error { + tx, e := s.db.BeginTx(ctx, nil) + if e != nil { + return e + } + defer tx.Rollback() + result, e := tx.ExecContext(ctx, `INSERT INTO mistral_snapshots(identity,captured_at,status) VALUES(?,?,?)`, snap.Identity, snap.CapturedAt.Format(time.RFC3339Nano), snap.Status) + if e != nil { + return e + } + id, e := result.LastInsertId() + if e != nil { + return e + } + for _, q := range snap.Quotas { + // Percentage-only console data leaves the amounts NULL, never zero. + var used, limit, resetsAt any + if !q.PercentOnly { + used, limit = q.Used, q.Limit + } + if q.ResetsAt != nil { + resetsAt = q.ResetsAt.Format(time.RFC3339Nano) + } + if _, e = tx.ExecContext(ctx, `INSERT INTO mistral_quota_values(snapshot_id,quota_name,used,limit_value,utilization,currency,resets_at) VALUES(?,?,?,?,?,?,?)`, id, q.Name, used, limit, q.Utilization, q.Currency, resetsAt); e != nil { + return e + } + } + if b := snap.Billing; b != nil && b.Amount != nil { + if _, e = tx.ExecContext(ctx, `INSERT INTO mistral_billing(snapshot_id,amount,currency,period_start,period_end,captured_at,status) VALUES(?,?,?,?,?,?,?)`, id, *b.Amount, b.Currency, b.PeriodStart.Format(time.RFC3339Nano), b.PeriodEnd.Format(time.RFC3339Nano), b.CapturedAt.Format(time.RFC3339Nano), b.Status); e != nil { + return e + } + } + if _, e = tx.ExecContext(ctx, `INSERT INTO settings(key,value) VALUES('mistral_identity',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, snap.Identity); e != nil { + return e + } + if e = tx.Commit(); e == nil { + snap.ID = id + } + return e +} +func (s *Store) LatestMistral(ctx context.Context) (*api.MistralSnapshot, error) { + snap := &api.MistralSnapshot{Quotas: []api.MistralQuota{}} + var captured string + e := s.db.QueryRowContext(ctx, mistralLatestSnapshotSQL).Scan(&snap.ID, &snap.Identity, &captured, &snap.Status) + if e == sql.ErrNoRows { + return nil, nil + } + if e != nil { + return nil, e + } + snap.CapturedAt, _ = time.Parse(time.RFC3339Nano, captured) + // Each quota comes from the newest snapshot that carried it, which may not + // be the newest snapshot overall, so capturedAt is taken per quota rather + // than inherited from the parent - the dashboard's staleness check needs it. + rows, e := s.db.QueryContext(ctx, `SELECT q.quota_name,q.used,q.limit_value,q.utilization,q.currency,q.resets_at,qs.captured_at FROM (SELECT 'api_included' AS name UNION ALL SELECT 'vibe_included') keys JOIN mistral_quota_values q ON q.quota_name=keys.name AND q.snapshot_id=(SELECT MAX(q2.snapshot_id) FROM mistral_quota_values q2 JOIN mistral_snapshots s ON s.id=q2.snapshot_id WHERE s.identity=? AND q2.quota_name=keys.name) JOIN mistral_snapshots qs ON qs.id=q.snapshot_id ORDER BY q.quota_name`, snap.Identity) + if e != nil { + return nil, e + } + for rows.Next() { + var name, currency, quotaAt string + var used, limit sql.NullFloat64 + var utilization float64 + var resetsAt sql.NullString + if e = rows.Scan(&name, &used, &limit, &utilization, ¤cy, &resetsAt, "aAt); e != nil { + rows.Close() + return nil, e + } + q := mistralQuotaFromRow(name, used, limit, utilization, currency, resetsAt) + q.CapturedAt, _ = time.Parse(time.RFC3339Nano, quotaAt) + snap.Quotas = append(snap.Quotas, q) + } + e = rows.Err() + rows.Close() + if e != nil { + return nil, e + } + var amount float64 + var currency, periodStart, periodEnd, billedAt, status string + e = s.db.QueryRowContext(ctx, mistralLatestBillingSQL, snap.Identity).Scan(&amount, ¤cy, &periodStart, &periodEnd, &billedAt, &status) + if e == nil { + snap.Billing = mistralBillingFromRow(amount, currency, periodStart, periodEnd, billedAt, status) + } else if e != sql.ErrNoRows { + return nil, e + } + return snap, nil +} + +// MistralHistory returns snapshots with their quota and billing rows in a +// single cursor. A per-snapshot query loop reads through up to 2N additional +// statements that are not inside a transaction, so on the file-backed store - +// which keeps two connections - they can straddle separate WAL read snapshots +// and return partial sets while the agent is writing. +func (s *Store) MistralHistory(ctx context.Context, start, end time.Time, limit int) ([]*api.MistralSnapshot, error) { + // The limit bounds snapshots, not joined rows, so apply it in a subquery + // before joining. The identity filter has to stay inside it too, or it no + // longer bounds what LIMIT sees. + rows, e := s.db.QueryContext(ctx, `SELECT r.id,r.identity,r.captured_at,r.status,q.quota_name,q.used,q.limit_value,q.utilization,q.currency,q.resets_at,b.amount,b.currency,b.period_start,b.period_end,b.captured_at,b.status FROM (`+mistralHistoryWindowSQL+`) r LEFT JOIN mistral_quota_values q ON q.snapshot_id=r.id LEFT JOIN mistral_billing b ON b.snapshot_id=r.id ORDER BY r.id, q.quota_name`, + start.Format(time.RFC3339Nano), end.Format(time.RFC3339Nano), max(1, min(limit, 200))) + if e != nil { + return nil, e + } + defer rows.Close() + result := []*api.MistralSnapshot{} + byID := map[int64]*api.MistralSnapshot{} + for rows.Next() { + var id int64 + var identity, capturedAt, status string + // Every joined column is nullable: a snapshot can have no quota rows + // and usually has no billing row. + var quotaName, quotaCurrency, resetsAt sql.NullString + var used, limit, utilization sql.NullFloat64 + var amount sql.NullFloat64 + var billCurrency, periodStart, periodEnd, billedAt, billStatus sql.NullString + if e = rows.Scan(&id, &identity, &capturedAt, &status, "aName, &used, &limit, &utilization, "aCurrency, &resetsAt, &amount, &billCurrency, &periodStart, &periodEnd, &billedAt, &billStatus); e != nil { + return nil, e + } + snap, ok := byID[id] + if !ok { + snap = &api.MistralSnapshot{ID: id, Identity: identity, Status: status, Quotas: []api.MistralQuota{}} + snap.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + byID[id] = snap + result = append(result, snap) + } + // mistral_billing.snapshot_id is the primary key, so billing repeats + // across a snapshot's quota rows rather than fanning them out. + if amount.Valid && snap.Billing == nil { + snap.Billing = mistralBillingFromRow(amount.Float64, billCurrency.String, periodStart.String, periodEnd.String, billedAt.String, billStatus.String) + } + if !quotaName.Valid { + continue + } + q := mistralQuotaFromRow(quotaName.String, used, limit, utilization.Float64, quotaCurrency.String, resetsAt) + q.CapturedAt = snap.CapturedAt + snap.Quotas = append(snap.Quotas, q) + } + return result, rows.Err() +} + +type MistralCycle struct { + ID int64 `json:"id"` + QuotaName string `json:"quotaName"` + CycleStart time.Time `json:"cycleStart"` + CycleEnd *time.Time `json:"cycleEnd"` + ResetsAt *time.Time `json:"resetsAt"` + PeakUtilization float64 `json:"peakUtilization"` + TotalDelta float64 `json:"totalDelta"` + IsActive bool `json:"isActive"` +} + +// TrackMistral updates persisted cycle state atomically. Only an observed period +// advance after the prior reset creates a cycle. Corrections/plan changes do not. +func (s *Store) TrackMistral(ctx context.Context, identity string, q api.MistralQuota) (bool, error) { + tx, e := s.db.BeginTx(ctx, nil) + if e != nil { + return false, e + } + defer tx.Rollback() + var id int64 + var reset sql.NullString + var lastUsed, lastLimit float64 + var lastAt string + e = tx.QueryRowContext(ctx, `SELECT id,resets_at,last_used,last_limit,last_at FROM mistral_cycles WHERE identity=? AND quota_name=? AND cycle_end IS NULL`, identity, q.Name).Scan(&id, &reset, &lastUsed, &lastLimit, &lastAt) + if e != nil && e != sql.ErrNoRows { + return false, e + } + fresh := e == sql.ErrNoRows + didReset := false + if !fresh { + previousAt, _ := time.Parse(time.RFC3339Nano, lastAt) + if !q.CapturedAt.After(previousAt) { + return false, nil + } + oldReset, _ := time.Parse(time.RFC3339Nano, reset.String) + didReset = reset.Valid && q.ResetsAt != nil && !q.CapturedAt.Before(oldReset) && q.ResetsAt.After(oldReset) + if didReset { + if _, e = tx.ExecContext(ctx, `UPDATE mistral_cycles SET cycle_end=? WHERE id=?`, oldReset.Format(time.RFC3339Nano), id); e != nil { + return false, e + } + fresh = true + } + } + var resetAt any + if q.ResetsAt != nil { + resetAt = q.ResetsAt.Format(time.RFC3339Nano) + } + if fresh { + _, e = tx.ExecContext(ctx, `INSERT INTO mistral_cycles(identity,quota_name,cycle_start,resets_at,peak,delta,last_used,last_limit,last_at) VALUES(?,?,?,?,?,0,?,?,?)`, identity, q.Name, q.CapturedAt.Format(time.RFC3339Nano), resetAt, q.Utilization, q.Used, q.Limit, q.CapturedAt.Format(time.RFC3339Nano)) + } else { + delta := 0.0 + if q.Limit == lastLimit && q.Limit > 0 && q.Used > lastUsed { + delta = (q.Used - lastUsed) / q.Limit * 100 + } + _, e = tx.ExecContext(ctx, `UPDATE mistral_cycles SET peak=MAX(peak,?),delta=delta+?,last_used=?,last_limit=?,last_at=?,resets_at=COALESCE(resets_at,?) WHERE id=?`, q.Utilization, delta, q.Used, q.Limit, q.CapturedAt.Format(time.RFC3339Nano), resetAt, id) + } + if e != nil { + return false, e + } + return didReset, tx.Commit() +} +func (s *Store) MistralCycles(ctx context.Context, name string, limit int) ([]MistralCycle, error) { + rows, e := s.db.QueryContext(ctx, `SELECT id,quota_name,cycle_start,cycle_end,resets_at,peak,delta FROM mistral_cycles WHERE identity=`+mistralActiveIdentity+` AND quota_name=? ORDER BY id DESC LIMIT ?`, name, max(1, min(limit, 200))) + if e != nil { + return nil, e + } + defer rows.Close() + result := []MistralCycle{} + for rows.Next() { + var c MistralCycle + var start string + var end, reset sql.NullString + if e = rows.Scan(&c.ID, &c.QuotaName, &start, &end, &reset, &c.PeakUtilization, &c.TotalDelta); e != nil { + return nil, e + } + c.CycleStart, _ = time.Parse(time.RFC3339Nano, start) + c.IsActive = !end.Valid + if end.Valid { + t, _ := time.Parse(time.RFC3339Nano, end.String) + c.CycleEnd = &t + } + if reset.Valid { + t, _ := time.Parse(time.RFC3339Nano, reset.String) + c.ResetsAt = &t + } + result = append(result, c) + } + return result, rows.Err() +} +func (s *Store) PruneMistral(ctx context.Context, before time.Time) error { + tx, e := s.db.BeginTx(ctx, nil) + if e != nil { + return e + } + defer tx.Rollback() + // Child rows are deleted explicitly rather than through ON DELETE CASCADE: + // foreign_keys is a per-connection setting and the pool only enables it on + // one of its connections, so a cascade cannot be relied on to fire. Rows it + // missed would outlive retention indefinitely. + // + // The doomed set is fixed first, because the "latest per source" rules below + // read the very tables being pruned. It lives in a temp table, which the + // transaction's single connection owns, so a large first prune does not hit + // SQLite's bound-variable limit. + for i, stmt := range []string{ + `DROP TABLE IF EXISTS temp.mistral_prune`, + // Preserve latest values per source for reconnect, even beyond retention. + `CREATE TEMP TABLE mistral_prune AS SELECT id FROM mistral_snapshots WHERE captured_at=? AND s.captured_at 5 { + t.Fatalf("limit %d returned %d rows", limit, len(rows)) + } + } +} + +// Quotas and billing are stored in typed columns, like every other provider's +// *_quota_values table, not as JSON. A percentage-only quota must keep its +// unknown amounts as NULL rather than zero, and every read path must rebuild +// the same values. +func TestMistralQuotasStoredAsTypedColumns(t *testing.T) { + s, e := New(":memory:") + if e != nil { + t.Fatal(e) + } + defer s.Close() + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Millisecond) + reset := at.Add(72 * time.Hour) + amount := 2.5 + if e = s.SaveMistral(ctx, &api.MistralSnapshot{Identity: "id", CapturedAt: at, Status: "ok", + Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 1.5, Limit: 10, Utilization: 15, Currency: "EUR", CapturedAt: at, ResetsAt: &reset}, + {Name: "vibe_included", Utilization: 8, PercentOnly: true, CapturedAt: at}, + }, + Billing: &api.MistralBilling{Amount: &amount, Currency: "EUR", Status: "ok", CapturedAt: at, PeriodStart: at, PeriodEnd: at.Add(24 * time.Hour)}, + }); e != nil { + t.Fatal(e) + } + + var used, limit sql.NullFloat64 + var currency string + if e = s.db.QueryRowContext(ctx, `SELECT used,limit_value,currency FROM mistral_quota_values WHERE quota_name='api_included'`).Scan(&used, &limit, ¤cy); e != nil { + t.Fatal(e) + } + if !used.Valid || used.Float64 != 1.5 || !limit.Valid || limit.Float64 != 10 || currency != "EUR" { + t.Fatalf("api quota columns: used=%v limit=%v currency=%q", used, limit, currency) + } + if e = s.db.QueryRowContext(ctx, `SELECT used,limit_value FROM mistral_quota_values WHERE quota_name='vibe_included'`).Scan(&used, &limit); e != nil { + t.Fatal(e) + } + if used.Valid || limit.Valid { + t.Fatalf("percentage-only quota stored amounts instead of NULL: used=%v limit=%v", used, limit) + } + var billed float64 + if e = s.db.QueryRowContext(ctx, `SELECT amount FROM mistral_billing`).Scan(&billed); e != nil || billed != amount { + t.Fatalf("billing amount=%v err=%v", billed, e) + } + + check := func(label string, qs []api.MistralQuota, b *api.MistralBilling) { + t.Helper() + if len(qs) != 2 { + t.Fatalf("%s: quotas=%d", label, len(qs)) + } + for _, q := range qs { + if q.Name == "" || !q.CapturedAt.Equal(at) { + t.Fatalf("%s: name/capturedAt lost: %+v", label, q) + } + } + if qs[0].Used != 1.5 || qs[0].Limit != 10 || qs[0].Utilization != 15 || qs[0].Currency != "EUR" || qs[0].ResetsAt == nil || !qs[0].ResetsAt.Equal(reset) { + t.Fatalf("%s: api quota mangled: %+v", label, qs[0]) + } + if !qs[1].PercentOnly || qs[1].Utilization != 8 || qs[1].ResetsAt != nil { + t.Fatalf("%s: vibe quota mangled: %+v", label, qs[1]) + } + if b == nil || b.Amount == nil || *b.Amount != amount || b.Currency != "EUR" || b.Status != "ok" || !b.PeriodEnd.Equal(at.Add(24*time.Hour)) { + t.Fatalf("%s: billing mangled: %+v", label, b) + } + } + latest, e := s.LatestMistral(ctx) + if e != nil || latest == nil { + t.Fatalf("latest=%v err=%v", latest, e) + } + check("LatestMistral", latest.Quotas, latest.Billing) + + rows, e := s.MistralHistory(ctx, at.Add(-time.Hour), at.Add(time.Hour), 200) + if e != nil || len(rows) != 1 { + t.Fatalf("history rows=%d err=%v", len(rows), e) + } + check("MistralHistory", rows[0].Quotas, rows[0].Billing) +} + +// LatestMistral runs on every dashboard refresh, menubar refresh and metrics +// scrape. Ordering by id alone made SQLite sort every snapshot the identity +// had - about 28ms a call at 90 days of history, growing with retention. Each +// statement must walk an index and stop at the first row instead. +func TestMistralHotQueriesAvoidFullSort(t *testing.T) { + s, e := New(":memory:") + if e != nil { + t.Fatal(e) + } + defer s.Close() + ctx := context.Background() + now := time.Now().UTC() + for name, c := range map[string]struct { + sql string + args []any + }{ + "latest snapshot": {mistralLatestSnapshotSQL, nil}, + "latest billing": {mistralLatestBillingSQL, []any{"id"}}, + "history window": {mistralHistoryWindowSQL, []any{now.Add(-time.Hour).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), 200}}, + } { + rows, e := s.db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+c.sql, c.args...) + if e != nil { + t.Fatalf("%s: %v", name, e) + } + var plan []string + for rows.Next() { + var id, parent, notused int + var detail string + if e = rows.Scan(&id, &parent, ¬used, &detail); e != nil { + t.Fatal(e) + } + plan = append(plan, detail) + } + rows.Close() + if strings.Contains(strings.Join(plan, " | "), "TEMP B-TREE FOR ORDER BY") { + t.Fatalf("%s sorts every candidate row instead of walking an index: %v", name, plan) + } + } +} + +// foreign_keys is a per-connection setting in SQLite, and the pool only +// enables it on one of its two connections. Pruning must therefore remove +// child rows itself: a cascade that silently does not fire leaves quota and +// billing rows behind forever, so retention stops bounding storage. +func TestMistralPruneRemovesChildrenWithoutForeignKeys(t *testing.T) { + s, e := New(":memory:") + if e != nil { + t.Fatal(e) + } + defer s.Close() + ctx := context.Background() + // The in-memory store has one connection, so this reproduces the second + // pool connection's defaults exactly. + if _, e = s.db.ExecContext(ctx, `PRAGMA foreign_keys=OFF`); e != nil { + t.Fatal(e) + } + now := time.Now().UTC() + amount := 1.0 + for _, at := range []time.Time{now.AddDate(0, 0, -400), now} { + if e = s.SaveMistral(ctx, &api.MistralSnapshot{Identity: "id", CapturedAt: at, Status: "ok", + Quotas: []api.MistralQuota{{Name: "api_included", Used: 1, Limit: 10, CapturedAt: at}}, + Billing: &api.MistralBilling{Amount: &amount, Currency: "EUR", Status: "ok", CapturedAt: at, PeriodStart: at, PeriodEnd: at.Add(time.Hour)}}); e != nil { + t.Fatal(e) + } + } + if e = s.PruneMistral(ctx, now.AddDate(0, 0, -90)); e != nil { + t.Fatal(e) + } + count := func(q string) int { + var n int + if e := s.db.QueryRowContext(ctx, q).Scan(&n); e != nil { + t.Fatal(e) + } + return n + } + if n := count(`SELECT COUNT(*) FROM mistral_snapshots`); n != 1 { + t.Fatalf("snapshots=%d, want the old one pruned and the latest kept", n) + } + if n := count(`SELECT COUNT(*) FROM mistral_quota_values WHERE snapshot_id NOT IN (SELECT id FROM mistral_snapshots)`); n != 0 { + t.Fatalf("%d orphaned quota rows survived the prune", n) + } + if n := count(`SELECT COUNT(*) FROM mistral_billing WHERE snapshot_id NOT IN (SELECT id FROM mistral_snapshots)`); n != 0 { + t.Fatalf("%d orphaned billing rows survived the prune", n) + } +} + +func TestMistralCycleOverview(t *testing.T) { + s, e := New(":memory:") + if e != nil { + t.Fatal(e) + } + defer s.Close() + ctx := context.Background() + now := time.Now().UTC() + + // No cycles yet: an empty overview, not an error. + overview, e := s.MistralCycleOverview(ctx, "api_included") + if e != nil || len(overview) != 0 { + t.Fatalf("empty overview = %+v, err=%v", overview, e) + } + + // Both quotas captured together in one snapshot, so the peak moment for + // api_included should carry vibe_included alongside it as a cross-quota. + apiQuota := api.MistralQuota{Name: "api_included", Used: 3, Limit: 10, Utilization: 30, CapturedAt: now} + vibeQuota := api.MistralQuota{Name: "vibe_included", Used: 20, Limit: 100, Utilization: 20, CapturedAt: now} + if e = s.SaveMistral(ctx, &api.MistralSnapshot{Identity: "a", CapturedAt: now, Status: "ok", Quotas: []api.MistralQuota{apiQuota, vibeQuota}}); e != nil { + t.Fatal(e) + } + if _, e = s.TrackMistral(ctx, "a", apiQuota); e != nil { + t.Fatal(e) + } + if _, e = s.TrackMistral(ctx, "a", vibeQuota); e != nil { + t.Fatal(e) + } + if e = s.SetSetting("mistral_identity", "a"); e != nil { + t.Fatal(e) + } + + overview, e = s.MistralCycleOverview(ctx, "api_included") + if e != nil || len(overview) != 1 { + t.Fatalf("overview=%+v err=%v", overview, e) + } + row := overview[0] + if row.QuotaType != "api_included" || row.PeakValue != 30 { + t.Fatalf("row=%+v", row) + } + if len(row.CrossQuotas) != 2 { + t.Fatalf("cross quotas=%+v, want api_included and vibe_included", row.CrossQuotas) + } + var sawVibe bool + for _, cq := range row.CrossQuotas { + if cq.Name == "vibe_included" { + sawVibe = true + if cq.Value != 20 || cq.Percent != 20 { + t.Fatalf("vibe cross quota=%+v", cq) + } + } + } + if !sawVibe { + t.Fatalf("cross quotas missing vibe_included: %+v", row.CrossQuotas) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 4e0cbdc0..641c8174 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -154,13 +154,28 @@ func preflightDatabasePath(dbPath string) error { return nil } +// sqliteConnectionPragmas are the settings SQLite scopes to a single +// connection, in the driver's _pragma DSN form. journal_mode is stored in the +// database file itself, so it stays with the one-off pragmas in New. +const sqliteConnectionPragmas = "_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=cache_size(-500)" + // New creates a new Store with the given database path func New(dbPath string) (*Store, error) { if err := preflightDatabasePath(dbPath); err != nil { return nil, err } - db, err := sql.Open("sqlite", dbPath) + // Per-connection pragmas travel in the DSN so the driver applies them to + // every connection the pool opens. Applied with db.Exec below they reached + // only one connection, leaving the pool's second one with foreign_keys off + // (ON DELETE CASCADE did nothing there), busy_timeout 0 (contended writes + // failed with SQLITE_BUSY at once) and SQLite's default 2MB page cache. A + // path that already carries a query string is left untouched. + dsn := dbPath + if !strings.Contains(dbPath, "?") { + dsn += "?" + sqliteConnectionPragmas + } + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } @@ -961,6 +976,58 @@ func (s *Store) createTables() error { CREATE INDEX IF NOT EXISTS idx_commandcode_cycles_name_start ON commandcode_reset_cycles(quota_name, cycle_start); CREATE INDEX IF NOT EXISTS idx_commandcode_cycles_name_active ON commandcode_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL; + -- Mistral tables. Quota amounts are NULL when Mistral only reports a + -- percentage, so an unknown amount is never recorded as zero. Billing + -- rows exist only when pay-as-you-go spend is actually known. + CREATE TABLE IF NOT EXISTS mistral_snapshots ( + id INTEGER PRIMARY KEY, + identity TEXT NOT NULL, + captured_at TEXT NOT NULL, + status TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mistral_snapshots ON mistral_snapshots(identity, captured_at); + + CREATE TABLE IF NOT EXISTS mistral_quota_values ( + snapshot_id INTEGER NOT NULL, + quota_name TEXT NOT NULL, + used REAL, + limit_value REAL, + utilization REAL NOT NULL, + currency TEXT NOT NULL DEFAULT '', + resets_at TEXT, + PRIMARY KEY (snapshot_id, quota_name), + FOREIGN KEY (snapshot_id) REFERENCES mistral_snapshots(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_mistral_quota_values_name ON mistral_quota_values(quota_name, snapshot_id); + + CREATE TABLE IF NOT EXISTS mistral_billing ( + snapshot_id INTEGER PRIMARY KEY, + amount REAL NOT NULL, + currency TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + captured_at TEXT NOT NULL, + status TEXT NOT NULL, + FOREIGN KEY (snapshot_id) REFERENCES mistral_snapshots(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_mistral_billing_period ON mistral_billing(period_start); + + CREATE TABLE IF NOT EXISTS mistral_cycles ( + id INTEGER PRIMARY KEY, + identity TEXT NOT NULL, + quota_name TEXT NOT NULL, + cycle_start TEXT NOT NULL, + cycle_end TEXT, + resets_at TEXT, + peak REAL NOT NULL, + delta REAL NOT NULL, + last_used REAL NOT NULL, + last_limit REAL NOT NULL, + last_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mistral_cycles ON mistral_cycles(identity, quota_name, cycle_start); + CREATE UNIQUE INDEX IF NOT EXISTS idx_mistral_active_cycle ON mistral_cycles(identity, quota_name) WHERE cycle_end IS NULL; + -- API integrations telemetry ingestion tables CREATE TABLE IF NOT EXISTS api_integration_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/tracker/mistral_tracker.go b/internal/tracker/mistral_tracker.go new file mode 100644 index 00000000..14ca06de --- /dev/null +++ b/internal/tracker/mistral_tracker.go @@ -0,0 +1,27 @@ +package tracker + +import ( + "context" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +type MistralTracker struct { + store *store.Store + onReset func(string) +} + +func NewMistralTracker(s *store.Store) *MistralTracker { return &MistralTracker{store: s} } +func (t *MistralTracker) SetOnReset(fn func(string)) { t.onReset = fn } +func (t *MistralTracker) Process(ctx context.Context, snap *api.MistralSnapshot) error { + for _, q := range snap.Quotas { + reset, e := t.store.TrackMistral(ctx, snap.Identity, q) + if e != nil { + return e + } + if reset && t.onReset != nil { + t.onReset(q.Name) + } + } + return nil +} diff --git a/internal/tracker/mistral_tracker_test.go b/internal/tracker/mistral_tracker_test.go new file mode 100644 index 00000000..36b112a6 --- /dev/null +++ b/internal/tracker/mistral_tracker_test.go @@ -0,0 +1,102 @@ +package tracker + +import ( + "context" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func mistralTrackerSnapshot(identity string, now time.Time, used, limit, utilization float64, resetsAt *time.Time) *api.MistralSnapshot { + return &api.MistralSnapshot{ + Identity: identity, + CapturedAt: now, + Status: "ok", + Quotas: []api.MistralQuota{{ + Name: "api_included", Used: used, Limit: limit, Utilization: utilization, + Currency: "EUR", CapturedAt: now, ResetsAt: resetsAt, + }}, + } +} + +func TestMistralTracker_ProcessPersistsQuota(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + tr := NewMistralTracker(s) + now := time.Now().UTC() + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", now, 5, 10, 50, nil)); err != nil { + t.Fatalf("process: %v", err) + } + if err := s.SetSetting("mistral_identity", "a"); err != nil { + t.Fatal(err) + } + cycles, err := s.MistralCycles(context.Background(), "api_included", 10) + if err != nil || len(cycles) != 1 || !cycles[0].IsActive { + t.Fatalf("cycles=%+v err=%v", cycles, err) + } +} + +func TestMistralTracker_ProcessFiresOnReset(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + tr := NewMistralTracker(s) + var resets []string + tr.SetOnReset(func(name string) { resets = append(resets, name) }) + + now := time.Now().UTC() + firstReset := now.Add(24 * time.Hour) + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", now, 5, 10, 50, &firstReset)); err != nil { + t.Fatalf("process: %v", err) + } + secondReset := firstReset.Add(30 * 24 * time.Hour) + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", firstReset.Add(time.Minute), 1, 10, 10, &secondReset)); err != nil { + t.Fatalf("process: %v", err) + } + if len(resets) != 1 || resets[0] != "api_included" { + t.Fatalf("resets = %v, want [api_included]", resets) + } +} + +// A tracker with no onReset callback registered (the default before +// SetOnReset is called) must not panic when a reset actually occurs. +func TestMistralTracker_ProcessWithoutOnResetCallback(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + tr := NewMistralTracker(s) + now := time.Now().UTC() + firstReset := now.Add(24 * time.Hour) + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", now, 5, 10, 50, &firstReset)); err != nil { + t.Fatalf("process: %v", err) + } + secondReset := firstReset.Add(30 * 24 * time.Hour) + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", firstReset.Add(time.Minute), 1, 10, 10, &secondReset)); err != nil { + t.Fatalf("process: %v", err) + } +} + +func TestMistralTracker_ProcessPropagatesStoreError(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + s.Close() // closed store: TrackMistral must fail, and Process must surface it + + tr := NewMistralTracker(s) + if err := tr.Process(context.Background(), mistralTrackerSnapshot("a", time.Now().UTC(), 1, 10, 10, nil)); err == nil { + t.Fatal("expected error from a closed store") + } +} diff --git a/internal/web/dashboard_tabs.go b/internal/web/dashboard_tabs.go index e7a34511..c0089470 100644 --- a/internal/web/dashboard_tabs.go +++ b/internal/web/dashboard_tabs.go @@ -40,6 +40,8 @@ func defaultProviderTabLabel(key string) string { return "Grok" case "kimi": return "Kimi" + case "mistral": + return "Mistral" case "opencode": return "OpenCode" case "ollama": diff --git a/internal/web/handlers.go b/internal/web/handlers.go index d3e00979..42036e69 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1141,6 +1141,7 @@ func providerCatalog() []providerCatalogItem { {Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true}, {Key: "grok", Name: "Grok", Description: "Grok (xAI) usage tracking", AutoDetectable: true}, {Key: "kimi", Name: "Kimi Code", Description: "Kimi Code CLI OAuth quota tracking", AutoDetectable: true}, + {Key: "mistral", Name: "Mistral", Description: "Included API, Vibe Code and pay-as-you-go tracking", AutoDetectable: true}, {Key: "opencode", Name: "OpenCode Go", Description: "OpenCode Go quota tracking", AutoDetectable: false}, {Key: "ollama", Name: "Ollama Cloud", Description: "Ollama Cloud included usage tracking", AutoDetectable: false}, {Key: "muse", Name: "Muse", Description: "Meta Muse coding-plan quota tracking", AutoDetectable: true}, @@ -1221,6 +1222,8 @@ func (h *Handler) isProviderConfigured(provider string) bool { return true } return api.DetectKimiCredentials(h.logger) != nil + case "mistral": + return h.config != nil && h.config.HasProvider("mistral") case "opencode": return h.config != nil && h.config.OpenCodeGoConfigured() case "ollama": @@ -1310,6 +1313,31 @@ func (h *Handler) tryAutoDetect(provider string) bool { return false } switch provider { + case "mistral": + // Enabling schedules discovery in the agent; never read browsers in HTTP handlers. + if h.store == nil { + return false + } + raw, err := h.store.GetSetting("provider_settings") + if err != nil { + return false + } + settings := map[string]map[string]interface{}{} + if raw != "" && json.Unmarshal([]byte(raw), &settings) != nil { + return false + } + if settings["mistral"] == nil { + settings["mistral"] = map[string]interface{}{} + } + settings["mistral"]["enabled"] = true + data, err := json.Marshal(settings) + if err != nil || h.store.SetSetting("provider_settings", string(data)) != nil { + return false + } + h.config.MistralEnabled = true + h.config.MistralDisabled = false + return true + case "anthropic": if token := strings.TrimSpace(api.DetectAnthropicToken(h.logger)); token != "" { h.config.AnthropicToken = token @@ -1401,6 +1429,12 @@ func applyProviderConfig(dst, src *config.Config) { dst.CodexToken = src.CodexToken dst.CodexAutoToken = src.CodexAutoToken dst.CodexAutoSource = src.CodexAutoSource + dst.MistralDisabled = src.MistralDisabled + dst.MistralEnabled = src.MistralEnabled + dst.MistralAuthCookie = src.MistralAuthCookie + dst.MistralAuthMode = src.MistralAuthMode + dst.MistralBrowser = src.MistralBrowser + dst.MistralBrowserProfile = src.MistralBrowserProfile dst.OpenCodeEnabled = src.OpenCodeEnabled dst.OpenCodeGoWorkspaceID = src.OpenCodeGoWorkspaceID dst.OpenCodeGoAuthCookie = src.OpenCodeGoAuthCookie @@ -1472,6 +1506,7 @@ func stripProviderSecrets(providers map[string]interface{}) { // providerEnumFields defines valid values for enum-type provider settings. // Fields not listed here pass through unvalidated (free-form strings, numbers). var providerEnumFields = map[string]map[string][]string{ + "mistral": {"display_mode": {"usage", "available"}, "auth_mode": {"automatic", "manual"}, "browser": {"auto", "chrome", "firefox", "safari", "edge"}, "show_payg": {"true", "false"}}, "global": { "display_mode": {"usage", "available"}, }, @@ -1624,6 +1659,28 @@ func ApplyProviderSettingsFromDB(st *store.Store, cfg *config.Config, logger *sl } // OpenCode (opencode-codex) feeds the Codex provider; the UI persists a // simple enabled flag, mirroring the OPENCODE_ENABLED env var. + if s := provSettings["mistral"]; s != nil { + if enabled, ok := s["enabled"].(string); ok { + cfg.MistralEnabled = enabled == "true" + cfg.MistralDisabled = !cfg.MistralEnabled + } + if enabled, ok := s["enabled"].(bool); ok { + cfg.MistralDisabled = !enabled + cfg.MistralEnabled = enabled + } + if v, ok := s["auth_mode"].(string); ok { + cfg.MistralAuthMode = v + } + if v, ok := s["browser"].(string); ok { + cfg.MistralBrowser = v + } + if v, ok := s["browser_profile"].(string); ok { + cfg.MistralBrowserProfile = v + } + if v, ok := s["auth_cookie"].(string); ok && v != "" { + cfg.MistralAuthCookie = v + } + } if s := provSettings["opencode"]; s != nil { if enabled, ok := s["enabled"].(bool); ok { cfg.OpenCodeEnabled = enabled @@ -2026,6 +2083,9 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) { h.currentGrok(w, r) case "kimi": h.currentKimi(w, r) + case "mistral": + h.currentMistral(w, r) + return case "opencode": h.currentOpenCode(w, r) case "ollama": @@ -2487,6 +2547,9 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") { response["kimi"] = h.buildKimiCurrent() } + if h.config.HasProvider("mistral") && providerTelemetryEnabled(visibility, "mistral") { + response["mistral"] = h.buildMistralCurrent() + } if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") { response["opencode"] = h.buildOpenCodeCurrent() } @@ -2871,6 +2934,9 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) { h.historyGrok(w, r) case "kimi": h.historyKimi(w, r) + case "mistral": + h.historyMistral(w, r) + return case "opencode": h.historyOpenCode(w, r) case "ollama": @@ -3294,6 +3360,12 @@ func (h *Handler) historyBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("mistral") && providerTelemetryEnabled(visibility, "mistral") && h.store != nil { + rows, err := h.store.MistralHistory(r.Context(), start, now, 200) + if err == nil { + response["mistral"] = rows + } + } if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") && h.store != nil { snapshots, err := h.store.QueryOpenCodeRange(start, now, 200) if err == nil { @@ -3988,6 +4060,9 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) case "kimi": respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) + case "mistral": + h.cyclesMistral(w, r) + return case "opencode": h.cyclesOpenCode(w, r) case "ollama": @@ -4367,6 +4442,9 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}}) case "kimi": respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}}) + case "mistral": + h.summaryMistral(w, r) + return case "opencode": h.summaryOpenCode(w, r) case "ollama": @@ -4452,6 +4530,9 @@ func (h *Handler) summaryBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("cursor") && h.cursorTracker != nil { response["cursor"] = h.buildCursorSummaryMap() } + if h.config.HasProvider("mistral") { + response["mistral"] = h.buildMistralSummary(r.Context()) + } respondJSON(w, http.StatusOK, response) } @@ -4783,7 +4864,7 @@ func (h *Handler) Sessions(w http.ResponseWriter, r *http.Request) { } else if provider == "minimax" { sessions, queryErr = h.queryMiniMaxSessions(h.parseMiniMaxAccountID(r)) } else { - sessions, queryErr = h.store.QuerySessionHistory(provider) + sessions, queryErr = h.store.QuerySessionHistory(h.mistralSessionProvider(provider)) } if queryErr != nil { h.logger.Error("failed to query sessions", "error", queryErr) @@ -4961,7 +5042,7 @@ func (h *Handler) sessionsBoth(w http.ResponseWriter, r *http.Request) { } else if provider == "minimax" { sessions, err = h.queryMiniMaxSessions(h.parseMiniMaxAccountID(r)) } else { - sessions, err = h.store.QuerySessionHistory(provider) + sessions, err = h.store.QuerySessionHistory(h.mistralSessionProvider(provider)) } if err != nil { return nil @@ -4989,6 +5070,9 @@ func (h *Handler) sessionsBoth(w http.ResponseWriter, r *http.Request) { return list } + if h.config.HasProvider("mistral") { + response["mistral"] = buildSessionList("mistral") + } if h.config.HasProvider("synthetic") { response["synthetic"] = buildSessionList("synthetic") } @@ -5192,6 +5276,9 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) { h.insightsGrok(w, r, rangeDur) case "kimi": h.insightsKimi(w, r, rangeDur) + case "mistral": + h.insightsMistral(w, r) + return case "opencode": h.insightsOpenCode(w, r, rangeDur) case "ollama": @@ -5294,6 +5381,9 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") { response["kimi"] = h.buildKimiInsights(hidden) } + if h.config.HasProvider("mistral") && providerTelemetryEnabled(visibility, "mistral") { + response["mistral"] = h.buildMistralInsights() + } if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") { response["opencode"] = h.buildOpenCodeInsights(hidden, rangeDur) } @@ -7929,6 +8019,9 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) { h.cycleOverviewGrok(w, r) case "kimi": h.cycleOverviewKimi(w, r) + case "mistral": + h.cycleOverviewMistral(w, r) + return case "opencode": h.cycleOverviewOpenCode(w, r) case "ollama": @@ -11579,6 +11672,9 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) { h.loggingHistoryGrok(w, r) case "kimi": h.loggingHistoryKimi(w, r) + case "mistral": + h.loggingHistoryMistral(w, r) + return case "opencode": h.loggingHistoryOpenCode(w, r) case "ollama": diff --git a/internal/web/menubar.go b/internal/web/menubar.go index 31a7aa67..dfc940fb 100644 --- a/internal/web/menubar.go +++ b/internal/web/menubar.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "fmt" + "github.com/onllm-dev/onwatch/v2/internal/api" "net" "net/http" "os" @@ -245,10 +246,11 @@ func (h *Handler) BuildMenubarSnapshot() (*menubar.Snapshot, error) { providers, latest := h.buildMenubarProviders(settings, false) aggregate := buildAggregate(providers) return &menubar.Snapshot{ - GeneratedAt: time.Now().UTC(), - UpdatedAgo: timeAgo(latest), - Aggregate: aggregate, - Providers: providers, + GeneratedAt: time.Now().UTC(), + UpdatedAgo: timeAgo(latest), + Aggregate: aggregate, + Providers: providers, + MistralEnabled: h.config != nil && h.config.HasProvider("mistral"), }, nil } @@ -405,6 +407,15 @@ func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidde } } } + if h.config != nil && h.config.HasProvider("mistral") && h.providerDashboardVisible("mistral", visibility) { + payload := h.buildMistralCurrent() + if card := normalizeProviderCard("mistral", resolveProviderTabLabel("mistral", labels), mistralBillingSubtitle(payload), payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { + providers = append(providers, *card) + if captured := parseCapturedAt(payload); captured.After(latest) { + latest = captured + } + } + } if h.config != nil && h.config.HasProvider("opencode") && h.providerDashboardVisible("opencode", visibility) { payload := h.buildOpenCodeCurrent() if card := normalizeProviderCard("opencode", resolveProviderTabLabel("opencode", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil { @@ -738,6 +749,10 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf return nil } status := "healthy" + connectionStatus := stringValue(payload, "status") + if connectionStatus == "reconnect" || connectionStatus == "stale" { + status = "warning" + } highest := 0.0 trends := make([]menubar.TrendSeries, 0, len(quotas)) for _, quota := range quotas { @@ -745,6 +760,9 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf highest = quota.Percent } status = worsenStatus(status, quota.Status) + if quota.IsStale { + status = worsenStatus(status, "warning") + } points := quota.SparklinePoints if len(points) == 0 { points = []float64{quota.Percent, quota.Percent, quota.Percent, quota.Percent} @@ -757,15 +775,16 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf }) } return &menubar.ProviderCard{ - ID: id, - BaseProvider: providerKeyBase(id), - Label: label, - Subtitle: subtitle, - Status: status, - HighestPercent: highest, - UpdatedAt: timeAgo(parseCapturedAt(payload)), - Quotas: quotas, - Trends: trends, + ID: id, + BaseProvider: providerKeyBase(id), + Label: label, + Subtitle: subtitle, + ConnectionStatus: connectionStatus, + Status: status, + HighestPercent: highest, + UpdatedAt: timeAgo(parseCapturedAt(payload)), + Quotas: quotas, + Trends: trends, } } @@ -818,6 +837,7 @@ func normalizeQuotas(payload map[string]interface{}, warningPercent, criticalPer Used: firstFloat(item, "usage", "used", "currentUsage", "currentUsed"), Limit: firstFloat(item, "limit", "total", "currentLimit", "entitlement"), Format: stringValue(item, "format"), + Currency: stringValue(item, "currency"), ResetAt: firstString(item, "renewsAt", "resetsAt", "resetDate", "resetTime", "resetAt"), TimeUntilReset: stringValue(item, "timeUntilReset"), ProjectedValue: firstFloat(item, "projectedUsage", "projectedUtil", "projectedValue"), @@ -825,6 +845,9 @@ func normalizeQuotas(payload map[string]interface{}, warningPercent, criticalPer Source: stringValue(item, "source"), AgeSeconds: int64(firstFloat(item, "ageSeconds")), } + if name := stringValue(item, "name"); name == "api_included" || name == "vibe_included" { + meter.Key = name + } if v, ok := item["isStale"]; ok { if b, ok := v.(bool); ok { meter.IsStale = b @@ -1142,3 +1165,22 @@ func firstFloat(item map[string]interface{}, keys ...string) float64 { } return 0 } + +// mistralBillingSubtitle labels the provider card with pay-as-you-go spend. +// An unavailable balance gets no subtitle: the dashboard's spend card already +// reports that, and a permanent "unavailable" line is only clutter in a +// compact menu. +func mistralBillingSubtitle(payload map[string]interface{}) string { + if payload["showBilling"] == false { + return "" + } + b, ok := payload["billing"].(*api.MistralBilling) + if !ok || b == nil || b.Amount == nil { + return "" + } + subtitle := fmt.Sprintf("Pay-as-you-go: %.2f %s", *b.Amount, b.Currency) + if b.Status != "" && b.Status != "ok" { + subtitle += " (" + b.Status + ")" + } + return subtitle +} diff --git a/internal/web/menubar_test.go b/internal/web/menubar_test.go index c31ef1f9..b515ed08 100644 --- a/internal/web/menubar_test.go +++ b/internal/web/menubar_test.go @@ -206,6 +206,61 @@ func TestMenubarSummaryIncludesCursorWhenEnabled(t *testing.T) { } } +// mistral_enabled must reflect config, not poll state: a provider card only +// appears in Providers once it has quota data, which would hide anything +// gated on it (e.g. the menubar's browser-access grant prompt) until after +// the first successful poll. +func TestMenubarSummaryReportsMistralEnabledIndependentlyOfPollData(t *testing.T) { + t.Setenv("ONWATCH_TEST_MODE", "1") + + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + for _, tc := range []struct { + name string + enabled bool + }{ + {"enabled", true}, + {"disabled", false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + MistralEnabled: tc.enabled, + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + DBPath: "./test.db", + } + h := NewHandler(s, nil, nil, nil, cfg) + + req := httptest.NewRequest(http.MethodGet, "/api/menubar/summary", nil) + req.RemoteAddr = "127.0.0.1:12345" + rr := httptest.NewRecorder() + h.MenubarSummary(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var snap menubar.Snapshot + if err := json.Unmarshal(rr.Body.Bytes(), &snap); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if snap.MistralEnabled != tc.enabled { + t.Fatalf("MistralEnabled=%v, want %v", snap.MistralEnabled, tc.enabled) + } + for _, p := range snap.Providers { + if p.BaseProvider == "mistral" { + t.Fatalf("no Mistral poll data was saved, but a mistral provider card was returned: %#v", p) + } + } + }) + } +} + func TestMenubarSummaryUsesConfiguredThresholds(t *testing.T) { t.Setenv("ONWATCH_TEST_MODE", "1") diff --git a/internal/web/mistral_handlers.go b/internal/web/mistral_handlers.go new file mode 100644 index 00000000..92418be8 --- /dev/null +++ b/internal/web/mistral_handlers.go @@ -0,0 +1,255 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +var mistralDisplayNames = map[string]string{"api_included": "Included API usage", "vibe_included": "Included Vibe Code usage"} + +func (h *Handler) buildMistralCurrent() map[string]interface{} { + showBilling := h.showMistralBilling() + result := map[string]interface{}{"quotas": []interface{}{}, "status": "waiting", "showBilling": showBilling, "billing": map[string]interface{}{"amount": nil, "status": "unavailable"}} + if h.store == nil { + return result + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if status, e := h.store.GetSetting("mistral_status"); e == nil && status != "" { + result["status"] = status + } + snap, e := h.store.LatestMistral(ctx) + if e != nil || snap == nil { + return result + } + if result["status"] == "waiting" { + result["status"] = snap.Status + } + result["capturedAt"] = snap.CapturedAt.Format(time.RFC3339) + result["accountType"] = "subscription" + allowancesFresh := len(snap.Quotas) == 2 + for _, q := range snap.Quotas { + age := time.Since(q.CapturedAt) + stale := age > 5*time.Minute || q.CapturedAt.Before(snap.CapturedAt) || result["status"] == "reconnect" || result["status"] == "stale" + allowancesFresh = allowancesFresh && !stale + entry := map[string]interface{}{"name": q.Name, "displayName": mistralDisplayNames[q.Name], "used": q.Used, "limit": q.Limit, "remaining": math.Max(0, q.Limit-q.Used), "utilization": q.Utilization, "format": "currency", "currency": q.Currency, "status": utilStatus(q.Utilization), "lastUpdatedAt": q.CapturedAt.Format(time.RFC3339), "ageSeconds": int64(age.Seconds()), "isStale": stale} + if q.PercentOnly { + entry["used"] = nil + entry["limit"] = nil + entry["remaining"] = nil + entry["percentOnly"] = true + entry["format"] = "percent" + } + if q.ResetsAt != nil { + entry["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + // The menubar falls back to the raw timestamp without this, so the + // countdown has to be formatted the same way every other provider + // formats it. + entry["timeUntilReset"] = formatDuration(time.Until(*q.ResetsAt)) + entry["timeUntilResetSeconds"] = int64(time.Until(*q.ResetsAt).Seconds()) + } + result["quotas"] = append(result["quotas"].([]interface{}), entry) + } + if snap.Billing != nil { + b := *snap.Billing + if time.Since(b.CapturedAt) > 5*time.Minute || b.CapturedAt.Before(snap.CapturedAt) || result["status"] == "reconnect" || result["status"] == "stale" { + b.Status = "stale" + } + result["billing"] = &b + } + if !showBilling && allowancesFresh && result["status"] == "partial" { + result["status"] = "ok" + } + applyDisplayModeToResponse(result, h.getDisplayMode("mistral")) + return result +} + +// This is a display preference, read live so changing it needs no restart. +func (h *Handler) showMistralBilling() bool { + if h.store == nil { + return true + } + raw, err := h.store.GetSetting("provider_settings") + if err != nil { + return true + } + var settings map[string]map[string]interface{} + if json.Unmarshal([]byte(raw), &settings) != nil { + return true + } + value := settings["mistral"]["show_payg"] + return value != "false" && value != false +} +func (h *Handler) currentMistral(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildMistralCurrent()) +} +func (h *Handler) historyMistral(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, 200, []interface{}{}) + return + } + dur := mistralRange(r.URL.Query().Get("range")) + rows, e := h.store.MistralHistory(r.Context(), time.Now().Add(-dur), time.Now(), 200) + if e != nil { + respondError(w, 500, "failed to query Mistral history") + return + } + respondJSON(w, 200, rows) +} +func mistralRange(value string) time.Duration { + switch value { + case "1h": + return time.Hour + case "6h": + return 6 * time.Hour + case "24h", "1d": + return 24 * time.Hour + case "3d": + return 72 * time.Hour + case "30d": + return 30 * 24 * time.Hour + default: + return 7 * 24 * time.Hour + } +} +func (h *Handler) cyclesMistral(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("type") + if name == "" { + name = r.URL.Query().Get("group_by") + } + if name == "" { + name = "api_included" + } + if h.store == nil { + respondJSON(w, 200, []interface{}{}) + return + } + cycles, e := h.store.MistralCycles(r.Context(), name, 50) + if e != nil { + respondError(w, 500, "failed to query Mistral cycles") + return + } + respondJSON(w, 200, cycles) +} +func (h *Handler) summaryMistral(w http.ResponseWriter, r *http.Request) { + respondJSON(w, 200, h.buildMistralSummary(r.Context())) +} +func (h *Handler) buildMistralSummary(ctx context.Context) map[string]interface{} { + result := map[string]interface{}{} + if h.store != nil { + snap, e := h.store.LatestMistral(ctx) + if e == nil && snap != nil { + for _, q := range snap.Quotas { + cycles, e := h.store.MistralCycles(ctx, q.Name, 50) + if e != nil { + continue + } + total, peak := 0.0, 0.0 + count := 0 + for _, c := range cycles { + total += c.TotalDelta + peak = math.Max(peak, c.PeakUtilization) + if !c.IsActive { + count++ + } + } + result[q.Name] = map[string]interface{}{"currentUtil": q.Utilization, "completedCycles": count, "peakCycle": peak, "totalTracked": total, "avgPerCycle": total / float64(max(1, count)), "resetsAt": q.ResetsAt} + } + } + } + return result +} +func (h *Handler) buildMistralInsights() map[string]interface{} { + result := map[string]interface{}{"stats": []interface{}{}, "insights": []interface{}{}} + if h.store == nil { + return result + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + now := time.Now() + rows, e := h.store.MistralHistory(ctx, now.Add(-30*time.Minute), now, 200) + if e != nil || len(rows) < 2 { + return result + } + first := map[string]api.MistralQuota{} + last := map[string]api.MistralQuota{} + for _, s := range rows { + for _, q := range s.Quotas { + if _, ok := first[q.Name]; !ok { + first[q.Name] = q + } + last[q.Name] = q + } + } + hidden := h.getHiddenInsightKeys() + for _, name := range []string{"api_included", "vibe_included"} { + a, ok := first[name] + b := last[name] + if !ok || hidden["forecast_"+name] || b.CapturedAt.Sub(a.CapturedAt) < 5*time.Minute || now.Sub(b.CapturedAt) > 5*time.Minute || a.Limit != b.Limit || a.ResetsAt == nil || b.ResetsAt == nil || !a.ResetsAt.Equal(*b.ResetsAt) || b.Used < a.Used { + continue + } + rate := (b.Utilization - a.Utilization) / b.CapturedAt.Sub(a.CapturedAt).Hours() + projected := b.Utilization + rate*math.Max(0, time.Until(*b.ResetsAt).Hours()) + result["stats"] = append(result["stats"].([]interface{}), map[string]interface{}{"label": mistralDisplayNames[name] + " Burn Rate", "value": fmt.Sprintf("%.1f%%/hr", rate), "sublabel": fmt.Sprintf("~%.0f%% by reset", projected), "key": "forecast_" + name}) + } + return result +} +func (h *Handler) insightsMistral(w http.ResponseWriter, r *http.Request) { + respondJSON(w, 200, h.buildMistralInsights()) +} +func (h *Handler) loggingHistoryMistral(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, 200, map[string]interface{}{"logs": []interface{}{}}) + return + } + start, end, limit := h.loggingHistoryRangeAndLimit(r) + rows, e := h.store.MistralHistory(r.Context(), start, end, limit) + if e != nil { + respondError(w, 500, "failed to query Mistral history") + return + } + var at []time.Time + var ids []int64 + var series []map[string]loggingHistoryCrossQuota + for _, s := range rows { + at = append(at, s.CapturedAt) + ids = append(ids, s.ID) + row := map[string]loggingHistoryCrossQuota{} + for _, q := range s.Quotas { + row[q.Name] = loggingHistoryCrossQuota{Name: q.Name, Value: q.Used, Limit: q.Limit, Percent: q.Utilization, HasValue: !q.PercentOnly, HasLimit: !q.PercentOnly} + } + series = append(series, row) + } + respondJSON(w, 200, map[string]interface{}{"provider": "mistral", "quotaNames": []string{"api_included", "vibe_included"}, "logs": loggingHistoryRowsFromSnapshots(at, ids, []string{"api_included", "vibe_included"}, series)}) +} + +func (h *Handler) cycleOverviewMistral(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, 200, []interface{}{}) + return + } + name := r.URL.Query().Get("group_by") + if name == "" { + name = "api_included" + } + rows, e := h.store.MistralCycleOverview(r.Context(), name) + if e != nil { + respondError(w, 500, "failed to query Mistral cycle overview") + return + } + respondJSON(w, 200, rows) +} + +func (h *Handler) mistralSessionProvider(provider string) string { + if provider != "mistral" || h.store == nil { + return provider + } + identity, _ := h.store.GetSetting("mistral_identity") + return "mistral:" + identity +} diff --git a/internal/web/mistral_handlers_test.go b/internal/web/mistral_handlers_test.go new file mode 100644 index 00000000..ed25d401 --- /dev/null +++ b/internal/web/mistral_handlers_test.go @@ -0,0 +1,203 @@ +package web + +import ( + "context" + "encoding/json" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMistralReporting(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + now := time.Now().UTC() + amount := 1.25 + snap := &api.MistralSnapshot{Identity: "test", CapturedAt: now, Status: "ok", Quotas: []api.MistralQuota{{Name: "api_included", Used: 0, Limit: 10, Currency: "EUR", CapturedAt: now}, {Name: "vibe_included", Used: 50, Limit: 100, Currency: "EUR", Utilization: 50, CapturedAt: now}}, Billing: &api.MistralBilling{Amount: &amount, Currency: "GBP", Status: "ok", CapturedAt: now, PeriodStart: now}} + if e = db.SaveMistral(context.Background(), snap); e != nil { + t.Fatal(e) + } + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true, SyntheticAPIKey: "syn_test", PollInterval: 120 * time.Second}) + current := h.buildMistralCurrent() + qs := current["quotas"].([]interface{}) + if len(qs) != 2 || qs[0].(map[string]interface{})["currency"] != "EUR" { + t.Fatalf("current=%v", current) + } + if b := current["billing"].(*api.MistralBilling); b.Currency != "GBP" || *b.Amount != 1.25 { + t.Fatalf("billing=%+v", b) + } + meters := normalizeQuotas(current, 80, 95) + if len(meters) != 2 || meters[0].Currency != "EUR" || meters[0].Key != "api_included" { + t.Fatalf("meters=%+v", meters) + } + for name, fn := range map[string]http.HandlerFunc{"current": h.Current, "history": h.History, "cycles": h.Cycles, "summary": h.Summary, "insights": h.Insights, "logging-history": h.LoggingHistory, "cycle-overview": h.CycleOverview} { + t.Run(name, func(t *testing.T) { + w := httptest.NewRecorder() + fn(w, httptest.NewRequest("GET", "/api/"+name+"?provider=mistral", nil)) + if w.Code != 200 || !json.Valid(w.Body.Bytes()) { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + }) + } + for name, fn := range map[string]http.HandlerFunc{"current": h.Current, "history": h.History, "summary": h.Summary, "insights": h.Insights} { + w := httptest.NewRecorder() + fn(w, httptest.NewRequest("GET", "/api/"+name+"?provider=both", nil)) + var result map[string]json.RawMessage + if json.Unmarshal(w.Body.Bytes(), &result) != nil || result["mistral"] == nil { + t.Errorf("aggregate %s omitted Mistral: %s", name, w.Body.String()) + } + } + + if e = db.SetSetting("mistral_status", "reconnect"); e != nil { + t.Fatal(e) + } + current = h.buildMistralCurrent() + if current["status"] != "reconnect" || current["billing"].(*api.MistralBilling).Status != "stale" { + t.Fatal(current) + } +} + +func TestMistralHideBillingKeepsAllowanceStatus(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + now := time.Now().UTC() + snap := &api.MistralSnapshot{Identity: "test", CapturedAt: now, Status: "partial", Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 1, Limit: 10, Currency: "EUR", CapturedAt: now}, + {Name: "vibe_included", Used: 2, Limit: 20, Currency: "EUR", CapturedAt: now}, + }} + if err := db.SaveMistral(context.Background(), snap); err != nil { + t.Fatal(err) + } + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true}) + if current := h.buildMistralCurrent(); current["showBilling"] != true || current["status"] != "partial" { + t.Fatalf("billing should be visible by default: %v", current) + } + if err := db.SetSetting("provider_settings", `{"mistral":{"show_payg":"false"}}`); err != nil { + t.Fatal(err) + } + current := h.buildMistralCurrent() + if current["showBilling"] != false || current["status"] != "ok" { + t.Fatalf("hidden billing should not flag working allowances: %v", current) + } + // A hidden optional endpoint must not conceal missing allowance data. + snap.Quotas = snap.Quotas[:1] + snap.CapturedAt = now.Add(time.Second) + snap.Quotas[0].CapturedAt = snap.CapturedAt + if err := db.SaveMistral(context.Background(), snap); err != nil { + t.Fatal(err) + } + if got := h.buildMistralCurrent()["status"]; got != "partial" { + t.Fatalf("stale allowance hidden: %v", got) + } + if err := db.SetSetting("mistral_status", "reconnect"); err != nil { + t.Fatal(err) + } + if got := h.buildMistralCurrent()["status"]; got != "reconnect" { + t.Fatalf("real connection failure hidden: %v", got) + } +} + +func TestMistralSettingsAndDiscovery(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + cfg := &config.Config{MistralAuthCookie: "environment-secret"} + if e = db.SetSetting("provider_settings", `{"mistral":{"enabled":false,"browser":"firefox","browser_profile":"/synthetic/profile::container=2","auth_mode":"automatic"}}`); e != nil { + t.Fatal(e) + } + ApplyProviderSettingsFromDB(db, cfg, nil) + if cfg.HasProvider("mistral") || cfg.MistralBrowser != "firefox" || cfg.MistralAuthMode != "automatic" { + t.Fatalf("settings did not override environment") + } + h := NewHandler(db, nil, nil, nil, cfg) + if !h.tryAutoDetect("mistral") || !cfg.HasProvider("mistral") { + t.Fatal("enabling automatic discovery failed") + } + // This operation only schedules discovery; no importer/browser API is called. + persisted, _ := db.GetSetting("provider_settings") + if !strings.Contains(persisted, `"enabled":true`) { + t.Fatal("enable not persisted") + } +} + +// The menubar shows quota.time_until_reset, falling back to the raw reset_at +// timestamp when it is absent. Without a formatted countdown Mistral rows +// displayed "2026-10-01T00:00:00Z" where every other provider shows "6d 11h". +func TestMistralQuotaCarriesFormattedCountdown(t *testing.T) { + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + now := time.Now().UTC() + reset := now.Add(50*time.Hour + 30*time.Minute) + snap := &api.MistralSnapshot{Identity: "t", CapturedAt: now, Status: "ok", Quotas: []api.MistralQuota{ + {Name: "api_included", Used: 1, Limit: 10, Currency: "EUR", CapturedAt: now, ResetsAt: &reset}, + }} + if e = db.SaveMistral(context.Background(), snap); e != nil { + t.Fatal(e) + } + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true}) + entry := h.buildMistralCurrent()["quotas"].([]interface{})[0].(map[string]interface{}) + got, _ := entry["timeUntilReset"].(string) + if got != "2d 2h" { + t.Fatalf("timeUntilReset=%q, want %q", got, "2d 2h") + } + if m := normalizeQuotas(h.buildMistralCurrent(), 80, 95); len(m) != 1 || m[0].TimeUntilReset != "2d 2h" { + t.Fatalf("meter TimeUntilReset=%+v", m) + } +} + +// An unavailable pay-as-you-go balance is not worth a line of its own in the +// menubar: the spend card already reports it on the dashboard. +func TestMistralBillingSubtitleOmitsUnavailable(t *testing.T) { + now := time.Now().UTC() + if got := mistralBillingSubtitle(map[string]interface{}{"billing": &api.MistralBilling{Status: "unavailable"}}); got != "" { + t.Fatalf("unavailable spend produced subtitle %q, want empty", got) + } + if got := mistralBillingSubtitle(map[string]interface{}{}); got != "" { + t.Fatalf("missing billing produced subtitle %q, want empty", got) + } + amount := 1.5 + if got := mistralBillingSubtitle(map[string]interface{}{"showBilling": false, "billing": &api.MistralBilling{Amount: &amount, Currency: "EUR", Status: "ok", CapturedAt: now}}); got != "" { + t.Fatalf("hidden spend produced subtitle %q", got) + } + if got := mistralBillingSubtitle(map[string]interface{}{"billing": &api.MistralBilling{Amount: &amount, Currency: "EUR", Status: "ok", CapturedAt: now}}); got != "Pay-as-you-go: 1.50 EUR" { + t.Fatalf("subtitle=%q", got) + } + if got := mistralBillingSubtitle(map[string]interface{}{"billing": &api.MistralBilling{Amount: &amount, Currency: "EUR", Status: "stale", CapturedAt: now}}); got != "Pay-as-you-go: 1.50 EUR (stale)" { + t.Fatalf("stale subtitle=%q", got) + } +} + +// The Mistral page carries a container for its one-time "how Mistral tracking +// works" panel. It starts hidden; the script fills and reveals it until the +// user dismisses it. +func TestMistralPageHasIntroPanel(t *testing.T) { + h := NewHandler(nil, nil, nil, nil, &config.Config{MistralEnabled: true}) + rr := httptest.NewRecorder() + h.Dashboard(rr, httptest.NewRequest(http.MethodGet, "/?provider=mistral", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `id="mistral-intro"`) { + t.Fatal("Mistral page is missing the intro panel container") + } + if !strings.Contains(body, `id="mistral-intro" role="note" hidden`) { + t.Fatal("intro panel must start hidden so a dismissed panel never flashes") + } +} diff --git a/internal/web/mistral_preview_test.go b/internal/web/mistral_preview_test.go new file mode 100644 index 00000000..d9eabde2 --- /dev/null +++ b/internal/web/mistral_preview_test.go @@ -0,0 +1,49 @@ +package web + +import ( + "context" + "fmt" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +// Opt-in local preview uses synthetic data only and never reads browser cookies. +func TestMistralPreview(t *testing.T) { + if os.Getenv("ONWATCH_MISTRAL_PREVIEW") != "1" { + t.Skip("local browser QA only") + } + db, e := store.New(":memory:") + if e != nil { + t.Fatal(e) + } + defer db.Close() + now := time.Now().UTC() + reset := now.Add(7 * 24 * time.Hour) + snap := &api.MistralSnapshot{Identity: "preview", CapturedAt: now, Status: "partial", Quotas: []api.MistralQuota{{Name: "api_included", Used: 2.5, Limit: 10, Utilization: 25, Currency: "EUR", CapturedAt: now, ResetsAt: &reset}, {Name: "vibe_included", Used: 50, Limit: 100, Utilization: 50, Currency: "EUR", CapturedAt: now, ResetsAt: &reset}}} + if e = db.SaveMistral(context.Background(), snap); e != nil { + t.Fatal(e) + } + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true, PollInterval: 120 * time.Second}) + mux := http.NewServeMux() + assets, _ := fs.Sub(staticFS, "static") + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(assets)))) + for path, fn := range map[string]http.HandlerFunc{"/": h.Dashboard, "/menubar": h.MenubarPage, "/api/current": h.Current, "/api/history": h.History, "/api/insights": h.Insights, "/api/summary": h.Summary, "/api/cycles": h.Cycles, "/api/cycle-overview": h.CycleOverview, "/api/logging-history": h.LoggingHistory, "/api/menubar/summary": h.MenubarSummary, "/api/menubar/preferences": h.MenubarPreferences, "/api/menubar/tray-title": h.MenubarTrayTitle, "/api/settings": h.GetSettings, "/api/providers": h.Providers, "/api/providers/status": h.ProvidersStatus, "/api/sessions": h.Sessions, "/api/capabilities": h.Capabilities} { + mux.HandleFunc(path, fn) + } + server := httptest.NewServer(mux) + defer server.Close() + fmt.Printf("MISTRAL_PREVIEW_URL=%s\n", server.URL) + if e := os.WriteFile("/tmp/onwatch-mistral-preview-url", []byte(server.URL), 0600); e != nil { + t.Fatal(e) + } + timer := time.NewTimer(3 * time.Minute) + defer timer.Stop() + <-timer.C +} diff --git a/internal/web/mistral_stale_test.go b/internal/web/mistral_stale_test.go new file mode 100644 index 00000000..205364e1 --- /dev/null +++ b/internal/web/mistral_stale_test.go @@ -0,0 +1,30 @@ +package web + +import ( + "testing" + "time" +) + +func TestMistralReconnectCard(t *testing.T) { + for _, tc := range []struct { + status string + stale bool + want string + }{ + {"reconnect", true, "warning"}, + {"stale", false, "warning"}, + {"partial", true, "warning"}, + {"ok", false, "healthy"}, + } { + t.Run(tc.status, func(t *testing.T) { + p := map[string]interface{}{"status": tc.status, "capturedAt": time.Now().Format(time.RFC3339), "quotas": []interface{}{map[string]interface{}{"name": "vibe_included", "utilization": 9.0, "isStale": tc.stale}}} + c := normalizeProviderCard("mistral", "Mistral", "", p, 80, 95) + if c == nil { + t.Fatal("no card") + } + if c.Status != tc.want || c.ConnectionStatus != tc.status || c.Quotas[0].IsStale != tc.stale { + t.Fatalf("connection state lost: %+v", c) + } + }) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9816daf0..cd5c7130 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -72,6 +72,7 @@ function getCurrentProvider() { if (grokGrid) return 'grok'; const kimiGrid = document.getElementById('quota-grid-kimi'); if (kimiGrid) return 'kimi'; + if (document.getElementById('quota-grid-mistral')) return 'mistral'; const opencodeGrid = document.getElementById('quota-grid-opencode'); if (opencodeGrid) return 'opencode'; const ollamaGrid = document.getElementById('quota-grid-ollama'); @@ -999,6 +1000,7 @@ function quotaOrderForProvider(provider) { if (provider === 'anthropic') return anthropicQuotaOrder; if (provider === 'codex') return codexQuotaOrder; if (provider === 'cursor') return cursorQuotaOrder; + if (provider === 'mistral') return ['api_included', 'vibe_included']; if (provider === 'opencode') return opencodeQuotaOrder; if (provider === 'ollama') return ollamaQuotaOrder; if (provider === 'muse') return museQuotaOrder; @@ -1229,6 +1231,7 @@ const renewalCategories = { { label: 'Credits', groupBy: 'credits' }, { label: 'On-Demand', groupBy: 'on_demand' } ], + mistral: [ {key:'api_included',label:'Included API',groupBy:'api_included'}, {key:'vibe_included',label:'Included Vibe Code',groupBy:'vibe_included'} ], opencode: [ { label: '5-Hour', groupBy: 'five_hour' }, { label: 'Weekly', groupBy: 'weekly' }, @@ -4684,6 +4687,8 @@ async function fetchCurrent() { updateKimiQuotaCards(data.quotas || [], 'quota-grid-kimi'); } } + } else if (provider === 'mistral') { + renderMistralCards(data); } else if (provider === 'opencode') { if (data.quotas) { const container = document.getElementById('quota-grid-opencode'); @@ -5743,6 +5748,8 @@ function initChart() { defaultDatasets = []; // Grok datasets are dynamic - populated when history data arrives } else if (provider === 'kimi') { defaultDatasets = []; // Kimi datasets are dynamic - populated when history data arrives + } else if (provider === 'mistral') { + defaultDatasets = []; } else if (provider === 'opencode') { defaultDatasets = []; // OpenCode datasets are dynamic } else if (provider === 'ollama') { @@ -5774,6 +5781,8 @@ function initChart() { ? [] : provider === 'kimi' ? [] + : provider === 'mistral' + ? [] : provider === 'opencode' ? [] : provider === 'ollama' @@ -6306,6 +6315,10 @@ async function fetchHistory(range) { State.chart.update(); return; } + if (provider === 'mistral') { + State.chart.data.datasets = buildDynamicDatasetsForRows(historyRows.map(row => {const flat={capturedAt:row.capturedAt}; (row.quotas||[]).forEach(q=>{flat[q.name]=q.utilization;});return flat;}), range, mistralDisplayNames, mistralChartColors, opencodeChartColorFallback, 'mistral'); + updateTimeScale(State.chart, range); State.chart.update(); return; + } if (provider === 'opencode') { const flattenedRows = historyRows.map(row => { const flat = { capturedAt: row.capturedAt }; @@ -6448,6 +6461,7 @@ const bothProviderNames = { cursor: 'Cursor', grok: 'Grok', kimi: 'Kimi Code', + mistral: 'Mistral', opencode: 'OpenCode', ollama: 'Ollama', muse: 'Muse', @@ -6789,12 +6803,14 @@ function buildAllProviderEntries() { ? 'Beta' : (provider === 'ollama' ? toTitleCase(payload.plan || '') - : (provider === 'cursor' || provider === 'opencode' + : (provider === 'mistral' || provider === 'cursor' || provider === 'opencode' ? (payload.planName || toTitleCase(payload.accountType || '')) : toTitleCase(payload.planType || ''))), promoHtml: provider === 'anthropic' && payload.promo ? promoTagHTML() : '', planType: payload.planType || '', quotas: normalizeBothQuotas(provider, payload), + billing: provider === "mistral" ? payload.billing : null, + showBilling: payload.showBilling !== false, insights: insights[provider] || { stats: [], insights: [] }, historyRows: Array.isArray(history[provider]) ? history[provider] : [], }); @@ -6815,7 +6831,7 @@ function renderProviderKPIHTML(quotas, provider) { const displayName = quota.displayName || quota.name || 'Quota'; const label = quota.cardLabel || 'Utilization'; const subtitle = quota.subtitle || minimaxSharedSubtitle(quota.sharedModels); - const usageFraction = Number.isFinite(Number(quota.used)) && Number.isFinite(Number(quota.total)) && Number(quota.total) > 0 + const usageFraction = provider === "mistral" ? (quota.percentOnly ? "Allowance amount unavailable" : mistralMoney(quota.used,quota.currency)+" / "+mistralMoney(quota.limit,quota.currency)) : Number.isFinite(Number(quota.used)) && Number.isFinite(Number(quota.total)) && Number(quota.total) > 0 ? `${formatNumber(quota.used)} / ${formatNumber(quota.total)}` : label; const icon = anthropicQuotaIcons[quota.name] @@ -7326,7 +7342,7 @@ function buildProviderCardDatasets(provider, rows, range) { if (provider === 'gemini') { return buildDynamicDatasetsForRows(rows, range, geminiDisplayNames, geminiChartColorMap, geminiChartColorFallback, 'gemini'); } - if (provider === 'cursor' || provider === 'opencode' || provider === 'ollama' || provider === 'muse' || provider === 'commandcode') { + if (provider === 'mistral' || provider === 'cursor' || provider === 'opencode' || provider === 'ollama' || provider === 'muse' || provider === 'commandcode') { const normalizedRows = rows.map((row) => { if (!Array.isArray(row.quotas)) return row; const entry = { capturedAt: row.capturedAt }; @@ -7338,6 +7354,7 @@ function buildProviderCardDatasets(provider, rows, range) { if (provider === 'cursor') { return buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor'); } + if (provider === 'mistral') return buildDynamicDatasetsForRows(normalizedRows, range, mistralDisplayNames, mistralChartColors, opencodeChartColorFallback, 'mistral'); if (provider === 'ollama') { return buildDynamicDatasetsForRows(normalizedRows, range, ollamaDisplayNames, ollamaChartColorMap, ollamaChartColorFallback, 'ollama'); } @@ -7462,6 +7479,7 @@ function renderAllProvidersView() { ${cardHeader}
${renderProviderKPIHTML(entry.quotas, entry.provider)}
+ ${entry.provider === "mistral" && entry.showBilling ? `

Pay-as-you-go: ${escapeHTML(mistralMoney(entry.billing?.amount,entry.billing?.currency))}

` : ""} ${(() => { const insightsHTML = renderProviderInsightsHTML(entry.provider, entry.insights); return insightsHTML ? `
${insightsHTML}
` : ''; @@ -7865,7 +7883,7 @@ async function fetchCycles() { const requestSeq = (State.cyclesRequestSeq || 0) + 1; State.cyclesRequestSeq = requestSeq; const provider = requestProvider; - const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi', 'opencode', 'ollama', 'muse', 'commandcode']); + const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi', 'mistral', 'opencode', 'ollama', 'muse', 'commandcode']); // All-accounts overview: fetch each account's logging history and merge, // tagging every row with its account name for the combined table. @@ -8059,7 +8077,7 @@ function renderCyclesTable() { const provider = getCurrentProvider(); const quotaNames = State.cyclesQuotaNames; - const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek' || provider === 'opencode' || provider === 'ollama'; + const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek' || provider === 'mistral' || provider === 'opencode' || provider === 'ollama'; const deltaUsesPercent = usePercent && provider !== 'minimax' && provider !== 'moonshot' && provider !== 'deepseek'; const isLoggingHistory = State.isLoggingHistory === true; const showAccount = isAccountsOverviewMode(provider); @@ -9422,7 +9440,7 @@ function renderOverviewTable() { const quotaNames = State.overviewQuotaNames; const overviewProv = getOverviewProvider(); - const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi' || overviewProv === 'opencode' || overviewProv === 'ollama' || overviewProv === 'muse' || overviewProv === 'commandcode'; + const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi' || overviewProv === 'mistral' || overviewProv === 'opencode' || overviewProv === 'ollama' || overviewProv === 'muse' || overviewProv === 'commandcode'; const deltaUsesPercent = usePercent && overviewProv !== 'minimax'; // MiniMax reports a percentage-based quota; the Duration and Total Delta // columns add no signal there, so omit them for this provider. @@ -10562,6 +10580,7 @@ const DEFAULT_PROVIDER_TAB_LABELS = { cursor: 'Cursor', grok: 'Grok', kimi: 'Kimi', + mistral: 'Mistral', opencode: 'OpenCode', ollama: 'Ollama', muse: 'Muse', @@ -11277,6 +11296,17 @@ const providerSettingsConfig = { desc: 'Gemini is auto-detected from your local credentials. Use the telemetry toggle to enable or disable tracking.', fields: [], }, + mistral: { + title: 'Mistral', desc: 'Track included API and Vibe allowances plus separate pay-as-you-go charges. Authentication changes require a daemon restart; display changes apply immediately.', + fields: [ + {id:'enabled',label:'Enable Mistral',type:'select',options:[{value:'true',text:'Enabled'},{value:'false',text:'Disabled'}],default:'false'}, + {id:'auth_mode',label:'Authentication',type:'select',options:[{value:'automatic',text:'Automatic browser import'},{value:'manual',text:'Manual cookies'}],default:'automatic'}, + {id:'browser',label:'Browser',type:'select',options:[{value:'auto',text:'Automatic'},{value:'chrome',text:'Chrome'},{value:'firefox',text:'Firefox'},{value:'safari',text:'Safari (macOS)'},{value:'edge',text:'Edge'}],default:'auto'}, + {id:'browser_profile',label:'Browser profile',type:'text',placeholder:'First working profile',hint:'Profile directory or name. Firefox containers: append ::container=ID. The selected source remains fixed until these settings change.'}, + {id:'auth_cookie',label:'Manual Cookie header',type:'password',placeholder:'Not configured',sensitive:true,hint:'Must contain an ory_session_ cookie. Only sent to Mistral. Overrides MISTRAL_AUTH_COOKIE.'}, + {id:'show_payg',label:'Show pay-as-you-go spend',type:'select',options:[{value:'true',text:'Show'},{value:'false',text:'Hide'}],default:'true',noRestart:true,hint:'Hide the PAYG card and menu bar amount if you only use included allowances. This does not change billing at Mistral.'}, + ] + }, opencode: { title: 'OpenCode Go', desc: 'Configure OpenCode Go quota tracking. Recommended: a console service-account key, which reads your plan\'s own 5-hour, weekly and monthly meters. The workspace ID + auth cookie scrape is the legacy fallback. Changes take effect after daemon restart.', @@ -11313,6 +11343,20 @@ const providerSettingsConfig = { }, }; +// isProviderConfigured reports whether the daemon currently treats a +// provider as switched on, from whatever source (settings, .env or a key). +async function isProviderConfigured(key) { + try { + const res = await authFetch(`${API_BASE}/api/providers/status`); + if (!res.ok) return false; + const data = await res.json(); + const p = (Array.isArray(data.providers) ? data.providers : []).find(x => x.key === key); + return !!(p && p.configured); + } catch (e) { + return false; + } +} + async function openProviderSettingsModal(providerKey) { const config = providerSettingsConfig[providerKey]; if (!config) return; @@ -11328,6 +11372,15 @@ async function openProviderSettingsModal(providerKey) { const saved = (State.providerSettings && State.providerSettings[providerKey]) || {}; + // A field shows its default when nothing is saved. Mistral's on/off switch + // must show the real state instead: Mistral can be switched on from .env, + // and showing "Disabled" there would switch it off on the next save. + const effectiveDefaults = {}; + if (providerKey === 'mistral' && saved.enabled === undefined) { + effectiveDefaults.enabled = (await isProviderConfigured('mistral')) ? 'true' : 'false'; + } + modal._effectiveDefaults = effectiveDefaults; + // Build fields HTML (shared for non-Codex providers) let buildFieldsHTML = () => { if (config.fields.length === 0) { @@ -11342,7 +11395,7 @@ async function openProviderSettingsModal(providerKey) { if (f.type === 'select') { html += `'; @@ -11595,7 +11648,8 @@ async function openProviderSettingsModal(providerKey) { accountsList.innerHTML = '

Failed to load accounts

'; } } else { - bodyEl.innerHTML = buildFieldsHTML(); + const intro = providerKey === 'mistral' ? `
${mistralIntroHTML('settings')}
` : ''; + bodyEl.innerHTML = intro + buildFieldsHTML(); } // Store which provider is being edited @@ -11660,7 +11714,7 @@ async function saveProviderSettings() { if (!(f.id in provData)) return; const changed = (f.type === 'password' && f.sensitive) ? true // sensitive fields are only present when newly typed - : String(provData[f.id]) !== String(baseline[f.id] === undefined ? (f.default ?? '') : baseline[f.id]); + : String(provData[f.id]) !== String(baseline[f.id] === undefined ? ((modal._effectiveDefaults || {})[f.id] ?? f.default ?? '') : baseline[f.id]); if (!changed) return; anyChange = true; if (!f.noRestart) restartNeeded = true; @@ -12475,6 +12529,7 @@ const _overrideQuotasByProvider = { { key: 'tokens', label: 'Tokens Limit' }, { key: 'time', label: 'Time Limit' }, ], + mistral: [ {key:'api_included',label:'Included API',groupBy:'api_included'}, {key:'vibe_included',label:'Included Vibe Code',groupBy:'vibe_included'} ], opencode: [ { key: 'five_hour', label: '5-Hour Limit' }, { key: 'weekly', label: 'Weekly Limit' }, @@ -12948,3 +13003,65 @@ document.addEventListener('DOMContentLoaded', async () => { } }); + +// Mistral is tracked differently from other providers, so the settings +// window and a one-time panel on the Mistral page explain it in plain words. +// Both render from this one list so they cannot drift apart. +const MISTRAL_INTRO_DISMISSED_KEY = 'onwatch-mistral-intro-dismissed'; +function mistralIntroPoints(where) { + const manual = where === 'settings' ? 'choose Manual cookies below' : 'choose Manual cookies in Mistral\'s settings'; + return [ + 'Uses your browser login. Unlike most providers, Mistral isn\'t tracked with an API key. onWatch reads your usage from the Mistral website using the login saved in your browser. It only reads Mistral\'s own login cookies, and never saves them.', + 'On a Mac, you may see a password prompt. macOS may ask for your Mac password (sometimes twice) so onWatch can read your browser\'s saved logins. That\'s macOS asking, not Mistral, and it can ask again after onWatch restarts. If Mistral keeps showing Reconnect, right-click the onWatch menubar icon and choose Grant Browser Access, then pick the folder it suggests.', + 'Prefer not to use your browser? Sign in to Mistral with Firefox, which avoids the password prompt, or ' + manual + ' and paste your Mistral cookie yourself.' + ]; +} +function mistralIntroHTML(where) { + const items = mistralIntroPoints(where).map(p => `
  • ${p}
  • `).join(''); + return `

    How Mistral tracking works

      ${items}
    `; +} +function renderMistralIntro() { + const panel = document.getElementById('mistral-intro'); + if (!panel || panel.dataset.ready === '1') return; + panel.dataset.ready = '1'; + let dismissed = false; + try { dismissed = localStorage.getItem(MISTRAL_INTRO_DISMISSED_KEY) === '1'; } catch (e) { /* storage unavailable: show it */ } + if (dismissed) return; + panel.innerHTML = mistralIntroHTML('page') + ''; + panel.querySelector('.provider-intro-dismiss').addEventListener('click', () => { + panel.hidden = true; + try { localStorage.setItem(MISTRAL_INTRO_DISMISSED_KEY, '1'); } catch (e) { /* hide for this visit only */ } + }); + panel.hidden = false; +} +const mistralDisplayNames = {api_included:'Included API usage',vibe_included:'Included Vibe Code usage'}; +const mistralChartColors = {api_included:{border:'#ff7000',bg:'rgba(255,112,0,.08)'},vibe_included:{border:'#f5b400',bg:'rgba(245,180,0,.08)'}}; +function mistralMoney(value,currency) { + if (value == null || !Number.isFinite(value)) return 'Unavailable'; + try {return new Intl.NumberFormat(undefined,{style:'currency',currency}).format(value);} catch {return value.toFixed(2)+' '+(currency || '');} +} +function renderMistralCards(data) { + renderMistralIntro(); + const container=document.getElementById('quota-grid-mistral'); if(!container) return; + container.replaceChildren(); + const add=(title,value,detail,footer,pct,name)=>{ + const card=document.createElement('article');card.className='quota-card';card.dataset.provider='mistral'; + if(name){card.dataset.quota=name;card.tabIndex=0;card.setAttribute('role','button');} + const h=document.createElement('h2');h.className='quota-title';h.textContent=title;card.append(h); + const v=document.createElement('p');v.className='usage-percent';v.textContent=value;card.append(v); + const d=document.createElement('p');d.className='usage-fraction';d.textContent=detail;card.append(d); + if(Number.isFinite(pct)){const bar=document.createElement('div');bar.className='progress-bar';bar.setAttribute('role','progressbar');bar.setAttribute('aria-valuemin','0');bar.setAttribute('aria-valuemax','100');bar.setAttribute('aria-valuenow',String(Math.min(100,pct)));const fill=document.createElement('div');fill.className='progress-fill';fill.style.width=Math.max(0,Math.min(100,pct))+'%';bar.append(fill);card.append(bar);} + const f=document.createElement('p');f.className='reset-time';f.textContent=footer;card.append(f);container.append(card); + }; + for(const name of ['api_included','vibe_included']){ + const q=(data.quotas||[]).find(q=>q.name===name); + if(!q){add(mistralDisplayNames[name],'Unavailable','Waiting for allowance data','',null,name);continue;} + const pct=q.cardPercent ?? q.utilization; + add(mistralDisplayNames[name],pct.toFixed(1)+'%',q.percentOnly ? 'Allowance amount unavailable' : mistralMoney(q.used,q.currency)+' / '+mistralMoney(q.limit,q.currency)+' · '+mistralMoney(q.remaining,q.currency)+' remaining',(q.isStale?'Stale · ':'')+'Updated '+new Date(q.lastUpdatedAt).toLocaleString()+(q.resetsAt?' · Resets '+new Date(q.resetsAt).toLocaleDateString():''),pct,name); + } + const b=data.billing||{}; + if (data.showBilling !== false) { + add('Pay-as-you-go spend',mistralMoney(b.amount,b.currency),b.amount==null?'Mistral has not returned a reliable spend amount.':new Date(b.periodStart).toLocaleDateString()+' - '+new Date(b.periodEnd).toLocaleDateString(),b.capturedAt&&b.amount!=null?(b.status==='stale'?'Stale · ':'')+'Updated '+new Date(b.capturedAt).toLocaleString():'You can hide PAYG in Settings > Providers > Mistral.',null); + } + const status=document.getElementById('mistral-connection-status');if(status) status.textContent=data.status==='reconnect'?'Reconnect: sign in to Mistral in the selected browser, or update manual cookies.':data.status==='partial'?'Some Mistral data is unavailable. Last successful values retain their original timestamps.':data.status==='stale'?'Mistral is temporarily unavailable. Retrying automatically.':''; +} diff --git a/internal/web/static/icons/mistral.svg b/internal/web/static/icons/mistral.svg new file mode 100644 index 00000000..dea035a1 --- /dev/null +++ b/internal/web/static/icons/mistral.svg @@ -0,0 +1 @@ + diff --git a/internal/web/static/menubar.html b/internal/web/static/menubar.html index 0b2f7a65..8e7a9ab8 100644 --- a/internal/web/static/menubar.html +++ b/internal/web/static/menubar.html @@ -387,6 +387,10 @@ mask-image: url("/static/icons/kimi.svg"); } + .provider-icon-mistral { + -webkit-mask-image: url("/static/icons/mistral.svg"); + mask-image: url("/static/icons/mistral.svg"); + } .provider-icon-opencode { -webkit-mask-image: url("/static/icons/opencode.svg"); mask-image: url("/static/icons/opencode.svg"); @@ -2248,6 +2252,7 @@

    Provider Order

    } function sourceTagMarkup(quota) { + if (quota.is_stale) return 'Stale data'; if (!quota.source || quota.source !== 'api') return ''; const age = quota.age_seconds || 0; let ageText = ''; @@ -2302,10 +2307,12 @@

    Provider Order

    ${escapeHTML(provider.label)} + ${provider.connection_status === 'reconnect' ? 'Reconnect required' : provider.connection_status === 'stale' ? 'Updates unavailable' : ''} ${provider.promo ? `${escapeHTML(promoLabel(provider.promo))}` : ''}
    ${escapeHTML(compactUpdatedLabel(provider.updated_at))}
    + ${provider.base_provider === "mistral" && provider.subtitle ? `
    ${escapeHTML(provider.subtitle)}
    ` : ""} `; } @@ -2762,7 +2769,9 @@

    Provider Order

    return "provider-icon-grok"; case "kimi": return "provider-icon-kimi"; - case "opencode": + case "mistral": + return "provider-icon-mistral"; + case "opencode": return "provider-icon-opencode"; case "ollama": return "provider-icon-ollama"; @@ -2817,7 +2826,10 @@

    Provider Order

    const used = Number(quota.used); if (Number.isFinite(limit) && limit > 0 && Number.isFinite(used) && used >= 0) { if (quota.format === "currency") { - return `$${formatUsd(used)}/$${formatUsd(limit)}`; + if (quota.currency && quota.currency !== "USD") { + try {const fmt=new Intl.NumberFormat(undefined,{style:"currency",currency:quota.currency});return `${fmt.format(used)}/${fmt.format(limit)}`;} catch {return `${formatUsd(used)}/${formatUsd(limit)} ${quota.currency}`;} + } + return `$${formatUsd(used)}/$${formatUsd(limit)}`; } if (quota.format === "credits") { return `${formatCredits(used)}/${formatCredits(limit)} cr`; diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 2b9473d9..ea2c632c 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -4322,3 +4322,49 @@ body.accounts-overview-mode #sessions-section { animation: none; } } + +/* Plain-language explanation for providers that work differently from the + rest (currently Mistral). Shown once on the provider page and always in its + settings window. */ +.provider-intro { + background: var(--surface-card); + border: 1px solid var(--border-default); + border-left: 3px solid var(--accent-teal); + border-radius: var(--radius-md); + padding: 14px 16px; + margin: 0 0 16px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} +.provider-intro-title { + margin: 0 0 6px; + font-weight: 600; + color: var(--text-primary); +} +.provider-intro-list { + margin: 0; + padding-left: 18px; +} +.provider-intro-list li + li { + margin-top: 4px; +} +.provider-intro-list strong { + color: var(--text-primary); +} +.provider-intro-dismiss { + margin-top: 10px; + padding: 4px 12px; + font-size: 12px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + cursor: pointer; +} +.provider-intro-dismiss:hover { + border-color: var(--accent-teal); +} +.provider-intro-settings { + margin-bottom: 20px; +} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 52acbfb3..e49f2411 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -137,7 +137,11 @@

    Dashboard

    {{else if eq .CurrentProvider "kimi"}}
    - {{else if eq .CurrentProvider "opencode"}} + {{else if eq .CurrentProvider "mistral"}} + +
    +

    + {{else if eq .CurrentProvider "opencode"}}
    {{else if eq .CurrentProvider "ollama"}} @@ -504,7 +508,7 @@

    {{end}} - {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor") (ne .CurrentProvider "opencode") (ne .CurrentProvider "ollama") (ne .CurrentProvider "muse") (ne .CurrentProvider "commandcode")}} + {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor") (ne .CurrentProvider "opencode") (ne .CurrentProvider "mistral") (ne .CurrentProvider "ollama") (ne .CurrentProvider "muse") (ne .CurrentProvider "commandcode")}}

    @@ -619,7 +623,7 @@

    - {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Usage Samples{{else}}Logging History{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "mistral") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Usage Samples{{else}}Logging History{{end}}

    @@ -633,7 +637,7 @@

    - {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Bucket{{else}}Group{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "mistral") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Bucket{{else}}Group{{end}}
    @@ -659,14 +663,14 @@

    - {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}No usage samples yet. Tracking begins after the first successful sync.{{else}}No polling data yet. Tracking begins on first poll.{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "mistral") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}No usage samples yet. Tracking begins after the first successful sync.{{else}}No polling data yet. Tracking begins on first poll.{{end}}

    @@ -677,11 +681,11 @@

    - {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Billing Cycle Overview{{else}}Cycle Overview{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "mistral") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Billing Cycle Overview{{else}}Cycle Overview{{end}}

    - {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Quota{{else}}Period{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode") (eq .CurrentProvider "mistral") (eq .CurrentProvider "ollama") (eq .CurrentProvider "muse") (eq .CurrentProvider "commandcode")}}Quota{{else}}Period{{end}}