Skip to content

feat(openrouter): add OpenRouter usage provider#763

Merged
robinebers merged 13 commits into
mainfrom
claude/romantic-sutherland-68c013
Jun 28, 2026
Merged

feat(openrouter): add OpenRouter usage provider#763
robinebers merged 13 commits into
mainfrom
claude/romantic-sutherland-68c013

Conversation

@robinebers

@robinebers robinebers commented Jun 27, 2026

Copy link
Copy Markdown
Owner

TL;DR

Adds a native OpenRouter usage provider to the Swift edition (closes #578) and an in-app Settings ▸ API Keys card for managing user-supplied keys — starting with OpenRouter, the first provider that needs one. The card shows live status, a native key field with reveal + clear, and an "Override With a Custom Key" flow; saving writes the config file the auth store already reads and force-refreshes the dashboard. Also fixes a latent layout bug where below-the-fold metrics added after release surfaced above the fold for existing users.

What was happening

  • OpenRouter (New Provider: openrouter.ai #578) had no provider in the Swift edition — the branch linked in the issue is the legacy Tauri JS plugin, not portable as-is.
  • OpenRouter is the first provider with no companion CLI/app that leaves a credential on the machine, so users had to hand-edit ~/.config/openusage/openrouter.json or set OPENROUTER_API_KEY — no in-app way to add or rotate a key.
  • Separately: the "Shown on expand" (below-the-fold) membership was only seeded on a genuinely fresh install. A default-expanded metric added in a later release was auto-enabled for existing layouts but landed above the fold, ignoring DefaultLayout.expandedMetricIDs. OpenRouter is the first provider to add several below-fold metrics post-release, so it's the first to expose this.

What this changes

New OpenRouter provider (Sources/OpenUsage/Providers/OpenRouter/)

  • OpenRouterAuthStore — reads the key from OPENROUTER_API_KEY (or OPENROUTER_KEY), else ~/.config/openusage/openrouter.json (apiKey/api_key/key, or a plain-text key file). Config file wins over the env var so rotating the key isn't shadowed by a stale env value. Also writes/deletes the key for the in-app UI, and reports a 4-state keyStatus (not set / from environment / saved / override active). The GUI app doesn't inherit the shell env, so the config file is the reliable path.
  • OpenRouterUsageClientGET /api/v1/credits (required) + GET /api/v1/key (best-effort), Bearer auth.
  • OpenRouterUsageMapper — Credits meter (total_usage / total_credits), Balance (remaining), Today/This Week/This Month spend (real \$0.00 shown, never "No data"), optional Key Limit meter when the key has a cap. Tier (is_free_tier) maps to the snapshot plan ("Pay as you go" / "Free tier"), not a separate tile.
  • Registered in AppContainer (alphabetical tail), with ErrorCategory conformances, a provider mark (Resources/ProviderIcons/openrouter.svg) + SF Symbol fallback.
  • Default layout: Credits (primary, pinned) + Balance primary; Today / This Week / This Month / Key Limit secondary. Confirmed placement with the owner.
  • Docs page (docs/providers/openrouter.md) + README entry.

In-app API Keys card (Sources/OpenUsage/Views/APIKeysSection.swift, Sources/OpenUsage/Providers/APIKeyManagement.swift)

  • A provider APIKeyManaging capability + 4-state APIKeyStatus so the UI is provider-agnostic; future user-key providers conform with no new UI.
  • A dedicated Settings ▸ API Keys card (separate from the Providers enable list): each row shows a red/green status dot + Edit/Add. Expanding reveals the native macOS key field with a clear (x) and an eye beside it — read-only by default showing a muted source hint, the eye reveals the real key, "Override With a Custom Key" flips the field to editable for a new key, and the clear (x) removes a saved/override key (falling back to env or none).
  • Save writes ~/.config/openusage/openrouter.json (the file the auth store already reads, so config > env makes "save" also "override the env key"), then clears the failure backoff and force-refreshes so the dashboard updates immediately.
  • AppContainer exposes apiKeyProviders; the card hides itself when no installed provider needs a key.

Layout migration fix (LayoutStore)

  • seedNewDefaultMetrics now reports the ids it newly auto-enabled; init unions the default-expanded ones into expandedMetricIDs so they enter below the caret on upgrade. Guarded to brand-new metrics only, so a metric the user already lived with is never silently hidden. This is broader than OpenRouter — it corrects placement for any future below-fold metric addition.

Review fixes (Bugbot)

  • deleteAPIKey clears every config path the auth store reads, so an alternate-path key can't resurface after the primary file is deleted.
  • refresh reports invalidKey only when both endpoints return 401/403 — a single 403 (e.g. /credits gated) while /key succeeded means the key is valid but gated, not invalid.

Heads-up

  • First user-supplied-key provider. A deliberate departure from the "read credentials already on the machine, never paste a token" principle, unavoidable since OpenRouter exposes nothing locally. No browser cookies are read. The in-app key form is the new supported path; the config file / env var still work.
  • The LayoutStore change touches the shared seeding path for every provider — the regression tests plus the existing LayoutStore tests cover it, but worth a careful look.
  • total_credits is lifetime credits purchased, so the Credits meter is a lifetime burn-down (subtitle "$X purchased"), not a recurring quota. Balance is the more glanceable number and is also primary.
  • Merged with main (notifications feat(notifications): quota pace alerts — 3 triggers, launch-prime, per-app stacking (#633) #786 + share screenshot feat(share): Share Screenshot footer submenu + copied-to-clipboard pill #785); the AppContainer conflict was resolved by keeping both apiKeyProviders and notificationSettings.

Tests

  • Provider: auth-source precedence, mapper shapes (real-zero spend, missing /key, auth failure, single-403-not-invalid), full refresh, save/delete (incl. clearing all config paths), 4-state keyStatus, provider APIKeyManaging conformance.
  • APIKeyStatus + maskedKeyPreview helper.
  • LayoutStore regression tests for the upgrade path and the expand-on-enable queue.
  • Full suite green after merging main.

Screenshots

OpenRouter provider (dashboard):

image

Settings ▸ API Keys card: to follow.


Note

Medium Risk
Touches shared layout seeding/persistence for all providers and introduces user-supplied API key storage on disk; provider refresh and auth classification changes are well-tested but warrant review on upgrade paths.

Overview
Adds OpenRouter as a usage provider (credits, balance, daily/weekly/monthly spend, optional key limit) via /credits and /key, with API keys from config or env and refresh logic that treats a key as invalid only when both endpoints return 401/403.

Introduces APIKeyManaging and a Settings ▸ API Keys card (save/reveal/clear, env override) that writes the same config files the auth store reads and force-refreshes after changes.

LayoutStore fixes upgrade behavior: newly auto-enabled default-expanded metrics tuck below the caret for existing users; expandOnEnable is persisted so expand-on-first-enable and explicit drag placement survive relaunch without wiping unrelated optional metrics.

Reviewed by Cursor Bugbot for commit 4b40bf0. Bugbot is set up for automated code reviews on this repo. Configure here.

robinebers and others added 2 commits June 27, 2026 09:27
Adds a native OpenRouter provider to the Swift edition (issue #578). Reads an
API key from OPENROUTER_API_KEY or ~/.config/openusage/openrouter.json, then
calls GET /credits (balance, required) and GET /key (tier, period spend, and an
optional per-key cap — best-effort). Maps to a Credits meter + Balance (primary),
with Today / This Week / This Month / Key Limit below the caret.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t on upgrade

Below-the-fold (expanded) membership was only seeded on a genuinely fresh
install, so a default-expanded metric added after release was auto-enabled but
landed above the fold for every existing layout. seedNewDefaultMetrics now
reports the ids it newly auto-enabled, and init unions the default-expanded ones
into expandedMetricIDs (guarded to brand-new metrics only, so a metric the user
already lived with is never silently hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread README.md Outdated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread Sources/OpenUsage/Stores/LayoutStore.swift
@greptile-apps

greptile-apps Bot commented Jun 27, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

robinebers and others added 2 commits June 27, 2026 09:48
…presence

The migration persists an expanded set when it tucks a new default-expanded
metric below the caret. That created a saved expandedMetrics key on the next
launch, which flipped init into the "saved set" branch and zeroed
defaultExpandedOnEnableIDs — so a legacy optional default-expanded metric (e.g.
cursor.requests) the user hadn't enabled yet would land above the fold instead
of below the caret. Compute the on-enable queue from the final expanded set
("default-expanded, not already a member, not placed") so it no longer depends
on whether an expanded set happens to be saved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rland-68c013

# Conflicts:
#	Sources/OpenUsage/Providers/ErrorCategory.swift
#	Sources/OpenUsage/Stores/DefaultLayout.swift
@validatedev

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35453c0d09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/OpenUsage/Providers/OpenRouter/OpenRouterProvider.swift Outdated
Comment thread Sources/OpenUsage/Stores/LayoutStore.swift Outdated
Comment thread Sources/OpenUsage/Providers/OpenRouter/OpenRouterAuthStore.swift
robinebers and others added 3 commits June 28, 2026 02:54
…g file

- Build the snapshot from whatever `/credits` and `/key` return instead of hard-
  requiring `/credits`: if it ever returns 403 (e.g. an endpoint gated to a
  management key) while `/key` succeeds, the spend rows still show rather than
  erroring out. Only fail when neither endpoint yields data, reporting an invalid
  key when either was rejected.
- Check the config file before the environment variable, matching the documented
  precedence, so editing the config to rotate the key isn't shadowed by a stale
  OPENROUTER_API_KEY left in the app environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g it

Recomputing defaultExpandedOnEnableIDs from defaults every launch resurrected a
fallback the user had already consumed by moving a disabled default-expanded
metric above the divider — so enabling it later forced it back below the caret
against the saved order. Persist the queue (seeded once, consumed durably on
enable / divider move / reset / undo) and load it as-is, re-filtering only for
metrics since placed or expanded. This also keeps the queue intact when the
OpenRouter migration persists an expanded set, so a legacy optional metric still
enters below the caret on the next launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

robinebers has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

In-app API Keys card in Settings (Option 2 of the API-key UX canvas) so
providers needing a user-supplied key can be configured without leaving
the app — starting with OpenRouter.

- APIKeyManaging capability + 4-state APIKeyStatus; OpenRouterAuthStore
  gains save/delete/keyStatus/currentAPIKey writing the config file it
  already reads (config > env, so save also overrides the env key).
- APIKeysSection: per-provider row (red/green status dot + Edit/Add) and
  a native bordered key field with eye reveal + leading clear; "Override
  With a Custom Key" flips the field to editable.
- AppContainer exposes apiKeyProviders; SettingsScreen renders the card
  below Providers. Save/clear clears failure backoff and force-refreshes.
- Tests for save/precedence/4-state status/delete + masking helper.
- Docs: openrouter.md leads with the in-app path; adding-a-provider.md
  documents APIKeyManaging.

Co-authored-by: Cursor <cursoragent@cursor.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

robinebers has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Comment thread Sources/OpenUsage/Providers/OpenRouter/OpenRouterAuthStore.swift
Comment thread Sources/OpenUsage/Providers/OpenRouter/OpenRouterProvider.swift
Hide "Override With a Custom Key" once a custom/saved key exists; the
field's clear (x) removes it, falling back to env (checkbox re-appears)
or to none (the notSet editor takes over).

Co-authored-by: Cursor <cursoragent@cursor.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

robinebers has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 60b51b6. Configure here.

Comment thread Sources/OpenUsage/Stores/LayoutStore.swift Outdated
robinebers and others added 2 commits June 28, 2026 15:23
…n both 401/403

- deleteAPIKey removes every config file the auth store reads, so clearing
  a key in Settings truly clears it (an alternate-path key no longer
  resurfaces after the primary file is deleted).
- refresh reports invalidKey only when BOTH endpoints return 401/403 — a
  single 403 (e.g. /credits gated) while /key succeeded means the key is
  valid but gated, not invalid.
- APIKeysSection header comment: eye is beside the field, not inside.
- Tests for both fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolves the AppContainer conflict by keeping both apiKeyProviders (this
PR's API Keys card) and notificationSettings (main's quota-pace alerts,

Co-authored-by: Cursor <cursoragent@cursor.com>
#786). Also brings in the Share Screenshot footer submenu (#785).

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

robinebers has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

applyMetricDividerOrderImpl cleared every metric in the reorder list from
the expand-on-enable queue (subtracting `seen`). Customize passes the full
metric list — metricOrderWithDivider includes disabled optional metrics
before the divider by default — so reordering primary rows also cleared
the below-caret default for disabled metrics the user never moved, and
they landed above the fold when later enabled.

Consume only the dragged metric's entry instead (matching reorderMetric).
The dragged id is now passed through applyMetricDividerOrder from both
callers (Customize, dashboard). Regression test added; existing divider
and queue tests unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

robinebers has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@robinebers robinebers merged commit 8360e93 into main Jun 28, 2026
3 checks passed
@robinebers robinebers deleted the claude/romantic-sutherland-68c013 branch June 28, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Provider: openrouter.ai

2 participants