diff --git a/.changeset/616fe162.md b/.changeset/616fe162.md new file mode 100644 index 0000000..fbb7d58 --- /dev/null +++ b/.changeset/616fe162.md @@ -0,0 +1,5 @@ +--- +"@mynameistito/opencode-usage-limits": patch +--- + +add OpenRouter API-key spending limit support diff --git a/.changeset/a96ffc92.md b/.changeset/a96ffc92.md new file mode 100644 index 0000000..0d5c7b6 --- /dev/null +++ b/.changeset/a96ffc92.md @@ -0,0 +1,5 @@ +--- +"@mynameistito/opencode-plugins-docs": patch +--- + +document OpenRouter spending limits diff --git a/apps/web/docs/index.mdx b/apps/web/docs/index.mdx index 774c248..c20063a 100644 --- a/apps/web/docs/index.mdx +++ b/apps/web/docs/index.mdx @@ -19,8 +19,8 @@ Both packages are published to npm and installed through the OpenCode CLI. They composer hint. - Track Codex, DeepSeek, Novita AI, Command Code, Z.AI, Synthetic, MiniMax, - Qwen, Alibaba Token Plan, and OpenCode GO windows and balances. + Track Codex, DeepSeek, Novita AI, OpenRouter, Command Code, Z.AI, Synthetic, + MiniMax, Qwen, Alibaba Token Plan, and OpenCode GO windows and balances. diff --git a/apps/web/docs/usage-limits.mdx b/apps/web/docs/usage-limits.mdx index 58242ee..8de230d 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -142,6 +142,10 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag OpenAI + + OpenRouter + OpenRouter + { "opencode-go": Object.keys( publishedSchema.$defs.openCodeGoProvider.properties ).toSorted(), + openrouter: Object.keys( + publishedSchema.$defs.openRouterProvider.properties + ).toSorted(), qwen: Object.keys( publishedSchema.$defs.qwenProvider.properties ).toSorted(), @@ -124,6 +129,7 @@ describe("configuration parsing", () => { minimax: { $ref: "#/$defs/minimaxProvider" }, "novita-ai": { $ref: "#/$defs/novitaAiProvider" }, "opencode-go": { $ref: "#/$defs/openCodeGoProvider" }, + openrouter: { $ref: "#/$defs/openRouterProvider" }, qwen: { $ref: "#/$defs/qwenProvider" }, synthetic: { $ref: "#/$defs/syntheticProvider" }, zai: { $ref: "#/$defs/zaiProvider" }, @@ -150,6 +156,7 @@ describe("configuration parsing", () => { providerFields.commandcode, providerFields.deepseek, providerFields["novita-ai"], + providerFields.openrouter, providerFields.minimax, providerFields["opencode-go"], providerFields.synthetic, @@ -217,6 +224,13 @@ describe("configuration parsing", () => { enabled: true, label: "Novita", }, + openrouter: { + apiKey: "openrouter-secret", + authPath: "~/.config/opencode/auth.json", + baseUrl: "https://openrouter.ai", + enabled: true, + label: "OR", + }, }, }); @@ -225,13 +239,15 @@ describe("configuration parsing", () => { const commandCodeApiKey = success?.providers.commandcode?.apiKey; const deepSeekApiKey = success?.providers.deepseek?.apiKey; const novitaAiApiKey = success?.providers["novita-ai"]?.apiKey; + const openRouterApiKey = success?.providers.openrouter?.apiKey; expect(Result.isSuccess(result)).toBeTruthy(); expect([ Redacted.isRedacted(apiKey), Redacted.isRedacted(commandCodeApiKey), Redacted.isRedacted(deepSeekApiKey), Redacted.isRedacted(novitaAiApiKey), - ]).toStrictEqual([true, true, true, true]); + Redacted.isRedacted(openRouterApiKey), + ]).toStrictEqual([true, true, true, true, true]); expect(String(apiKey)).not.toContain("do-not-log"); expect(success?.providers).toMatchObject({ codex: { @@ -263,6 +279,12 @@ describe("configuration parsing", () => { enabled: true, label: "Novita", }, + openrouter: { + authPath: "~/.config/opencode/auth.json", + baseUrl: "https://openrouter.ai", + enabled: true, + label: "OR", + }, }); }); @@ -539,6 +561,7 @@ describe("configuration loading", () => { openai: { accountId: "account" }, opencode: { key: "opencode" }, "opencode-go": { key: "go" }, + openrouter: { key: "openrouter" }, synthetic: { apiKey: "synthetic" }, zai: { key: "zai" }, "zai-coding-plan": { key: "zai-plan" }, @@ -554,6 +577,7 @@ describe("configuration loading", () => { credentialValue(auth.commandcode?.key), credentialValue(auth.deepseek?.key), credentialValue(auth["novita-ai"]?.apiKey), + credentialValue(auth.openrouter?.key), credentialValue(auth.synthetic?.apiKey), credentialValue(auth.zai?.key), credentialValue(auth["zai-coding-plan"]?.key), @@ -567,6 +591,7 @@ describe("configuration loading", () => { "commandcode", "deepseek", "novita-ai", + "openrouter", "synthetic", "zai", "zai-plan", diff --git a/packages/opencode-usage-limits/__tests__/format.test.ts b/packages/opencode-usage-limits/__tests__/format.test.ts index a694201..033b64d 100644 --- a/packages/opencode-usage-limits/__tests__/format.test.ts +++ b/packages/opencode-usage-limits/__tests__/format.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { bottomWindowMainText, + formatQuotaText, formatTimestamp, formatTokenCount, limitLabelForWindow, @@ -68,6 +69,31 @@ describe("format helpers", () => { expect(bottomWindowMainText(window)).toBe("USD balance $12.34 remaining"); }); + it("formats unitized USD count quotas as spending amounts", () => { + const quota = countQuota( + Result.getOrThrow(parseUsageCount(25.5)), + Result.getOrThrow(parseUsageCount(100)), + Result.getOrThrow(parseUsagePercentage(25.5)), + "USD" + ); + + expect( + windowMainText(usageWindow({ label: "spend", quota, resetsAt: null })) + ).toBe("spend: $25.50 / $100.00 used"); + expect( + bottomWindowMainText( + usageWindow({ label: "spend", quota, resetsAt: null }) + ) + ).toBe("spend $25.50 / $100.00 used"); + }); + + it("appends used only to percentage-style component text", () => { + const quota = percentageQuota(Result.getOrThrow(parseUsagePercentage(42))); + + expect(formatQuotaText(quota)).toBe("42%"); + expect(formatQuotaText(quota, true)).toBe("42% used"); + }); + it("formats zero balances and preserves generic units", () => { const zero = balanceQuota(Result.getOrThrow(parseUsageBalance(0)), "CNY"); const credits = balanceQuota( diff --git a/packages/opencode-usage-limits/__tests__/providers/helpers.ts b/packages/opencode-usage-limits/__tests__/providers/helpers.ts index 34095a3..8b8562d 100644 --- a/packages/opencode-usage-limits/__tests__/providers/helpers.ts +++ b/packages/opencode-usage-limits/__tests__/providers/helpers.ts @@ -20,5 +20,6 @@ export const resetFetchMock = () => { delete process.env.OC_USAGE_LIMITS_SYNTHETIC_KEY; delete process.env.OC_USAGE_LIMITS_MINIMAX_KEY; delete process.env.DEEPSEEK_API_KEY; + delete process.env.OPENROUTER_API_KEY; vi.restoreAllMocks(); }; diff --git a/packages/opencode-usage-limits/__tests__/providers/index.test.ts b/packages/opencode-usage-limits/__tests__/providers/index.test.ts index a01c2f3..1d9954e 100644 --- a/packages/opencode-usage-limits/__tests__/providers/index.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/index.test.ts @@ -48,6 +48,7 @@ describe("provider manifest", () => { ["openai", pluginProviderForOpenCode("openai")], ["deepseek", pluginProviderForOpenCode("deepseek")], ["novita-ai", pluginProviderForOpenCode("novita-ai")], + ["openrouter", pluginProviderForOpenCode("openrouter")], ["zai-coding-plan", pluginProviderForOpenCode("zai-coding-plan")], ["minimax-coding-plan", pluginProviderForOpenCode("minimax-coding-plan")], ["minimax", pluginProviderForOpenCode("minimax")], @@ -63,6 +64,7 @@ describe("provider manifest", () => { ["openai", "codex"], ["deepseek", "deepseek"], ["novita-ai", "novita-ai"], + ["openrouter", "openrouter"], ["zai-coding-plan", "zai"], ["minimax-coding-plan", "minimax"], ["minimax", "minimax"], diff --git a/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts b/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts new file mode 100644 index 0000000..9afa7fb --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts @@ -0,0 +1,285 @@ +import { rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { parseOpenCodeAuth } from "@/config-schema.ts"; +import { + ProviderResponseDecodeError, + ProviderTransportError, +} from "@/errors.ts"; +import { pluginProviderForOpenCode } from "@/providers/index.ts"; +import { fetchOpenRouterUsage } from "@/providers/openrouter.ts"; + +import { installFetchMock, resetFetchMock } from "./helpers.ts"; + +interface KeyResponseData { + readonly byok_usage?: number; + readonly include_byok_in_limit?: boolean; + readonly limit?: number | string | null; + readonly limit_remaining?: number | string | null; + readonly limit_reset?: string | null; + readonly rate_limit?: { + readonly interval: string; + readonly requests: number; + }; + readonly usage?: number; +} + +const keyResponse = (data: KeyResponseData) => Response.json({ data }); + +describe("OpenRouter provider", () => { + afterEach(resetFetchMock); + + it.each([ + ["daily", "daily"], + ["weekly", "weekly"], + ["monthly", "monthly"], + [null, "credits"], + [undefined, "credits"], + ["unspecified", "credits"], + ] as const)( + "maps reset cadence %s without inventing a reset", + async (cadence, kind) => { + const fetchMock = installFetchMock( + keyResponse({ limit: 100, limit_remaining: 74.5, limit_reset: cadence }) + ); + + const usage = await fetchOpenRouterUsage( + undefined, + { openrouter: { key: "key" } }, + 1000 + ); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://openrouter.ai/api/v1/key", + { + headers: { Accept: "application/json", Authorization: "Bearer key" }, + method: "GET", + }, + ]); + expect(usage.windows).toStrictEqual([ + { + kind, + label: "spend", + quota: { + _tag: "Count", + current: 25.5, + remainingPercent: 74.5, + total: 100, + unit: "USD", + usedPercent: 25.5, + }, + resetsAt: null, + }, + ]); + } + ); + + it.each([false, true])( + "does not double-count BYOK usage when include_byok_in_limit is %s", + async (includeByok) => { + const fetchMock = installFetchMock( + keyResponse({ + byok_usage: 17.38, + include_byok_in_limit: includeByok, + limit: 100, + limit_remaining: 74.5, + limit_reset: "monthly", + rate_limit: { interval: "1h", requests: 1000 }, + usage: 25.5, + }) + ); + const auth = parseOpenCodeAuth({ openrouter: { key: "key" } }); + + const usage = await fetchOpenRouterUsage(undefined, auth, 1000); + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer key" }, + }); + expect(usage.windows[0]?.quota).toMatchObject({ + current: 25.5, + total: 100, + }); + } + ); + + it("supports zero and fully consumed spending limits", async () => { + installFetchMock(keyResponse({ limit: 100, limit_remaining: 100 })); + const unused = await fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000); + expect(unused.windows[0]?.quota).toMatchObject({ + current: 0, + usedPercent: 0, + }); + + installFetchMock(keyResponse({ limit: 100, limit_remaining: 0 })); + const spent = await fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000); + expect(spent.windows[0]?.quota).toMatchObject({ + current: 100, + usedPercent: 100, + }); + }); + + it("clamps a remaining amount larger than the limit", async () => { + installFetchMock(keyResponse({ limit: 100, limit_remaining: 120 })); + + const usage = await fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000); + + expect(usage.windows[0]?.quota).toMatchObject({ + current: 0, + remainingPercent: 100, + usedPercent: 0, + }); + }); + + it.each([ + ["null limit", { limit: null, limit_remaining: null }], + ["missing limit", { limit_remaining: 10 }], + ["zero limit", { limit: 0, limit_remaining: 0 }], + ["null remaining", { limit: 100, limit_remaining: null }], + ])("does not invent a quota for %s", async (_label, data) => { + installFetchMock(keyResponse(data)); + + const usage = await fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000); + + expect(usage.windows[0]).toMatchObject({ + kind: "credits", + label: "spend limit", + quota: { _tag: "Unknown" }, + resetsAt: null, + }); + }); + + it.each([ + ["string limit", { limit: "100", limit_remaining: 50 }], + ["negative limit", { limit: -1, limit_remaining: 0 }], + ["negative remaining", { limit: 100, limit_remaining: -1 }], + ["string remaining", { limit: 100, limit_remaining: "50" }], + ])("rejects malformed %s", async (_label, data) => { + installFetchMock(keyResponse(data)); + + await expect( + fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderResponseDecodeError); + }); + + it("uses explicit environment-backed credentials", async () => { + process.env.OPENROUTER_API_KEY = "environment-key"; + const fetchMock = installFetchMock( + keyResponse({ limit: 100, limit_remaining: 74.5, limit_reset: "monthly" }) + ); + + await fetchOpenRouterUsage( + { apiKey: "{env:OPENROUTER_API_KEY}" }, + {}, + 1000 + ); + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer environment-key" }, + }); + }); + + it("prefers authPath to OpenCode and configured credentials", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-openrouter-${crypto.randomUUID()}.json` + ); + await writeFile( + authPath, + JSON.stringify({ openrouter: { key: "file-key" } }) + ); + try { + const fetchMock = installFetchMock( + keyResponse({ + limit: 100, + limit_remaining: 74.5, + limit_reset: "monthly", + }) + ); + + await fetchOpenRouterUsage( + { apiKey: "config-key", authPath }, + { openrouter: { key: "opencode-key" } }, + 1000 + ); + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer file-key" }, + }); + } finally { + await rm(authPath, { force: true }); + } + }); + + it("requires an explicit credential on custom origins", async () => { + await expect( + fetchOpenRouterUsage( + { baseUrl: "https://router.example.test" }, + { openrouter: { key: "opencode-key" } }, + 1000 + ) + ).rejects.toThrow("missing OpenRouter key"); + + const fetchMock = installFetchMock( + keyResponse({ limit: 100, limit_remaining: 74.5, limit_reset: "monthly" }) + ); + await fetchOpenRouterUsage( + { + apiKey: "explicit-key", + baseUrl: "https://router.example.test/v1?tenant=one#fragment", + }, + { openrouter: { key: "opencode-key" } }, + 1000 + ); + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://router.example.test/api/v1/key", + { headers: { Authorization: "Bearer explicit-key" } }, + ]); + }); + + it("uses an explicitly configured authPath credential on custom origins", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-openrouter-custom-${crypto.randomUUID()}.json` + ); + await writeFile(authPath, JSON.stringify({ key: "auth-file-key" })); + try { + const fetchMock = installFetchMock( + keyResponse({ limit: 100, limit_remaining: 74.5 }) + ); + + await fetchOpenRouterUsage( + { authPath, baseUrl: "https://router.example.test" }, + {}, + 1000 + ); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://router.example.test/api/v1/key", + { headers: { Authorization: "Bearer auth-file-key" } }, + ]); + } finally { + await rm(authPath, { force: true }); + } + }); + + it("classifies HTTP and malformed-envelope failures", async () => { + installFetchMock( + Response.json({ message: "unauthorized" }, { status: 401 }) + ); + const request = fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000); + await expect(request).rejects.toBeInstanceOf(ProviderTransportError); + await expect(request).rejects.toThrow("provider credentials were rejected"); + + installFetchMock(Response.json({ data: [] })); + await expect( + fetchOpenRouterUsage({ apiKey: "explicit" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderResponseDecodeError); + }); + + it("maps OpenCode sessions to the OpenRouter provider", () => { + expect(pluginProviderForOpenCode("openrouter")).toBe("openrouter"); + }); +}); diff --git a/packages/opencode-usage-limits/examples/usage-limits.jsonc b/packages/opencode-usage-limits/examples/usage-limits.jsonc index 06f773e..e482a25 100644 --- a/packages/opencode-usage-limits/examples/usage-limits.jsonc +++ b/packages/opencode-usage-limits/examples/usage-limits.jsonc @@ -47,6 +47,16 @@ "apiKey": "{env:NOVITA_API_KEY}", // Optional fallback when OpenCode auth has no Novita AI key "baseUrl": "https://api.novita.ai", }, + "openrouter": { + "enabled": false, + "label": "OpenRouter", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", + "apiKey": "{env:OPENROUTER_API_KEY}", // Optional explicit credential; normally OpenCode auth is discovered + "baseUrl": "https://openrouter.ai", + }, "zai": { "enabled": true, "label": "ZAI", diff --git a/packages/opencode-usage-limits/package.json b/packages/opencode-usage-limits/package.json index 6db7eca..7eb3e3b 100644 --- a/packages/opencode-usage-limits/package.json +++ b/packages/opencode-usage-limits/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "@mynameistito/opencode-usage-limits", "version": "1.2.2", - "description": "OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, and Qwen usage limits in the sidebar and prompt footer.", + "description": "OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, and Qwen usage limits in the sidebar and prompt footer.", "keywords": [ "ai", "ai-coding", @@ -19,6 +19,7 @@ "opencode-go", "opencode-plugin", "opencode-tui", + "openrouter", "prompt-footer", "quota", "qwen", diff --git a/packages/opencode-usage-limits/src/components.tsx b/packages/opencode-usage-limits/src/components.tsx index 81bc6f4..26988ef 100644 --- a/packages/opencode-usage-limits/src/components.tsx +++ b/packages/opencode-usage-limits/src/components.tsx @@ -5,8 +5,7 @@ import { createMemo, For } from "solid-js"; import type { ConfigDiagnostic } from "@/config.ts"; import { bottomWindowMainText, - formatBalance, - formatPercent, + formatQuotaText, formatTimestamp, percentBar, windowResetText, @@ -98,10 +97,7 @@ const UsageWindowRows = (props: { {(window) => { const usedPercent = quotaUsedPercent(window.quota); const showBar = props.showBar && window.quota._tag !== "Balance"; - const quotaText = - window.quota._tag === "Balance" - ? formatBalance(window.quota) - : `${formatPercent(usedPercent)} used`; + const quotaText = formatQuotaText(window.quota, true); return ( diff --git a/packages/opencode-usage-limits/src/config-schema.ts b/packages/opencode-usage-limits/src/config-schema.ts index 4d98421..a9126b5 100644 --- a/packages/opencode-usage-limits/src/config-schema.ts +++ b/packages/opencode-usage-limits/src/config-schema.ts @@ -106,6 +106,14 @@ const novitaAiProviderConfigSchema = Schema.Struct({ baseUrl: Schema.optionalKey(Schema.String), }); +/** Schema for OpenRouter key spending-limit configuration. */ +const openRouterProviderConfigSchema = Schema.Struct({ + ...commonProviderFields, + apiKey: Schema.optionalKey(secret), + authPath: Schema.optionalKey(Schema.String), + baseUrl: Schema.optionalKey(Schema.String), +}); + /** Schema for Qwen provider configuration. */ const qwenProviderConfigSchema = Schema.Struct(commonProviderFields); @@ -138,6 +146,7 @@ const providersSchema = Schema.Struct({ minimax: Schema.optionalKey(minimaxProviderConfigSchema), "novita-ai": Schema.optionalKey(novitaAiProviderConfigSchema), "opencode-go": Schema.optionalKey(openCodeGoProviderConfigSchema), + openrouter: Schema.optionalKey(openRouterProviderConfigSchema), qwen: Schema.optionalKey(qwenProviderConfigSchema), synthetic: Schema.optionalKey(syntheticProviderConfigSchema), zai: Schema.optionalKey(zaiProviderConfigSchema), @@ -250,6 +259,7 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { const minimax = parseAuthEntry(input.minimax); const novitaAi = parseAuthEntry(input["novita-ai"]); + const openrouter = parseAuthEntry(input.openrouter); const deepseek = parseAuthEntry(input.deepseek); const minimaxCodingPlan = parseAuthEntry(input["minimax-coding-plan"]); const minimaxTokenPlan = parseAuthEntry(input["minimax-token-plan"]); @@ -269,6 +279,9 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { if (novitaAi) { auth["novita-ai"] = novitaAi; } + if (openrouter) { + auth.openrouter = openrouter; + } if (deepseek) { auth.deepseek = deepseek; } diff --git a/packages/opencode-usage-limits/src/errors-shared.ts b/packages/opencode-usage-limits/src/errors-shared.ts index 88d89d4..4a8b346 100644 --- a/packages/opencode-usage-limits/src/errors-shared.ts +++ b/packages/opencode-usage-limits/src/errors-shared.ts @@ -15,6 +15,7 @@ export const ProviderIDSchema = Schema.Literals([ "opencode-go", "commandcode", "novita-ai", + "openrouter", ]); /** Safe, user-facing missing-credential message for each provider. */ @@ -26,6 +27,7 @@ export const credentialMessages = { minimax: "missing MiniMax key", "novita-ai": "missing Novita AI key", "opencode-go": "missing OpenCode GO key", + openrouter: "missing OpenRouter key", qwen: "missing Qwen credentials", synthetic: "missing Synthetic key", zai: "missing ZAI key", diff --git a/packages/opencode-usage-limits/src/errors/response-decode.ts b/packages/opencode-usage-limits/src/errors/response-decode.ts index 021a7c2..3f3b490 100644 --- a/packages/opencode-usage-limits/src/errors/response-decode.ts +++ b/packages/opencode-usage-limits/src/errors/response-decode.ts @@ -27,6 +27,7 @@ export class ProviderResponseDecodeError extends schemaTaggedError { * report a percentage. * @returns A rounded usage string, or `? used` when usage is unknown. */ -export const formatPercent = (value: number | null): string => +const formatPercent = (value: number | null): string => value === null ? "?" : `${Math.round(value)}%`; const CURRENCY_SYMBOLS = new Map([ @@ -64,7 +64,7 @@ const CURRENCY_SYMBOLS = new Map([ * @param quota - The remaining balance to render. * @returns A concise amount followed by `remaining`. */ -export const formatBalance = (quota: BalanceQuota): string => { +const formatBalance = (quota: BalanceQuota): string => { const symbol = CURRENCY_SYMBOLS.get(quota.unit.toUpperCase()); const rounded = symbol ? quota.remaining.toFixed(2) : ""; const displayAmount = @@ -75,10 +75,29 @@ export const formatBalance = (quota: BalanceQuota): string => { return `${amount} remaining`; }; -const quotaMainText = (quota: UsageQuota): string => - quota._tag === "Balance" - ? formatBalance(quota) - : formatPercent(quotaUsedPercent(quota)); +/** Formats an explicitly-unitized count quota without losing its unit. */ +const formatCountQuota = ( + quota: Extract +): string => { + const symbol = CURRENCY_SYMBOLS.get(quota.unit?.toUpperCase() ?? ""); + const amount = (value: number): string => + symbol ? `${symbol}${value.toFixed(2)}` : `${value} ${quota.unit}`; + return `${amount(quota.current)} / ${amount(quota.total)} used`; +}; + +export const formatQuotaText = ( + quota: UsageQuota, + appendUsedSuffix = false +): string => { + if (quota._tag === "Balance") { + return formatBalance(quota); + } + if (quota._tag === "Count" && quota.unit) { + return formatCountQuota(quota); + } + const text = formatPercent(quotaUsedPercent(quota)); + return appendUsedSuffix ? `${text} used` : text; +}; /** * Builds the primary line of text for a usage window in the sidebar panel. @@ -87,7 +106,7 @@ const quotaMainText = (quota: UsageQuota): string => * @returns A label and quota summary such as `daily: 42%` or `$12.34 remaining`. */ export const windowMainText = (window: UsageWindow): string => - `${window.label}: ${quotaMainText(window.quota)}`; + `${window.label}: ${formatQuotaText(window.quota)}`; /** * Builds the compact prompt-footer text for the active provider's primary window. @@ -96,7 +115,7 @@ export const windowMainText = (window: UsageWindow): string => * @returns A compact quota label such as `daily 42%` or `$12.34 remaining`. */ export const bottomWindowMainText = (window: UsageWindow): string => - `${window.label} ${quotaMainText(window.quota)}`; + `${window.label} ${formatQuotaText(window.quota)}`; /** * Formats the reset suffix for a usage window. diff --git a/packages/opencode-usage-limits/src/providers/index.ts b/packages/opencode-usage-limits/src/providers/index.ts index e7a3e32..b027802 100644 --- a/packages/opencode-usage-limits/src/providers/index.ts +++ b/packages/opencode-usage-limits/src/providers/index.ts @@ -6,6 +6,7 @@ import type { ProviderDefinition } from "@/providers/definition.ts"; import { minimaxProvider } from "@/providers/minimax.ts"; import { novitaAiProvider } from "@/providers/novita-ai.ts"; import { openCodeGoProvider } from "@/providers/opencode-go.ts"; +import { openRouterProvider } from "@/providers/openrouter.ts"; import { qwenProvider } from "@/providers/qwen.ts"; import { syntheticProvider } from "@/providers/synthetic.ts"; import { zaiProvider } from "@/providers/zai-coding-plan.ts"; @@ -24,6 +25,7 @@ const PROVIDER_MANIFEST: ProviderRegistry = { minimax: minimaxProvider, "novita-ai": novitaAiProvider, "opencode-go": openCodeGoProvider, + openrouter: openRouterProvider, qwen: qwenProvider, synthetic: syntheticProvider, zai: zaiProvider, diff --git a/packages/opencode-usage-limits/src/providers/openrouter.ts b/packages/opencode-usage-limits/src/providers/openrouter.ts new file mode 100644 index 0000000..f309357 --- /dev/null +++ b/packages/opencode-usage-limits/src/providers/openrouter.ts @@ -0,0 +1,226 @@ +import { Clock, Effect, Redacted, Result } from "effect"; + +import { + MissingProviderCredentialsError, + ProviderResponseDecodeError, +} from "@/errors.ts"; +import { readProviderAuthFileCredential } from "@/providers/auth-file.ts"; +import type { ProviderDefinition } from "@/providers/definition.ts"; +import { isJsonNumber, isJsonString } from "@/providers/json.ts"; +import { ProviderEnvironment } from "@/providers/runtime/environment.ts"; +import { ProviderHttpClient } from "@/providers/runtime/http.ts"; +import { ProviderRuntimeLive } from "@/providers/runtime/index.ts"; +import type { + OpenCodeAuth, + OpenRouterProviderConfig, + ProviderUsage, + UsageWindow, +} from "@/types.ts"; +import { + countQuota, + parseUsageCount, + parseUsagePercentage, + unknownQuota, +} from "@/usage.ts"; +import type { QuotaCount } from "@/usage.ts"; +import { isRecord } from "@/utils.ts"; +import type { JsonObject, JsonValue } from "@/utils.ts"; +import { resolveHttpsBaseUrl } from "@/utils/url.ts"; + +const OFFICIAL_ORIGIN = "https://openrouter.ai"; +const KEY_PATH = "/api/v1/key"; +const PROVIDER_ID = "openrouter" as const; +type CredentialInput = JsonValue | Redacted.Redacted | undefined; + +interface KeyLimit { + readonly limit: QuotaCount; + readonly remaining: QuotaCount; + readonly resetKind: UsageWindow["kind"]; +} + +const keyFromAuth = ( + value: JsonObject, + credential: ( + value: JsonValue | undefined + ) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + const entry = isRecord(value.openrouter) ? value.openrouter : value; + return credential(entry.key) ?? credential(entry.apiKey); +}; + +const keyFromOpenCodeAuth = ( + value: OpenCodeAuth, + credential: (value: CredentialInput) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + if (!isRecord(value) || !isRecord(value.openrouter)) { + return undefined; + } + return ( + credential(value.openrouter.key) ?? credential(value.openrouter.apiKey) + ); +}; + +const resetKind = (value: JsonValue | undefined): UsageWindow["kind"] => { + if (!isJsonString(value)) { + return "credits"; + } + switch (value) { + case "daily": + case "weekly": + case "monthly": { + return value; + } + default: { + return "credits"; + } + } +}; + +const optionalLimitNumber = ( + value: JsonValue | undefined +): QuotaCount | null | undefined => { + if (value === undefined || value === null) { + return value; + } + if (!isJsonNumber(value) || !Number.isFinite(value) || value < 0) { + return undefined; + } + const parsed = parseUsageCount(value); + return Result.isSuccess(parsed) ? parsed.success : undefined; +}; + +const parseKeyLimit = (value: JsonObject): KeyLimit | null | undefined => { + const limit = optionalLimitNumber(value.limit); + const remaining = optionalLimitNumber(value.limit_remaining); + const invalidLimit = + value.limit !== undefined && value.limit !== null && limit === undefined; + const invalidRemaining = + value.limit_remaining !== undefined && + value.limit_remaining !== null && + remaining === undefined; + if (invalidLimit || invalidRemaining) { + return undefined; + } + if (limit === undefined || limit === null || limit === 0) { + return null; + } + if (remaining === undefined || remaining === null) { + return null; + } + return { + limit, + remaining: Result.getOrThrow(parseUsageCount(Math.min(limit, remaining))), + resetKind: resetKind(value.limit_reset), + }; +}; + +const keyLimitWindow = (keyLimit: KeyLimit | null): UsageWindow => { + if (keyLimit === null) { + return { + kind: "credits", + label: "spend limit", + quota: unknownQuota, + resetsAt: null, + }; + } + const current = Result.getOrThrow( + parseUsageCount(keyLimit.limit - keyLimit.remaining) + ); + const percentage = Result.getOrThrow( + parseUsagePercentage((current / keyLimit.limit) * 100) + ); + return { + kind: keyLimit.resetKind, + label: "spend", + quota: countQuota(current, keyLimit.limit, percentage, "USD"), + resetsAt: null, + }; +}; + +const keyUrl = (baseUrl: string): string => { + const url = new URL(baseUrl); + url.pathname = KEY_PATH; + url.search = ""; + url.hash = ""; + return url.toString(); +}; + +const fetchOpenRouterUsageEffect = ( + config: OpenRouterProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): ReturnType["fetch"]> => + Effect.gen(function* runFetchOpenRouterUsage() { + const environment = yield* ProviderEnvironment; + const http = yield* ProviderHttpClient; + const baseUrl = resolveHttpsBaseUrl(config?.baseUrl, OFFICIAL_ORIGIN); + const isOfficialOrigin = new URL(baseUrl).origin === OFFICIAL_ORIGIN; + const authFileKey = yield* readProviderAuthFileCredential( + config?.authPath, + PROVIDER_ID, + keyFromAuth + ); + const configuredKey = environment.resolveCredential(config?.apiKey); + const openCodeKey = isRecord(openCodeAuth) + ? keyFromOpenCodeAuth(openCodeAuth, environment.credential) + : undefined; + const apiKey = + authFileKey ?? + (isOfficialOrigin ? (openCodeKey ?? configuredKey) : configuredKey); + if (!apiKey) { + return yield* new MissingProviderCredentialsError({ + operation: "fetch-usage", + providerID: PROVIDER_ID, + }); + } + + const payload = yield* http.requestJson({ + headers: { + Accept: "application/json", + Authorization: `Bearer ${Redacted.value(apiKey)}`, + }, + method: "GET", + providerID: PROVIDER_ID, + timeoutMs, + url: keyUrl(baseUrl), + }); + const data = + isRecord(payload) && isRecord(payload.data) ? payload.data : null; + const parsed = data ? parseKeyLimit(data) : undefined; + if (parsed === undefined) { + return yield* new ProviderResponseDecodeError({ + cause: "schema", + operation: "decode-response", + providerID: PROVIDER_ID, + }); + } + + return { + capturedAt: new Date(yield* Clock.currentTimeMillis), + id: PROVIDER_ID, + label: config?.label ?? "OpenRouter", + windows: [keyLimitWindow(parsed)], + }; + }); + +/** Fetches OpenRouter key spending-limit usage from its documented current-key API. */ +export const fetchOpenRouterUsage = ( + config: OpenRouterProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): Promise> => + Effect.runPromise( + fetchOpenRouterUsageEffect(config, openCodeAuth, timeoutMs).pipe( + Effect.provide(ProviderRuntimeLive) + ) + ); + +/** OpenRouter key spending-limit adapter and OpenCode provider mapping. */ +export const openRouterProvider = { + defaultLabel: "OpenRouter", + displayOrder: 10, + fetch: fetchOpenRouterUsageEffect, + footerWindowKind: "credits", + id: PROVIDER_ID, + openCodeProviderIDs: [PROVIDER_ID], +} as const satisfies ProviderDefinition<"openrouter">; diff --git a/packages/opencode-usage-limits/src/types.ts b/packages/opencode-usage-limits/src/types.ts index a4c316e..626e782 100644 --- a/packages/opencode-usage-limits/src/types.ts +++ b/packages/opencode-usage-limits/src/types.ts @@ -31,7 +31,8 @@ export type ProviderID = | "qwen" | "opencode-go" | "commandcode" - | "novita-ai"; + | "novita-ai" + | "openrouter"; /** Sensitive string accepted by parsed config and legacy provider boundaries. */ type Credential = Redacted.Redacted | string; @@ -202,6 +203,16 @@ export interface NovitaAiProviderConfig extends CommonProviderConfig { readonly baseUrl?: string; } +/** OpenRouter API-key spending-limit configuration. */ +export interface OpenRouterProviderConfig extends CommonProviderConfig { + /** OpenRouter API credential override. */ + readonly apiKey?: Credential; + /** Optional path to an auth file; supports a leading `~`. */ + readonly authPath?: string; + /** HTTPS API base URL override. */ + readonly baseUrl?: string; +} + /** Qwen settings; provider credentials are obtained from the Qwen CLI. */ export type QwenProviderConfig = CommonProviderConfig; @@ -243,6 +254,8 @@ export interface ProviderConfigMap { readonly minimax: MiniMaxProviderConfig; /** Novita AI balance settings. */ readonly "novita-ai": NovitaAiProviderConfig; + /** OpenRouter key spending-limit settings. */ + readonly openrouter: OpenRouterProviderConfig; /** Qwen CLI settings. */ readonly qwen: QwenProviderConfig; /** Synthetic settings. */ @@ -304,6 +317,8 @@ export interface OpenCodeAuth { commandcode?: OpenCodeAuthEntry | null; /** Novita AI credentials stored under the provider's catalog ID. */ "novita-ai"?: OpenCodeAuthEntry | null; + /** OpenRouter credentials stored under the provider's catalog ID. */ + openrouter?: OpenCodeAuthEntry | null; /** OpenCode Zen credentials stored under the legacy provider ID. */ opencode?: OpenCodeAuthEntry | null; } diff --git a/packages/opencode-usage-limits/src/usage.ts b/packages/opencode-usage-limits/src/usage.ts index ec9e00c..996ad4b 100644 --- a/packages/opencode-usage-limits/src/usage.ts +++ b/packages/opencode-usage-limits/src/usage.ts @@ -39,6 +39,7 @@ export type UsageQuota = readonly current: QuotaCount; readonly remainingPercent: Percentage; readonly total: QuotaCount; + readonly unit?: string; readonly usedPercent: Percentage; } | BalanceQuota @@ -151,7 +152,8 @@ export const percentageQuota = (usedPercent: Percentage): UsageQuota => { export const countQuota = ( current: QuotaCount, total: QuotaCount, - usedPercent: Percentage + usedPercent: Percentage, + unit?: string ): UsageQuota => { const parsedCurrent = QuotaCountSchema.make(current); const parsedTotal = QuotaCountSchema.make(total); @@ -159,13 +161,16 @@ export const countQuota = ( if (parsedCurrent > parsedTotal) { throw new RangeError("quota current count cannot exceed total count"); } - return { + const quota = { _tag: "Count", current: parsedCurrent, remainingPercent: PercentageSchema.make(100 - parsedUsed), total: parsedTotal, usedPercent: parsedUsed, - }; + } as const; + return unit === undefined + ? quota + : { ...quota, unit: Schema.String.make(unit) }; }; /** diff --git a/packages/opencode-usage-limits/usage-limits.schema.json b/packages/opencode-usage-limits/usage-limits.schema.json index 8383015..519af17 100644 --- a/packages/opencode-usage-limits/usage-limits.schema.json +++ b/packages/opencode-usage-limits/usage-limits.schema.json @@ -20,6 +20,7 @@ "deepseek": { "$ref": "#/$defs/deepSeekProvider" }, "minimax": { "$ref": "#/$defs/minimaxProvider" }, "novita-ai": { "$ref": "#/$defs/novitaAiProvider" }, + "openrouter": { "$ref": "#/$defs/openRouterProvider" }, "opencode-go": { "$ref": "#/$defs/openCodeGoProvider" }, "qwen": { "$ref": "#/$defs/qwenProvider" }, "synthetic": { "$ref": "#/$defs/syntheticProvider" }, @@ -229,6 +230,32 @@ "baseUrl": { "type": "string" } } }, + "openRouterProvider": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "$ref": "#/$defs/commonDisplayFields/properties/enabled" }, + "showSidebarBar": { + "$ref": "#/$defs/commonDisplayFields/properties/showSidebarBar" + }, + "showFooterBar": { + "$ref": "#/$defs/commonDisplayFields/properties/showFooterBar" + }, + "sidebarWindow": { + "$ref": "#/$defs/commonDisplayFields/properties/sidebarWindow" + }, + "footerWindow": { + "$ref": "#/$defs/commonDisplayFields/properties/footerWindow" + }, + "label": { "$ref": "#/$defs/commonDisplayFields/properties/label" }, + "authPath": { "type": "string" }, + "apiKey": { + "type": "string", + "description": "Optional literal API key or {env:OPENROUTER_API_KEY} credential." + }, + "baseUrl": { "type": "string" } + } + }, "openCodeGoProvider": { "type": "object", "additionalProperties": false,