diff --git a/README.md b/README.md index 5fb0e1b..3e23487 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,7 @@ UsageButtons/ │ │ ├── ollama/ # Ollama (browser) │ │ ├── openai/ # OpenAI org cost API (admin key) │ │ ├── openclaw/ # OpenClaw (self-hosted gateway, WS) -│ │ ├── opencode/ # OpenCode (browser) -│ │ ├── opencodego/ # OpenCode Go (browser) +│ │ ├── opencode/ # OpenCode — Black, Go (Lite), shared billing (browser) │ │ ├── openrouter/ # OpenRouter (API key) │ │ ├── perplexity/ # Perplexity (browser) │ │ ├── synthetic/ # Synthetic (API key) diff --git a/cmd/genkeys/main.go b/cmd/genkeys/main.go index cc19f3c..b4c43df 100644 --- a/cmd/genkeys/main.go +++ b/cmd/genkeys/main.go @@ -30,7 +30,6 @@ var providerColors = map[string]string{ "mistral": "#ff500f", "ollama": "#f9fafb", "opencode": "#3b82f6", - "opencodego": "#3b82f6", "openrouter": "#6467f2", "perplexity": "#20b2aa", "synthetic": "#141414", diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 0cf14da..1f26911 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -56,7 +56,6 @@ import ( _ "github.com/anthonybaldwin/UsageButtons/internal/providers/openai" _ "github.com/anthonybaldwin/UsageButtons/internal/providers/openclaw" _ "github.com/anthonybaldwin/UsageButtons/internal/providers/opencode" - _ "github.com/anthonybaldwin/UsageButtons/internal/providers/opencodego" _ "github.com/anthonybaldwin/UsageButtons/internal/providers/openrouter" _ "github.com/anthonybaldwin/UsageButtons/internal/providers/perplexity" _ "github.com/anthonybaldwin/UsageButtons/internal/providers/synthetic" @@ -97,6 +96,16 @@ 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 + // actionMigrationLogOnce gates the first-run summary log emitted + // when a saved button's action UUID maps to a folded-in provider + // (opencodego → opencode). Same pacing as + // metricMigrationLogOnce. + actionMigrationLogOnce sync.Once ) func globalSettingsLoaded() bool { @@ -177,7 +186,7 @@ func starfieldAnimationLoop(conn *streamdeck.Connection) { mu.Lock() var animateCtxs []string for ctx, key := range visibleKeys { - providerID := streamdeck.ProviderIDFromAction(key.action) + providerID := providerIDForAction(key.action) if providerID == "" { continue } @@ -210,7 +219,7 @@ func scheduleDueKeys(conn *streamdeck.Connection) { now := time.Now() var due []string for ctx, key := range visibleKeys { - providerID := streamdeck.ProviderIDFromAction(key.action) + providerID := providerIDForAction(key.action) interval := time.Duration(settings.ResolveRefreshMs(key.settings, providerID)) * time.Millisecond if key.nextPollAt.IsZero() && !key.lastPollAt.IsZero() { key.nextPollAt = nextPollTime(key.lastPollAt, interval, ctx, providerID) @@ -266,7 +275,7 @@ func refreshOrRedrawVisible(conn *streamdeck.Connection) { for ctx, key := range visibleKeys { contexts = append(contexts, visibleContext{ context: ctx, - providerID: streamdeck.ProviderIDFromAction(key.action), + providerID: providerIDForAction(key.action), }) } mu.Unlock() @@ -331,10 +340,22 @@ func handleEvent(conn *streamdeck.Connection, ev streamdeck.Event) { } func handleWillAppear(conn *streamdeck.Connection, ev streamdeck.Event) { - providerID := streamdeck.ProviderIDFromAction(ev.Action) - if providerID == "" { + rawProviderID := streamdeck.ProviderIDFromAction(ev.Action) + if rawProviderID == "" { return } + // Folded-in providers (opencodego → opencode) keep their original + // action UUID on user-pinned buttons after the manifest entry is + // removed. Resolve to the canonical registered provider for fetch + // dispatch, but keep rawProviderID for metric-ID migration so + // e.g. opencodego/session-percent → go-rolling-percent (not + // opencode/black-rolling-percent). + providerID := canonicalProviderID(rawProviderID) + if rawProviderID != providerID { + actionMigrationLogOnce.Do(func() { + conn.Logf("action UUID migration: %s → %s (rebinding folded providers silently)", rawProviderID, providerID) + }) + } var payload streamdeck.WillAppearPayload json.Unmarshal(ev.Payload, &payload) @@ -342,6 +363,22 @@ func handleWillAppear(conn *streamdeck.Connection, ev streamdeck.Event) { var ks settings.KeySettings json.Unmarshal(payload.Settings, &ks) + // Migrate stale metricID + persist if it changed. Reads continue + // to work via effectiveMetricID's alias lookup, but persisting + // the canonical ID keeps SetSettings round-trips clean and stops + // the PI dropdown from showing the old ID after a button reload. + if ks.MetricID != "" { + if migrated := migrateMetricID(rawProviderID, 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)", rawProviderID, old, migrated) + }) + } + } + metricID := effectiveMetricID(ks, providerID) // Track this (provider, metric) pair so multi-endpoint providers // can opt into skipping work for unbound metrics. Paired with the @@ -512,7 +549,7 @@ func handleWillDisappear(conn *streamdeck.Connection, ev streamdeck.Event) { key, ok := visibleKeys[ev.Context] var providerID, metricID string if ok { - providerID = streamdeck.ProviderIDFromAction(key.action) + providerID = providerIDForAction(key.action) metricID = effectiveMetricID(key.settings, providerID) } delete(visibleKeys, ev.Context) @@ -536,7 +573,7 @@ func handleTitleParametersDidChange(conn *streamdeck.Connection, ev streamdeck.E key, ok := visibleKeys[ev.Context] if ok { key.showTitle = payload.TitleParameters.ShowTitle - providerID := streamdeck.ProviderIDFromAction(key.action) + providerID := providerIDForAction(key.action) key.customTitle = isCustomTitle( payload.Title, key.lastAutoTitle, @@ -561,7 +598,7 @@ func handleDidReceiveSettings(conn *streamdeck.Connection, ev streamdeck.Event) key, ok := visibleKeys[ev.Context] var providerID, oldMetric, newMetric string if ok { - providerID = streamdeck.ProviderIDFromAction(key.action) + providerID = providerIDForAction(key.action) oldMetric = effectiveMetricID(key.settings, providerID) newMetric = effectiveMetricID(ks, providerID) key.settings = ks @@ -848,7 +885,7 @@ func replyUnregisterCookieHost(conn *streamdeck.Connection, ctxStr, action strin } func replyProviderStatus(conn *streamdeck.Connection, ctxStr, action string) { - providerID := streamdeck.ProviderIDFromAction(action) + providerID := providerIDForAction(action) prov := providers.Get(providerID) if prov == nil { return @@ -912,7 +949,7 @@ func handleKeyDown(conn *streamdeck.Connection, ev streamdeck.Event) { conn.OpenURL(update.URL()) return } - providerID := streamdeck.ProviderIDFromAction(ev.Action) + providerID := providerIDForAction(ev.Action) // Cookie-based providers can lock up when the host's bot-detection // JS (DataDome on portal.nousresearch.com) only refreshes its // fingerprint cookie on page load. Whenever the cached snapshot is @@ -1026,7 +1063,7 @@ func refreshProviderSiblings(conn *streamdeck.Connection, providerID, skipContex if ctx == skipContext { continue } - if streamdeck.ProviderIDFromAction(key.action) == providerID { + if providerIDForAction(key.action) == providerID { siblings = append(siblings, ctx) } } @@ -1071,7 +1108,7 @@ func refreshKey(conn *streamdeck.Connection, context string, force bool) { now := time.Now() action := key.action ks := key.settings - providerID := streamdeck.ProviderIDFromAction(action) + providerID := providerIDForAction(action) interval := time.Duration(settings.ResolveRefreshMs(ks, providerID)) * time.Millisecond key.lastPollAt = now key.nextPollAt = nextPollTime(now, interval, context, providerID) @@ -1118,7 +1155,7 @@ func redrawKeyFromCache(conn *streamdeck.Connection, context string) { keyCopy := *key mu.Unlock() - providerID := streamdeck.ProviderIDFromAction(keyCopy.action) + providerID := providerIDForAction(keyCopy.action) if providerID == "" { return } @@ -1701,6 +1738,57 @@ var metricIDAliases = map[string]map[string]string{ "alibaba": { "opus-percent": "monthly-percent", }, + // OpenCode collapsed two providers (opencode + opencodego) into a + // single provider with three namespaced metric lanes + // (black-* / go-* / billing-*) in v0.9. Pre-rename buttons land + // here: + // - opencode buttons used "session-percent" / "weekly-percent" + // for what is really the Black plan's rolling/weekly windows. + // - opencodego buttons used the same Claude-flavored IDs (plus + // "monthly-percent") for the Lite plan; they hit the + // opencodego action UUID, which migrateActionUUID rebinds to + // opencode at willAppear time. + "opencode": { + "session-percent": "black-rolling-percent", + "weekly-percent": "black-weekly-percent", + }, + "opencodego": { + "session-percent": "go-rolling-percent", + "weekly-percent": "go-weekly-percent", + "monthly-percent": "go-monthly-percent", + }, +} + +// providerIDActionAliases maps legacy action-UUID-derived provider IDs +// to their current canonical equivalents. Stream Deck identifies a key +// by its action UUID, which lower-cases to a provider ID via +// streamdeck.ProviderIDFromAction. When a UUID is removed from the +// manifest (e.g. opencodego folded into opencode), buttons pinned by +// users still surface the old UUID at willAppear time. Resolving via +// this alias lets the unified provider handle them — combined with +// metricIDAliases ("opencodego" entry) and SetSettings persistence, +// the rebinding sticks across plugin restarts. +var providerIDActionAliases = map[string]string{ + "opencodego": "opencode", +} + +// canonicalProviderID returns the registered provider ID a saved +// button should resolve to, applying any legacy action-UUID aliases. +// Pass the raw output of streamdeck.ProviderIDFromAction here. +func canonicalProviderID(providerID string) string { + if alias, ok := providerIDActionAliases[providerID]; ok { + return alias + } + return providerID +} + +// providerIDForAction extracts the canonical registered provider ID +// from an action UUID — equivalent to canonicalProviderID composed +// with streamdeck.ProviderIDFromAction. Folded providers (opencodego +// → opencode) resolve transparently for fetch dispatch, while +// metric-ID migration uses the raw form. +func providerIDForAction(action string) string { + return canonicalProviderID(streamdeck.ProviderIDFromAction(action)) } // 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..b0bfff2 100644 --- a/cmd/plugin/main_test.go +++ b/cmd/plugin/main_test.go @@ -69,6 +69,13 @@ 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"}, + // OpenCode (opencode + opencodego folded). Pre-rename buttons + // land on Black/Go lanes by raw action UUID. + {"opencode session → black-rolling", "opencode", "session-percent", "black-rolling-percent"}, + {"opencode weekly → black-weekly", "opencode", "weekly-percent", "black-weekly-percent"}, + {"opencodego session → go-rolling", "opencodego", "session-percent", "go-rolling-percent"}, + {"opencodego weekly → go-weekly", "opencodego", "weekly-percent", "go-weekly-percent"}, + {"opencodego monthly → go-monthly", "opencodego", "monthly-percent", "go-monthly-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"}, @@ -84,3 +91,50 @@ func TestMigrateMetricID(t *testing.T) { }) } } + +// TestCanonicalProviderID locks in the action-UUID rebinding contract: +// folded provider IDs (opencodego, removed from the manifest in v0.9) +// must resolve to their canonical equivalents so user-pinned buttons +// keep working without manual re-binding. +func TestCanonicalProviderID(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"opencodego folds into opencode", "opencodego", "opencode"}, + {"opencode passthrough", "opencode", "opencode"}, + {"unrelated provider passthrough", "claude", "claude"}, + {"empty passthrough", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := canonicalProviderID(tc.in); got != tc.want { + t.Errorf("canonicalProviderID(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestProviderIDForAction covers the action-UUID → registered-provider +// path: legacy `...opencodego` UUIDs resolve to opencode, while live +// UUIDs pass through unchanged. +func TestProviderIDForAction(t *testing.T) { + tests := []struct { + name string + action string + want string + }{ + {"opencodego folds", "io.github.anthonybaldwin.UsageButtons.opencodego", "opencode"}, + {"opencode passthrough", "io.github.anthonybaldwin.UsageButtons.opencode", "opencode"}, + {"claude passthrough", "io.github.anthonybaldwin.UsageButtons.claude", "claude"}, + {"unknown prefix returns empty", "com.example.other.foo", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := providerIDForAction(tc.action); got != tc.want { + t.Errorf("providerIDForAction(%q) = %q, want %q", tc.action, got, tc.want) + } + }) + } +} diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 877a7c0..37a37e2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -40,8 +40,7 @@ account or API response includes that quota lane. | 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. | -| OpenCode | Usage Buttons Helper from `opencode.ai`. Optional workspace override: `CODEXBAR_OPENCODE_WORKSPACE_ID`. | 5-hour usage remaining %, weekly usage remaining %. | -| OpenCode Go | Usage Buttons Helper from `opencode.ai`. Optional workspace override: `CODEXBAR_OPENCODEGO_WORKSPACE_ID`. | 5-hour usage remaining %, weekly usage remaining %, monthly usage remaining %. | +| OpenCode | Usage Buttons Helper from `opencode.ai`. Covers all paid tiers. Optional workspace override: `OPENCODE_WORKSPACE_ID`. | Black plan (paid Zen $20/$100/$200): 5-hour usage remaining %, weekly usage remaining %, rolling/weekly status, plan tier badge. Go (Lite, $12/$30/$60): 5-hour, weekly, and monthly usage remaining %, plus per-window status. Shared billing: credit balance ($), monthly spending limit ($), month-to-date spend ($), monthly spend % of limit, auto-reload state + trigger + amount, payment last 4, subscription plan label. | | OpenRouter | OpenRouter API key from the Provider tab or `OPENROUTER_API_KEY`. Optional API base URL override in the Provider tab. | Credit balance ($), total usage ($), per-key quota remaining %, rate limit (requests / interval). | | Perplexity | Usage Buttons Helper from `perplexity.ai`. | Recurring credits remaining %, bonus credits remaining %, purchased credits remaining %. | | Synthetic | Synthetic API key from the Provider tab or `SYNTHETIC_API_KEY`. | Five-hour quota remaining %, weekly tokens remaining %, search hourly remaining %. | diff --git a/docs/index.html b/docs/index.html index 4701aa1..7f06db4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -479,7 +479,7 @@

Standalone native binary

Supported providers

-

35 providers are live, spanning hosted AI assistants (Claude, Codex, Cursor, Copilot, Gemini, Grok), self-hosted gateways (OpenClaw, Hermes Agent, Ollama), portal accounts (Nous Research, Perplexity, Mistral, Kimi, MiniMax, Abacus, Alibaba, Augment, Amp, Droid, OpenCode, JetBrains AI, Antigravity, Kilo, Kiro), and direct API keys (OpenRouter, DeepSeek, Moonshot, Anthropic, OpenAI, Vertex AI, Synthetic, Warp, z.ai, Kimrel).

+

34 providers are live, spanning hosted AI assistants (Claude, Codex, Cursor, Copilot, Gemini, Grok), self-hosted gateways (OpenClaw, Hermes Agent, Ollama), portal accounts (Nous Research, Perplexity, Mistral, Kimi, MiniMax, Abacus, Alibaba, Augment, Amp, Droid, OpenCode, JetBrains AI, Antigravity, Kilo, Kiro), and direct API keys (OpenRouter, DeepSeek, Moonshot, Anthropic, OpenAI, Vertex AI, Synthetic, Warp, z.ai, Kimrel).

Claude Anthropic @@ -506,7 +506,6 @@

Supported providers

DeepSeek Moonshot OpenCode - OpenCode Go Perplexity MiniMax Mistral diff --git a/docs/provider-icons/opencodego.svg b/docs/provider-icons/opencodego.svg deleted file mode 100644 index d2f0eec..0000000 --- a/docs/provider-icons/opencodego.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/internal/icons/opencodego.go b/internal/icons/opencodego.go deleted file mode 100644 index c36a86c..0000000 --- a/internal/icons/opencodego.go +++ /dev/null @@ -1,10 +0,0 @@ -package icons - -import "github.com/anthonybaldwin/UsageButtons/internal/render" - -func init() { - ProviderIcons["opencodego"] = &render.ProviderGlyph{ - ViewBox: "0 0 100 100", - Markup: ``, - } -} diff --git a/internal/providers/cache.go b/internal/providers/cache.go index a42aa48..514a59c 100644 --- a/internal/providers/cache.go +++ b/internal/providers/cache.go @@ -566,7 +566,7 @@ func shouldPersistProviderSnapshot(providerID string, s Snapshot) bool { // browser session that providerConfigFingerprint cannot validate at startup. func usesUnfingerprintedBrowserSession(providerID string, s Snapshot) bool { switch providerID { - case "abacus", "alibaba", "cursor", "ollama", "amp", "perplexity", "opencode", "opencodego": + case "abacus", "alibaba", "cursor", "ollama", "amp", "perplexity", "opencode": return true case "claude", "codex", "augment", "factory", "kimi", "minimax", "mistral": return s.Source == "cookie" diff --git a/internal/providers/opencode/opencode.go b/internal/providers/opencode/opencode.go index 1dcb8cd..62642df 100644 --- a/internal/providers/opencode/opencode.go +++ b/internal/providers/opencode/opencode.go @@ -1,7 +1,33 @@ -// Package opencode implements the OpenCode usage provider. +// Package opencode implements the unified OpenCode usage provider. // -// Auth: Usage Buttons Helper extension with the user's opencode.ai browser -// session. Endpoints: POST/GET https://opencode.ai/_server. +// OpenCode has four product tiers: +// +// - Free — IP-based rate limit inside Zen proxy. No console +// data accessible. No metrics exposed by this +// provider; users on Free see "no data" once their +// button can't bind to a Black, Go, or Billing +// metric. +// - Lite (Go) — paid prepaid plan ($12 / $30 / $60). Exposes +// rolling, weekly, and monthly usage windows via +// the `lite.subscription.get` server function. +// - Black — paid Zen subscription ($20 / $100 / $200). +// Exposes rolling and weekly usage windows via the +// `subscription.get` server function. No monthly +// window for this tier. +// - Enterprise — sales-only, no console route, not surfaced. +// +// Cross-cutting credit / balance / auto-reload state lives on the +// shared `billing.get` server function, used by both Lite and Black +// users. +// +// This provider folds the previous `opencodego` provider back in: +// rather than two action UUIDs and two registry entries that share +// auth, host, and request envelope, one provider exposes namespaced +// `black-*` / `go-*` / `billing-*` metrics and lets the user pick +// whichever lane matches their plan via the property inspector. +// +// Auth: Usage Buttons Helper extension with the user's opencode.ai +// browser session. Endpoint: POST/GET https://opencode.ai/_server. package opencode import ( @@ -17,6 +43,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/anthonybaldwin/UsageButtons/internal/cookies" @@ -27,31 +54,106 @@ import ( ) const ( - baseURL = "https://opencode.ai" - serverURL = "https://opencode.ai/_server" - workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" - subscriptionServerID = "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4" + baseURL = "https://opencode.ai" + serverURL = "https://opencode.ai/_server" + workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + // blackServerID is the SolidStart content-addressed hash for the + // `subscription.get` server function — the Black plan's rolling + + // weekly usage windows. Captured from a live opencode.ai session. + blackServerID = "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4" + // liteServerID is the SolidStart content-addressed hash for the + // `lite.subscription.get` server function — the Lite (Go) plan's + // rolling + weekly + monthly usage windows. SolidStart hashes the + // function key + file location; the value here is a placeholder + // until captured from a live session's DevTools network panel. + // When empty, Lite metrics fall through to "no data" without + // erroring the whole snapshot. + liteServerID = "" + // billingServerID is the SolidStart content-addressed hash for the + // `billing.get` server function — the cross-cutting balance / + // auto-reload / monthly cap state shared by Lite and Black. Same + // "captured from DevTools" gap as liteServerID; an empty value + // disables billing metrics until the hash lands. + billingServerID = "" ) -var workspaceIDRE = regexp.MustCompile(`id\s*:\s*\\?"(wrk_[^\\"]+)`) +var ( + workspaceIDRE = regexp.MustCompile(`id\s*:\s*\\?"(wrk_[^\\"]+)`) + // missingHashLogOnce keeps Fetch() from spamming the log with the + // same "lite/billing hash not captured" warning each minute. The + // gap is documented; one warning per plugin launch is enough. + missingHashLogOnce sync.Once +) + +// blackSnapshot captures the Black-plan fields parsed from +// subscription.get. Pointer fields stay nil when absent so callers can +// distinguish "user has no Black plan" from "Black plan is at 0% +// usage". UpdatedAt is populated unconditionally — it's the parse +// timestamp, not a payload field. +type blackSnapshot struct { + HasSubscription bool + Plan string // "20" | "100" | "200" — best-effort + RollingUsagePercent float64 + WeeklyUsagePercent float64 + RollingResetInSec int + WeeklyResetInSec int + RollingStatus string // "ok" | "rate-limited" — best-effort + WeeklyStatus string + UpdatedAt time.Time +} -// usageSnapshot is OpenCode rolling and weekly usage. -type usageSnapshot struct { +// liteSnapshot captures the Lite (Go) plan fields parsed from +// lite.subscription.get. +type liteSnapshot struct { + HasSubscription bool RollingUsagePercent float64 WeeklyUsagePercent float64 + MonthlyUsagePercent float64 RollingResetInSec int WeeklyResetInSec int + MonthlyResetInSec int + RollingStatus string + WeeklyStatus string + MonthlyStatus string UpdatedAt time.Time } -// windowCandidate is one parsed usage window from a flexible JSON payload. +// billingSnapshot captures the shared billing.get fields. +type billingSnapshot struct { + HasBilling bool + BalanceUSD float64 // micro-cents → USD + MonthlyLimitUSD float64 // cents → USD + MonthlyUsageUSD float64 // micro-cents → USD + HasMonthlyLimit bool + HasMonthlyUsage bool + AutoReloadOn bool + ReloadTriggerUSD float64 + ReloadAmountUSD float64 + HasReloadAmounts bool + ReloadError string + PaymentLast4 string + SubscriptionPlan string + UpdatedAt time.Time +} + +// usageWindow is one rolling/weekly/monthly window parsed from a +// flexible JSON payload. +type usageWindow struct { + Percent float64 + ResetInSec int + Status string +} + +// windowCandidate is one parsed usage window with its JSON path so the +// black/go disambiguation logic can pick the right one. type windowCandidate struct { Percent float64 ResetInSec int + Status string PathLower string } -// Provider fetches OpenCode usage data. +// Provider fetches OpenCode usage data for all three lanes. type Provider struct{} // ID returns the provider identifier used by the registry. @@ -66,19 +168,48 @@ func (Provider) BrandColor() string { return "#3b82f6" } // BrandBg returns the background color used on button faces. func (Provider) BrandBg() string { return "#081a33" } -// MetricIDs enumerates the metrics this provider can emit. +// MetricIDs enumerates every metric this provider can emit, namespaced +// by lane (`black-*` / `go-*` / `billing-*`). The PI surfaces them in +// optgroups; the user picks one per button. func (Provider) MetricIDs() []string { - return []string{"session-percent", "weekly-percent"} + return []string{ + // Black (paid Zen subscription). + "black-rolling-percent", + "black-weekly-percent", + "black-rolling-status", + "black-weekly-status", + "black-plan", + // Go (Lite). + "go-rolling-percent", + "go-weekly-percent", + "go-monthly-percent", + "go-rolling-status", + "go-weekly-status", + "go-monthly-status", + // Billing (shared). + "billing-balance-usd", + "billing-monthly-limit-usd", + "billing-monthly-usage-usd", + "billing-monthly-percent", + "billing-auto-reload-on", + "billing-reload-trigger-usd", + "billing-reload-amount-usd", + "billing-reload-error", + "billing-payment-last4", + "billing-subscription-plan", + } } -// Fetch returns the latest OpenCode usage snapshot. +// Fetch returns the latest OpenCode usage snapshot. Black, Lite, and +// Billing lanes fetch in parallel; missing or null lanes degrade +// gracefully to "no data" rather than erroring the whole snapshot. func (Provider) Fetch(_ providers.FetchContext) (providers.Snapshot, error) { ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() if !cookies.HostAvailable(ctx) { return errorSnapshot(cookieaux.MissingMessage("opencode.ai")), nil } - usage, err := fetchUsage(ctx) + wsID, err := workspaceID(ctx) if err != nil { var httpErr *httputil.Error if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) { @@ -89,50 +220,178 @@ func (Provider) Fetch(_ providers.FetchContext) (providers.Snapshot, error) { } return errorSnapshot(err.Error()), nil } - return snapshotFromUsage(usage), nil + + now := time.Now().UTC() + var ( + wg sync.WaitGroup + black blackSnapshot + lite liteSnapshot + billing billingSnapshot + blackErr, liteErr, billingErr error + ) + + wg.Add(1) + go func() { + defer wg.Done() + black, blackErr = fetchBlack(ctx, wsID, now) + }() + + wg.Add(1) + go func() { + defer wg.Done() + if liteServerID == "" { + // Hash not captured yet; surface no-data rather than HTTP + // 400 / null. logMissingHashes() handles the user-visible + // note via plugin log. + lite = liteSnapshot{UpdatedAt: now} + return + } + lite, liteErr = fetchLite(ctx, wsID, now) + }() + + wg.Add(1) + go func() { + defer wg.Done() + if billingServerID == "" { + billing = billingSnapshot{UpdatedAt: now} + return + } + billing, billingErr = fetchBilling(ctx, wsID, now) + }() + + wg.Wait() + logMissingHashes() + + // If every lane errored AND we got a clear signed-out signal, surface + // stale-cookie. Otherwise per-lane errors degrade to no-data for that + // lane only. + if blackErr != nil && liteErr != nil && billingErr != nil { + if errIsSignedOut(blackErr) || errIsSignedOut(liteErr) || errIsSignedOut(billingErr) { + return errorSnapshot(cookieaux.StaleMessage("opencode.ai")), nil + } + } + + return assembleSnapshot(black, lite, billing, now), nil +} + +// errIsSignedOut reports whether err looks like an auth failure. +func errIsSignedOut(err error) bool { + if err == nil { + return false + } + var httpErr *httputil.Error + if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) { + return true + } + return looksSignedOut(err.Error()) +} + +// logMissingHashes emits a one-time launch-time warning when the lite +// or billing server-fn hashes haven't been captured yet. Lets users +// know why those metrics return "no data" without a recurring nag. +func logMissingHashes() { + if liteServerID != "" && billingServerID != "" { + return + } + missingHashLogOnce.Do(func() { + fmt.Fprintln(os.Stderr, + "opencode: lite.subscription.get / billing.get hashes not captured; "+ + "go-* and billing-* metrics will return no data until populated. "+ + "See plans/opencode-tier-coverage.md.") + }) +} + +// fetchBlack fetches and parses the Black plan's subscription.get. +func fetchBlack(ctx context.Context, wsID string, now time.Time) (blackSnapshot, error) { + text, err := callServerFn(ctx, blackServerID, []any{wsID}, + fmt.Sprintf("%s/workspace/%s/billing", baseURL, wsID)) + if err != nil { + return blackSnapshot{UpdatedAt: now}, err + } + if looksSignedOut(text) { + return blackSnapshot{UpdatedAt: now}, fmt.Errorf("OpenCode session is signed out") + } + return parseBlackResponse(text, now), nil +} + +// fetchLite fetches and parses the Lite plan's lite.subscription.get. +func fetchLite(ctx context.Context, wsID string, now time.Time) (liteSnapshot, error) { + text, err := callServerFn(ctx, liteServerID, []any{wsID}, + fmt.Sprintf("%s/workspace/%s/go", baseURL, wsID)) + if err != nil { + return liteSnapshot{UpdatedAt: now}, err + } + if looksSignedOut(text) { + return liteSnapshot{UpdatedAt: now}, fmt.Errorf("OpenCode session is signed out") + } + return parseLiteResponse(text, now), nil } -// fetchUsage fetches workspace and subscription usage data. -func fetchUsage(ctx context.Context) (usageSnapshot, error) { - workspaceID, err := workspaceID(ctx) +// fetchBilling fetches and parses the shared billing.get. +func fetchBilling(ctx context.Context, wsID string, now time.Time) (billingSnapshot, error) { + text, err := callServerFn(ctx, billingServerID, []any{wsID}, + fmt.Sprintf("%s/workspace/%s/billing", baseURL, wsID)) if err != nil { - return usageSnapshot{}, err + return billingSnapshot{UpdatedAt: now}, err + } + if looksSignedOut(text) { + return billingSnapshot{UpdatedAt: now}, fmt.Errorf("OpenCode session is signed out") } + return parseBillingResponse(text, now), nil +} + +// callServerFn performs a GET _server call and falls back to POST when +// the GET response is null or unrecognized. Mirrors the existing +// fetchSubscriptionInfo behavior so all three lanes share retry logic. +func callServerFn(ctx context.Context, serverID string, args []any, referer string) (string, error) { text, err := serverText(ctx, serverRequest{ - ServerID: subscriptionServerID, - Args: []any{workspaceID}, + ServerID: serverID, + Args: args, Method: "GET", - Referer: fmt.Sprintf("%s/workspace/%s/billing", baseURL, workspaceID), + Referer: referer, }) if err != nil { - return usageSnapshot{}, err - } - if looksSignedOut(text) { - return usageSnapshot{}, fmt.Errorf("OpenCode session is signed out") + return "", err } - // POST fallback only when the GET response is null or unrecognized. - // Recognized empty-state payloads (no subscription / no recorded - // windows) are valid responses, so don't waste a second round-trip. - if (isNullPayload(text) || !subscriptionLooksUsable(text)) && !looksLikeEmptyUsage(text) { - fallback, fallbackErr := serverText(ctx, serverRequest{ - ServerID: subscriptionServerID, - Args: []any{workspaceID}, + if (isNullPayload(text) || !payloadLooksUsable(text)) && !looksLikeEmptyUsage(text) { + fallback, fbErr := serverText(ctx, serverRequest{ + ServerID: serverID, + Args: args, Method: "POST", - Referer: fmt.Sprintf("%s/workspace/%s/billing", baseURL, workspaceID), + Referer: referer, }) - if fallbackErr == nil && !isNullPayload(fallback) { + if fbErr == nil && !isNullPayload(fallback) { text = fallback } } - return parseSubscription(text, time.Now().UTC()) + return text, nil +} + +// payloadLooksUsable reports whether text contains any plausible usage +// markers — covers all three lanes' field names so the GET→POST retry +// triggers across the same "looks empty but isn't a real empty-state" +// shapes the original opencode provider already handled. +func payloadLooksUsable(text string) bool { + for _, marker := range []string{ + "rollingUsage", "weeklyUsage", "monthlyUsage", "usagePercent", + "balance", "monthlyLimit", "subscriptionPlan", "reloadAmount", + } { + if strings.Contains(text, marker) { + return true + } + } + return false } // workspaceID returns an override or discovers the first OpenCode workspace. func workspaceID(ctx context.Context) (string, error) { - return WorkspaceID(ctx, "CODEXBAR_OPENCODE_WORKSPACE_ID") + return WorkspaceID(ctx, "OPENCODE_WORKSPACE_ID") } -// WorkspaceID returns an override or discovers the first OpenCode workspace. +// WorkspaceID returns an override or discovers the first OpenCode +// workspace. Exported so the legacy migration path can resolve the +// workspace via the same logic without round-tripping through the +// provider registry. func WorkspaceID(ctx context.Context, envName string) (string, error) { if override := normalizeWorkspaceID(os.Getenv(envName)); override != "" { return override, nil @@ -176,6 +435,9 @@ type serverRequest struct { // serverText calls an OpenCode _server endpoint through the Helper. func serverText(ctx context.Context, req serverRequest) (string, error) { + if req.ServerID == "" { + return "", fmt.Errorf("OpenCode: empty server-fn hash") + } method := strings.ToUpper(strings.TrimSpace(req.Method)) if method == "" { method = "GET" @@ -237,41 +499,316 @@ func serverRequestURL(serverID string, args []any, method string) (string, []byt return u.String(), nil, nil } -// parseSubscription parses rolling and weekly usage from text or JSON. -func parseSubscription(text string, now time.Time) (usageSnapshot, error) { - if usage, ok := parseSubscriptionJSON(text, now); ok { - return usage, nil - } - rollingPercent := extractFloat(`rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) - rollingReset := extractInt(`rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)`, text) - weeklyPercent := extractFloat(`weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) - weeklyReset := extractInt(`weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)`, text) - if rollingPercent != nil && rollingReset != nil && weeklyPercent != nil && weeklyReset != nil { - return usageSnapshot{ - RollingUsagePercent: clampPercent(*rollingPercent), - WeeklyUsagePercent: clampPercent(*weeklyPercent), - RollingResetInSec: *rollingReset, - WeeklyResetInSec: *weeklyReset, - UpdatedAt: now, - }, nil - } - // Workspaces with no active subscription or no recorded windows return - // Solid SSR hydration payloads that lack rollingUsage/weeklyUsage entirely - // (e.g. {usage:[],keys:[...]} or {subscription:null,...}). Surface these - // as zero usage rather than a parse error so the button stays useful. - if looksLikeEmptyUsage(text) { - return usageSnapshot{UpdatedAt: now}, nil - } - dumpUnknownResponse(text) - return usageSnapshot{}, fmt.Errorf("OpenCode parse error: missing usage fields") -} - -// looksLikeEmptyUsage reports whether text is an OpenCode _server response -// that conveys "no rolling/weekly usage" rather than a schema break. -// usagePercent is the field every populated response carries — its absence -// (combined with a Solid SSR marker) is a reliable empty-state signal. The -// schema key names (rollingUsage/weeklyUsage) can themselves appear with -// `null` values in empty responses, so we don't short-circuit on those. +// parseBlackResponse parses the Black plan's subscription.get payload. +// Empty / null / no-subscription shapes return a snapshot with +// HasSubscription=false rather than erroring, so the unified Fetch +// can degrade those metrics to no-data while keeping other lanes. +func parseBlackResponse(text string, now time.Time) blackSnapshot { + out := blackSnapshot{UpdatedAt: now} + rolling, weekly, ok := extractBlackWindows(text, now) + if ok { + out.HasSubscription = true + out.RollingUsagePercent = rolling.Percent + out.WeeklyUsagePercent = weekly.Percent + out.RollingResetInSec = rolling.ResetInSec + out.WeeklyResetInSec = weekly.ResetInSec + out.RollingStatus = rolling.Status + out.WeeklyStatus = weekly.Status + } + if plan := extractFirstString(text, []string{"subscriptionPlan", "plan"}); plan != "" { + // Plan strings come through as "20" / "100" / "200" — surface + // verbatim so the badge metric stays consistent with the + // console UI. + if plan != "null" { + out.Plan = plan + out.HasSubscription = true + } + } + return out +} + +// parseLiteResponse parses the Lite (Go) plan's lite.subscription.get +// payload. Same degrade-on-empty contract as parseBlackResponse. +func parseLiteResponse(text string, now time.Time) liteSnapshot { + out := liteSnapshot{UpdatedAt: now} + rolling, weekly, monthly, ok := extractLiteWindows(text, now) + if ok { + out.HasSubscription = true + out.RollingUsagePercent = rolling.Percent + out.WeeklyUsagePercent = weekly.Percent + out.RollingResetInSec = rolling.ResetInSec + out.WeeklyResetInSec = weekly.ResetInSec + out.RollingStatus = rolling.Status + out.WeeklyStatus = weekly.Status + if monthly != nil { + out.MonthlyUsagePercent = monthly.Percent + out.MonthlyResetInSec = monthly.ResetInSec + out.MonthlyStatus = monthly.Status + } + } + return out +} + +// parseBillingResponse parses the shared billing.get payload. Returns +// HasBilling=false on null/empty shapes — billing metrics gracefully +// degrade to no-data, just like the per-plan lanes. +func parseBillingResponse(text string, now time.Time) billingSnapshot { + out := billingSnapshot{UpdatedAt: now} + + // Try strict JSON first. + var raw any + if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &raw); err == nil { + fillBillingFromJSON(raw, &out) + if out.HasBilling { + return out + } + } + + // Fall through to regex extraction over Solid SSR text — the same + // approach the Black/Lite parsers use, since the SSR output isn't + // valid JSON and a regex over balanced-curly limited windows is + // more reliable than a custom parser for the full Solid format. + if balance, ok := extractFloatField(text, "balance"); ok { + // balance is in micro-cents (1e-6 USD per unit per the schema). + out.BalanceUSD = balance / 1_000_000.0 + out.HasBilling = true + } + if limit, ok := extractFloatField(text, "monthlyLimit"); ok { + // monthlyLimit is in cents → USD. + out.MonthlyLimitUSD = limit / 100.0 + out.HasMonthlyLimit = true + out.HasBilling = true + } + if usage, ok := extractFloatField(text, "monthlyUsage"); ok { + // monthlyUsage is in micro-cents → USD. + out.MonthlyUsageUSD = usage / 1_000_000.0 + out.HasMonthlyUsage = true + out.HasBilling = true + } + if reload := extractFirstString(text, []string{"reload"}); reload == "true" { + out.AutoReloadOn = true + out.HasBilling = true + } + if trig, ok := extractFloatField(text, "reloadTrigger"); ok { + out.ReloadTriggerUSD = trig / 100.0 + out.HasReloadAmounts = true + out.HasBilling = true + } + if amt, ok := extractFloatField(text, "reloadAmount"); ok { + out.ReloadAmountUSD = amt / 100.0 + out.HasReloadAmounts = true + out.HasBilling = true + } + if errStr := extractFirstString(text, []string{"reloadError"}); errStr != "" && errStr != "null" { + out.ReloadError = errStr + out.HasBilling = true + } + if last4 := extractFirstString(text, []string{"paymentMethodLast4"}); last4 != "" && last4 != "null" { + out.PaymentLast4 = last4 + out.HasBilling = true + } + if plan := extractFirstString(text, []string{"subscriptionPlan"}); plan != "" && plan != "null" { + out.SubscriptionPlan = plan + out.HasBilling = true + } + return out +} + +// fillBillingFromJSON walks a decoded JSON value and copies any +// recognized billing fields into out. Quietly tolerates null fields +// and missing keys. +func fillBillingFromJSON(raw any, out *billingSnapshot) { + switch v := raw.(type) { + case map[string]any: + if balance, ok := providerutil.FirstFloat(v, "balance"); ok { + out.BalanceUSD = balance / 1_000_000.0 + out.HasBilling = true + } + if limit, ok := providerutil.FirstFloat(v, "monthlyLimit"); ok { + out.MonthlyLimitUSD = limit / 100.0 + out.HasMonthlyLimit = true + out.HasBilling = true + } + if usage, ok := providerutil.FirstFloat(v, "monthlyUsage"); ok { + out.MonthlyUsageUSD = usage / 1_000_000.0 + out.HasMonthlyUsage = true + out.HasBilling = true + } + if reloadVal, exists := v["reload"]; exists { + if b, ok := reloadVal.(bool); ok && b { + out.AutoReloadOn = true + out.HasBilling = true + } + } + if trig, ok := providerutil.FirstFloat(v, "reloadTrigger"); ok { + out.ReloadTriggerUSD = trig / 100.0 + out.HasReloadAmounts = true + out.HasBilling = true + } + if amt, ok := providerutil.FirstFloat(v, "reloadAmount"); ok { + out.ReloadAmountUSD = amt / 100.0 + out.HasReloadAmounts = true + out.HasBilling = true + } + if s := providerutil.FirstString(v, "reloadError"); s != "" { + out.ReloadError = s + out.HasBilling = true + } + if s := providerutil.FirstString(v, "paymentMethodLast4"); s != "" { + out.PaymentLast4 = s + out.HasBilling = true + } + if s := providerutil.FirstString(v, "subscriptionPlan"); s != "" { + out.SubscriptionPlan = s + out.HasBilling = true + } + // Recurse into nested objects so wrapped envelopes + // ({data: {...}}, etc.) still fill out. + for _, item := range v { + fillBillingFromJSON(item, out) + } + case []any: + for _, item := range v { + fillBillingFromJSON(item, out) + } + } +} + +// extractBlackWindows returns the rolling/weekly windows from a Black +// subscription payload. Mirrors the previous parseSubscription path but +// keeps the windowCandidate type local to the new parser shape. +func extractBlackWindows(text string, now time.Time) (rolling, weekly usageWindow, ok bool) { + if r, w, found := windowsFromJSON(text, now, false); found { + return r, w, true + } + rPercent := extractFloat(`rollingUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + rReset := extractInt(`rollingUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + wPercent := extractFloat(`weeklyUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + wReset := extractInt(`weeklyUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + if rPercent != nil && rReset != nil && wPercent != nil && wReset != nil { + return usageWindow{ + Percent: clampPercent(*rPercent), + ResetInSec: *rReset, + Status: extractWindowStatus(text, "rollingUsage"), + }, + usageWindow{ + Percent: clampPercent(*wPercent), + ResetInSec: *wReset, + Status: extractWindowStatus(text, "weeklyUsage"), + }, + true + } + return usageWindow{}, usageWindow{}, false +} + +// extractLiteWindows returns the rolling/weekly/monthly windows from a +// Lite subscription payload. +func extractLiteWindows(text string, now time.Time) (rolling, weekly usageWindow, monthly *usageWindow, ok bool) { + if r, w, found := windowsFromJSON(text, now, true); found { + // JSON path may surface a monthly window too — extract via + // regex from the same text since the JSON walker doesn't keep + // path metadata in the typed return. + mPercent := extractFloat(`monthlyUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + mReset := extractInt(`monthlyUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + var m *usageWindow + if mPercent != nil { + reset := 0 + if mReset != nil { + reset = *mReset + } + m = &usageWindow{ + Percent: clampPercent(*mPercent), + ResetInSec: reset, + Status: extractWindowStatus(text, "monthlyUsage"), + } + } + return r, w, m, true + } + rPercent := extractFloat(`rollingUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + rReset := extractInt(`rollingUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + wPercent := extractFloat(`weeklyUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + wReset := extractInt(`weeklyUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + if rPercent != nil && rReset != nil && wPercent != nil && wReset != nil { + rolling = usageWindow{ + Percent: clampPercent(*rPercent), + ResetInSec: *rReset, + Status: extractWindowStatus(text, "rollingUsage"), + } + weekly = usageWindow{ + Percent: clampPercent(*wPercent), + ResetInSec: *wReset, + Status: extractWindowStatus(text, "weeklyUsage"), + } + mPercent := extractFloat(`monthlyUsage[^}]*?usagePercent"?\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) + mReset := extractInt(`monthlyUsage[^}]*?resetInSec"?\s*:\s*([0-9]+)`, text) + if mPercent != nil { + reset := 0 + if mReset != nil { + reset = *mReset + } + monthly = &usageWindow{ + Percent: clampPercent(*mPercent), + ResetInSec: reset, + Status: extractWindowStatus(text, "monthlyUsage"), + } + } + return rolling, weekly, monthly, true + } + return usageWindow{}, usageWindow{}, nil, false +} + +// extractWindowStatus pulls a "status: \"...\"" value scoped to a +// named window block. Returns "" when no status field is present — +// callers map an empty string to "ok" for the metric value. +func extractWindowStatus(text, windowName string) string { + // Allow letters and dashes in the status value (e.g. "rate-limited"). + // `"?` absorbs JSON's closing field-name quote on both sides. + pat := fmt.Sprintf(`%s"?[^}]*?status"?\s*:\s*\\?"([a-zA-Z-]+)"`, regexp.QuoteMeta(windowName)) + re, err := regexp.Compile(pat) + if err != nil { + return "" + } + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return "" + } + return m[1] +} + +// windowsFromJSON tries strict JSON parsing first. Returns rolling and +// weekly when both are present. wantMonthly only affects which +// candidate ranks are picked — the typed return doesn't include +// monthly because the typed path is rare; callers fall back to regex +// extraction for monthly. +func windowsFromJSON(text string, now time.Time, _ bool) (rolling, weekly usageWindow, ok bool) { + var raw any + if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &raw); err != nil { + return usageWindow{}, usageWindow{}, false + } + var candidates []windowCandidate + collectWindowCandidates(raw, now, nil, &candidates) + if len(candidates) == 0 { + return usageWindow{}, usageWindow{}, false + } + r := pickWindow(candidates, true, "rolling", "hour", "5h", "5-hour") + w := pickWindow(candidates, false, "weekly", "week") + if r == nil { + r = pickAnyWindow(candidates, true, nil) + } + if w == nil { + w = pickAnyWindow(candidates, false, r) + } + if r == nil || w == nil { + return usageWindow{}, usageWindow{}, false + } + return usageWindow{Percent: r.Percent, ResetInSec: r.ResetInSec, Status: r.Status}, + usageWindow{Percent: w.Percent, ResetInSec: w.ResetInSec, Status: w.Status}, + true +} + +// looksLikeEmptyUsage reports whether text is an OpenCode _server +// response that conveys "no rolling/weekly usage" rather than a +// schema break. Identical contract to the previous package. func looksLikeEmptyUsage(text string) bool { if strings.Contains(text, "usagePercent") { return false @@ -280,14 +817,13 @@ func looksLikeEmptyUsage(text string) bool { return false } compact := strings.Join(strings.Fields(text), "") - // Solid SSR may resolve the entire server-fn payload to null — - // the response then ends with `,null)` after the array assignment. if strings.HasSuffix(compact, ",null)") { return true } for _, marker := range []string{ "rollingUsage:null", "weeklyUsage:null", + "monthlyUsage:null", "subscription:null", "subscriptionPlan:null", "monthlyUsage:null", @@ -304,37 +840,6 @@ func looksLikeEmptyUsage(text string) bool { return false } -// parseSubscriptionJSON parses flexible JSON usage payloads. -func parseSubscriptionJSON(text string, now time.Time) (usageSnapshot, bool) { - var raw any - if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &raw); err != nil { - return usageSnapshot{}, false - } - var candidates []windowCandidate - collectWindowCandidates(raw, now, nil, &candidates) - if len(candidates) == 0 { - return usageSnapshot{}, false - } - rolling := pickWindow(candidates, true, "rolling", "hour", "5h", "5-hour") - weekly := pickWindow(candidates, false, "weekly", "week") - if rolling == nil { - rolling = pickAnyWindow(candidates, true, nil) - } - if weekly == nil { - weekly = pickAnyWindow(candidates, false, rolling) - } - if rolling == nil || weekly == nil { - return usageSnapshot{}, false - } - return usageSnapshot{ - RollingUsagePercent: rolling.Percent, - WeeklyUsagePercent: weekly.Percent, - RollingResetInSec: rolling.ResetInSec, - WeeklyResetInSec: weekly.ResetInSec, - UpdatedAt: now, - }, true -} - // collectWindowCandidates finds quota-like objects in arbitrary JSON. func collectWindowCandidates(value any, now time.Time, path []string, out *[]windowCandidate) { switch v := value.(type) { @@ -343,6 +848,7 @@ func collectWindowCandidates(value any, now time.Time, path []string, out *[]win *out = append(*out, windowCandidate{ Percent: window.Percent, ResetInSec: window.ResetInSec, + Status: window.Status, PathLower: strings.ToLower(strings.Join(path, ".")), }) } @@ -356,14 +862,8 @@ func collectWindowCandidates(value any, now time.Time, path []string, out *[]win } } -// parsedWindow is one quota window parsed from a JSON object. -type parsedWindow struct { - Percent float64 - ResetInSec int -} - -// parseWindow extracts percent and reset data from a JSON object. -func parseWindow(m map[string]any, now time.Time) (parsedWindow, bool) { +// parseWindow extracts percent, reset, and status from a JSON object. +func parseWindow(m map[string]any, now time.Time) (usageWindow, bool) { percentKeys := []string{ "usagePercent", "usedPercent", "percentUsed", "percent", "usage_percent", "used_percent", "utilization", @@ -387,7 +887,7 @@ func parseWindow(m map[string]any, now time.Time) (parsedWindow, bool) { } } if !ok { - return parsedWindow{}, false + return usageWindow{}, false } reset, resetOK := providerutil.FirstFloat(m, resetInKeys...) if !resetOK { @@ -399,9 +899,11 @@ func parseWindow(m map[string]any, now time.Time) (parsedWindow, bool) { if !resetOK { reset = 0 } - return parsedWindow{ + status := providerutil.FirstString(m, "status") + return usageWindow{ Percent: clampPercent(percent), ResetInSec: int(math.Round(reset)), + Status: status, }, true } @@ -442,13 +944,127 @@ func pickAnyWindow(candidates []windowCandidate, pickShorter bool, excluding *wi return picked } -// snapshotFromUsage maps parsed OpenCode usage into Stream Deck metrics. -func snapshotFromUsage(usage usageSnapshot) providers.Snapshot { - now := usage.UpdatedAt.UTC().Format(time.RFC3339) - metrics := []providers.MetricValue{ - percentMetric("session-percent", "SESSION", "OpenCode session window remaining (5h)", usage.RollingUsagePercent, usage.RollingResetInSec, "", now), - percentMetric("weekly-percent", "WEEKLY", "OpenCode weekly window remaining", usage.WeeklyUsagePercent, usage.WeeklyResetInSec, "", now), +// assembleSnapshot translates the three lane snapshots into the final +// Stream Deck metric set. Lane fields that aren't populated produce a +// "no data" caption so the button stays clean rather than reading 0. +func assembleSnapshot(black blackSnapshot, lite liteSnapshot, billing billingSnapshot, now time.Time) providers.Snapshot { + nowStr := now.Format(time.RFC3339) + metrics := []providers.MetricValue{} + + // --- Black lane --- + if black.HasSubscription { + metrics = append(metrics, + percentMetric("black-rolling-percent", "ROLLING", + "OpenCode Black rolling window remaining (5h)", + black.RollingUsagePercent, black.RollingResetInSec, nowStr), + percentMetric("black-weekly-percent", "WEEKLY", + "OpenCode Black weekly window remaining", + black.WeeklyUsagePercent, black.WeeklyResetInSec, nowStr), + statusMetric("black-rolling-status", "STATUS", + "OpenCode Black rolling window status", + statusOrOK(black.RollingStatus), nowStr), + statusMetric("black-weekly-status", "STATUS", + "OpenCode Black weekly window status", + statusOrOK(black.WeeklyStatus), nowStr), + ) + if black.Plan != "" { + metrics = append(metrics, planMetric("black-plan", "PLAN", + "OpenCode Black plan tier", + black.Plan, nowStr)) + } } + + // --- Go (Lite) lane --- + if lite.HasSubscription { + metrics = append(metrics, + percentMetric("go-rolling-percent", "ROLLING", + "OpenCode Go rolling window remaining (5h)", + lite.RollingUsagePercent, lite.RollingResetInSec, nowStr), + percentMetric("go-weekly-percent", "WEEKLY", + "OpenCode Go weekly window remaining", + lite.WeeklyUsagePercent, lite.WeeklyResetInSec, nowStr), + percentMetric("go-monthly-percent", "MONTHLY", + "OpenCode Go monthly window remaining", + lite.MonthlyUsagePercent, lite.MonthlyResetInSec, nowStr), + statusMetric("go-rolling-status", "STATUS", + "OpenCode Go rolling window status", + statusOrOK(lite.RollingStatus), nowStr), + statusMetric("go-weekly-status", "STATUS", + "OpenCode Go weekly window status", + statusOrOK(lite.WeeklyStatus), nowStr), + statusMetric("go-monthly-status", "STATUS", + "OpenCode Go monthly window status", + statusOrOK(lite.MonthlyStatus), nowStr), + ) + } + + // --- Billing lane --- + if billing.HasBilling { + metrics = append(metrics, + dollarMetric("billing-balance-usd", "BALANCE", + "OpenCode credit balance", + billing.BalanceUSD, nowStr), + ) + if billing.HasMonthlyLimit { + metrics = append(metrics, + dollarMetric("billing-monthly-limit-usd", "LIMIT", + "OpenCode monthly spending limit", + billing.MonthlyLimitUSD, nowStr), + ) + } + if billing.HasMonthlyUsage { + metrics = append(metrics, + dollarMetric("billing-monthly-usage-usd", "MONTH", + "OpenCode month-to-date spend", + billing.MonthlyUsageUSD, nowStr), + ) + } + if billing.HasMonthlyLimit && billing.HasMonthlyUsage && billing.MonthlyLimitUSD > 0 { + pct := math.Min(100, math.Max(0, billing.MonthlyUsageUSD/billing.MonthlyLimitUSD*100)) + metrics = append(metrics, + percentMetric("billing-monthly-percent", "MONTH", + "OpenCode monthly spend % of limit", + pct, 0, nowStr), + ) + } + metrics = append(metrics, + toggleMetric("billing-auto-reload-on", "RELOAD", + "OpenCode auto-reload enabled", + billing.AutoReloadOn, nowStr), + ) + if billing.HasReloadAmounts { + metrics = append(metrics, + dollarMetric("billing-reload-trigger-usd", "TRIGGER", + "OpenCode auto-reload trigger threshold", + billing.ReloadTriggerUSD, nowStr), + dollarMetric("billing-reload-amount-usd", "TOPUP", + "OpenCode auto-reload top-up amount", + billing.ReloadAmountUSD, nowStr), + ) + } + if billing.ReloadError != "" { + metrics = append(metrics, + stringMetric("billing-reload-error", "ERROR", + "OpenCode auto-reload error", + billing.ReloadError, nowStr), + ) + } + if billing.PaymentLast4 != "" { + metrics = append(metrics, + stringMetric("billing-payment-last4", "CARD", + "OpenCode payment method last4", + "…"+billing.PaymentLast4, nowStr), + ) + } + if billing.SubscriptionPlan != "" { + metrics = append(metrics, + planMetric("billing-subscription-plan", "PLAN", + "OpenCode subscription plan", + billing.SubscriptionPlan, nowStr), + ) + } + } + return providers.Snapshot{ ProviderID: "opencode", ProviderName: "OpenCode", @@ -458,14 +1074,93 @@ func snapshotFromUsage(usage usageSnapshot) providers.Snapshot { } } +// statusOrOK normalizes an empty status to "ok" so the metric value +// reads the same whether the API omitted the field or returned the +// happy-path string. +func statusOrOK(status string) string { + s := strings.TrimSpace(status) + if s == "" { + return "ok" + } + return s +} + // percentMetric builds a remaining-percent OpenCode metric. -func percentMetric(id, label, name string, usedPct float64, resetSeconds int, caption string, now string) providers.MetricValue { +func percentMetric(id, label, name string, usedPct float64, resetSeconds int, now string) providers.MetricValue { var resetAt *time.Time if resetSeconds > 0 { t := time.Now().Add(time.Duration(resetSeconds) * time.Second) resetAt = &t } - return providerutil.PercentRemainingMetric(id, label, name, usedPct, resetAt, caption, now) + return providerutil.PercentRemainingMetric(id, label, name, usedPct, resetAt, "", now) +} + +// statusMetric builds a string-valued status metric (rate-limited / +// ok). Reference card style — no fill bar. +func statusMetric(id, label, name, value, now string) providers.MetricValue { + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: value, + UpdatedAt: now, + } +} + +// dollarMetric builds a dollar-valued reference metric. +func dollarMetric(id, label, name string, valueUSD float64, now string) providers.MetricValue { + v := valueUSD + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: fmt.Sprintf("$%.2f", valueUSD), + NumericValue: &v, + NumericUnit: "dollars", + UpdatedAt: now, + } +} + +// toggleMetric builds an on/off boolean metric. +func toggleMetric(id, label, name string, on bool, now string) providers.MetricValue { + value := "OFF" + if on { + value = "ON" + } + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: value, + UpdatedAt: now, + } +} + +// planMetric builds a plan-tier label metric. +func planMetric(id, label, name, plan, now string) providers.MetricValue { + display := plan + // Bare "20" / "100" / "200" are friendlier as "$20". + if _, err := strconv.Atoi(plan); err == nil { + display = "$" + plan + } + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: display, + UpdatedAt: now, + } +} + +// stringMetric builds a generic string-valued reference metric. +func stringMetric(id, label, name, value, now string) providers.MetricValue { + return providers.MetricValue{ + ID: id, + Label: label, + Name: name, + Value: value, + UpdatedAt: now, + } } // parseWorkspaceIDs finds workspace IDs in serialized text or JSON. @@ -540,13 +1235,6 @@ func normalizeWorkspaceID(raw string) string { return re.FindString(trimmed) } -// subscriptionLooksUsable reports whether text likely contains usage data. -func subscriptionLooksUsable(text string) bool { - return strings.Contains(text, "rollingUsage") || - strings.Contains(text, "weeklyUsage") || - strings.Contains(text, "usagePercent") -} - // isNullPayload reports explicit null responses. func isNullPayload(text string) bool { trimmed := strings.TrimSpace(text) @@ -591,6 +1279,46 @@ func extractInt(pattern string, text string) *int { return &v } +// extractFloatField pulls a numeric value for a named field from +// minified Solid SSR or JSON text. Tolerates `"field": 123`, +// `field:123`, and scientific notation. +func extractFloatField(text, field string) (float64, bool) { + pat := fmt.Sprintf(`%s"?\s*:\s*(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+\-]?[0-9]+)?)`, + regexp.QuoteMeta(field)) + v := extractFloat(pat, text) + if v == nil { + return 0, false + } + return *v, true +} + +// extractFirstString returns the first quoted or bare value matching +// any of fields. Quoted strings come back without the surrounding +// quotes. `null` is returned verbatim so callers can distinguish "no +// match" (empty) from "explicit null". +// +// Both JSON-quoted (`"field":"value"`) and Solid-SSR-bare +// (`field:"value"` or `field:value`) shapes are accepted — the +// optional `"?` after the field name absorbs JSON's closing field-name +// quote. +func extractFirstString(text string, fields []string) string { + for _, f := range fields { + // Quoted variant first. + quoted := fmt.Sprintf(`%s"?\s*:\s*\\?"([^"]*)"`, regexp.QuoteMeta(f)) + re := regexp.MustCompile(quoted) + if m := re.FindStringSubmatch(text); len(m) >= 2 { + return m[1] + } + // Bare variant for booleans / null / numbers used as labels. + bare := fmt.Sprintf(`%s"?\s*:\s*([a-zA-Z0-9_]+)`, regexp.QuoteMeta(f)) + re = regexp.MustCompile(bare) + if m := re.FindStringSubmatch(text); len(m) >= 2 { + return m[1] + } + } + return "" +} + // clampPercent normalizes 0..1 or 0..100 values to 0..100. func clampPercent(value float64) float64 { if value >= 0 && value <= 1 { @@ -612,9 +1340,7 @@ func errorSnapshot(message string) providers.Snapshot { } // newRequestID returns a v4-style UUID string used in X-Server-Instance. -// OpenCode's server appears to expect a unique ID per call (CodexBar parity). -// crypto/rand.Read failures are best-effort: an all-zero buffer still formats -// as a valid v4-shape, so the header value stays UUID-shaped either way. +// OpenCode's server appears to expect a unique ID per call. func newRequestID() string { var b [16]byte _, _ = rand.Read(b[:]) @@ -624,12 +1350,11 @@ func newRequestID() string { b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } -// dumpUnknownResponse appends a truncated OpenCode response to a temp file -// when parseSubscription can't classify it. Helps diagnose new empty-state -// shapes without asking the user to enable verbose logging. Owner-only perms -// (0o600) so the response — which may contain workspace IDs / billing data — -// is not world-readable. Append mode preserves earlier shapes; a per-call -// snippet cap and total-file cap keep growth bounded. +// dumpUnknownResponse appends a truncated OpenCode response to a temp +// file when no parser can classify it. Owner-only perms (0o600); a +// per-call snippet cap and total-file cap keep growth bounded. +// +//nolint:unused // retained for future parser regressions. func dumpUnknownResponse(text string) { const ( maxSnippetBytes = 16 * 1024 diff --git a/internal/providers/opencode/opencode_test.go b/internal/providers/opencode/opencode_test.go index b76de53..a781161 100644 --- a/internal/providers/opencode/opencode_test.go +++ b/internal/providers/opencode/opencode_test.go @@ -1,33 +1,42 @@ package opencode import ( - "strings" "testing" "time" ) -func TestParseSubscription_ActiveUsage(t *testing.T) { +// TestParseBlackResponse_ActiveUsage covers the populated subscription +// shape the existing provider always handled — rolling + weekly +// usagePercent + resetInSec from the Black plan's subscription.get. +func TestParseBlackResponse_ActiveUsage(t *testing.T) { now := time.Now().UTC() - text := `{"rollingUsage":{"usagePercent":42.5,"resetInSec":1800},"weeklyUsage":{"usagePercent":18,"resetInSec":345600}}` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("unexpected error: %v", err) + text := `{"rollingUsage":{"usagePercent":42.5,"resetInSec":1800,"status":"ok"},"weeklyUsage":{"usagePercent":18,"resetInSec":345600,"status":"ok"},"subscriptionPlan":"100"}` + got := parseBlackResponse(text, now) + if !got.HasSubscription { + t.Fatal("HasSubscription = false, want true") } - if usage.RollingUsagePercent != 42.5 { - t.Errorf("RollingUsagePercent = %v, want 42.5", usage.RollingUsagePercent) + if got.RollingUsagePercent != 42.5 { + t.Errorf("RollingUsagePercent = %v, want 42.5", got.RollingUsagePercent) } - if usage.WeeklyUsagePercent != 18 { - t.Errorf("WeeklyUsagePercent = %v, want 18", usage.WeeklyUsagePercent) + if got.WeeklyUsagePercent != 18 { + t.Errorf("WeeklyUsagePercent = %v, want 18", got.WeeklyUsagePercent) } - if usage.RollingResetInSec != 1800 { - t.Errorf("RollingResetInSec = %v, want 1800", usage.RollingResetInSec) + if got.RollingResetInSec != 1800 { + t.Errorf("RollingResetInSec = %v, want 1800", got.RollingResetInSec) } - if usage.WeeklyResetInSec != 345600 { - t.Errorf("WeeklyResetInSec = %v, want 345600", usage.WeeklyResetInSec) + if got.WeeklyResetInSec != 345600 { + t.Errorf("WeeklyResetInSec = %v, want 345600", got.WeeklyResetInSec) + } + if got.Plan != "100" { + t.Errorf("Plan = %q, want %q", got.Plan, "100") } } -func TestParseSubscription_EmptyWorkspace_KeysShape(t *testing.T) { +// TestParseBlackResponse_EmptyWorkspace_KeysShape verifies the +// /usage:[] /keys:[] shape — a workspace that exists but has no Black +// subscription — degrades to HasSubscription=false rather than +// erroring. +func TestParseBlackResponse_EmptyWorkspace_KeysShape(t *testing.T) { now := time.Now().UTC() text := `;0x000000d7; ((self.$R = self.$R || {})["server-fn:0"] = [], @@ -35,96 +44,291 @@ func TestParseSubscription_EmptyWorkspace_KeysShape(t *testing.T) { usage: $R[1] = [], keys: $R[2] = [$R[3] = {id: "key_X", displayName: "x@y.z", deleted: !1}] })($R["server-fn:0"]))` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("expected no error for empty-workspace response, got: %v", err) + got := parseBlackResponse(text, now) + if got.HasSubscription { + t.Fatal("HasSubscription = true, want false for empty-workspace shape") } - if usage.RollingUsagePercent != 0 || usage.WeeklyUsagePercent != 0 { + if got.RollingUsagePercent != 0 || got.WeeklyUsagePercent != 0 { t.Errorf("expected zero percents, got rolling=%v weekly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent) + got.RollingUsagePercent, got.WeeklyUsagePercent) } } -func TestParseSubscription_NoSubscription_BillingShape(t *testing.T) { +// TestParseBlackResponse_NullUsageFields covers the schema-keys-present +// but values-null shape we believe applies to unsubscribed accounts — +// no usagePercent anywhere. +func TestParseBlackResponse_NullUsageFields(t *testing.T) { now := time.Now().UTC() - text := `;0x000001f9; -((self.$R = self.$R || {})["server-fn:6"] = [], + text := `;0x000000aa; +((self.$R = self.$R || {})["server-fn:3"] = [], ($R => $R[0] = { - customerID: null, balance: 0, - monthlyLimit: null, monthlyUsage: null, - subscription: null, subscriptionID: null, subscriptionPlan: null -})($R["server-fn:6"]))` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("expected no error for no-subscription response, got: %v", err) - } - if usage.RollingUsagePercent != 0 || usage.WeeklyUsagePercent != 0 { - t.Errorf("expected zero percents, got rolling=%v weekly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent) + rollingUsage: null, + weeklyUsage: null +})($R["server-fn:3"]))` + got := parseBlackResponse(text, now) + if got.HasSubscription { + t.Fatal("HasSubscription = true, want false for null-usage shape") } } -func TestParseSubscription_NullPayload_SolidWrapped(t *testing.T) { +// TestParseBlackResponse_NoSubscription_Minified is the same null +// shape but minified the way OpenCode emits compressed Solid SSR. +func TestParseBlackResponse_NoSubscription_Minified(t *testing.T) { + now := time.Now().UTC() + text := `;0x000001f9;((self.$R=self.$R||{})["server-fn:6"]=[],($R=>$R[0]={customerID:null,balance:0,monthlyLimit:null,monthlyUsage:null,subscription:null,subscriptionID:null,subscriptionPlan:null})($R["server-fn:6"]))` + got := parseBlackResponse(text, now) + if got.HasSubscription { + t.Fatal("HasSubscription = true, want false") + } +} + +// TestParseBlackResponse_NullPayload_SolidWrapped covers a totally +// null payload Solid wraps in its SSR scaffolding. +func TestParseBlackResponse_NullPayload_SolidWrapped(t *testing.T) { now := time.Now().UTC() - // Real shape captured from an unsubscribed account hitting the - // subscription server-fn — entire payload resolves to null after the - // Solid SSR wrapper. 93 bytes total, no usage fields anywhere. text := `;0x00000051;((self.$R=self.$R||{})["server-fn:00000000-0000-4000-8000-000000000000"]=[],null)` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("expected no error for Solid-wrapped null payload, got: %v", err) + got := parseBlackResponse(text, now) + if got.HasSubscription { + t.Fatal("HasSubscription = true, want false for Solid-wrapped null") } - if usage.RollingUsagePercent != 0 || usage.WeeklyUsagePercent != 0 { - t.Errorf("expected zero percents, got rolling=%v weekly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent) +} + +// TestParseLiteResponse_ActiveUsage covers the lite.subscription.get +// payload's three windows. +func TestParseLiteResponse_ActiveUsage(t *testing.T) { + now := time.Now().UTC() + text := `{"rollingUsage":{"usagePercent":35,"resetInSec":3600,"status":"ok"},"weeklyUsage":{"usagePercent":12,"resetInSec":172800,"status":"ok"},"monthlyUsage":{"usagePercent":4,"resetInSec":2500000,"status":"ok"}}` + got := parseLiteResponse(text, now) + if !got.HasSubscription { + t.Fatal("HasSubscription = false, want true") + } + if got.RollingUsagePercent != 35 { + t.Errorf("RollingUsagePercent = %v, want 35", got.RollingUsagePercent) + } + if got.WeeklyUsagePercent != 12 { + t.Errorf("WeeklyUsagePercent = %v, want 12", got.WeeklyUsagePercent) + } + if got.MonthlyUsagePercent != 4 { + t.Errorf("MonthlyUsagePercent = %v, want 4", got.MonthlyUsagePercent) } } -func TestParseSubscription_NullUsageFields(t *testing.T) { +// TestParseLiteResponse_NoSubscription verifies a null lite payload +// degrades to HasSubscription=false (the Lite-specific empty state per +// the plan: workspace exists but has no liteSubscriptionID). +func TestParseLiteResponse_NoSubscription(t *testing.T) { now := time.Now().UTC() - // Subscription endpoint shape we believe applies to unsubscribed - // accounts: schema keys present, values null, no usagePercent anywhere. - text := `;0x000000aa; -((self.$R = self.$R || {})["server-fn:3"] = [], -($R => $R[0] = { - rollingUsage: null, - weeklyUsage: null -})($R["server-fn:3"]))` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("expected no error for null-usage response, got: %v", err) + text := `;0x00000051;((self.$R=self.$R||{})["server-fn:0"]=[],null)` + got := parseLiteResponse(text, now) + if got.HasSubscription { + t.Fatal("HasSubscription = true, want false") } - if usage.RollingUsagePercent != 0 || usage.WeeklyUsagePercent != 0 { - t.Errorf("expected zero percents, got rolling=%v weekly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent) +} + +// TestParseBillingResponse_PopulatedJSON covers a typical billing.get +// response with balance, monthly limit/usage, auto-reload, and last4. +// Units per console/core/src/billing.ts: balance is micro-cents, +// monthlyLimit is cents, monthlyUsage is micro-cents. +func TestParseBillingResponse_PopulatedJSON(t *testing.T) { + now := time.Now().UTC() + // $42.50 balance = 4_250_000 micro-cents. + // $100 monthly limit = 10_000 cents. + // $7.25 month-to-date = 725_000 micro-cents. + // reloadTrigger $10 = 1000 cents; reloadAmount $25 = 2500 cents. + text := `{ + "balance": 4250000, + "monthlyLimit": 10000, + "monthlyUsage": 725000, + "reload": true, + "reloadTrigger": 1000, + "reloadAmount": 2500, + "reloadError": null, + "paymentMethodLast4": "4242", + "subscriptionPlan": "100" + }` + got := parseBillingResponse(text, now) + if !got.HasBilling { + t.Fatal("HasBilling = false, want true") + } + if got.BalanceUSD != 4.25 { + t.Errorf("BalanceUSD = %v, want 4.25", got.BalanceUSD) + } + if got.MonthlyLimitUSD != 100 { + t.Errorf("MonthlyLimitUSD = %v, want 100", got.MonthlyLimitUSD) + } + if got.MonthlyUsageUSD != 0.725 { + t.Errorf("MonthlyUsageUSD = %v, want 0.725", got.MonthlyUsageUSD) + } + if !got.AutoReloadOn { + t.Error("AutoReloadOn = false, want true") + } + if got.ReloadTriggerUSD != 10 { + t.Errorf("ReloadTriggerUSD = %v, want 10", got.ReloadTriggerUSD) + } + if got.ReloadAmountUSD != 25 { + t.Errorf("ReloadAmountUSD = %v, want 25", got.ReloadAmountUSD) + } + if got.PaymentLast4 != "4242" { + t.Errorf("PaymentLast4 = %q, want %q", got.PaymentLast4, "4242") + } + if got.SubscriptionPlan != "100" { + t.Errorf("SubscriptionPlan = %q, want %q", got.SubscriptionPlan, "100") } } -func TestParseSubscription_NoSubscription_Minified(t *testing.T) { +// TestParseBillingResponse_NullsDegrade verifies a billing payload with +// every field null returns HasBilling=false rather than reading every +// metric as zero. +func TestParseBillingResponse_NullsDegrade(t *testing.T) { now := time.Now().UTC() - // Same shape as the billing response but without spaces after colons — - // what we'd see if OpenCode minifies the Solid SSR output. - text := `;0x000001f9;((self.$R=self.$R||{})["server-fn:6"]=[],($R=>$R[0]={customerID:null,balance:0,monthlyLimit:null,monthlyUsage:null,subscription:null,subscriptionID:null,subscriptionPlan:null})($R["server-fn:6"]))` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("expected no error for minified no-subscription response, got: %v", err) + text := `{ + "balance": null, + "monthlyLimit": null, + "monthlyUsage": null, + "reload": false, + "paymentMethodLast4": null, + "subscriptionPlan": null + }` + got := parseBillingResponse(text, now) + if got.HasBilling { + t.Fatal("HasBilling = true, want false for all-null shape") } - if usage.RollingUsagePercent != 0 || usage.WeeklyUsagePercent != 0 { - t.Errorf("expected zero percents, got rolling=%v weekly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent) +} + +// TestParseBillingResponse_MinifiedSolid covers the same balance fields +// embedded in a minified Solid SSR wrapper — the regex fallback path. +func TestParseBillingResponse_MinifiedSolid(t *testing.T) { + now := time.Now().UTC() + text := `;0x000001f9;((self.$R=self.$R||{})["server-fn:6"]=[],($R=>$R[0]={customerID:"cus_x",balance:1500000,monthlyLimit:5000,monthlyUsage:200000,reload:true,reloadTrigger:500,reloadAmount:1000,reloadError:null,paymentMethodLast4:"1234",subscriptionPlan:"20"})($R["server-fn:6"]))` + got := parseBillingResponse(text, now) + if !got.HasBilling { + t.Fatal("HasBilling = false, want true for minified-Solid populated payload") + } + if got.BalanceUSD != 1.5 { + t.Errorf("BalanceUSD = %v, want 1.5", got.BalanceUSD) + } + if got.MonthlyLimitUSD != 50 { + t.Errorf("MonthlyLimitUSD = %v, want 50", got.MonthlyLimitUSD) + } + if got.PaymentLast4 != "1234" { + t.Errorf("PaymentLast4 = %q, want %q", got.PaymentLast4, "1234") } } -func TestParseSubscription_BrokenResponse_StillErrors(t *testing.T) { +// TestAssembleSnapshot_BlackOnly covers the user-on-Black path: only +// black-* metrics emit, go-* and billing-* are absent. +func TestAssembleSnapshot_BlackOnly(t *testing.T) { now := time.Now().UTC() - // No Solid markers, no empty-state markers — looks like a genuine - // schema regression we want to surface, not silently zero. - text := `Internal error` - _, err := parseSubscription(text, now) - if err == nil { - t.Fatal("expected error for unrecognized response, got nil") - } - if !strings.Contains(err.Error(), "missing usage fields") { - t.Errorf("expected 'missing usage fields' error, got: %v", err) + black := blackSnapshot{ + HasSubscription: true, + Plan: "100", + RollingUsagePercent: 30, + WeeklyUsagePercent: 10, + RollingResetInSec: 1800, + WeeklyResetInSec: 300_000, + UpdatedAt: now, + } + snap := assembleSnapshot(black, liteSnapshot{UpdatedAt: now}, billingSnapshot{UpdatedAt: now}, now) + if snap.ProviderID != "opencode" { + t.Errorf("ProviderID = %q, want opencode", snap.ProviderID) + } + wantIDs := map[string]bool{ + "black-rolling-percent": true, + "black-weekly-percent": true, + "black-rolling-status": true, + "black-weekly-status": true, + "black-plan": true, + } + for _, m := range snap.Metrics { + if _, ok := wantIDs[m.ID]; !ok { + t.Errorf("unexpected metric in black-only snapshot: %s", m.ID) + } + delete(wantIDs, m.ID) + } + if len(wantIDs) != 0 { + t.Errorf("missing black metrics: %v", wantIDs) + } +} + +// TestAssembleSnapshot_GoOnly covers the user-on-Lite path. +func TestAssembleSnapshot_GoOnly(t *testing.T) { + now := time.Now().UTC() + lite := liteSnapshot{ + HasSubscription: true, + RollingUsagePercent: 20, + WeeklyUsagePercent: 8, + MonthlyUsagePercent: 3, + UpdatedAt: now, + } + snap := assembleSnapshot(blackSnapshot{UpdatedAt: now}, lite, billingSnapshot{UpdatedAt: now}, now) + wantIDs := map[string]bool{ + "go-rolling-percent": true, + "go-weekly-percent": true, + "go-monthly-percent": true, + "go-rolling-status": true, + "go-weekly-status": true, + "go-monthly-status": true, + } + for _, m := range snap.Metrics { + if _, ok := wantIDs[m.ID]; !ok { + t.Errorf("unexpected metric in go-only snapshot: %s", m.ID) + } + delete(wantIDs, m.ID) + } + if len(wantIDs) != 0 { + t.Errorf("missing go metrics: %v", wantIDs) + } +} + +// TestAssembleSnapshot_BillingDerivesMonthlyPercent verifies the +// billing-monthly-percent metric is computed from limit + usage when +// both are present. +func TestAssembleSnapshot_BillingDerivesMonthlyPercent(t *testing.T) { + now := time.Now().UTC() + billing := billingSnapshot{ + HasBilling: true, + BalanceUSD: 10, + HasMonthlyLimit: true, + HasMonthlyUsage: true, + MonthlyLimitUSD: 100, + MonthlyUsageUSD: 25, + UpdatedAt: now, + } + snap := assembleSnapshot(blackSnapshot{UpdatedAt: now}, liteSnapshot{UpdatedAt: now}, billing, now) + var found bool + for _, m := range snap.Metrics { + if m.ID != "billing-monthly-percent" { + continue + } + found = true + // PercentRemainingMetric stores remaining=75 (100-25 used). + if m.NumericValue == nil { + t.Fatal("billing-monthly-percent NumericValue is nil") + } + if *m.NumericValue != 75 { + t.Errorf("billing-monthly-percent remaining = %v, want 75", *m.NumericValue) + } + } + if !found { + t.Error("billing-monthly-percent metric not present despite limit + usage data") + } +} + +// TestAssembleSnapshot_NothingActive verifies the plain Free / +// no-data path: no lanes populated, the snapshot is empty (operational, +// no metrics) — the user sees "no data" caption per metric. +func TestAssembleSnapshot_NothingActive(t *testing.T) { + now := time.Now().UTC() + snap := assembleSnapshot( + blackSnapshot{UpdatedAt: now}, + liteSnapshot{UpdatedAt: now}, + billingSnapshot{UpdatedAt: now}, + now, + ) + if len(snap.Metrics) != 0 { + t.Errorf("expected 0 metrics for empty snapshot, got %d", len(snap.Metrics)) + } + if snap.Status != "operational" { + t.Errorf("Status = %q, want operational", snap.Status) } } diff --git a/internal/providers/opencodego/opencodego.go b/internal/providers/opencodego/opencodego.go deleted file mode 100644 index a8420d7..0000000 --- a/internal/providers/opencodego/opencodego.go +++ /dev/null @@ -1,438 +0,0 @@ -// Package opencodego implements the OpenCode Go usage provider. -// -// Auth: Usage Buttons Helper extension with the user's opencode.ai browser -// session. Endpoint: https://opencode.ai/workspace/{workspace}/go. -package opencodego - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/anthonybaldwin/UsageButtons/internal/cookies" - "github.com/anthonybaldwin/UsageButtons/internal/httputil" - "github.com/anthonybaldwin/UsageButtons/internal/providers" - "github.com/anthonybaldwin/UsageButtons/internal/providers/cookieaux" - "github.com/anthonybaldwin/UsageButtons/internal/providers/opencode" - "github.com/anthonybaldwin/UsageButtons/internal/providers/providerutil" -) - -const baseURL = "https://opencode.ai" - -// usageSnapshot is OpenCode Go rolling, weekly, and optional monthly usage. -type usageSnapshot struct { - HasMonthlyUsage bool - RollingUsagePercent float64 - WeeklyUsagePercent float64 - MonthlyUsagePercent float64 - RollingResetInSec int - WeeklyResetInSec int - MonthlyResetInSec int - UpdatedAt time.Time -} - -// windowCandidate is one parsed usage window from flexible JSON. -type windowCandidate struct { - Percent float64 - ResetInSec int - PathLower string -} - -// parsedWindow is one quota window parsed from a JSON object. -type parsedWindow struct { - Percent float64 - ResetInSec int -} - -// Provider fetches OpenCode Go usage data. -type Provider struct{} - -// ID returns the provider identifier used by the registry. -func (Provider) ID() string { return "opencodego" } - -// Name returns the human-readable provider name. -func (Provider) Name() string { return "OpenCode Go" } - -// BrandColor returns the accent color used on button faces. -func (Provider) BrandColor() string { return "#3b82f6" } - -// BrandBg returns the background color used on button faces. -func (Provider) BrandBg() string { return "#081a33" } - -// MetricIDs enumerates the metrics this provider can emit. -func (Provider) MetricIDs() []string { - return []string{"session-percent", "weekly-percent", "monthly-percent"} -} - -// Fetch returns the latest OpenCode Go usage snapshot. -func (Provider) Fetch(_ providers.FetchContext) (providers.Snapshot, error) { - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - defer cancel() - if !cookies.HostAvailable(ctx) { - return errorSnapshot(cookieaux.MissingMessage("opencode.ai")), nil - } - workspaceID, err := opencode.WorkspaceID(ctx, "CODEXBAR_OPENCODEGO_WORKSPACE_ID") - if err != nil { - return errorSnapshot(err.Error()), nil - } - text, err := fetchUsagePage(ctx, workspaceID) - if err != nil { - var httpErr *httputil.Error - if errors.As(err, &httpErr) && (httpErr.Status == 401 || httpErr.Status == 403) { - return errorSnapshot(cookieaux.StaleMessage("opencode.ai")), nil - } - if looksSignedOut(err.Error()) { - return errorSnapshot(cookieaux.StaleMessage("opencode.ai")), nil - } - return errorSnapshot(err.Error()), nil - } - if looksSignedOut(text) { - return errorSnapshot(cookieaux.StaleMessage("opencode.ai")), nil - } - usage, err := parseSubscription(text, time.Now().UTC()) - if err != nil { - return errorSnapshot(err.Error()), nil - } - return snapshotFromUsage(usage), nil -} - -// fetchUsagePage fetches the workspace Go usage page. -func fetchUsagePage(ctx context.Context, workspaceID string) (string, error) { - rawURL := fmt.Sprintf("%s/workspace/%s/go", baseURL, workspaceID) - resp, err := cookies.Fetch(ctx, cookies.Request{ - URL: rawURL, - Method: "GET", - Headers: map[string]string{ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "User-Agent": httputil.DefaultUserAgent, - }, - }) - if err != nil { - return "", err - } - if resp.Status < 200 || resp.Status >= 300 { - return "", &httputil.Error{ - Status: resp.Status, - StatusText: resp.StatusText, - Body: string(resp.Body), - URL: rawURL, - } - } - return string(resp.Body), nil -} - -// parseSubscription parses rolling, weekly, and optional monthly usage. -func parseSubscription(text string, now time.Time) (usageSnapshot, error) { - if usage, ok := parseSubscriptionJSON(text, now); ok { - return usage, nil - } - rollingPercent := extractFloat(`rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) - rollingReset := extractInt(`rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)`, text) - weeklyPercent := extractFloat(`weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) - weeklyReset := extractInt(`weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)`, text) - if rollingPercent != nil && rollingReset != nil && weeklyPercent != nil && weeklyReset != nil { - monthlyPercent := extractFloat(`monthlyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)`, text) - monthlyReset := extractInt(`monthlyUsage[^}]*?resetInSec\s*:\s*([0-9]+)`, text) - usage := usageSnapshot{ - HasMonthlyUsage: monthlyPercent != nil || monthlyReset != nil, - RollingUsagePercent: clampPercent(*rollingPercent), - WeeklyUsagePercent: clampPercent(*weeklyPercent), - RollingResetInSec: *rollingReset, - WeeklyResetInSec: *weeklyReset, - UpdatedAt: now, - } - if monthlyPercent != nil { - usage.MonthlyUsagePercent = clampPercent(*monthlyPercent) - } - if monthlyReset != nil { - usage.MonthlyResetInSec = *monthlyReset - } - return usage, nil - } - // /workspace//go is rendered by Solid Start. If we got past - // fetchUsagePage + looksSignedOut and the page lacks any usagePercent - // literal, the workspace has no Go subscription. Surface a clear - // error instead of the cryptic "missing usage fields" parse-error - // (and instead of faking zero usage, which misleads users into - // thinking they have a fresh quota). - if looksLikeEmptyUsage(text) { - return usageSnapshot{}, fmt.Errorf("No active OpenCode Go subscription") - } - dumpUnknownResponse(text) - return usageSnapshot{}, fmt.Errorf("OpenCode Go parse error: missing usage fields") -} - -// looksLikeEmptyUsage reports whether text is a Solid-rendered -// /workspace//go page that simply doesn't carry usage numbers. -// Requires both: no usagePercent literal anywhere AND a recognizable -// Solid SSR marker, so unrelated HTML / error pages still surface as -// the original parse error. -func looksLikeEmptyUsage(text string) bool { - if strings.Contains(text, "usagePercent") { - return false - } - return strings.Contains(text, "$R") || strings.Contains(text, "server-fn:") -} - -// dumpUnknownResponse appends a truncated /workspace//go response to -// a temp file when parseSubscription can't classify it. Owner-only perms -// (0o600) so the response — which may contain workspace IDs / billing -// hints — is not world-readable. Append mode + size caps preserve -// successive shapes for a debugging session without unbounded growth. -func dumpUnknownResponse(text string) { - const ( - maxSnippetBytes = 16 * 1024 - maxFileBytes = 256 * 1024 - ) - path := filepath.Join(os.TempDir(), "usagebuttons-opencodego-debug.txt") - if info, err := os.Stat(path); err == nil && info.Size() >= maxFileBytes { - return - } - snippet := text - truncated := false - if len(snippet) > maxSnippetBytes { - snippet = snippet[:maxSnippetBytes] - truncated = true - } - body := fmt.Sprintf("[%s] length=%d truncated=%v\n%s\n\n", - time.Now().UTC().Format(time.RFC3339), len(text), truncated, snippet) - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return - } - defer func() { _ = f.Close() }() - _, _ = f.WriteString(body) -} - -// parseSubscriptionJSON parses flexible JSON usage payloads. -func parseSubscriptionJSON(text string, now time.Time) (usageSnapshot, bool) { - var raw any - if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &raw); err != nil { - return usageSnapshot{}, false - } - var candidates []windowCandidate - collectWindowCandidates(raw, now, nil, &candidates) - if len(candidates) == 0 { - return usageSnapshot{}, false - } - rolling := pickWindow(candidates, true, "rolling", "hour", "5h", "5-hour") - weekly := pickWindow(candidates, false, "weekly", "week") - monthly := pickWindow(candidates, false, "monthly", "month") - if rolling == nil { - rolling = pickAnyWindow(candidates, true, nil) - } - if weekly == nil { - weekly = pickAnyWindow(candidates, false, rolling) - } - if rolling == nil || weekly == nil { - return usageSnapshot{}, false - } - usage := usageSnapshot{ - HasMonthlyUsage: monthly != nil, - RollingUsagePercent: rolling.Percent, - WeeklyUsagePercent: weekly.Percent, - RollingResetInSec: rolling.ResetInSec, - WeeklyResetInSec: weekly.ResetInSec, - UpdatedAt: now, - } - if monthly != nil { - usage.MonthlyUsagePercent = monthly.Percent - usage.MonthlyResetInSec = monthly.ResetInSec - } - return usage, true -} - -// collectWindowCandidates finds quota-like objects in arbitrary JSON. -func collectWindowCandidates(value any, now time.Time, path []string, out *[]windowCandidate) { - switch v := value.(type) { - case map[string]any: - if window, ok := parseWindow(v, now); ok { - *out = append(*out, windowCandidate{ - Percent: window.Percent, - ResetInSec: window.ResetInSec, - PathLower: strings.ToLower(strings.Join(path, ".")), - }) - } - for key, item := range v { - collectWindowCandidates(item, now, append(path, key), out) - } - case []any: - for i, item := range v { - collectWindowCandidates(item, now, append(path, fmt.Sprintf("[%d]", i)), out) - } - } -} - -// parseWindow extracts percent and reset data from a JSON object. -func parseWindow(m map[string]any, now time.Time) (parsedWindow, bool) { - percent, ok := providerutil.FirstFloat(m, - "usagePercent", "usedPercent", "percentUsed", "percent", - "usage_percent", "used_percent", "utilization", - "utilizationPercent", "utilization_percent", "usage") - if !ok { - used, usedOK := providerutil.FirstFloat(m, "used", "usage", "consumed", "count", "usedTokens") - limit, limitOK := providerutil.FirstFloat(m, "limit", "total", "quota", "max", "cap", "tokenLimit") - if usedOK && limitOK && limit > 0 { - percent = used / limit * 100 - ok = true - } - } - if !ok { - return parsedWindow{}, false - } - reset, resetOK := providerutil.FirstFloat(m, - "resetInSec", "resetInSeconds", "resetSeconds", "reset_sec", - "reset_in_sec", "resetsInSec", "resetsInSeconds", "resetIn", "resetSec") - if !resetOK { - if resetAt, ok := providerutil.FirstTime(m, - "resetAt", "resetsAt", "reset_at", "resets_at", - "nextReset", "next_reset", "renewAt", "renew_at"); ok { - reset = math.Max(0, resetAt.Sub(now).Seconds()) - resetOK = true - } - } - if !resetOK { - reset = 0 - } - return parsedWindow{ - Percent: clampPercent(percent), - ResetInSec: int(math.Round(reset)), - }, true -} - -// pickWindow chooses a candidate matching one of the path hints. -func pickWindow(candidates []windowCandidate, pickShorter bool, hints ...string) *windowCandidate { - var filtered []windowCandidate - for _, candidate := range candidates { - for _, hint := range hints { - if strings.Contains(candidate.PathLower, hint) { - filtered = append(filtered, candidate) - break - } - } - } - return pickAnyWindow(filtered, pickShorter, nil) -} - -// pickAnyWindow chooses by shortest or longest reset. -func pickAnyWindow(candidates []windowCandidate, pickShorter bool, excluding *windowCandidate) *windowCandidate { - var picked *windowCandidate - for _, candidate := range candidates { - if excluding != nil && candidate.PathLower == excluding.PathLower && candidate.ResetInSec == excluding.ResetInSec { - continue - } - c := candidate - if picked == nil { - picked = &c - continue - } - if pickShorter { - if candidate.ResetInSec < picked.ResetInSec { - picked = &c - } - } else if candidate.ResetInSec > picked.ResetInSec { - picked = &c - } - } - return picked -} - -// snapshotFromUsage maps parsed OpenCode Go usage into Stream Deck metrics. -func snapshotFromUsage(usage usageSnapshot) providers.Snapshot { - now := usage.UpdatedAt.UTC().Format(time.RFC3339) - metrics := []providers.MetricValue{ - percentMetric("session-percent", "SESSION", "OpenCode Go session window remaining (5h)", usage.RollingUsagePercent, usage.RollingResetInSec, "", now), - percentMetric("weekly-percent", "WEEKLY", "OpenCode Go weekly window remaining", usage.WeeklyUsagePercent, usage.WeeklyResetInSec, "", now), - } - if usage.HasMonthlyUsage { - metrics = append(metrics, percentMetric("monthly-percent", "MONTHLY", "OpenCode Go monthly window remaining", usage.MonthlyUsagePercent, usage.MonthlyResetInSec, "", now)) - } - return providers.Snapshot{ - ProviderID: "opencodego", - ProviderName: "OpenCode Go", - Source: "cookie", - Metrics: metrics, - Status: "operational", - } -} - -// percentMetric builds a remaining-percent OpenCode Go metric. -func percentMetric(id, label, name string, usedPct float64, resetSeconds int, caption string, now string) providers.MetricValue { - var resetAt *time.Time - if resetSeconds > 0 { - t := time.Now().Add(time.Duration(resetSeconds) * time.Second) - resetAt = &t - } - return providerutil.PercentRemainingMetric(id, label, name, usedPct, resetAt, caption, now) -} - -// looksSignedOut reports whether text is an auth/login response. -func looksSignedOut(text string) bool { - lower := strings.ToLower(text) - return strings.Contains(lower, "login") || - strings.Contains(lower, "sign in") || - strings.Contains(lower, "auth/authorize") || - strings.Contains(lower, "not associated with an account") || - strings.Contains(lower, `actor of type "public"`) -} - -// extractFloat extracts a float from the first capture group. -func extractFloat(pattern string, text string) *float64 { - re := regexp.MustCompile(pattern) - match := re.FindStringSubmatch(text) - if len(match) < 2 { - return nil - } - v, err := strconv.ParseFloat(match[1], 64) - if err != nil { - return nil - } - return &v -} - -// extractInt extracts an int from the first capture group. -func extractInt(pattern string, text string) *int { - re := regexp.MustCompile(pattern) - match := re.FindStringSubmatch(text) - if len(match) < 2 { - return nil - } - v, err := strconv.Atoi(match[1]) - if err != nil { - return nil - } - return &v -} - -// clampPercent normalizes 0..1 or 0..100 values to 0..100. -func clampPercent(value float64) float64 { - if value >= 0 && value <= 1 { - value *= 100 - } - return math.Max(0, math.Min(100, value)) -} - -// errorSnapshot returns an OpenCode Go setup or auth failure snapshot. -func errorSnapshot(message string) providers.Snapshot { - return providers.Snapshot{ - ProviderID: "opencodego", - ProviderName: "OpenCode Go", - Source: "cookie", - Metrics: []providers.MetricValue{}, - Status: "unknown", - Error: message, - } -} - -// init registers the OpenCode Go provider with the package registry. -func init() { - providers.Register(Provider{}) -} diff --git a/internal/providers/opencodego/opencodego_test.go b/internal/providers/opencodego/opencodego_test.go deleted file mode 100644 index 7ba6484..0000000 --- a/internal/providers/opencodego/opencodego_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package opencodego - -import ( - "strings" - "testing" - "time" -) - -func TestParseSubscription_ActiveUsage(t *testing.T) { - now := time.Now().UTC() - text := `{"rollingUsage":{"usagePercent":42,"resetInSec":1800},"weeklyUsage":{"usagePercent":18,"resetInSec":345600},"monthlyUsage":{"usagePercent":7,"resetInSec":2400000}}` - usage, err := parseSubscription(text, now) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !usage.HasMonthlyUsage { - t.Error("expected HasMonthlyUsage = true") - } - if usage.RollingUsagePercent != 42 || usage.WeeklyUsagePercent != 18 || usage.MonthlyUsagePercent != 7 { - t.Errorf("percents wrong: rolling=%v weekly=%v monthly=%v", - usage.RollingUsagePercent, usage.WeeklyUsagePercent, usage.MonthlyUsagePercent) - } -} - -func TestParseSubscription_NoSubscription_SolidPage(t *testing.T) { - now := time.Now().UTC() - // Solid-rendered /workspace//go page with no usage numbers — - // what we expect for a workspace with no Go subscription. Should - // surface a clear "No active OpenCode Go subscription" error, - // not a fake zero-usage snapshot. - text := ` - -
- -` - _, err := parseSubscription(text, now) - if err == nil { - t.Fatal("expected an error for unsubscribed workspace, got nil") - } - if !strings.Contains(err.Error(), "No active OpenCode Go subscription") { - t.Errorf("expected 'No active OpenCode Go subscription' error, got: %v", err) - } -} - -func TestParseSubscription_BrokenResponse_StillErrors(t *testing.T) { - now := time.Now().UTC() - // No Solid markers → unknown shape, surface the parse error so - // genuine schema regressions stay visible. - text := `

500 Internal Server Error

` - _, err := parseSubscription(text, now) - if err == nil { - t.Fatal("expected error for unrecognized response, got nil") - } - if !strings.Contains(err.Error(), "missing usage fields") { - t.Errorf("expected 'missing usage fields' error, got: %v", err) - } -} diff --git a/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego-key.svg b/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego-key.svg deleted file mode 100644 index 79ee99b..0000000 --- a/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego-key.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego.svg b/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego.svg deleted file mode 100644 index d2f0eec..0000000 --- a/io.github.anthonybaldwin.UsageButtons.sdPlugin/assets/action-opencodego.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/io.github.anthonybaldwin.UsageButtons.sdPlugin/manifest.json b/io.github.anthonybaldwin.UsageButtons.sdPlugin/manifest.json index ce07792..d55888b 100644 --- a/io.github.anthonybaldwin.UsageButtons.sdPlugin/manifest.json +++ b/io.github.anthonybaldwin.UsageButtons.sdPlugin/manifest.json @@ -202,6 +202,22 @@ } ] }, + { + "UUID": "io.github.anthonybaldwin.UsageButtons.deepseek", + "Name": "DeepSeek", + "Tooltip": "DeepSeek usage — balance + paid/granted; with the Helper extension on platform.deepseek.com adds today/yesterday/7d/MTD/30d/burn/projected and tokens MTD.", + "Icon": "assets/action-deepseek", + "PropertyInspectorPath": "ui/stat.html", + "SupportedInMultiActions": false, + "UserTitleEnabled": true, + "States": [ + { + "Image": "assets/action-deepseek-key", + "ShowTitle": false, + "TitleAlignment": "top" + } + ] + }, { "UUID": "io.github.anthonybaldwin.UsageButtons.factory", "Name": "Droid", @@ -461,7 +477,7 @@ { "UUID": "io.github.anthonybaldwin.UsageButtons.opencode", "Name": "OpenCode", - "Tooltip": "OpenCode usage stats — five-hour and weekly usage.", + "Tooltip": "OpenCode usage stats — Black plan, Go (Lite) plan, and shared billing balance.", "Icon": "assets/action-opencode", "PropertyInspectorPath": "ui/stat.html", "SupportedInMultiActions": false, @@ -474,38 +490,6 @@ } ] }, - { - "UUID": "io.github.anthonybaldwin.UsageButtons.opencodego", - "Name": "OpenCode Go", - "Tooltip": "OpenCode Go usage stats — five-hour, weekly, and monthly usage.", - "Icon": "assets/action-opencodego", - "PropertyInspectorPath": "ui/stat.html", - "SupportedInMultiActions": false, - "UserTitleEnabled": true, - "States": [ - { - "Image": "assets/action-opencodego-key", - "ShowTitle": false, - "TitleAlignment": "top" - } - ] - }, - { - "UUID": "io.github.anthonybaldwin.UsageButtons.deepseek", - "Name": "DeepSeek", - "Tooltip": "DeepSeek usage — balance + paid/granted; with the Helper extension on platform.deepseek.com adds today/yesterday/7d/MTD/30d/burn/projected and tokens MTD.", - "Icon": "assets/action-deepseek", - "PropertyInspectorPath": "ui/stat.html", - "SupportedInMultiActions": false, - "UserTitleEnabled": true, - "States": [ - { - "Image": "assets/action-deepseek-key", - "ShowTitle": false, - "TitleAlignment": "top" - } - ] - }, { "UUID": "io.github.anthonybaldwin.UsageButtons.openrouter", "Name": "OpenRouter", diff --git a/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html b/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html index 254dca8..f75be86 100644 --- a/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html +++ b/io.github.anthonybaldwin.UsageButtons.sdPlugin/ui/stat.html @@ -397,12 +397,7 @@ - - - @@ -1032,13 +1027,33 @@ ["weekly-pace", "Weekly pace (burn rate)", "pace", "browser"], ], opencode: [ - ["session-percent", "5-hour usage remaining %", "pct"], - ["weekly-percent", "Weekly usage remaining %", "pct"], - ], - opencodego: [ - ["session-percent", "5-hour usage remaining %", "pct"], - ["weekly-percent", "Weekly usage remaining %", "pct"], - ["monthly-percent", "Monthly usage remaining %", "pct"], + // Black plan ($20 / $100 / $200 paid Zen subscription). + ["__group__:Black (paid Zen)"], + ["black-rolling-percent", "Black 5-hour usage remaining %", "pct", "browser"], + ["black-weekly-percent", "Black weekly usage remaining %", "pct", "browser"], + ["black-rolling-status", "Black 5-hour status", "ref", "browser"], + ["black-weekly-status", "Black weekly status", "ref", "browser"], + ["black-plan", "Black plan tier ($20 / $100 / $200)", "ref", "browser"], + // Go (Lite) plan — replaces the standalone OpenCode Go provider. + ["__group__:Go (Lite)"], + ["go-rolling-percent", "Go 5-hour usage remaining %", "pct", "browser"], + ["go-weekly-percent", "Go weekly usage remaining %", "pct", "browser"], + ["go-monthly-percent", "Go monthly usage remaining %", "pct", "browser"], + ["go-rolling-status", "Go 5-hour status", "ref", "browser"], + ["go-weekly-status", "Go weekly status", "ref", "browser"], + ["go-monthly-status", "Go monthly status", "ref", "browser"], + // Shared billing (Lite + Black accounts). + ["__group__:Billing & balance"], + ["billing-balance-usd", "Credit balance ($)", "ref-dollar", "browser"], + ["billing-monthly-limit-usd", "Monthly spending limit ($)", "ref-dollar", "browser"], + ["billing-monthly-usage-usd", "Month-to-date spend ($)", "ref-dollar", "browser"], + ["billing-monthly-percent", "Monthly spend % of limit", "pct", "browser"], + ["billing-auto-reload-on", "Auto-reload enabled", "toggle", "browser"], + ["billing-reload-trigger-usd", "Auto-reload trigger ($)", "ref-dollar", "browser"], + ["billing-reload-amount-usd", "Auto-reload top-up ($)", "ref-dollar", "browser"], + ["billing-reload-error", "Auto-reload error message", "ref", "browser"], + ["billing-payment-last4", "Payment method last 4 digits", "ref", "browser"], + ["billing-subscription-plan", "Subscription plan label", "ref", "browser"], ], cursor: [ ["total-percent", "Total plan usage remaining %", "pct", "browser"], @@ -1275,7 +1290,6 @@ augment: "#6366f1", amp: "#dc2626", opencode: "#3b82f6", - opencodego: "#3b82f6", openrouter: "#6467f2", deepseek: "#4d6bfe", moonshot: "#0a84ff", @@ -1316,7 +1330,6 @@ augment: "#10112f", amp: "#250b0b", opencode: "#081a33", - opencodego: "#081a33", openrouter: "#101028", deepseek: "#0a1330", moonshot: "#0c0d20", @@ -1340,7 +1353,7 @@ }; // Providers that have auth config panels - const AUTH_PROVIDERS = ["claude", "anthropic", "codex", "openai", "copilot", "cursor", "deepseek", "factory", "gemini", "vertexai", "abacus", "alibaba", "antigravity", "augment", "amp", "grok", "nousresearch", "hermes-agent", "ollama", "openclaw", "opencode", "opencodego", "openrouter", "perplexity", "warp", "zai", "kimi", "minimax", "moonshot", "kimi-k2", "jetbrains", "kilo", "kiro", "mistral", "synthetic"]; + const AUTH_PROVIDERS = ["claude", "anthropic", "codex", "openai", "copilot", "cursor", "deepseek", "factory", "gemini", "vertexai", "abacus", "alibaba", "antigravity", "augment", "amp", "grok", "nousresearch", "hermes-agent", "ollama", "openclaw", "opencode", "openrouter", "perplexity", "warp", "zai", "kimi", "minimax", "moonshot", "kimi-k2", "jetbrains", "kilo", "kiro", "mistral", "synthetic"]; // Display name shown on the per-provider tab. const PROVIDER_DISPLAY_NAMES = { @@ -1360,7 +1373,6 @@ amp: "Amp", ollama: "Ollama", opencode: "OpenCode", - opencodego: "OpenCode Go", openrouter: "OpenRouter", deepseek: "DeepSeek", moonshot: "Moonshot", @@ -1442,20 +1454,49 @@ alibaba: { "opus-percent": "monthly-percent", }, + opencode: { + "session-percent": "black-rolling-percent", + "weekly-percent": "black-weekly-percent", + }, + // opencodego entries cover buttons whose provider ID came in as + // the legacy `opencodego` string before the action UUID was + // folded into opencode plugin-side. Same mechanism — the + // dropdown still surfaces opencode metrics, but the read-side + // alias maps the saved Claude-flavored IDs onto the Go lane. + opencodego: { + "session-percent": "go-rolling-percent", + "weekly-percent": "go-weekly-percent", + "monthly-percent": "go-monthly-percent", + }, }; 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__: