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
6 changes: 6 additions & 0 deletions .changeset/moonshotai-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@mynameistito/opencode-usage-limits": minor
"@mynameistito/opencode-plugins-docs": patch
---

Add isolated global and China Moonshot/Kimi API balance providers.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Supported providers:
- [Novita AI](https://novita.ai/)
- [OpenCode GO](https://opencode.ai/go)
- [MiniMax Token Plan](https://platform.minimax.io/subscribe/token-plan)
- [Moonshot/Kimi API](https://platform.kimi.ai/docs/api/balance) (pay-as-you-go global USD / China CNY balances; distinct from Kimi For Coding subscription quota)
- [Synthetic](https://synthetic.ai/)
- [Qwen](https://qwen.ai/)
- [ZAI Coding Plan](https://zai.ai/)
Expand Down
5 changes: 3 additions & 2 deletions apps/web/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ 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, OpenRouter, Command Code, Z.AI, Synthetic,
MiniMax, Qwen, Alibaba Token Plan, and OpenCode GO windows and balances.
Track Codex, DeepSeek, Moonshot/Kimi API balances, Novita AI, OpenRouter,
Command Code, Z.AI, Synthetic, MiniMax, Qwen, Alibaba Token Plan, and
OpenCode GO windows and balances.
</Card>
</CardGroup>

Expand Down
49 changes: 49 additions & 0 deletions apps/web/docs/usage-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,25 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag
<img src="https://cdn.simpleicons.org/minimax" alt="MiniMax" />
<span class="provider-logo-name">MiniMax</span>
</a>
<a href="#moonshot-kimi-api" aria-label="Moonshot/Kimi API">
<svg
class="provider-logo-moonshot"
width="32"
height="33"
viewBox="0 0 32 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M28.8186 6.53427C26.7444 3.70464 23.7481 1.53765 20.1255 0.555191C16.5029 -0.427271 12.837 -0.067732 9.64385 1.3328L28.8186 6.53427ZM2.80611 7.0269C4.22375 4.93586 6.09629 3.23501 8.24272 2.03098L22.3683 5.86327C21.4413 6.40467 20.4483 7.33766 19.6796 8.36332L31.294 11.5142C31.6236 12.6172 31.8383 13.7606 31.9285 14.9277L2.80611 7.0269ZM31.4282 20.3772C31.2644 20.9974 31.0669 21.5994 30.8384 22.1812L0.168318 13.8609C0.257781 13.2408 0.384406 12.62 0.548192 11.9998C0.933572 10.5429 1.50338 9.18622 2.22597 7.94874L17.2345 12.0207C16.6337 12.8903 16.0866 13.8352 15.6049 14.8462L31.6917 19.2108C31.6181 19.6003 31.53 19.9891 31.4275 20.3786L31.4282 20.3772ZM0.928067 21.6161C0.184146 19.507 -0.133105 17.2257 0.0513275 14.9075L14.2058 18.7475C14.1039 19.0736 14.0083 19.4038 13.9195 19.739C13.7392 20.4211 13.5919 21.1019 13.4777 21.7778L28.7621 25.9243C28.0506 26.881 27.2399 27.7478 26.3501 28.5129L0.928067 21.6161ZM11.8508 31.8212C7.02602 30.5119 3.31123 27.1033 1.4091 22.8257L12.9272 25.9508C12.9086 27.0385 12.9753 28.0983 13.1219 29.1149L21.4909 31.385C18.5035 32.4971 15.1596 32.7186 11.8508 31.8212Z"
fill="white"
/>
</svg>
<span class="provider-logo-name">Moonshot/Kimi API</span>
</a>
<a href="#novita-ai" aria-label="Novita AI">
<span class="provider-logo-name">Novita AI</span>
</a>
Expand Down Expand Up @@ -254,6 +273,36 @@ The adapter uses `total_balance` directly. It does not add `granted_balance` and

Token Plan rolling five-hour and weekly windows. Uses MiniMax auth or `OC_MINIMAX_TOKEN_PLAN_KEY`. See the [MiniMax documentation](https://platform.minimax.io/docs/token-plan/intro).

### Moonshot/Kimi API

Moonshot/Kimi Open Platform is a pay-as-you-go API balance, separate from Kimi For Coding's subscription-based 5-hour and weekly quota. Choose the provider matching the region where the API key was created:

| Provider ID | Official balance endpoint | Currency | OpenCode auth ID |
| --- | --- | --- | --- |
| `moonshotai` | `https://api.moonshot.ai/v1/users/me/balance` | USD | `moonshotai` |
| `moonshotai-cn` | `https://api.moonshot.cn/v1/users/me/balance` | CNY | `moonshotai-cn` |

The adapter displays `data.available_balance` directly as the remaining balance. It does not recompute cash plus voucher balances, convert the amount to a percentage, or infer a reset time. Zero and negative balances remain visible. The response must have `code: 0`, `status: true`, and a finite numeric `data.available_balance`; malformed or unsuccessful responses fail closed.

Enable either region with the matching OpenCode auth entry, or provide an explicit key:

```jsonc
{
"providers": {
"moonshotai": {
"enabled": true,
"apiKey": "{env:MOONSHOT_API_KEY}", // Optional global key
},
"moonshotai-cn": {
"enabled": true,
"apiKey": "{env:MOONSHOT_API_KEY_CN}", // Optional China key
},
},
}
```

Global and China keys are independent and are never sent to the other region. OpenCode-discovered credentials are sent only to the exact corresponding official origin. A custom `baseUrl` requires an explicitly configured `authPath` or `apiKey`.

### Novita AI

Displays the current available API balance as a remaining USD amount, not a monthly usage window. Novita's official [`GET /openapi/v1/billing/balance/detail`](https://novita.ai/docs/api-reference/basic-get-user-balance) endpoint returns monetary fields in 1/10,000 USD units; the plugin converts `availableBalance` to USD and does not infer a percentage, balance total, or reset time.
Expand Down
4 changes: 4 additions & 0 deletions apps/web/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
filter: invert(1);
}

:root:not([data-theme="dark"]) .provider-logo-moonshot {
filter: invert(1);
}

.provider-logo-opencode-light {
display: none;
}
Expand Down
26 changes: 25 additions & 1 deletion packages/opencode-usage-limits/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# @mynameistito/opencode-usage-limits

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.
OpenCode TUI plugin that shows Codex, DeepSeek, Moonshot/Kimi API balances, 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 remaining pay-as-you-go Moonshot/Kimi API balance for global (USD) and China (CNY) accounts.
- 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.
Expand Down Expand Up @@ -157,6 +158,8 @@ 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` |
| `moonshotai` | Moonshot/Kimi API balance (USD) | `MOONSHOT_API_KEY` | Bearer | `https://api.moonshot.ai` |
| `moonshotai-cn` | Moonshot/Kimi API balance (CNY) | `MOONSHOT_API_KEY_CN` | Bearer | `https://api.moonshot.cn` |
| `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` |
Expand All @@ -174,6 +177,13 @@ 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}`.

| Provider | Billing model | What we show |
| ----------------- | ------------- | -------------------------------- |
| Kimi For Coding | Subscription | 5-hour and weekly quota |
| Moonshot/Kimi API | Pay-as-you-go | Remaining USD or CNY API balance |

The global `moonshotai` provider reads `GET https://api.moonshot.ai/v1/users/me/balance` in USD; `moonshotai-cn` reads `GET https://api.moonshot.cn/v1/users/me/balance` in CNY. Both display the API's authoritative `data.available_balance` directly as a remaining balance; cash/voucher fields are not recombined and no percentage or reset is inferred. Global and China API keys are independent and are never sent to the other region. OpenCode credentials are used only for the matching exact official origin; custom `baseUrl` values require an explicitly configured `authPath` or `apiKey`.

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`.
Expand Down Expand Up @@ -214,6 +224,14 @@ DeepSeek lookup order:
2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `deepseek`.
3. Config `apiKey`, including `{env:DEEPSEEK_API_KEY}` references.

Moonshot/Kimi lookup order, separately for each provider:

1. Config `authPath` JSON file (`{ "key": "..." }`, `{ "apiKey": "..." }`, or the matching provider ID nested inside it).
2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `moonshotai` or `moonshotai-cn` matching the configured provider. This is used only for the corresponding official origin.
3. Config `apiKey`, including `{env:MOONSHOT_API_KEY}` for global or `{env:MOONSHOT_API_KEY_CN}` for China.

The global and China credentials are not interchangeable. A custom origin never receives an automatically discovered OpenCode credential.

Novita AI lookup order:

1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "novita-ai": { "key": "..." } }`).
Expand Down Expand Up @@ -254,6 +272,10 @@ MiniMax
DeepSeek
USD: $12.50 remaining
CNY: ¥0.00 remaining
Moonshot AI
USD balance: $49.59 remaining
Moonshot AI CN
CNY balance: ¥49.59 remaining
Novita AI
balance: $100.00 remaining
OpenRouter
Expand All @@ -272,6 +294,8 @@ Provider mapping:

- OpenCode provider `openai` -> Codex usage.
- OpenCode provider `deepseek` -> DeepSeek balance usage.
- OpenCode provider `moonshotai` -> global Moonshot/Kimi USD balance.
- OpenCode provider `moonshotai-cn` -> China Moonshot/Kimi CNY balance.
- OpenCode provider `novita-ai` -> Novita AI available-balance usage.
- OpenCode provider `zai-coding-plan` -> ZAI token usage.
- OpenCode provider `synthetic` -> Synthetic usage.
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode-usage-limits/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface PublishedSchema {
commonDisplayFields: PublishedProviderDefinition;
deepSeekProvider: PublishedProviderDefinition;
minimaxProvider: PublishedProviderDefinition;
moonshotAiProvider: PublishedProviderDefinition;
novitaAiProvider: PublishedProviderDefinition;
openRouterProvider: PublishedProviderDefinition;
openCodeGoProvider: PublishedProviderDefinition;
Expand All @@ -67,6 +68,8 @@ interface PublishedSchema {
commandcode: { $ref: "#/$defs/commandCodeProvider" };
deepseek: { $ref: "#/$defs/deepSeekProvider" };
minimax: { $ref: "#/$defs/minimaxProvider" };
moonshotai: { $ref: "#/$defs/moonshotAiProvider" };
"moonshotai-cn": { $ref: "#/$defs/moonshotAiProvider" };
"novita-ai": { $ref: "#/$defs/novitaAiProvider" };
openrouter: { $ref: "#/$defs/openRouterProvider" };
"opencode-go": { $ref: "#/$defs/openCodeGoProvider" };
Expand Down Expand Up @@ -103,6 +106,9 @@ describe("configuration parsing", () => {
minimax: Object.keys(
publishedSchema.$defs.minimaxProvider.properties
).toSorted(),
moonshotai: Object.keys(
publishedSchema.$defs.moonshotAiProvider.properties
).toSorted(),
"novita-ai": Object.keys(
publishedSchema.$defs.novitaAiProvider.properties
).toSorted(),
Expand All @@ -127,6 +133,8 @@ describe("configuration parsing", () => {
commandcode: { $ref: "#/$defs/commandCodeProvider" },
deepseek: { $ref: "#/$defs/deepSeekProvider" },
minimax: { $ref: "#/$defs/minimaxProvider" },
moonshotai: { $ref: "#/$defs/moonshotAiProvider" },
"moonshotai-cn": { $ref: "#/$defs/moonshotAiProvider" },
"novita-ai": { $ref: "#/$defs/novitaAiProvider" },
"opencode-go": { $ref: "#/$defs/openCodeGoProvider" },
openrouter: { $ref: "#/$defs/openRouterProvider" },
Expand Down Expand Up @@ -158,6 +166,7 @@ describe("configuration parsing", () => {
providerFields["novita-ai"],
providerFields.openrouter,
providerFields.minimax,
providerFields.moonshotai,
providerFields["opencode-go"],
providerFields.synthetic,
];
Expand Down Expand Up @@ -217,6 +226,16 @@ describe("configuration parsing", () => {
enabled: true,
label: "DS",
},
moonshotai: {
apiKey: "moonshot-global-secret",
baseUrl: "https://api.moonshot.ai",
enabled: true,
},
"moonshotai-cn": {
apiKey: "moonshot-cn-secret",
baseUrl: "https://api.moonshot.cn",
enabled: true,
},
"novita-ai": {
apiKey: "novita-secret",
authPath: "~/.config/opencode/auth.json",
Expand Down Expand Up @@ -557,6 +576,8 @@ describe("configuration loading", () => {
minimax: { key: "minimax" },
"minimax-coding-plan": { apiKey: "coding" },
"minimax-token-plan": { key: "token-plan" },
moonshotai: { key: "moonshot-global" },
"moonshotai-cn": { apiKey: "moonshot-cn" },
"novita-ai": { apiKey: "novita-ai" },
openai: { accountId: "account" },
opencode: { key: "opencode" },
Expand All @@ -576,6 +597,8 @@ describe("configuration loading", () => {
credentialValue(auth["opencode-go"]?.key),
credentialValue(auth.commandcode?.key),
credentialValue(auth.deepseek?.key),
credentialValue(auth.moonshotai?.key),
credentialValue(auth["moonshotai-cn"]?.apiKey),
credentialValue(auth["novita-ai"]?.apiKey),
credentialValue(auth.openrouter?.key),
credentialValue(auth.synthetic?.apiKey),
Expand All @@ -590,6 +613,8 @@ describe("configuration loading", () => {
"go",
"commandcode",
"deepseek",
"moonshot-global",
"moonshot-cn",
"novita-ai",
"openrouter",
"synthetic",
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode-usage-limits/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
balanceQuota,
countQuota,
parseUsageBalance,
parseUsageBalanceAmount,
parseUsageCount,
parseUsagePercentage,
percentageQuota,
Expand Down Expand Up @@ -118,6 +119,24 @@ describe("format helpers", () => {
);
});

it("renders zero and negative authoritative currency balances accurately", () => {
const negative = balanceQuota(
Result.getOrThrow(parseUsageBalanceAmount(-0.004)),
"USD"
);
const debt = balanceQuota(
Result.getOrThrow(parseUsageBalanceAmount(-12.5)),
"CNY"
);

expect(
windowMainText(usageWindow({ label: "USD balance", quota: negative }))
).toBe("USD balance: -$<0.01 remaining");
expect(
windowMainText(usageWindow({ label: "CNY balance", quota: debt }))
).toBe("CNY balance: -¥12.50 remaining");
});

it.each([
[null, ""],
[0, " · now"],
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode-usage-limits/__tests__/providers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ export const resetFetchMock = () => {
delete process.env.OC_USAGE_LIMITS_MINIMAX_KEY;
delete process.env.DEEPSEEK_API_KEY;
delete process.env.OPENROUTER_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.MOONSHOT_API_KEY_CN;
vi.restoreAllMocks();
};
37 changes: 37 additions & 0 deletions packages/opencode-usage-limits/__tests__/providers/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ describe("provider manifest", () => {
expect([
["openai", pluginProviderForOpenCode("openai")],
["deepseek", pluginProviderForOpenCode("deepseek")],
["moonshotai", pluginProviderForOpenCode("moonshotai")],
["moonshotai-cn", pluginProviderForOpenCode("moonshotai-cn")],
["novita-ai", pluginProviderForOpenCode("novita-ai")],
["openrouter", pluginProviderForOpenCode("openrouter")],
["zai-coding-plan", pluginProviderForOpenCode("zai-coding-plan")],
Expand All @@ -63,6 +65,8 @@ describe("provider manifest", () => {
]).toStrictEqual([
["openai", "codex"],
["deepseek", "deepseek"],
["moonshotai", "moonshotai"],
["moonshotai-cn", "moonshotai-cn"],
["novita-ai", "novita-ai"],
["openrouter", "openrouter"],
["zai-coding-plan", "zai"],
Expand Down Expand Up @@ -119,6 +123,39 @@ describe("provider manifest", () => {
await expect(result).resolves.toMatchObject({ id: "codex" });
});

it("dispatches Moonshot provider IDs through the registry definition", async () => {
const fetchMock = installFetchMock(
Response.json({
code: 0,
data: { available_balance: -2.5 },
status: true,
})
);

const usage = await Effect.runPromise(
fetchProviderEffect(
"moonshotai-cn",
{ apiKey: "china-key" },
{},
1000
).pipe(Effect.provide(ProviderRuntimeLive))
);

expect(fetchMock.mock.calls[0]).toMatchObject([
"https://api.moonshot.cn/v1/users/me/balance",
{ headers: { Authorization: "Bearer china-key" } },
]);
expect(usage).toMatchObject({
id: "moonshotai-cn",
windows: [
{
label: "CNY balance",
quota: { _tag: "Balance", remaining: -2.5, unit: "CNY" },
},
],
});
});

it("rejects unknown provider ids asynchronously", async () => {
const unknownEffect = fetchProviderEffect("unknown", undefined, {}, 1000);
expect(unknownEffect).toBeDefined();
Expand Down
Loading
Loading