Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/616fe162.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mynameistito/opencode-usage-limits": patch
---

add OpenRouter API-key spending limit support
5 changes: 5 additions & 0 deletions .changeset/a96ffc92.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mynameistito/opencode-plugins-docs": patch
---

document OpenRouter spending limits
4 changes: 2 additions & 2 deletions apps/web/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Both packages are published to npm and installed through the OpenCode CLI. They
composer hint.
</Card>
<Card title="Usage Limits" href="/usage-limits" icon="gauge">
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.
</Card>
</CardGroup>

Expand Down
23 changes: 23 additions & 0 deletions apps/web/docs/usage-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag
</svg>
<span class="provider-logo-name">OpenAI</span>
</a>
<a href="#openrouter" aria-label="OpenRouter">
<img src="https://cdn.simpleicons.org/openrouter" alt="OpenRouter" />
<span class="provider-logo-name">OpenRouter</span>
</a>
<a href="#opencode-go" aria-label="OpenCode GO">
<svg
class="provider-logo-opencode provider-logo-opencode-dark"
Expand Down Expand Up @@ -269,6 +273,25 @@ Enable the `novita-ai` provider using OpenCode's saved credential, or configure

OpenCode credentials are only sent to `https://api.novita.ai`; a custom `baseUrl` requires an explicit `authPath` or `apiKey`.

### OpenRouter

Displays the current OpenRouter API key's finite spending limit from the official [`GET /api/v1/key`](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key) endpoint. The `spend` window uses `limit` and `limit_remaining` as USD amounts; it is key-level spending-limit usage, not total account balance. `daily`, `weekly`, and `monthly` reset cadences are shown without inferring an absolute reset time. Unbounded or unavailable limits appear as unknown. Deprecated `rate_limit` and BYOK usage are ignored.

OpenCode's saved `openrouter` credential is used automatically, or configure an explicit environment-backed API key:

```jsonc
{
"providers": {
"openrouter": {
"enabled": true,
"apiKey": "{env:OPENROUTER_API_KEY}", // Optional fallback
},
},
}
```

OpenCode-discovered credentials are sent only to the exact official `https://openrouter.ai` origin. A custom `baseUrl` requires an explicit `authPath` or `apiKey`.

### OpenAI

ChatGPT/OpenAI rolling windows, daily, weekly, or monthly. Credentials come from OpenCode `openai` auth, then the Codex auth file. See the [OpenAI documentation](https://developers.openai.com/codex).
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode-usage-limits/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# @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

- Adds a `Usage Limits` block under the sidebar `Context` section.
- 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.
- Shows current Qwen Token Plan windows from the local `qwencloud` CLI.
- 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.

Expand Down Expand Up @@ -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` |
Expand All @@ -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
Expand Down Expand Up @@ -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": "..." } }`).
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 26 additions & 1 deletion packages/opencode-usage-limits/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface PublishedSchema {
deepSeekProvider: PublishedProviderDefinition;
minimaxProvider: PublishedProviderDefinition;
novitaAiProvider: PublishedProviderDefinition;
openRouterProvider: PublishedProviderDefinition;
openCodeGoProvider: PublishedProviderDefinition;
qwenProvider: PublishedProviderDefinition;
syntheticProvider: PublishedProviderDefinition;
Expand All @@ -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" };
Expand Down Expand Up @@ -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(),
Expand All @@ -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" },
Expand All @@ -150,6 +156,7 @@ describe("configuration parsing", () => {
providerFields.commandcode,
providerFields.deepseek,
providerFields["novita-ai"],
providerFields.openrouter,
providerFields.minimax,
providerFields["opencode-go"],
providerFields.synthetic,
Expand Down Expand Up @@ -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",
},
},
});

Expand All @@ -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: {
Expand Down Expand Up @@ -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",
},
});
});

Expand Down Expand Up @@ -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" },
Expand All @@ -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),
Expand All @@ -567,6 +591,7 @@ describe("configuration loading", () => {
"commandcode",
"deepseek",
"novita-ai",
"openrouter",
"synthetic",
"zai",
"zai-plan",
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode-usage-limits/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";

import {
bottomWindowMainText,
formatQuotaText,
formatTimestamp,
formatTokenCount,
limitLabelForWindow,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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"],
Expand Down
Loading
Loading