From 5eaaab1f6694082b101f0513bea7b10cb059f357 Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Sun, 3 May 2026 14:26:06 -0700 Subject: [PATCH] feat(mistral): expand to 13 namespaced metrics from existing usage payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single "session-percent" metric was a misnomer — it was always raw EUR spend, not a quota percent. Replace it with the namespaced metric inventory the existing parser was already decoding and throwing away: Costs: monthly-cost, monthly-cost-completion, monthly-cost-ocr, monthly-cost-audio, monthly-cost-connectors, monthly-cost-libraries, monthly-cost-fine-tuning, monthly-cost-vibe Tokens: monthly-input-tokens, monthly-output-tokens, monthly-cached-tokens Account: model-count, period-end No new HTTP calls — the existing GET admin.mistral.ai/api/billing/v2/usage already returns every field. The payload's currency symbol is preserved verbatim (typically EUR) — no synthetic USD conversion. Pinned buttons bound to the legacy "session-percent" auto-rebind to "monthly-cost" via metricIDAliases. handleWillAppear now persists the migrated ID back to KeySettings (was read-only before) and emits a one-line summary log on the first migration per plugin launch. stat.html METRICS["mistral"] is grouped via "__group__:" rows; a small extension to populateMetrics renders them as separators ("Costs" / "Tokens" / "Account"). Other providers continue to render as flat option lists. Tests cover the parser (token totals, per-category spend, period-end, empty response) and the snapshot emission (all 13 metric IDs present, legacy ID gone, currency formatting). Fixture ported from tmp/CodexBar/Tests/CodexBarTests/MistralUsageParserTests.swift, with extra categories added to exercise the new code paths. Refs plans/mistral-tier-coverage.md (v1). Co-Authored-By: Claude --- cmd/plugin/main.go | 31 ++ cmd/plugin/main_test.go | 3 + docs/PROVIDERS.md | 2 +- internal/providers/mistral/mistral.go | 254 +++++++++++++--- internal/providers/mistral/mistral_test.go | 276 ++++++++++++++++++ .../mistral/testdata/billing-v2-usage.json | 232 +++++++++++++++ .../ui/stat.html | 77 ++++- 7 files changed, 828 insertions(+), 47 deletions(-) create mode 100644 internal/providers/mistral/mistral_test.go create mode 100644 internal/providers/mistral/testdata/billing-v2-usage.json diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 0cf14da..26663e8 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -97,6 +97,11 @@ var ( globalSettingsSeen bool autoRegisterOnce sync.Once + // metricMigrationLogOnce gates the first-run summary log emitted + // when a saved button's metric ID is rewritten by metricIDAliases. + // One log per plugin launch is enough — subsequent migrations are + // silent. + metricMigrationLogOnce sync.Once ) func globalSettingsLoaded() bool { @@ -409,6 +414,24 @@ func handleWillAppear(conn *streamdeck.Connection, ev streamdeck.Event) { } } + // Stale metricID migration. Rewrites pinned KeySettings.MetricID + // when an alias maps it to a new ID — keeps reads consistent (the + // alias map handles read-side lookups too) but persists the new + // value so PI dropdowns and downstream tooling see the canonical + // name. Logs one summary on the first migration per plugin + // launch; subsequent rebinds stay silent. + if ks.MetricID != "" { + if migrated := migrateMetricID(providerID, ks.MetricID); migrated != ks.MetricID { + old := ks.MetricID + ks.MetricID = migrated + raw, _ := json.Marshal(ks) + conn.SetSettings(ev.Context, raw) + metricMigrationLogOnce.Do(func() { + conn.Logf("metricID migration: %s/%s → %s (rebinding stale buttons silently)", providerID, old, migrated) + }) + } + } + // If an update is pending, show the update face. if !settings.SkipUpdateCheckEnabled() && update.IsAvailable() { now := time.Now() @@ -1701,6 +1724,14 @@ var metricIDAliases = map[string]map[string]string{ "alibaba": { "opus-percent": "monthly-percent", }, + // Mistral renamed "session-percent" → "monthly-cost" when it grew + // from a single MTD-cost metric to a 13-metric inventory in v0.9. + // The legacy ID was Claude-flavored and misled users into thinking + // it was a quota percent — the surfaced number was always raw EUR + // spend. See plans/mistral-tier-coverage.md. + "mistral": { + "session-percent": "monthly-cost", + }, } // migrateMetricID returns the current metric ID for a (provider, diff --git a/cmd/plugin/main_test.go b/cmd/plugin/main_test.go index b6ea49e..9a25f70 100644 --- a/cmd/plugin/main_test.go +++ b/cmd/plugin/main_test.go @@ -69,6 +69,9 @@ func TestMigrateMetricID(t *testing.T) { {"alibaba opus → monthly", "alibaba", "opus-percent", "monthly-percent"}, {"alibaba session unchanged", "alibaba", "session-percent", "session-percent"}, {"alibaba weekly unchanged", "alibaba", "weekly-percent", "weekly-percent"}, + {"mistral session → monthly-cost", "mistral", "session-percent", "monthly-cost"}, + {"mistral monthly-cost passthrough", "mistral", "monthly-cost", "monthly-cost"}, + {"mistral weekly unchanged", "mistral", "weekly-percent", "weekly-percent"}, {"claude passthrough", "claude", "session-percent", "session-percent"}, {"claude opus passthrough", "claude", "weekly-opus-percent", "weekly-opus-percent"}, {"unknown provider passthrough", "unknown", "opus-percent", "opus-percent"}, diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 877a7c0..010451f 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -36,7 +36,7 @@ account or API response includes that quota lane. | Kimrel | Kimrel API key from the Provider tab or `KIMREL_API_KEY` (older `KIMI_K2_API_KEY` / `KIMI_API_KEY` / `KIMI_KEY` still resolve). Kimrel (kimrel.com, formerly kimi-k2.ai) is an **independent third-party reseller** of Kimi K2 model access — not affiliated with, endorsed by, or sponsored by Moonshot AI. Use the Moonshot provider for the official Moonshot dev platform. | Credits remaining. | | Kiro | `kiro-cli`; run `kiro-cli login` first. | Monthly credits remaining %, bonus credits remaining %. | | MiniMax | MiniMax API key from the Provider tab / `MINIMAX_API_KEY`, or Usage Buttons Helper from `minimax.io`. Optional region override: `MINIMAX_REGION`. | Coding prompts remaining %. | -| Mistral | Usage Buttons Helper from `admin.mistral.ai`. | Monthly billing usage. | +| Mistral | Usage Buttons Helper from `admin.mistral.ai`. | Monthly billing usage (total), per-category spend (completion / OCR / audio / connectors / RAG libraries / fine-tuning / Vibe), input / output / cached input tokens MTD, distinct completion models used, days until billing reset. Currency from response is preserved verbatim — typically EUR. | | Moonshot (Kimi platform) | Moonshot API key from the Provider tab or `MOONSHOT_API_KEY` / `KIMI_PLATFORM_API_KEY`. Optional China-region host override: `MOONSHOT_API_HOST=https://api.moonshot.cn`. | Available balance ($), voucher balance ($), cash balance ($). Distinct from the Kimi provider (per-user CLI quotas) and Kimrel (third-party reseller credits) — Moonshot is the org-wide paid developer API balance. | | Ollama | Usage Buttons Helper from the signed-in Ollama web session. | Session usage remaining %, session pace (burn rate), weekly usage remaining %, weekly pace (burn rate). | | OpenAI | OpenAI admin API key (`sk-admin-…`) from the Provider tab or `OPENAI_ADMIN_API_KEY` (kept namespaced so it doesn't shadow the SDK-standard `OPENAI_API_KEY`). Org admins only — personal `sk-` keys are rejected by the admin endpoints. | Org spend today (UTC, $), yesterday ($), last 7 days ($), month-to-date ($), last 30 days ($), 7-day burn rate ($/day), projected month total ($). Distinct from the Codex provider (per-user session/weekly window from ChatGPT OAuth) — this is the org-wide cost view. | diff --git a/internal/providers/mistral/mistral.go b/internal/providers/mistral/mistral.go index d2c1756..433cdbd 100644 --- a/internal/providers/mistral/mistral.go +++ b/internal/providers/mistral/mistral.go @@ -3,6 +3,12 @@ // Auth: Usage Buttons Helper extension with the user's admin.mistral.ai // browser session. Endpoint: // https://admin.mistral.ai/api/billing/v2/usage?month=...&year=.... +// +// One Stream Deck action emits a namespaced metric inventory derived +// from the single billing endpoint. The response already breaks spend +// down by category (completion / OCR / audio / connectors / libraries +// / fine-tuning / Vibe), so each category surfaces as its own metric +// alongside aggregate cost, token, and model-count metrics. package mistral import ( @@ -24,6 +30,25 @@ import ( const adminBaseURL = "https://admin.mistral.ai" +// Metric IDs surfaced by this provider. The set is namespaced under +// "monthly-" so a future "tier-progress" / "spend-limit-percent" can +// land alongside without colliding. +const ( + metricMonthlyCost = "monthly-cost" + metricMonthlyCostCompletion = "monthly-cost-completion" + metricMonthlyCostOCR = "monthly-cost-ocr" + metricMonthlyCostAudio = "monthly-cost-audio" + metricMonthlyCostConnectors = "monthly-cost-connectors" + metricMonthlyCostLibraries = "monthly-cost-libraries" + metricMonthlyCostFineTuning = "monthly-cost-fine-tuning" + metricMonthlyCostVibe = "monthly-cost-vibe" + metricMonthlyInputTokens = "monthly-input-tokens" + metricMonthlyOutputTokens = "monthly-output-tokens" + metricMonthlyCachedTokens = "monthly-cached-tokens" + metricModelCount = "model-count" + metricPeriodEnd = "period-end" +) + // billingResponse mirrors Mistral's billing usage response. type billingResponse struct { Completion *modelCategory `json:"completion"` @@ -79,16 +104,33 @@ type priceEntry struct { Price string `json:"price"` } +// categorySpend captures per-category spend in the response currency. +// Each field maps 1:1 to a "monthly-cost-" metric. +type categorySpend struct { + Completion float64 + OCR float64 + Audio float64 + Connectors float64 + Libraries float64 + FineTuning float64 + Vibe float64 +} + +// Total returns the sum across all categories — the same number the +// previous single-metric implementation surfaced as "monthly-cost". +func (c categorySpend) Total() float64 { + return c.Completion + c.OCR + c.Audio + c.Connectors + c.Libraries + c.FineTuning + c.Vibe +} + // usageSnapshot is the parsed Mistral monthly billing state. type usageSnapshot struct { - TotalCost float64 + Spend categorySpend Currency string CurrencySymbol string InputTokens int OutputTokens int CachedTokens int ModelCount int - VibeUsage float64 PeriodEnd *time.Time UpdatedAt time.Time } @@ -108,9 +150,27 @@ func (Provider) BrandColor() string { return "#ff500f" } // BrandBg returns the background color used on button faces. func (Provider) BrandBg() string { return "#111214" } -// MetricIDs enumerates the metrics this provider can emit. +// MetricIDs enumerates the metrics this provider can emit. Order is +// the PI's preferred display order: aggregate spend first, then per- +// category, then token totals, then account-shape metrics. Used as the +// dropdown's default ordering and as the "first metric" fallback when +// a freshly-dropped key has no saved metric ID yet. func (Provider) MetricIDs() []string { - return []string{"session-percent"} + return []string{ + metricMonthlyCost, + metricMonthlyCostCompletion, + metricMonthlyCostOCR, + metricMonthlyCostAudio, + metricMonthlyCostConnectors, + metricMonthlyCostLibraries, + metricMonthlyCostFineTuning, + metricMonthlyCostVibe, + metricMonthlyInputTokens, + metricMonthlyOutputTokens, + metricMonthlyCachedTokens, + metricModelCount, + metricPeriodEnd, + } } // Fetch returns the latest Mistral usage snapshot. @@ -175,41 +235,52 @@ func usageURL(now time.Time) string { // parseUsage aggregates Mistral usage categories into one monthly snapshot. func parseUsage(body billingResponse, now time.Time) usageSnapshot { prices := priceIndex(body.Prices) - var totalCost float64 + var inputTokens, outputTokens, cachedTokens, modelCount int + var spend categorySpend if body.Completion != nil { input, output, cached, cost, models := aggregateModelMap(body.Completion.Models, prices) inputTokens += input outputTokens += output cachedTokens += cached - totalCost += cost + spend.Completion = cost modelCount += models } - for _, category := range []*modelCategory{body.OCR, body.Connectors, body.Audio} { - if category == nil { - continue - } - _, _, _, cost, _ := aggregateModelMap(category.Models, prices) - totalCost += cost + if body.OCR != nil { + _, _, _, cost, _ := aggregateModelMap(body.OCR.Models, prices) + spend.OCR = cost + } + if body.Audio != nil { + _, _, _, cost, _ := aggregateModelMap(body.Audio.Models, prices) + spend.Audio = cost + } + if body.Connectors != nil { + _, _, _, cost, _ := aggregateModelMap(body.Connectors.Models, prices) + spend.Connectors = cost } if body.LibrariesAPI != nil { + var librariesCost float64 for _, category := range []*modelCategory{body.LibrariesAPI.Pages, body.LibrariesAPI.Tokens} { if category == nil { continue } _, _, _, cost, _ := aggregateModelMap(category.Models, prices) - totalCost += cost + librariesCost += cost } + spend.Libraries = librariesCost } if body.FineTuning != nil { + var ftCost float64 for _, models := range []map[string]modelUsage{body.FineTuning.Training, body.FineTuning.Storage} { _, _, _, cost, _ := aggregateModelMap(models, prices) - totalCost += cost + ftCost += cost } + spend.FineTuning = ftCost } if body.VibeUsage != nil { - totalCost += *body.VibeUsage + spend.Vibe = *body.VibeUsage } + currency := body.Currency if currency == "" { currency = "EUR" @@ -224,7 +295,7 @@ func parseUsage(body billingResponse, now time.Time) usageSnapshot { periodEnd = &t } return usageSnapshot{ - TotalCost: totalCost, + Spend: spend, Currency: currency, CurrencySymbol: symbol, InputTokens: inputTokens, @@ -297,36 +368,151 @@ func entryValue(entry usageEntry) float64 { } // snapshotFromUsage maps Mistral usage into Stream Deck metrics. +// +// Mistral is pay-as-you-go with no quota limit, so cost metrics are +// raw amounts (NumericGoodWhen=low) — there's no remaining-percent +// concept. The total-cost metric carries the period reset countdown so +// pinning "MONTHLY" gives users both the current spend and how long +// until the cycle resets. func snapshotFromUsage(usage usageSnapshot) providers.Snapshot { now := usage.UpdatedAt.UTC().Format(time.RFC3339) - value := fmt.Sprintf("%s%.4f", usage.CurrencySymbol, usage.TotalCost) - caption := "No usage this month" - if usage.TotalCost > 0 { - caption = fmt.Sprintf("%d models", usage.ModelCount) - } - metric := providers.MetricValue{ - ID: "session-percent", - Label: "MONTHLY", - Name: "Mistral monthly usage cost", - Value: value, - NumericValue: &usage.TotalCost, - NumericUnit: "dollars", - NumericGoodWhen: "low", - Caption: caption, - UpdatedAt: now, + resetSecs := periodResetSeconds(usage) + + var metrics []providers.MetricValue + + // Total monthly cost (was "session-percent" pre-rename). + total := usage.Spend.Total() + totalMetric := costMetric(metricMonthlyCost, "MONTHLY", "Mistral monthly usage cost", total, usage, now) + if totalMetric.Caption == "" && total > 0 { + totalMetric.Caption = fmt.Sprintf("%d models", usage.ModelCount) + } else if total == 0 { + totalMetric.Caption = "No usage this month" } - if usage.PeriodEnd != nil { - metric.ResetInSeconds = providerutil.ResetSeconds(*usage.PeriodEnd) + if resetSecs != nil { + totalMetric.ResetInSeconds = resetSecs + } + metrics = append(metrics, totalMetric) + + // Per-category cost. Emitted unconditionally so the PI dropdown + // items always work — a zero spend renders as "€0.0000" with the + // "No usage this month" caption when no categories saw activity. + categories := []struct { + id, label, name string + amount float64 + }{ + {metricMonthlyCostCompletion, "COMPLETION", "Completion model spend", usage.Spend.Completion}, + {metricMonthlyCostOCR, "OCR", "OCR pages spend", usage.Spend.OCR}, + {metricMonthlyCostAudio, "AUDIO", "Audio (Voxtral / TTS / STT) spend", usage.Spend.Audio}, + {metricMonthlyCostConnectors, "CONNECT", "Agent / connector spend", usage.Spend.Connectors}, + {metricMonthlyCostLibraries, "LIBS", "RAG library spend", usage.Spend.Libraries}, + {metricMonthlyCostFineTuning, "FT", "Fine-tuning training + storage spend", usage.Spend.FineTuning}, + {metricMonthlyCostVibe, "VIBE", "Mistral Vibe flat charge", usage.Spend.Vibe}, } + for _, c := range categories { + m := costMetric(c.id, c.label, c.name, c.amount, usage, now) + metrics = append(metrics, m) + } + + // Token totals. Cached-on-input ratio surfaces in the input-tokens + // caption so users can see cache-hit health without pinning a + // separate metric — same data, friendlier presentation. + inputCaption := "" + if usage.InputTokens > 0 && usage.CachedTokens > 0 { + ratio := float64(usage.CachedTokens) / float64(usage.InputTokens) * 100 + inputCaption = fmt.Sprintf("%.0f%% cached", ratio) + } + metrics = append(metrics, tokenMetric(metricMonthlyInputTokens, "INPUT", "Total input tokens MTD", usage.InputTokens, inputCaption, now)) + metrics = append(metrics, tokenMetric(metricMonthlyOutputTokens, "OUTPUT", "Total output tokens MTD", usage.OutputTokens, "", now)) + metrics = append(metrics, tokenMetric(metricMonthlyCachedTokens, "CACHED", "Cached input tokens MTD", usage.CachedTokens, "", now)) + + // Distinct completion models used MTD. + modelCountValue := float64(usage.ModelCount) + modelMetric := providers.MetricValue{ + ID: metricModelCount, + Label: "MODELS", + Name: "Distinct completion models used", + Value: usage.ModelCount, + NumericValue: &modelCountValue, + NumericUnit: "count", + Caption: "this month", + UpdatedAt: now, + } + metrics = append(metrics, modelMetric) + + // Days until billing reset. Surfaces as a countdown on the button + // face via ResetInSeconds; the value is the human-readable day + // count for the rare provider that bypasses ResetInSeconds. + periodMetric := providers.MetricValue{ + ID: metricPeriodEnd, + Label: "RESET", + Name: "Days until billing reset", + Value: "—", + Caption: "monthly cycle", + UpdatedAt: now, + } + if resetSecs != nil { + days := math.Ceil(*resetSecs / 86400) + daysVal := days + periodMetric.Value = fmt.Sprintf("%.0fd", days) + periodMetric.NumericValue = &daysVal + periodMetric.NumericUnit = "count" + periodMetric.ResetInSeconds = resetSecs + } + metrics = append(metrics, periodMetric) + return providers.Snapshot{ ProviderID: "mistral", ProviderName: "Mistral", Source: "cookie", - Metrics: []providers.MetricValue{metric}, + Metrics: metrics, Status: "operational", } } +// costMetric builds a numeric-currency metric. Mistral's currency +// symbol is used verbatim — no synthetic conversion to USD even if +// the user expects dollars. Four decimals because per-token spend on +// the smaller models often rounds to <€0.01 over a month, and a "€0" +// face would be misleading after a real but tiny session. +func costMetric(id, label, name string, amount float64, usage usageSnapshot, now string) providers.MetricValue { + value := fmt.Sprintf("%s%.4f", usage.CurrencySymbol, amount) + amt := amount + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: value, + NumericValue: &amt, + NumericUnit: "dollars", + NumericGoodWhen: "low", + UpdatedAt: now, + } +} + +// tokenMetric builds an integer count metric. +func tokenMetric(id, label, name string, count int, caption, now string) providers.MetricValue { + v := float64(count) + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: count, + NumericValue: &v, + NumericUnit: "count", + Caption: caption, + UpdatedAt: now, + } +} + +// periodResetSeconds returns seconds until the billing-period end as a +// pointer suitable for MetricValue.ResetInSeconds. +func periodResetSeconds(usage usageSnapshot) *float64 { + if usage.PeriodEnd == nil { + return nil + } + return providerutil.ResetSeconds(*usage.PeriodEnd) +} + // errorSnapshot returns a Mistral setup or auth failure snapshot. func errorSnapshot(message string) providers.Snapshot { return providers.Snapshot{ diff --git a/internal/providers/mistral/mistral_test.go b/internal/providers/mistral/mistral_test.go new file mode 100644 index 0000000..30f543a --- /dev/null +++ b/internal/providers/mistral/mistral_test.go @@ -0,0 +1,276 @@ +package mistral + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "testing" + "time" + + "github.com/anthonybaldwin/UsageButtons/internal/providers" +) + +// loadFixture decodes the testdata billing response into a billingResponse. +func loadFixture(t *testing.T) billingResponse { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "billing-v2-usage.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var body billingResponse + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return body +} + +// findMetric looks up a metric by ID; fails the test if absent so the +// rest of the assertion reads naturally. +func findMetric(t *testing.T, snap providers.Snapshot, id string) providers.MetricValue { + t.Helper() + for _, m := range snap.Metrics { + if m.ID == id { + return m + } + } + t.Fatalf("metric %q not present in snapshot", id) + return providers.MetricValue{} +} + +func TestParseUsage_TokenTotalsAndModelCount(t *testing.T) { + body := loadFixture(t) + got := parseUsage(body, time.Now().UTC()) + + // mistral-large input: 11121, mistral-small input: 20+100=120. + if want := 11121 + 120; got.InputTokens != want { + t.Errorf("InputTokens = %d, want %d", got.InputTokens, want) + } + // mistral-large output: 1115, mistral-small output: 500+2482=2982. + if want := 1115 + 2982; got.OutputTokens != want { + t.Errorf("OutputTokens = %d, want %d", got.OutputTokens, want) + } + // mistral-small cached: 50. + if want := 50; got.CachedTokens != want { + t.Errorf("CachedTokens = %d, want %d", got.CachedTokens, want) + } + if got.ModelCount != 2 { + t.Errorf("ModelCount = %d, want 2", got.ModelCount) + } + if got.Currency != "EUR" { + t.Errorf("Currency = %q, want %q", got.Currency, "EUR") + } + if got.CurrencySymbol != "€" { + t.Errorf("CurrencySymbol = %q, want €", got.CurrencySymbol) + } +} + +func TestParseUsage_CategorySpend(t *testing.T) { + body := loadFixture(t) + got := parseUsage(body, time.Now().UTC()) + + // Completion cost — same math as CodexBar's existing test, plus a + // small cached-token contribution from the fixture (50 * 4.25e-8). + expectedCompletion := 11121*0.0000017 + 1115*0.0000051 + 120*8.5e-8 + 2982*2.55e-7 + 50*4.25e-8 + if !approxEqual(got.Spend.Completion, expectedCompletion, 1e-6) { + t.Errorf("Spend.Completion = %v, want %v", got.Spend.Completion, expectedCompletion) + } + // OCR: 200 pages * 0.001. + if !approxEqual(got.Spend.OCR, 0.2, 1e-9) { + t.Errorf("Spend.OCR = %v, want 0.2", got.Spend.OCR) + } + // Audio: 4 minutes * 0.05. + if !approxEqual(got.Spend.Audio, 0.2, 1e-9) { + t.Errorf("Spend.Audio = %v, want 0.2", got.Spend.Audio) + } + // Connectors: 10 calls * 0.01. + if !approxEqual(got.Spend.Connectors, 0.1, 1e-9) { + t.Errorf("Spend.Connectors = %v, want 0.1", got.Spend.Connectors) + } + // Libraries: 5 pages * 0.02 + 1000 tokens * 0.0001. + if !approxEqual(got.Spend.Libraries, 0.2, 1e-9) { + t.Errorf("Spend.Libraries = %v, want 0.2", got.Spend.Libraries) + } + // Fine-tuning: 2 * 0.5 + 1 * 0.25. + if !approxEqual(got.Spend.FineTuning, 1.25, 1e-9) { + t.Errorf("Spend.FineTuning = %v, want 1.25", got.Spend.FineTuning) + } + // Vibe: 1.5 from response. + if !approxEqual(got.Spend.Vibe, 1.5, 1e-9) { + t.Errorf("Spend.Vibe = %v, want 1.5", got.Spend.Vibe) + } + + // Sanity: total = sum of all categories. + want := got.Spend.Completion + got.Spend.OCR + got.Spend.Audio + + got.Spend.Connectors + got.Spend.Libraries + got.Spend.FineTuning + got.Spend.Vibe + if !approxEqual(got.Spend.Total(), want, 1e-9) { + t.Errorf("Spend.Total() = %v, want %v", got.Spend.Total(), want) + } +} + +func TestParseUsage_PeriodEnd(t *testing.T) { + body := loadFixture(t) + got := parseUsage(body, time.Now().UTC()) + + if got.PeriodEnd == nil { + t.Fatal("PeriodEnd is nil; expected parsed end_date") + } + // The +1 second nudge in parseUsage rolls "2025-11-30T23:59:59.999Z" + // over the midnight boundary so countdown math hits the next cycle + // cleanly. Either side of the boundary is acceptable here. + if got.PeriodEnd.Year() != 2025 { + t.Errorf("PeriodEnd year = %d, want 2025", got.PeriodEnd.Year()) + } + if month := got.PeriodEnd.Month(); month != time.November && month != time.December { + t.Errorf("PeriodEnd month = %v, want November or December", month) + } +} + +func TestParseUsage_EmptyResponse(t *testing.T) { + body := billingResponse{ + Completion: &modelCategory{Models: map[string]modelUsage{}}, + OCR: &modelCategory{Models: map[string]modelUsage{}}, + Connectors: &modelCategory{Models: map[string]modelUsage{}}, + LibrariesAPI: &librariesCategory{}, + FineTuning: &fineTuningCategory{}, + Audio: &modelCategory{Models: map[string]modelUsage{}}, + Currency: "EUR", + CurrencySymbol: "€", + } + got := parseUsage(body, time.Now().UTC()) + if got.Spend.Total() != 0 { + t.Errorf("Spend.Total() = %v, want 0 for empty response", got.Spend.Total()) + } + if got.ModelCount != 0 { + t.Errorf("ModelCount = %d, want 0", got.ModelCount) + } +} + +// TestSnapshotFromUsage_AllMetricsPresent asserts every namespaced +// metric ID surfaces in the snapshot — this is the v1 acceptance gate +// from plans/mistral-tier-coverage.md. Buttons bound to any of these +// IDs need a hit, otherwise the dropdown advertises a metric the +// provider can't actually emit. +func TestSnapshotFromUsage_AllMetricsPresent(t *testing.T) { + body := loadFixture(t) + usage := parseUsage(body, mustParse("2025-11-15T12:00:00Z")) + snap := snapshotFromUsage(usage) + + wantIDs := []string{ + metricMonthlyCost, + metricMonthlyCostCompletion, + metricMonthlyCostOCR, + metricMonthlyCostAudio, + metricMonthlyCostConnectors, + metricMonthlyCostLibraries, + metricMonthlyCostFineTuning, + metricMonthlyCostVibe, + metricMonthlyInputTokens, + metricMonthlyOutputTokens, + metricMonthlyCachedTokens, + metricModelCount, + metricPeriodEnd, + } + if len(wantIDs) != len(Provider{}.MetricIDs()) { + t.Errorf("MetricIDs() length %d, test expects %d", len(Provider{}.MetricIDs()), len(wantIDs)) + } + + got := map[string]providers.MetricValue{} + for _, m := range snap.Metrics { + got[m.ID] = m + } + for _, id := range wantIDs { + if _, ok := got[id]; !ok { + t.Errorf("snapshot missing metric %q", id) + } + } +} + +func TestSnapshotFromUsage_MonthlyCostFormatting(t *testing.T) { + body := loadFixture(t) + usage := parseUsage(body, mustParse("2025-11-15T12:00:00Z")) + snap := snapshotFromUsage(usage) + + m := findMetric(t, snap, metricMonthlyCost) + value, ok := m.Value.(string) + if !ok { + t.Fatalf("Value type %T, want string", m.Value) + } + // Currency symbol from response is preserved verbatim. + if value[:len("€")] != "€" { + t.Errorf("Value %q does not start with €", value) + } + if m.NumericValue == nil || *m.NumericValue != usage.Spend.Total() { + t.Errorf("NumericValue = %v, want %v", m.NumericValue, usage.Spend.Total()) + } + if m.NumericGoodWhen != "low" { + t.Errorf("NumericGoodWhen = %q, want low", m.NumericGoodWhen) + } + if m.ResetInSeconds == nil { + t.Error("ResetInSeconds nil; expected populated from end_date") + } +} + +func TestSnapshotFromUsage_TokensAndModelCount(t *testing.T) { + body := loadFixture(t) + usage := parseUsage(body, mustParse("2025-11-15T12:00:00Z")) + snap := snapshotFromUsage(usage) + + in := findMetric(t, snap, metricMonthlyInputTokens) + if got, _ := in.Value.(int); got != usage.InputTokens { + t.Errorf("input tokens Value = %v, want %d", in.Value, usage.InputTokens) + } + // Cached ratio should appear in caption when both >0. + if usage.CachedTokens > 0 && in.Caption == "" { + t.Error("input tokens caption empty; expected '%% cached' suffix") + } + + out := findMetric(t, snap, metricMonthlyOutputTokens) + if got, _ := out.Value.(int); got != usage.OutputTokens { + t.Errorf("output tokens Value = %v, want %d", out.Value, usage.OutputTokens) + } + + cached := findMetric(t, snap, metricMonthlyCachedTokens) + if got, _ := cached.Value.(int); got != usage.CachedTokens { + t.Errorf("cached tokens Value = %v, want %d", cached.Value, usage.CachedTokens) + } + + mc := findMetric(t, snap, metricModelCount) + if got, _ := mc.Value.(int); got != usage.ModelCount { + t.Errorf("model-count Value = %v, want %d", mc.Value, usage.ModelCount) + } +} + +func TestMetricIDsContainsMonthlyCost(t *testing.T) { + // Lock in the rename: "session-percent" is gone; "monthly-cost" is + // the canonical ID for the aggregate spend metric. The migration + // helper in cmd/plugin/main.go rebinds existing buttons. + ids := Provider{}.MetricIDs() + for _, id := range ids { + if id == "session-percent" { + t.Error("MetricIDs() still advertises legacy 'session-percent'") + } + } + hasMonthly := false + for _, id := range ids { + if id == metricMonthlyCost { + hasMonthly = true + break + } + } + if !hasMonthly { + t.Errorf("MetricIDs() missing %q", metricMonthlyCost) + } +} + +func mustParse(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +func approxEqual(a, b, eps float64) bool { + return math.Abs(a-b) <= eps +} diff --git a/internal/providers/mistral/testdata/billing-v2-usage.json b/internal/providers/mistral/testdata/billing-v2-usage.json new file mode 100644 index 0000000..cafbfb2 --- /dev/null +++ b/internal/providers/mistral/testdata/billing-v2-usage.json @@ -0,0 +1,232 @@ +{ + "completion": { + "models": { + "mistral-large-latest::mistral-large-2411": { + "input": [ + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-large-2411", + "billing_display_name": "mistral-large-latest", + "billing_group": "input", + "timestamp": "2025-11-14", + "value": 11121, + "value_paid": 11121 + } + ], + "output": [ + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-large-2411", + "billing_display_name": "mistral-large-latest", + "billing_group": "output", + "timestamp": "2025-11-14", + "value": 1115, + "value_paid": 1115 + } + ] + }, + "mistral-small-latest::mistral-small-2506": { + "input": [ + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-small-2506", + "billing_display_name": "mistral-small-latest", + "billing_group": "input", + "timestamp": "2025-11-14", + "value": 20, + "value_paid": 20 + }, + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-small-2506", + "billing_display_name": "mistral-small-latest", + "billing_group": "input", + "timestamp": "2025-11-24", + "value": 100, + "value_paid": 100 + } + ], + "output": [ + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-small-2506", + "billing_display_name": "mistral-small-latest", + "billing_group": "output", + "timestamp": "2025-11-14", + "value": 500, + "value_paid": 500 + }, + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-small-2506", + "billing_display_name": "mistral-small-latest", + "billing_group": "output", + "timestamp": "2025-11-24", + "value": 2482, + "value_paid": 2482 + } + ], + "cached": [ + { + "usage_type": "usage", + "event_type": "api_tokens", + "billing_metric": "mistral-small-2506", + "billing_display_name": "mistral-small-latest", + "billing_group": "cached", + "timestamp": "2025-11-24", + "value": 50, + "value_paid": 50 + } + ] + } + } + }, + "ocr": { + "models": { + "mistral-ocr-latest::mistral-ocr-2509": { + "input": [ + { + "usage_type": "usage", + "event_type": "ocr_pages", + "billing_metric": "mistral-ocr-2509", + "billing_display_name": "mistral-ocr-latest", + "billing_group": "pages", + "timestamp": "2025-11-14", + "value": 200, + "value_paid": 200 + } + ] + } + } + }, + "connectors": { + "models": { + "mistral-connector::websearch": { + "input": [ + { + "usage_type": "usage", + "event_type": "connector_calls", + "billing_metric": "mistral-connector", + "billing_group": "websearch", + "timestamp": "2025-11-14", + "value": 10, + "value_paid": 10 + } + ] + } + } + }, + "libraries_api": { + "pages": { + "models": { + "mistral-libraries::pages": { + "input": [ + { + "usage_type": "usage", + "event_type": "library_pages", + "billing_metric": "mistral-libraries", + "billing_group": "pages", + "timestamp": "2025-11-14", + "value": 5, + "value_paid": 5 + } + ] + } + } + }, + "tokens": { + "models": { + "mistral-libraries::tokens": { + "input": [ + { + "usage_type": "usage", + "event_type": "library_tokens", + "billing_metric": "mistral-libraries", + "billing_group": "tokens", + "timestamp": "2025-11-14", + "value": 1000, + "value_paid": 1000 + } + ] + } + } + } + }, + "fine_tuning": { + "training": { + "ft-train::mistral-small-2506": { + "input": [ + { + "usage_type": "usage", + "event_type": "ft_training", + "billing_metric": "ft-train", + "billing_group": "mistral-small-2506", + "timestamp": "2025-11-14", + "value": 2, + "value_paid": 2 + } + ] + } + }, + "storage": { + "ft-storage::mistral-small-2506": { + "input": [ + { + "usage_type": "usage", + "event_type": "ft_storage", + "billing_metric": "ft-storage", + "billing_group": "mistral-small-2506", + "timestamp": "2025-11-14", + "value": 1, + "value_paid": 1 + } + ] + } + } + }, + "audio": { + "models": { + "voxtral-latest::voxtral": { + "input": [ + { + "usage_type": "usage", + "event_type": "audio_minutes", + "billing_metric": "voxtral", + "billing_group": "input", + "timestamp": "2025-11-14", + "value": 4, + "value_paid": 4 + } + ] + } + } + }, + "vibe_usage": 1.5, + "date": "2025-11-01T00:00:00Z", + "previous_month": "2025-10", + "next_month": "2025-12", + "start_date": "2025-11-01T00:00:00Z", + "end_date": "2025-11-30T23:59:59.999Z", + "currency": "EUR", + "currency_symbol": "€", + "prices": [ + {"event_type": "api_tokens", "billing_metric": "mistral-large-2411", "billing_group": "input", "price": "0.0000017000"}, + {"event_type": "api_tokens", "billing_metric": "mistral-large-2411", "billing_group": "output", "price": "0.0000051000"}, + {"event_type": "api_tokens", "billing_metric": "mistral-small-2506", "billing_group": "input", "price": "8.50E-8"}, + {"event_type": "api_tokens", "billing_metric": "mistral-small-2506", "billing_group": "output", "price": "2.550E-7"}, + {"event_type": "api_tokens", "billing_metric": "mistral-small-2506", "billing_group": "cached", "price": "4.250E-8"}, + {"event_type": "ocr_pages", "billing_metric": "mistral-ocr-2509", "billing_group": "pages", "price": "0.001"}, + {"event_type": "connector_calls","billing_metric": "mistral-connector", "billing_group": "websearch","price": "0.01"}, + {"event_type": "library_pages", "billing_metric": "mistral-libraries", "billing_group": "pages", "price": "0.02"}, + {"event_type": "library_tokens", "billing_metric": "mistral-libraries", "billing_group": "tokens", "price": "0.0001"}, + {"event_type": "ft_training", "billing_metric": "ft-train", "billing_group": "mistral-small-2506","price": "0.5"}, + {"event_type": "ft_storage", "billing_metric": "ft-storage", "billing_group": "mistral-small-2506","price": "0.25"}, + {"event_type": "audio_minutes", "billing_metric": "voxtral", "billing_group": "input", "price": "0.05"} + ] +} diff --git a/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html b/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html index 254dca8..a72a5d5 100644 --- a/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html +++ b/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html @@ -1249,7 +1249,25 @@ ["weekly-percent", "Bonus credits remaining %", "pct"], ], mistral: [ - ["session-percent", "Monthly billing usage", "ref-dollar", "browser"], + // Costs (verbatim currency from the response — typically EUR). + ["__group__:Costs"], + ["monthly-cost", "Monthly billing usage (total)", "ref-dollar", "browser"], + ["monthly-cost-completion", "Completion model spend", "ref-dollar", "browser"], + ["monthly-cost-ocr", "OCR pages spend", "ref-dollar", "browser"], + ["monthly-cost-audio", "Audio (Voxtral / TTS / STT) spend", "ref-dollar", "browser"], + ["monthly-cost-connectors", "Agent / connector spend", "ref-dollar", "browser"], + ["monthly-cost-libraries", "RAG library spend", "ref-dollar", "browser"], + ["monthly-cost-fine-tuning", "Fine-tuning training + storage spend","ref-dollar","browser"], + ["monthly-cost-vibe", "Mistral Vibe flat charge", "ref-dollar", "browser"], + // Tokens (completion category — totals MTD). + ["__group__:Tokens"], + ["monthly-input-tokens", "Input tokens MTD", "ref", "browser"], + ["monthly-output-tokens", "Output tokens MTD", "ref", "browser"], + ["monthly-cached-tokens", "Cached input tokens MTD", "ref", "browser"], + // Account-shape metrics. + ["__group__:Account"], + ["model-count", "Distinct completion models used", "ref", "browser"], + ["period-end", "Days until billing reset", "ref", "browser"], ], synthetic: [ ["session-percent", "Five-hour quota remaining %", "pct"], @@ -1442,20 +1460,38 @@ alibaba: { "opus-percent": "monthly-percent", }, + mistral: { + "session-percent": "monthly-cost", + }, }; function migrateMetricId(providerId, metricId) { const aliases = METRIC_ID_ALIASES[providerId]; return (aliases && aliases[metricId]) || metricId; } + // ---------- Metric row helpers ---------- + // METRICS[provider] entries come in two shapes: + // ["__group__: