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
12 changes: 7 additions & 5 deletions src/hooks/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}
},

Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/providers/openrouter/auth-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
const seenNames = new Set<string>()

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>): string {
const normalized = candidate.toLowerCase()
if (!seenNames.has(normalized)) return candidate

let suffix = 2
while (seenNames.has(`${normalized}-${suffix}`)) suffix += 1
return `${candidate}-${suffix}`
}
1 change: 1 addition & 0 deletions src/providers/openrouter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const OpenRouterProvider: UsageProvider<OpenRouterAuth> = {
},
updatedAt: now,
openrouterQuota: {
keyName: auth.keyName ?? data.data.label ?? null,
limit: data.data.limit,
usage: data.data.usage,
limitRemaining: data.data.limit_remaining,
Expand Down
1 change: 1 addition & 0 deletions src/providers/openrouter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import z from "zod"

export interface OpenRouterAuth {
key: string
keyName?: string
}

export const openRouterAuthResponseSchema = z.object({
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export interface UsageConfig {
showAll?: boolean
displayNames?: Record<string, string>
}
openrouterKeys?: Array<{
name: string
key: string
enabled?: boolean
}>
}

export interface ZaiQuota {
Expand Down Expand Up @@ -132,6 +137,7 @@ export interface AnthropicQuota {
}

export interface OpenRouterQuota {
keyName?: string | null
limit: number | null
usage: number
limitRemaining: number | null
Expand Down
3 changes: 2 additions & 1 deletion src/ui/formatters/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
8 changes: 7 additions & 1 deletion src/usage/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ export async function loadUsageConfig(): Promise<UsageConfig> {
"zai": true,
"anthropic": true,
"openrouter": true
}
},

// Optional: Multiple named OpenRouter API keys for /usage openrouter <key_name>
// "openrouterKeys": [
// { "name": "work", "key": "sk-or-v1-..." },
// { "name": "personal", "key": "sk-or-v1-...", "enabled": true }
// ]
}
`

Expand Down
57 changes: 49 additions & 8 deletions src/usage/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedProviderAuthEntry, { providerID: "openrouter" }>

const CORE_PROVIDERS = ["codex", "proxy", "copilot", "zai-coding-plan", "anthropic", "openrouter"]

export async function fetchUsageSnapshots(filter?: string): Promise<UsageSnapshot[]> {
export async function fetchUsageSnapshots(filter?: string, openrouterKeyName?: string): Promise<UsageSnapshot[]> {
const target = resolveFilter(filter)
const normalizedOpenRouterKey = openrouterKeyName?.trim().toLowerCase()
const config = await loadUsageConfig().catch(() => null)
const toggles = config?.providers ?? {}

Expand All @@ -24,17 +28,25 @@ export async function fetchUsageSnapshots(filter?: string): Promise<UsageSnapsho
}

const { auths, codexDiagnostics } = await loadMergedAuths()
const entries = resolveProviderAuths(auths, null)
const entries = resolveProviderAuthsWithConfig(auths, null, config)
const snapshotsMap = new Map<string, UsageSnapshot>()
const fetched = new Set<string>()
const fetchedOpenRouterEntryIDs = new Set<string>()

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)
}
Comment on lines 44 to 50

Choose a reason for hiding this comment

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

P2 Badge Track OpenRouter fetches per key to avoid silent drops

When multiple OpenRouter keys are configured, the fetch loop records success in fetched by provider ID only. If one key succeeds and another key fails (bad key, rate limit, etc.), appendMissingStates will think OpenRouter is fully fetched and won’t emit any missing snapshot for the failed key, so that key silently disappears from /usage output. This regression shows up only when multiple keys are configured and at least one fails; consider tracking fetched by entryID (or emitting per-key missing snapshots) so users see failures for individual keys.

Useful? React with 👍 / 👎.

})

Expand All @@ -55,7 +67,15 @@ export async function fetchUsageSnapshots(filter?: string): Promise<UsageSnapsho

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 {
Expand All @@ -80,9 +100,12 @@ function appendMissingStates(
fetched: Set<string>,
isEnabled: (id: string) => boolean,
target?: string,
diagnostics?: string[]
diagnostics?: string[],
openRouterEntries: OpenRouterEntry[] = [],
fetchedOpenRouterEntryIDs: Set<string> = 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(),
Expand All @@ -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
}

Expand Down
24 changes: 19 additions & 5 deletions src/usage/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,10 +21,12 @@ export type AuthEntry = {
export type AuthRecord = Record<string, AuthEntry>

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"]
Expand Down Expand Up @@ -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]
}