From 7049a98cd9b34df089a4a643af3e18c4fa3c1241 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Sun, 8 Feb 2026 14:13:09 +0000 Subject: [PATCH 1/2] feat(openrouter): support multiple named keys in usage command --- src/hooks/command.ts | 12 ++++--- src/providers/openrouter/index.ts | 1 + src/providers/openrouter/types.ts | 1 + src/types.ts | 6 ++++ src/ui/formatters/openrouter.ts | 3 +- src/usage/config.ts | 8 ++++- src/usage/fetch.ts | 13 ++++--- src/usage/registry.ts | 56 ++++++++++++++++++++++++++++--- 8 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/hooks/command.ts b/src/hooks/command.ts index fca5e32..11f2a48 100644 --- a/src/hooks/command.ts +++ b/src/hooks/command.ts @@ -29,7 +29,7 @@ export function commandHooks(options: { config.command ??= {} config.command["usage"] = { template: "/usage", - description: "Show API usage and rate limits (anthropic/codex/proxy/copilot/zai)", + description: "Show API usage and rate limits (anthropic/codex/proxy/copilot/zai/openrouter)", } }, @@ -48,12 +48,14 @@ export function commandHooks(options: { throw new Error("__USAGE_SUPPORT_HANDLED__") } - const filter = args || undefined - const targetProvider = resolveProviderFilter(filter) + const parts = args ? args.split(/\s+/).filter(Boolean) : [] + const providerArg = parts[0] + const openrouterKeyName = parts.length > 1 ? parts.slice(1).join(" ") : undefined + const targetProvider = resolveProviderFilter(providerArg) - let effectiveFilter = targetProvider ? filter : undefined + let effectiveFilter = targetProvider ? providerArg : undefined - const snapshots = await fetchUsageSnapshots(effectiveFilter) + const snapshots = await fetchUsageSnapshots(effectiveFilter, targetProvider === "openrouter" ? openrouterKeyName : undefined) const filteredSnapshots = snapshots.filter(s => { if (targetProvider) return true diff --git a/src/providers/openrouter/index.ts b/src/providers/openrouter/index.ts index 3148c02..cd7d8e6 100644 --- a/src/providers/openrouter/index.ts +++ b/src/providers/openrouter/index.ts @@ -45,6 +45,7 @@ export const OpenRouterProvider: UsageProvider = { }, updatedAt: now, openrouterQuota: { + keyName: auth.keyName ?? data.data.label ?? null, limit: data.data.limit, usage: data.data.usage, limitRemaining: data.data.limit_remaining, diff --git a/src/providers/openrouter/types.ts b/src/providers/openrouter/types.ts index 50538b1..a6f76fb 100644 --- a/src/providers/openrouter/types.ts +++ b/src/providers/openrouter/types.ts @@ -6,6 +6,7 @@ import z from "zod" export interface OpenRouterAuth { key: string + keyName?: string } export const openRouterAuthResponseSchema = z.object({ diff --git a/src/types.ts b/src/types.ts index 05f3e04..529456a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,11 @@ export interface UsageConfig { showAll?: boolean displayNames?: Record } + openrouterKeys?: Array<{ + name: string + key: string + enabled?: boolean + }> } export interface ZaiQuota { @@ -132,6 +137,7 @@ export interface AnthropicQuota { } export interface OpenRouterQuota { + keyName?: string | null limit: number | null usage: number limitRemaining: number | null diff --git a/src/ui/formatters/openrouter.ts b/src/ui/formatters/openrouter.ts index 72ff22f..c440287 100644 --- a/src/ui/formatters/openrouter.ts +++ b/src/ui/formatters/openrouter.ts @@ -9,7 +9,8 @@ export function formatOpenRouterSnapshot(snapshot: UsageSnapshot): string[] { const or = snapshot.openrouterQuota if (!or) return formatMissingSnapshot(snapshot) - const lines = ["→ [OPENROUTER]"] + const label = or.keyName ? `: ${or.keyName}` : "" + const lines = [`→ [OPENROUTER${label}]`] if (or.limit === null) { lines.push(` ${"Credit:".padEnd(13)} Unlimited`) diff --git a/src/usage/config.ts b/src/usage/config.ts index b10045b..1251745 100644 --- a/src/usage/config.ts +++ b/src/usage/config.ts @@ -48,7 +48,13 @@ export async function loadUsageConfig(): Promise { "zai": true, "anthropic": true, "openrouter": true - } + }, + + // Optional: Multiple named OpenRouter API keys for /usage openrouter + // "openrouterKeys": [ + // { "name": "work", "key": "sk-or-v1-..." }, + // { "name": "personal", "key": "sk-or-v1-...", "enabled": true } + // ] } ` diff --git a/src/usage/fetch.ts b/src/usage/fetch.ts index 10050a0..b7b59b6 100644 --- a/src/usage/fetch.ts +++ b/src/usage/fetch.ts @@ -7,12 +7,13 @@ import type { UsageSnapshot } from "../types" import { providers } from "../providers" import { loadUsageConfig } from "./config" import { loadMergedAuths } from "./auth/loader" -import { resolveProviderAuths } from "./registry" +import { resolveProviderAuthsWithConfig } from "./registry" const CORE_PROVIDERS = ["codex", "proxy", "copilot", "zai-coding-plan", "anthropic", "openrouter"] -export async function fetchUsageSnapshots(filter?: string): Promise { +export async function fetchUsageSnapshots(filter?: string, openrouterKeyName?: string): Promise { const target = resolveFilter(filter) + const normalizedOpenRouterKey = openrouterKeyName?.trim().toLowerCase() const config = await loadUsageConfig().catch(() => null) const toggles = config?.providers ?? {} @@ -24,16 +25,20 @@ export async function fetchUsageSnapshots(filter?: string): Promise() const fetched = new Set() const fetches = entries + .filter(e => { + if (e.providerID !== "openrouter" || !normalizedOpenRouterKey) return true + return e.auth.keyName?.toLowerCase() === normalizedOpenRouterKey + }) .filter(e => (!target || e.providerID === target) && isEnabled(e.providerID)) .map(async e => { const snap = await providers[e.providerID]?.fetchUsage?.(e.auth).catch(() => null) if (snap) { - snapshotsMap.set(e.providerID, snap) + snapshotsMap.set(e.entryID, snap) fetched.add(e.providerID) } }) diff --git a/src/usage/registry.ts b/src/usage/registry.ts index 50eeffc..a2e977c 100644 --- a/src/usage/registry.ts +++ b/src/usage/registry.ts @@ -6,6 +6,7 @@ import type { CodexAuth } from "../providers/codex" import type { CopilotAuthData } from "../providers/copilot/types" import type { ZaiAuth } from "../providers/zai/types" import type { OpenRouterAuth } from "../providers/openrouter/types" +import type { UsageConfig } from "../types" export type AuthEntry = { type?: string @@ -19,10 +20,10 @@ export type AuthEntry = { export type AuthRecord = Record type ProviderAuthEntry = - | { providerID: "codex"; auth: CodexAuth } - | { providerID: "copilot"; auth: CopilotAuthData } - | { providerID: "zai-coding-plan"; auth: ZaiAuth } - | { providerID: "openrouter"; auth: OpenRouterAuth } + | { providerID: "codex"; entryID: string; auth: CodexAuth } + | { providerID: "copilot"; entryID: string; auth: CopilotAuthData } + | { providerID: "zai-coding-plan"; entryID: string; auth: ZaiAuth } + | { providerID: "openrouter"; entryID: string; auth: OpenRouterAuth } type ProviderDescriptor = { id: ProviderAuthEntry["providerID"] @@ -78,7 +79,52 @@ export function resolveProviderAuths(auths: AuthRecord, usageToken: string | nul if (!auth) continue if (descriptor.requiresOAuth && auth.type && auth.type !== "oauth" && auth.type !== "token") continue const built = descriptor.buildAuth(auth, usageToken) - entries.push({ providerID: descriptor.id, auth: built } as ProviderAuthEntry) + entries.push({ providerID: descriptor.id, entryID: descriptor.id, auth: built } as ProviderAuthEntry) + } + + return entries +} + +export function resolveProviderAuthsWithConfig( + auths: AuthRecord, + usageToken: string | null, + config: UsageConfig | null, +): ProviderAuthEntry[] { + const baseEntries = resolveProviderAuths(auths, usageToken).filter(e => e.providerID !== "openrouter") + const openRouterEntries = resolveOpenRouterAuths(auths, config) + return [...baseEntries, ...openRouterEntries] +} + +function resolveOpenRouterAuths(auths: AuthRecord, config: UsageConfig | null): ProviderAuthEntry[] { + const entries: ProviderAuthEntry[] = [] + const seenKeys = new Set() + + const configuredKeys = Array.isArray(config?.openrouterKeys) ? config.openrouterKeys : [] + for (const configured of configuredKeys) { + const key = configured?.key?.trim() + if (!key || seenKeys.has(key)) continue + const rawName = configured?.name?.trim() + const name = rawName || `key-${entries.length + 1}` + if (configured.enabled === false) continue + seenKeys.add(key) + entries.push({ + providerID: "openrouter", + entryID: `openrouter:${name}`, + auth: { key, keyName: name }, + }) + } + + for (const authKey of ["openrouter", "or"]) { + const auth = auths[authKey] + const key = (auth?.key || auth?.access || "").trim() + if (!key || seenKeys.has(key)) continue + seenKeys.add(key) + entries.push({ + providerID: "openrouter", + entryID: "openrouter", + auth: { key, keyName: "default" }, + }) + break } return entries From fde03edc6da34f9aa8c153d2c552d1900aab2335 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Sun, 8 Feb 2026 14:22:12 +0000 Subject: [PATCH 2/2] fix(openrouter): handle per-key failures and extract auth resolver --- src/providers/openrouter/auth-resolver.ts | 65 +++++++++++++++++++++++ src/usage/fetch.ts | 44 +++++++++++++-- src/usage/registry.ts | 38 ++----------- 3 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 src/providers/openrouter/auth-resolver.ts diff --git a/src/providers/openrouter/auth-resolver.ts b/src/providers/openrouter/auth-resolver.ts new file mode 100644 index 0000000..d1c9758 --- /dev/null +++ b/src/providers/openrouter/auth-resolver.ts @@ -0,0 +1,65 @@ +/** + * Resolves OpenRouter auth entries from config and auth records. + * Deduplicates by key and ensures unique display names. + */ + +import type { UsageConfig } from "../../types" +import type { AuthRecord } from "../../usage/registry" +import type { OpenRouterAuth } from "./types" + +export type OpenRouterResolvedAuthEntry = { + providerID: "openrouter" + entryID: string + auth: OpenRouterAuth +} + +export function resolveOpenRouterAuths( + auths: AuthRecord, + config: UsageConfig | null, +): OpenRouterResolvedAuthEntry[] { + const entries: OpenRouterResolvedAuthEntry[] = [] + const seenKeys = new Set() + const seenNames = new Set() + + const configuredKeys = Array.isArray(config?.openrouterKeys) ? config.openrouterKeys : [] + for (const configured of configuredKeys) { + const key = configured?.key?.trim() + if (!key || seenKeys.has(key)) continue + const rawName = configured?.name?.trim() + if (configured.enabled === false) continue + const name = getUniqueOpenRouterName(rawName || `key-${entries.length + 1}`, seenNames) + seenKeys.add(key) + seenNames.add(name.toLowerCase()) + entries.push({ + providerID: "openrouter", + entryID: `openrouter:${name}`, + auth: { key, keyName: name }, + }) + } + + for (const authKey of ["openrouter", "or"]) { + const auth = auths[authKey] + const key = (auth?.key || auth?.access || "").trim() + if (!key || seenKeys.has(key)) continue + const name = getUniqueOpenRouterName("default", seenNames) + seenKeys.add(key) + seenNames.add(name.toLowerCase()) + entries.push({ + providerID: "openrouter", + entryID: `openrouter:${name}`, + auth: { key, keyName: name }, + }) + break + } + + return entries +} + +function getUniqueOpenRouterName(candidate: string, seenNames: Set): string { + const normalized = candidate.toLowerCase() + if (!seenNames.has(normalized)) return candidate + + let suffix = 2 + while (seenNames.has(`${normalized}-${suffix}`)) suffix += 1 + return `${candidate}-${suffix}` +} diff --git a/src/usage/fetch.ts b/src/usage/fetch.ts index b7b59b6..08234ef 100644 --- a/src/usage/fetch.ts +++ b/src/usage/fetch.ts @@ -8,6 +8,9 @@ import { providers } from "../providers" import { loadUsageConfig } from "./config" import { loadMergedAuths } from "./auth/loader" import { resolveProviderAuthsWithConfig } from "./registry" +import type { ResolvedProviderAuthEntry } from "./registry" + +type OpenRouterEntry = Extract const CORE_PROVIDERS = ["codex", "proxy", "copilot", "zai-coding-plan", "anthropic", "openrouter"] @@ -28,18 +31,22 @@ export async function fetchUsageSnapshots(filter?: string, openrouterKeyName?: s const entries = resolveProviderAuthsWithConfig(auths, null, config) const snapshotsMap = new Map() const fetched = new Set() + const fetchedOpenRouterEntryIDs = new Set() - const fetches = entries + const filteredEntries = entries .filter(e => { if (e.providerID !== "openrouter" || !normalizedOpenRouterKey) return true return e.auth.keyName?.toLowerCase() === normalizedOpenRouterKey }) .filter(e => (!target || e.providerID === target) && isEnabled(e.providerID)) + + const fetches = filteredEntries .map(async e => { const snap = await providers[e.providerID]?.fetchUsage?.(e.auth).catch(() => null) if (snap) { snapshotsMap.set(e.entryID, snap) - fetched.add(e.providerID) + if (e.providerID === "openrouter") fetchedOpenRouterEntryIDs.add(e.entryID) + else fetched.add(e.providerID) } }) @@ -60,7 +67,15 @@ export async function fetchUsageSnapshots(filter?: string, openrouterKeyName?: s await Promise.race([Promise.all(fetches), new Promise(r => setTimeout(r, 5000))]) const snapshots = Array.from(snapshotsMap.values()) - return appendMissingStates(snapshots, fetched, isEnabled, target, codexDiagnostics) + return appendMissingStates( + snapshots, + fetched, + isEnabled, + target, + codexDiagnostics, + filteredEntries.filter((e): e is OpenRouterEntry => e.providerID === "openrouter"), + fetchedOpenRouterEntryIDs, + ) } function resolveFilter(f?: string): string | undefined { @@ -85,9 +100,12 @@ function appendMissingStates( fetched: Set, isEnabled: (id: string) => boolean, target?: string, - diagnostics?: string[] + diagnostics?: string[], + openRouterEntries: OpenRouterEntry[] = [], + fetchedOpenRouterEntryIDs: Set = new Set(), ): UsageSnapshot[] { for (const id of CORE_PROVIDERS) { + if (id === "openrouter" && openRouterEntries.length > 0) continue if (isEnabled(id) && !fetched.has(id) && (!target || target === id)) { snaps.push({ timestamp: Date.now(), @@ -104,6 +122,24 @@ function appendMissingStates( }) } } + + for (const entry of openRouterEntries) { + if (fetchedOpenRouterEntryIDs.has(entry.entryID)) continue + const keyName = entry.auth.keyName ?? "unknown" + snaps.push({ + timestamp: Date.now(), + provider: "openrouter", + planType: null, + primary: null, + secondary: null, + codeReview: null, + credits: null, + updatedAt: Date.now(), + isMissing: true, + missingReason: `OpenRouter key \"${keyName}\" failed to fetch`, + }) + } + return snaps } diff --git a/src/usage/registry.ts b/src/usage/registry.ts index a2e977c..787d853 100644 --- a/src/usage/registry.ts +++ b/src/usage/registry.ts @@ -7,6 +7,7 @@ import type { CopilotAuthData } from "../providers/copilot/types" import type { ZaiAuth } from "../providers/zai/types" import type { OpenRouterAuth } from "../providers/openrouter/types" import type { UsageConfig } from "../types" +import { resolveOpenRouterAuths } from "../providers/openrouter/auth-resolver" export type AuthEntry = { type?: string @@ -25,6 +26,8 @@ type ProviderAuthEntry = | { providerID: "zai-coding-plan"; entryID: string; auth: ZaiAuth } | { providerID: "openrouter"; entryID: string; auth: OpenRouterAuth } +export type ResolvedProviderAuthEntry = ProviderAuthEntry + type ProviderDescriptor = { id: ProviderAuthEntry["providerID"] authKeys: string[] @@ -94,38 +97,3 @@ export function resolveProviderAuthsWithConfig( const openRouterEntries = resolveOpenRouterAuths(auths, config) return [...baseEntries, ...openRouterEntries] } - -function resolveOpenRouterAuths(auths: AuthRecord, config: UsageConfig | null): ProviderAuthEntry[] { - const entries: ProviderAuthEntry[] = [] - const seenKeys = new Set() - - const configuredKeys = Array.isArray(config?.openrouterKeys) ? config.openrouterKeys : [] - for (const configured of configuredKeys) { - const key = configured?.key?.trim() - if (!key || seenKeys.has(key)) continue - const rawName = configured?.name?.trim() - const name = rawName || `key-${entries.length + 1}` - if (configured.enabled === false) continue - seenKeys.add(key) - entries.push({ - providerID: "openrouter", - entryID: `openrouter:${name}`, - auth: { key, keyName: name }, - }) - } - - for (const authKey of ["openrouter", "or"]) { - const auth = auths[authKey] - const key = (auth?.key || auth?.access || "").trim() - if (!key || seenKeys.has(key)) continue - seenKeys.add(key) - entries.push({ - providerID: "openrouter", - entryID: "openrouter", - auth: { key, keyName: "default" }, - }) - break - } - - return entries -}