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/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/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..08234ef 100644 --- a/src/usage/fetch.ts +++ b/src/usage/fetch.ts @@ -7,12 +7,16 @@ 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" +import type { ResolvedProviderAuthEntry } from "./registry" + +type OpenRouterEntry = Extract 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,17 +28,25 @@ export async function fetchUsageSnapshots(filter?: string): Promise() 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.providerID, snap) - fetched.add(e.providerID) + snapshotsMap.set(e.entryID, snap) + if (e.providerID === "openrouter") fetchedOpenRouterEntryIDs.add(e.entryID) + else fetched.add(e.providerID) } }) @@ -55,7 +67,15 @@ export async function fetchUsageSnapshots(filter?: string): Promise 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 { @@ -80,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(), @@ -99,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 50eeffc..787d853 100644 --- a/src/usage/registry.ts +++ b/src/usage/registry.ts @@ -6,6 +6,8 @@ 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" +import { resolveOpenRouterAuths } from "../providers/openrouter/auth-resolver" export type AuthEntry = { type?: string @@ -19,10 +21,12 @@ 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 } + +export type ResolvedProviderAuthEntry = ProviderAuthEntry type ProviderDescriptor = { id: ProviderAuthEntry["providerID"] @@ -78,8 +82,18 @@ 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] +}