Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion cmd/genkeys/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ var providerColors = map[string]string{
"mistral": "#ff500f",
"ollama": "#f9fafb",
"opencode": "#3b82f6",
"opencodego": "#3b82f6",
"openrouter": "#6467f2",
"perplexity": "#20b2aa",
"synthetic": "#141414",
Expand Down
116 changes: 102 additions & 14 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -331,17 +340,45 @@ 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)

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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions cmd/plugin/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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)
}
})
}
}
3 changes: 1 addition & 2 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 %. |
Expand Down
3 changes: 1 addition & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ <h3>Standalone native binary</h3>
</div>

<h2 style="margin-top: 56px">Supported providers</h2>
<p class="sub">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).</p>
<p class="sub">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).</p>
<div class="providers-row">
<span class="pill active"><img src="provider-icons/claude.svg" alt="">Claude</span>
<span class="pill active"><img src="provider-icons/anthropic.svg" alt="">Anthropic</span>
Expand All @@ -506,7 +506,6 @@ <h2 style="margin-top: 56px">Supported providers</h2>
<span class="pill active"><img src="provider-icons/deepseek.svg" alt="">DeepSeek</span>
<span class="pill active"><img src="provider-icons/moonshot.svg" alt="">Moonshot</span>
<span class="pill active"><img src="provider-icons/opencode.svg" alt="">OpenCode</span>
<span class="pill active"><img src="provider-icons/opencodego.svg" alt="">OpenCode Go</span>
<span class="pill active"><img src="provider-icons/perplexity.svg" alt="">Perplexity</span>
<span class="pill active"><img src="provider-icons/minimax.svg" alt="">MiniMax</span>
<span class="pill active"><img src="provider-icons/mistral.svg" alt="">Mistral</span>
Expand Down
5 changes: 0 additions & 5 deletions docs/provider-icons/opencodego.svg

This file was deleted.

10 changes: 0 additions & 10 deletions internal/icons/opencodego.go

This file was deleted.

2 changes: 1 addition & 1 deletion internal/providers/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading