From 986f8dc676bf784c77276c4428b1f3967f2827fb Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 18:28:48 +1300 Subject: [PATCH 1/4] feat(usage-limits): add OpenRouter spending limit provider --- .changeset/616fe162.md | 5 + packages/opencode-usage-limits/README.md | 16 +- .../__tests__/config.test.ts | 27 +- .../__tests__/format.test.ts | 18 ++ .../__tests__/providers/helpers.ts | 1 + .../__tests__/providers/index.test.ts | 2 + .../__tests__/providers/openrouter.test.ts | 259 ++++++++++++++++++ .../examples/usage-limits.jsonc | 10 + packages/opencode-usage-limits/package.json | 3 +- .../opencode-usage-limits/src/components.tsx | 19 +- .../src/config-schema.ts | 13 + .../src/errors-shared.ts | 2 + .../src/errors/response-decode.ts | 1 + packages/opencode-usage-limits/src/format.ts | 23 +- .../src/providers/index.ts | 2 + .../src/providers/openrouter.ts | 226 +++++++++++++++ packages/opencode-usage-limits/src/types.ts | 17 +- packages/opencode-usage-limits/src/usage.ts | 11 +- .../usage-limits.schema.json | 27 ++ 19 files changed, 666 insertions(+), 16 deletions(-) create mode 100644 .changeset/616fe162.md create mode 100644 packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts create mode 100644 packages/opencode-usage-limits/src/providers/openrouter.ts 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/packages/opencode-usage-limits/README.md b/packages/opencode-usage-limits/README.md index 2e0b09a..3fa9a18 100644 --- a/packages/opencode-usage-limits/README.md +++ b/packages/opencode-usage-limits/README.md @@ -1,6 +1,6 @@ # @mynameistito/opencode-usage-limits -OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, Qwen, and Alibaba Token Plan usage limits in the sidebar and prompt footer. +OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, Qwen, and Alibaba Token Plan usage limits in the sidebar and prompt footer. ## Features @@ -8,6 +8,7 @@ OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, Command Code, OpenCod - Shows current Codex usage windows from OpenAI/Codex auth. - Shows current DeepSeek currency balances from the official balance API. - Shows the current available Novita AI API balance from its official billing API. +- Shows the OpenRouter API-key spending limit, not the total account balance. - Shows current ZAI quota windows from ZAI Coding Plan auth. - Shows current Synthetic rolling 5-hour and weekly windows. - Shows current MiniMax Token Plan rolling 5-hour and weekly windows. @@ -15,7 +16,7 @@ OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, Command Code, OpenCod - Shows current Alibaba Token Plan 5-hour and weekly windows from the local `bl` CLI. - Shows current OpenCode GO rolling, weekly, and monthly windows. - Displays current Command Code 5-hour, weekly, and derived monthly credit usage. -- Adds compact prompt-footer usage when the current session uses an OpenAI, DeepSeek, Novita AI, Command Code, OpenCode GO, ZAI Coding Plan, Synthetic, MiniMax Token Plan, or Qwen Token Plan model. +- Adds compact prompt-footer usage when the current session uses an OpenAI, DeepSeek, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI Coding Plan, Synthetic, MiniMax Token Plan, or Qwen Token Plan model. - Providers are toggled from `~/.config/opencode/usage-limits.jsonc`. - Reads OpenCode-connected credentials first, then falls back to explicit config/env credentials. @@ -156,6 +157,7 @@ The response contract follows the official CLI's [`usage/token-plan.ts`](https:/ | `codex` | ChatGPT Codex usage | — | Bearer | `https://chatgpt.com/backend-api` | | `deepseek` | DeepSeek balances | `DEEPSEEK_API_KEY` | Bearer | `https://api.deepseek.com` | | `novita-ai` | Novita AI available API balance (USD) | `NOVITA_API_KEY` | Bearer | `https://api.novita.ai` | +| `openrouter` | OpenRouter API-key spending limit (USD) | `OPENROUTER_API_KEY` | Bearer | `https://openrouter.ai` | | `zai` | Z.AI Coding Plan quota | `OC_ZAI_API_KEY` | raw / Bearer | `https://api.z.ai` | | `synthetic` | Synthetic quotas | `OC_SYNTHETIC_API_KEY` | Bearer | `https://api.synthetic.new` | | `minimax` | MiniMax Token Plan | `OC_MINIMAX_TOKEN_PLAN_KEY` | Bearer | `https://www.minimax.io` | @@ -172,6 +174,8 @@ DeepSeek reads `GET https://api.deepseek.com/user/balance`. Each reported curren Novita AI reads `GET https://api.novita.ai/openapi/v1/billing/balance/detail` and displays `availableBalance` as the remaining USD balance. API monetary values are strings in units of 1/10,000 USD (for example, `10000` is `$1.00`). The adapter does not derive the balance from cash, credit, debt, or invoice fields, and does not invent a percentage or reset time. OpenCode-discovered credentials are used only for the exact official Novita origin; a custom `baseUrl` requires an explicit `authPath` or `apiKey`, including `{env:NOVITA_API_KEY}`. +OpenRouter reads `GET https://openrouter.ai/api/v1/key` and displays the current API key's finite USD spending limit using `limit` and `limit_remaining`. This is **key-level spending-limit usage**, not total OpenRouter account balance. Reset cadence is shown only when the API reports `daily`, `weekly`, or `monthly`; no absolute reset countdown is inferred. Unbounded or unavailable limits are shown as unknown. Deprecated `rate_limit` and BYOK usage are ignored. OpenCode-discovered credentials are used only for the exact official OpenRouter origin; a custom `baseUrl` requires an explicit `authPath` or `apiKey`, including `{env:OPENROUTER_API_KEY}`. + Set `baseUrl` on `minimax` to `https://api.minimaxi.com` when using the mainland-China region. MiniMax always uses `Bearer` auth and ignores `authorizationScheme`. ## Credential Lookup @@ -216,6 +220,12 @@ Novita AI lookup order: 2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `novita-ai`. 3. Config `apiKey`, including `{env:NOVITA_API_KEY}` references. +OpenRouter lookup order: + +1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "openrouter": { "key": "..." } }`). +2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `openrouter` (only for the exact official OpenRouter origin). +3. Config `apiKey`, including `{env:OPENROUTER_API_KEY}` references. A custom `baseUrl` never receives an automatically discovered OpenCode credential. + Command Code lookup order: 1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "commandcode": { "key": "..." } }`). @@ -246,6 +256,8 @@ DeepSeek CNY: ¥0.00 remaining Novita AI balance: $100.00 remaining +OpenRouter + spend: $25.50 / $100.00 used ``` Prompt footer shows compact usage when the current session model belongs to a supported provider: diff --git a/packages/opencode-usage-limits/__tests__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts index c894c02..3aff97c 100644 --- a/packages/opencode-usage-limits/__tests__/config.test.ts +++ b/packages/opencode-usage-limits/__tests__/config.test.ts @@ -53,6 +53,7 @@ interface PublishedSchema { deepSeekProvider: PublishedProviderDefinition; minimaxProvider: PublishedProviderDefinition; novitaAiProvider: PublishedProviderDefinition; + openRouterProvider: PublishedProviderDefinition; openCodeGoProvider: PublishedProviderDefinition; qwenProvider: PublishedProviderDefinition; syntheticProvider: PublishedProviderDefinition; @@ -67,6 +68,7 @@ interface PublishedSchema { 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" }; @@ -107,6 +109,9 @@ describe("configuration parsing", () => { "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..b7238e1 100644 --- a/packages/opencode-usage-limits/__tests__/format.test.ts +++ b/packages/opencode-usage-limits/__tests__/format.test.ts @@ -68,6 +68,24 @@ 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("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..7c4d5ef --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts @@ -0,0 +1,259 @@ +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("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..2e94801 100644 --- a/packages/opencode-usage-limits/src/components.tsx +++ b/packages/opencode-usage-limits/src/components.tsx @@ -6,6 +6,7 @@ import type { ConfigDiagnostic } from "@/config.ts"; import { bottomWindowMainText, formatBalance, + formatCountQuota, formatPercent, formatTimestamp, percentBar, @@ -89,6 +90,19 @@ const dotColor = (usedPercent: number | null, theme: ThemeColors): RGBA => { return theme.success; }; +const quotaTextForWindow = ( + window: UsageWindow, + usedPercent: number | null +) => { + if (window.quota._tag === "Balance") { + return formatBalance(window.quota); + } + if (window.quota._tag === "Count" && window.quota.unit) { + return formatCountQuota(window.quota); + } + return `${formatPercent(usedPercent)} used`; +}; + const UsageWindowRows = (props: { showBar: boolean; theme: ThemeColors; @@ -98,10 +112,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 = quotaTextForWindow(window, usedPercent); 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 { 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. */ +export 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`; +}; + +const quotaMainText = (quota: UsageQuota): string => { + if (quota._tag === "Balance") { + return formatBalance(quota); + } + if (quota._tag === "Count" && quota.unit) { + return formatCountQuota(quota); + } + return formatPercent(quotaUsedPercent(quota)); +}; /** * Builds the primary line of text for a usage window in the sidebar panel. 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, From a60733ad8a71fac35e95960f4d5d81bbe68f6320 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 18:28:57 +1300 Subject: [PATCH 2/4] docs(site): document OpenRouter spending limits --- .changeset/a96ffc92.md | 5 +++++ apps/web/docs/index.mdx | 4 ++-- apps/web/docs/usage-limits.mdx | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .changeset/a96ffc92.md 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 + Date: Mon, 5 Oct 2026 20:36:19 +1300 Subject: [PATCH 3/4] test(usage-limits): cover auth files on custom origins --- .../__tests__/providers/openrouter.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts b/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts index 7c4d5ef..9afa7fb 100644 --- a/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/openrouter.test.ts @@ -239,6 +239,32 @@ describe("OpenRouter provider", () => { ]); }); + 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 }) From 0fd21d2d0b2719a3abb2e96694a0a77b88bfed96 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 20:39:19 +1300 Subject: [PATCH 4/4] refactor(usage-limits): share quota text formatting --- .../__tests__/format.test.ts | 8 ++++++++ .../opencode-usage-limits/src/components.tsx | 19 ++----------------- packages/opencode-usage-limits/src/format.ts | 18 +++++++++++------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/opencode-usage-limits/__tests__/format.test.ts b/packages/opencode-usage-limits/__tests__/format.test.ts index b7238e1..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, @@ -86,6 +87,13 @@ describe("format helpers", () => { ).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/src/components.tsx b/packages/opencode-usage-limits/src/components.tsx index 2e94801..26988ef 100644 --- a/packages/opencode-usage-limits/src/components.tsx +++ b/packages/opencode-usage-limits/src/components.tsx @@ -5,9 +5,7 @@ import { createMemo, For } from "solid-js"; import type { ConfigDiagnostic } from "@/config.ts"; import { bottomWindowMainText, - formatBalance, - formatCountQuota, - formatPercent, + formatQuotaText, formatTimestamp, percentBar, windowResetText, @@ -90,19 +88,6 @@ const dotColor = (usedPercent: number | null, theme: ThemeColors): RGBA => { return theme.success; }; -const quotaTextForWindow = ( - window: UsageWindow, - usedPercent: number | null -) => { - if (window.quota._tag === "Balance") { - return formatBalance(window.quota); - } - if (window.quota._tag === "Count" && window.quota.unit) { - return formatCountQuota(window.quota); - } - return `${formatPercent(usedPercent)} used`; -}; - const UsageWindowRows = (props: { showBar: boolean; theme: ThemeColors; @@ -112,7 +97,7 @@ const UsageWindowRows = (props: { {(window) => { const usedPercent = quotaUsedPercent(window.quota); const showBar = props.showBar && window.quota._tag !== "Balance"; - const quotaText = quotaTextForWindow(window, usedPercent); + const quotaText = formatQuotaText(window.quota, true); return ( diff --git a/packages/opencode-usage-limits/src/format.ts b/packages/opencode-usage-limits/src/format.ts index c040661..e2247be 100644 --- a/packages/opencode-usage-limits/src/format.ts +++ b/packages/opencode-usage-limits/src/format.ts @@ -46,7 +46,7 @@ const duration = (seconds: number | null): string => { * 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 = @@ -76,7 +76,7 @@ export const formatBalance = (quota: BalanceQuota): string => { }; /** Formats an explicitly-unitized count quota without losing its unit. */ -export const formatCountQuota = ( +const formatCountQuota = ( quota: Extract ): string => { const symbol = CURRENCY_SYMBOLS.get(quota.unit?.toUpperCase() ?? ""); @@ -85,14 +85,18 @@ export const formatCountQuota = ( return `${amount(quota.current)} / ${amount(quota.total)} used`; }; -const quotaMainText = (quota: UsageQuota): string => { +export const formatQuotaText = ( + quota: UsageQuota, + appendUsedSuffix = false +): string => { if (quota._tag === "Balance") { return formatBalance(quota); } if (quota._tag === "Count" && quota.unit) { return formatCountQuota(quota); } - return formatPercent(quotaUsedPercent(quota)); + const text = formatPercent(quotaUsedPercent(quota)); + return appendUsedSuffix ? `${text} used` : text; }; /** @@ -102,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. @@ -111,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.