diff --git a/.changeset/540e02b2.md b/.changeset/540e02b2.md
new file mode 100644
index 0000000..9db3dbf
--- /dev/null
+++ b/.changeset/540e02b2.md
@@ -0,0 +1,5 @@
+---
+"@mynameistito/opencode-usage-limits": patch
+---
+
+Add DeepSeek balance usage provider
diff --git a/.changeset/dee7d71f.md b/.changeset/dee7d71f.md
new file mode 100644
index 0000000..c031667
--- /dev/null
+++ b/.changeset/dee7d71f.md
@@ -0,0 +1,5 @@
+---
+"@mynameistito/opencode-plugins-docs": patch
+---
+
+Document the DeepSeek balance provider and add its logo to the Usage Limits guide.
diff --git a/README.md b/README.md
index 0398536..30f6a65 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Supported providers:
- [Alibaba Token Plan](https://www.alibabacloud.com/help/en/model-studio/token-plan-personal-overview)
- [ChatGPT](https://chatgpt.com/)
- [Command Code](https://commandcode.ai/)
+- [DeepSeek](https://www.deepseek.com/)
- [OpenCode GO](https://opencode.ai/go)
- [MiniMax Token Plan](https://platform.minimax.io/subscribe/token-plan)
- [Synthetic](https://synthetic.ai/)
diff --git a/apps/web/docs/index.mdx b/apps/web/docs/index.mdx
index c8bd8ce..83f397f 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, Command Code, Z.AI, Synthetic, MiniMax, Qwen, Alibaba Token
- Plan, and OpenCode GO windows.
+ Track Codex, DeepSeek, 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 e9e14da..76643fc 100644
--- a/apps/web/docs/usage-limits.mdx
+++ b/apps/web/docs/usage-limits.mdx
@@ -1,6 +1,6 @@
---
title: Usage Limits
-description: Show provider quota windows in the OpenCode sidebar and prompt footer.
+description: Show provider quota windows and account balances in the OpenCode sidebar and prompt footer.
sidebar:
label: Usage Limits
order: 3
@@ -105,6 +105,23 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag
Command Code
+
+
+ DeepSeek
+
MiniMax
@@ -207,6 +224,25 @@ Credentials are discovered from OpenCode auth for the `commandcode` provider. As
}
```
+### DeepSeek
+
+DeepSeek exposes a current account balance rather than a rolling or monthly usage quota. The provider reads the official [`GET /user/balance`](https://api-docs.deepseek.com/api/get-user-balance/) endpoint and displays each reported currency independently, for example `$12.34 remaining` for USD.
+
+Enable it with the DeepSeek key already saved by OpenCode, or configure an explicit key:
+
+```jsonc
+{
+ "providers": {
+ "deepseek": {
+ "enabled": true,
+ "apiKey": "{env:DEEPSEEK_API_KEY}",
+ },
+ },
+}
+```
+
+The adapter uses `total_balance` directly. It does not add `granted_balance` and `topped_up_balance`, combine currencies, or invent a percentage/reset time. OpenCode-discovered credentials are sent only to `https://api.deepseek.com`; a custom `baseUrl` requires an explicit `authPath` or `apiKey`.
+
### MiniMax
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).
@@ -233,7 +269,7 @@ Z.AI Coding Plan token rolling window. Uses OpenCode `zai-coding-plan` or `zai`
## Display and window selection
-Sidebar rows show provider labels, usage percentages or counts, reset times, and stale data when a refresh fails. The footer selects the active session's provider and renders a compact primary window.
+Sidebar rows show provider labels, usage percentages, counts, or remaining balances, reset times, and stale data when a refresh fails. The footer selects the active session's provider and renders a compact primary window.
Supported `sidebarWindow` values are `all`, `rolling`, `daily`, `weekly`, `monthly`, `credits`, and `other`. `footerWindow` accepts `auto` or one of those values. An unavailable explicit choice falls back to the provider's automatic selection, then its first available window.
diff --git a/apps/web/theme.css b/apps/web/theme.css
index 002bb34..037d9ac 100644
--- a/apps/web/theme.css
+++ b/apps/web/theme.css
@@ -54,6 +54,15 @@
text-align: center;
}
+.provider-logo-deepseek {
+ width: 2.25rem !important;
+ height: 1.67rem !important;
+}
+
+:root[data-theme="dark"] .provider-logo-deepseek {
+ filter: invert(1);
+}
+
.provider-logo-opencode-light {
display: none;
}
diff --git a/packages/opencode-usage-limits/README.md b/packages/opencode-usage-limits/README.md
index a85b484..6397ee2 100644
--- a/packages/opencode-usage-limits/README.md
+++ b/packages/opencode-usage-limits/README.md
@@ -1,11 +1,12 @@
# @mynameistito/opencode-usage-limits
-OpenCode TUI plugin that shows Codex, 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, 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 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.
@@ -13,7 +14,7 @@ OpenCode TUI plugin that shows Codex, Command Code, OpenCode GO, ZAI, Synthetic,
- 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, 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, 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.
@@ -152,6 +153,7 @@ The response contract follows the official CLI's [`usage/token-plan.ts`](https:/
| Provider ID | Service | Env var | Auth header | Default base URL |
| --- | --- | --- | --- | --- |
| `codex` | ChatGPT Codex usage | — | Bearer | `https://chatgpt.com/backend-api` |
+| `deepseek` | DeepSeek balances | `DEEPSEEK_API_KEY` | Bearer | `https://api.deepseek.com` |
| `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` |
@@ -164,6 +166,8 @@ Qwen usage requires the local `qwencloud` CLI to be installed and authenticated
Synthetic always uses `Bearer` auth and ignores `authorizationScheme`.
+DeepSeek reads `GET https://api.deepseek.com/user/balance`. Each reported currency is displayed as an independent `credits` balance using `total_balance`; the component balances are not summed. OpenCode auth is used only for the exact official DeepSeek origin. A custom `baseUrl` requires an explicit `authPath` or `apiKey`, including `{env:DEEPSEEK_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
@@ -196,6 +200,12 @@ MiniMax Token Plan lookup order:
2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `minimax-coding-plan`, `minimax`, or `minimax-token-plan`.
3. Config `apiKey`, including `{env:OC_MINIMAX_TOKEN_PLAN_KEY}` references.
+DeepSeek lookup order:
+
+1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "deepseek": { "key": "..." } }`).
+2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `deepseek`.
+3. Config `apiKey`, including `{env:DEEPSEEK_API_KEY}` references.
+
Command Code lookup order:
1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "commandcode": { "key": "..." } }`).
@@ -221,6 +231,9 @@ Synthetic
weekly: 11% used resets 7m
MiniMax
5h: 10% used resets 2h 56m
+DeepSeek
+ USD: $12.50 remaining
+ CNY: ¥0.00 remaining
```
Prompt footer shows compact usage when the current session model belongs to a supported provider:
@@ -234,6 +247,7 @@ Command Code sessions use the rolling 5-hour window in the prompt footer.
Provider mapping:
- OpenCode provider `openai` -> Codex usage.
+- OpenCode provider `deepseek` -> DeepSeek balance usage.
- OpenCode provider `zai-coding-plan` -> ZAI token usage.
- OpenCode provider `synthetic` -> Synthetic usage.
- OpenCode provider `minimax-coding-plan` -> MiniMax Token Plan usage (prompt footer); `minimax` is also accepted as an alias.
diff --git a/packages/opencode-usage-limits/__tests__/components.test.tsx b/packages/opencode-usage-limits/__tests__/components.test.tsx
index f271706..5d1c2a1 100644
--- a/packages/opencode-usage-limits/__tests__/components.test.tsx
+++ b/packages/opencode-usage-limits/__tests__/components.test.tsx
@@ -11,7 +11,12 @@ import type {
ProviderUsage,
UsageWindow,
} from "@/types.ts";
-import { parseUsagePercentage, percentageQuota } from "@/usage.ts";
+import {
+ balanceQuota,
+ parseUsageBalance,
+ parseUsagePercentage,
+ percentageQuota,
+} from "@/usage.ts";
const color = RGBA.fromValues(1, 2, 3, 255);
@@ -191,6 +196,37 @@ describe(UsageLimitsPanel, () => {
expect(text).toContain("[█████░░░░░░░]");
});
+ it("renders a remaining balance without a percentage or progress bar", async () => {
+ const text = await renderPanelText(
+ [
+ {
+ data: usage({
+ windows: [
+ usageWindow({
+ label: "USD balance",
+ quota: balanceQuota(
+ Result.getOrThrow(parseUsageBalance(12.34)),
+ "USD"
+ ),
+ resetsAt: null,
+ }),
+ ],
+ }),
+ id: "codex",
+ label: "Codex",
+ stale: false,
+ status: "ready",
+ },
+ ],
+ true
+ );
+
+ expect(text).toContain("USD balance");
+ expect(text).toContain("$12.34 remaining");
+ expect(text).not.toContain("%");
+ expect(text).not.toContain("[");
+ });
+
it("filters windows by the provider sidebar window", async () => {
const text = await renderPanelText(
[
@@ -424,4 +460,30 @@ describe(UsageLimitsPanel, () => {
setup.renderer.destroy();
}
});
+
+ it("renders footer balances without a percentage or progress bar", async () => {
+ const setup = await testRender(
+ () => (
+
+ ),
+ { height: 4, width: 80 }
+ );
+ try {
+ await setup.flush();
+ const text = setup.captureCharFrame();
+ expect(text).toContain("CNY balance ¥0.00 remaining");
+ expect(text).not.toContain("%");
+ expect(text).not.toContain("[");
+ } finally {
+ setup.renderer.destroy();
+ }
+ });
});
diff --git a/packages/opencode-usage-limits/__tests__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts
index 8b6c579..035ab43 100644
--- a/packages/opencode-usage-limits/__tests__/config.test.ts
+++ b/packages/opencode-usage-limits/__tests__/config.test.ts
@@ -50,6 +50,7 @@ interface PublishedSchema {
codexProvider: PublishedProviderDefinition;
commandCodeProvider: PublishedProviderDefinition;
commonDisplayFields: PublishedProviderDefinition;
+ deepSeekProvider: PublishedProviderDefinition;
minimaxProvider: PublishedProviderDefinition;
openCodeGoProvider: PublishedProviderDefinition;
qwenProvider: PublishedProviderDefinition;
@@ -62,6 +63,7 @@ interface PublishedSchema {
"alibaba-token-plan": { $ref: "#/$defs/alibabaTokenPlanProvider" };
codex: { $ref: "#/$defs/codexProvider" };
commandcode: { $ref: "#/$defs/commandCodeProvider" };
+ deepseek: { $ref: "#/$defs/deepSeekProvider" };
minimax: { $ref: "#/$defs/minimaxProvider" };
"opencode-go": { $ref: "#/$defs/openCodeGoProvider" };
qwen: { $ref: "#/$defs/qwenProvider" };
@@ -91,6 +93,9 @@ describe("configuration parsing", () => {
commandcode: Object.keys(
publishedSchema.$defs.commandCodeProvider.properties
).toSorted(),
+ deepseek: Object.keys(
+ publishedSchema.$defs.deepSeekProvider.properties
+ ).toSorted(),
minimax: Object.keys(
publishedSchema.$defs.minimaxProvider.properties
).toSorted(),
@@ -110,6 +115,7 @@ describe("configuration parsing", () => {
"alibaba-token-plan": { $ref: "#/$defs/alibabaTokenPlanProvider" },
codex: { $ref: "#/$defs/codexProvider" },
commandcode: { $ref: "#/$defs/commandCodeProvider" },
+ deepseek: { $ref: "#/$defs/deepSeekProvider" },
minimax: { $ref: "#/$defs/minimaxProvider" },
"opencode-go": { $ref: "#/$defs/openCodeGoProvider" },
qwen: { $ref: "#/$defs/qwenProvider" },
@@ -136,6 +142,7 @@ describe("configuration parsing", () => {
);
const apiKeyProviders = [
providerFields.commandcode,
+ providerFields.deepseek,
providerFields.minimax,
providerFields["opencode-go"],
providerFields.synthetic,
@@ -189,34 +196,51 @@ describe("configuration parsing", () => {
enabled: true,
label: "CC",
},
+ deepseek: {
+ apiKey: "deepseek-secret",
+ authPath: "~/.config/opencode/auth.json",
+ baseUrl: "https://api.deepseek.com",
+ enabled: true,
+ label: "DS",
+ },
},
});
const success = Result.isSuccess(result) ? result.success : undefined;
const apiKey = success?.providers.codex?.apiKey;
const commandCodeApiKey = success?.providers.commandcode?.apiKey;
+ const deepSeekApiKey = success?.providers.deepseek?.apiKey;
expect(Result.isSuccess(result)).toBeTruthy();
expect([
Redacted.isRedacted(apiKey),
Redacted.isRedacted(commandCodeApiKey),
- ]).toStrictEqual([true, true]);
+ Redacted.isRedacted(deepSeekApiKey),
+ ]).toStrictEqual([true, true, true]);
expect(String(apiKey)).not.toContain("do-not-log");
- expect(success?.providers.codex).toMatchObject({
- authPath: "~/.codex/auth.json",
- authorizationScheme: "bearer",
- baseUrl: "https://example.com",
- enabled: true,
- footerWindow: "weekly",
- label: "Work",
- showFooterBar: false,
- showSidebarBar: true,
- sidebarWindow: "weekly",
- });
- expect(success?.providers.commandcode).toMatchObject({
- authPath: "~/.config/opencode/auth.json",
- baseUrl: "https://api.commandcode.ai",
- enabled: true,
- label: "CC",
+ expect(success?.providers).toMatchObject({
+ codex: {
+ authPath: "~/.codex/auth.json",
+ authorizationScheme: "bearer",
+ baseUrl: "https://example.com",
+ enabled: true,
+ footerWindow: "weekly",
+ label: "Work",
+ showFooterBar: false,
+ showSidebarBar: true,
+ sidebarWindow: "weekly",
+ },
+ commandcode: {
+ authPath: "~/.config/opencode/auth.json",
+ baseUrl: "https://api.commandcode.ai",
+ enabled: true,
+ label: "CC",
+ },
+ deepseek: {
+ authPath: "~/.config/opencode/auth.json",
+ baseUrl: "https://api.deepseek.com",
+ enabled: true,
+ label: "DS",
+ },
});
});
@@ -485,6 +509,7 @@ describe("configuration loading", () => {
it("parses every recognized auth entry and ignores non-object input", () => {
const auth = parseOpenCodeAuth({
commandcode: { key: "commandcode" },
+ deepseek: { key: "deepseek" },
minimax: { key: "minimax" },
"minimax-coding-plan": { apiKey: "coding" },
"minimax-token-plan": { key: "token-plan" },
@@ -504,6 +529,7 @@ describe("configuration loading", () => {
credentialValue(auth.opencode?.key),
credentialValue(auth["opencode-go"]?.key),
credentialValue(auth.commandcode?.key),
+ credentialValue(auth.deepseek?.key),
credentialValue(auth.synthetic?.apiKey),
credentialValue(auth.zai?.key),
credentialValue(auth["zai-coding-plan"]?.key),
@@ -515,6 +541,7 @@ describe("configuration loading", () => {
"opencode",
"go",
"commandcode",
+ "deepseek",
"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 59a166c..a694201 100644
--- a/packages/opencode-usage-limits/__tests__/format.test.ts
+++ b/packages/opencode-usage-limits/__tests__/format.test.ts
@@ -15,7 +15,9 @@ import {
import type { UsageWindow } from "@/types.ts";
import type { UsageQuota } from "@/usage.ts";
import {
+ balanceQuota,
countQuota,
+ parseUsageBalance,
parseUsageCount,
parseUsagePercentage,
percentageQuota,
@@ -57,6 +59,39 @@ describe("format helpers", () => {
);
});
+ it("formats currency balances without percentages", () => {
+ const remaining = Result.getOrThrow(parseUsageBalance(12.34));
+ const quota = balanceQuota(remaining, "USD");
+ const window = usageWindow({ label: "USD balance", quota });
+
+ expect(windowMainText(window)).toBe("USD balance: $12.34 remaining");
+ expect(bottomWindowMainText(window)).toBe("USD balance $12.34 remaining");
+ });
+
+ it("formats zero balances and preserves generic units", () => {
+ const zero = balanceQuota(Result.getOrThrow(parseUsageBalance(0)), "CNY");
+ const credits = balanceQuota(
+ Result.getOrThrow(parseUsageBalance(12.5)),
+ "credits"
+ );
+
+ expect(
+ windowMainText(usageWindow({ label: "CNY balance", quota: zero }))
+ ).toBe("CNY balance: ¥0.00 remaining");
+ expect(
+ bottomWindowMainText(usageWindow({ label: "credits", quota: credits }))
+ ).toBe("credits 12.5 credits remaining");
+ });
+
+ it("keeps tiny positive currency balances visible", () => {
+ const remaining = Result.getOrThrow(parseUsageBalance(0.004));
+ const quota = balanceQuota(remaining, "USD");
+
+ expect(windowMainText(usageWindow({ label: "USD balance", quota }))).toBe(
+ "USD balance: $<0.01 remaining"
+ );
+ });
+
it.each([
[null, ""],
[0, " · now"],
diff --git a/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts
new file mode 100644
index 0000000..5fd131b
--- /dev/null
+++ b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts
@@ -0,0 +1,332 @@
+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 {
+ ProviderRateLimitError,
+ ProviderResponseDecodeError,
+ ProviderTransportError,
+} from "@/errors.ts";
+import { fetchDeepSeekBalanceUsage } from "@/providers/deepseek.ts";
+
+import { installFetchMock, resetFetchMock } from "./helpers.ts";
+
+interface BalanceInfoInput {
+ readonly currency?: string;
+ readonly granted_balance?: string | null;
+ readonly topped_up_balance?: string | null;
+ readonly total_balance?: string | null;
+}
+
+const balance = (overrides: BalanceInfoInput = {}): BalanceInfoInput => ({
+ currency: "USD",
+ granted_balance: "10.00",
+ topped_up_balance: "2.50",
+ total_balance: "12.50",
+ ...overrides,
+});
+
+const response = (
+ balanceInfos: readonly BalanceInfoInput[],
+ isAvailable = true
+) => Response.json({ balance_infos: balanceInfos, is_available: isAvailable });
+
+const regressionApiKey = "deepseek-regression-api-key";
+
+const rejectDeepSeekRequest = async (status: number): Promise => {
+ installFetchMock(
+ Response.json(
+ { error: regressionApiKey },
+ {
+ headers: { "retry-after": "later" },
+ status,
+ }
+ )
+ );
+ try {
+ await fetchDeepSeekBalanceUsage({ apiKey: regressionApiKey }, {}, 1000);
+ } catch (error) {
+ if (error instanceof Error) {
+ return error;
+ }
+ return new Error(String(error));
+ }
+ return new Error(`expected DeepSeek request to reject with HTTP ${status}`);
+};
+
+const expectSafeProviderFailure = (error: Error, message: string): void => {
+ expect(error.message).toBe(message);
+ expect(String(error)).not.toContain(regressionApiKey);
+ const serialized = JSON.stringify(error);
+ expect(serialized).toBeDefined();
+ expect(serialized).not.toContain(regressionApiKey);
+};
+
+describe("DeepSeek provider", () => {
+ afterEach(resetFetchMock);
+
+ it("requests the official balance endpoint and preserves currencies independently", async () => {
+ const fetchMock = installFetchMock(
+ response([
+ balance(),
+ balance({
+ currency: "CNY",
+ granted_balance: "0.00",
+ topped_up_balance: "0.00",
+ total_balance: "0.00",
+ }),
+ ])
+ );
+
+ const usage = await fetchDeepSeekBalanceUsage(
+ { apiKey: "deepseek-key", label: "My DeepSeek" },
+ {},
+ 1000
+ );
+
+ expect(fetchMock.mock.calls[0]).toMatchObject([
+ "https://api.deepseek.com/user/balance",
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: "Bearer deepseek-key",
+ },
+ method: "GET",
+ },
+ ]);
+ expect(usage).toMatchObject({
+ id: "deepseek",
+ label: "My DeepSeek",
+ windows: [
+ {
+ kind: "credits",
+ label: "USD",
+ quota: { _tag: "Balance", remaining: 12.5, unit: "USD" },
+ resetsAt: null,
+ },
+ {
+ kind: "credits",
+ label: "CNY",
+ quota: { _tag: "Balance", remaining: 0, unit: "CNY" },
+ resetsAt: null,
+ },
+ ],
+ });
+ });
+
+ it("keeps zero balances visible when the account is unavailable", async () => {
+ installFetchMock(
+ response(
+ [
+ balance({
+ granted_balance: "0",
+ topped_up_balance: "0",
+ total_balance: "0",
+ }),
+ ],
+ false
+ )
+ );
+
+ const usage = await fetchDeepSeekBalanceUsage(
+ { apiKey: "deepseek-key" },
+ {},
+ 1000
+ );
+
+ expect(usage.metadata).toStrictEqual({ isAvailable: false });
+ expect(usage.windows[0]?.quota).toStrictEqual({
+ _tag: "Balance",
+ remaining: 0,
+ unit: "USD",
+ });
+ });
+
+ it("skips malformed entries when another valid balance remains", async () => {
+ installFetchMock(
+ response([
+ balance({ total_balance: "-1" }),
+ balance({
+ currency: "EUR",
+ granted_balance: "1.00",
+ topped_up_balance: "2.00",
+ total_balance: "3.00",
+ }),
+ balance({ granted_balance: "not-a-decimal" }),
+ ])
+ );
+
+ const usage = await fetchDeepSeekBalanceUsage(
+ { apiKey: "deepseek-key" },
+ {},
+ 1000
+ );
+
+ expect(usage.windows).toHaveLength(1);
+ expect(usage.windows[0]).toMatchObject({
+ label: "EUR",
+ quota: { _tag: "Balance", remaining: 3, unit: "EUR" },
+ });
+ });
+
+ it.each([
+ ["missing total_balance", { total_balance: null }],
+ ["negative total_balance", { total_balance: "-1" }],
+ ["exponent total_balance", { total_balance: "1e3" }],
+ ["NaN total_balance", { total_balance: "NaN" }],
+ ["infinite total_balance", { total_balance: "Infinity" }],
+ ["negative granted_balance", { granted_balance: "-1" }],
+ ["infinite topped_up_balance", { topped_up_balance: "Infinity" }],
+ ] as const)(
+ "fails closed when all balances have %s",
+ async (_label, overrides) => {
+ installFetchMock(response([balance(overrides)]));
+
+ const result = fetchDeepSeekBalanceUsage(
+ { apiKey: "deepseek-key" },
+ {},
+ 1000
+ );
+ await expect(result).rejects.toBeInstanceOf(ProviderResponseDecodeError);
+ await expect(result).rejects.toThrow("invalid DeepSeek usage");
+ }
+ );
+
+ it("requires the complete response envelope", async () => {
+ installFetchMock(Response.json({ balance_infos: [balance()] }));
+
+ await expect(
+ fetchDeepSeekBalanceUsage({ apiKey: "deepseek-key" }, {}, 1000)
+ ).rejects.toBeInstanceOf(ProviderResponseDecodeError);
+ });
+
+ it.each([
+ [401, "unauthorized", "provider credentials were rejected"],
+ [403, "forbidden", "provider access was forbidden"],
+ [500, "http", "provider request failed (HTTP 500)"],
+ ] as const)(
+ "classifies DeepSeek HTTP %d as a safe transport error",
+ async (status, cause, message) => {
+ const error = await rejectDeepSeekRequest(status);
+
+ expect(error).toBeInstanceOf(ProviderTransportError);
+ if (!(error instanceof ProviderTransportError)) {
+ return;
+ }
+ expect(error.cause).toBe(cause);
+ expect(error.operation).toBe("fetch-usage");
+ expect(error.providerID).toBe("deepseek");
+ expect(error.status).toBe(status);
+ expectSafeProviderFailure(error, message);
+ }
+ );
+
+ it("classifies DeepSeek rate limits without leaking the API key", async () => {
+ const error = await rejectDeepSeekRequest(429);
+
+ expect(error).toBeInstanceOf(ProviderRateLimitError);
+ if (!(error instanceof ProviderRateLimitError)) {
+ return;
+ }
+ expect(error.operation).toBe("fetch-usage");
+ expect(error.providerID).toBe("deepseek");
+ expect(error.retryAfterMs).toBeUndefined();
+ expectSafeProviderFailure(error, "provider rate limit reached");
+ });
+
+ it("uses authPath before OpenCode auth and configured credentials", async () => {
+ const authPath = path.join(
+ tmpdir(),
+ `oc-usage-limits-deepseek-${crypto.randomUUID()}.json`
+ );
+ await writeFile(
+ authPath,
+ JSON.stringify({ deepseek: { key: "file-key" } })
+ );
+ try {
+ const fetchMock = installFetchMock(response([balance()]));
+
+ await fetchDeepSeekBalanceUsage(
+ { apiKey: "config-key", authPath },
+ { deepseek: { key: "open-code-key" } },
+ 1000
+ );
+
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ headers: { Authorization: "Bearer file-key" },
+ });
+ } finally {
+ await rm(authPath, { force: true });
+ }
+ });
+
+ it("uses the DeepSeek OpenCode auth provider before configured credentials", async () => {
+ const fetchMock = installFetchMock(response([balance()]));
+
+ await fetchDeepSeekBalanceUsage(
+ { apiKey: "config-key" },
+ { deepseek: { apiKey: "open-code-key" } },
+ 1000
+ );
+
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ headers: { Authorization: "Bearer open-code-key" },
+ });
+ });
+
+ it("resolves the explicit DeepSeek environment credential", async () => {
+ process.env.DEEPSEEK_API_KEY = "environment-key";
+ const fetchMock = installFetchMock(response([balance()]));
+
+ await fetchDeepSeekBalanceUsage(
+ { apiKey: "{env:DEEPSEEK_API_KEY}" },
+ {},
+ 1000
+ );
+
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ headers: { Authorization: "Bearer environment-key" },
+ });
+ });
+
+ it("requires explicit credentials for custom base URLs", async () => {
+ await expect(
+ fetchDeepSeekBalanceUsage(
+ { baseUrl: "https://deepseek.example.test" },
+ { deepseek: { key: "open-code-key" } },
+ 1000
+ )
+ ).rejects.toThrow("missing DeepSeek key");
+
+ const fetchMock = installFetchMock(response([balance()]));
+ await fetchDeepSeekBalanceUsage(
+ { apiKey: "explicit-key", baseUrl: "https://deepseek.example.test" },
+ { deepseek: { key: "open-code-key" } },
+ 1000
+ );
+ expect(fetchMock.mock.calls[0]).toMatchObject([
+ "https://deepseek.example.test/user/balance",
+ { headers: { Authorization: "Bearer explicit-key" } },
+ ]);
+ });
+
+ it("appends the balance path before query and fragment components", async () => {
+ const fetchMock = installFetchMock(response([balance()]));
+
+ await fetchDeepSeekBalanceUsage(
+ {
+ apiKey: "explicit-key",
+ baseUrl: "https://deepseek.example.test/v1?tenant=example#balance",
+ },
+ {},
+ 1000
+ );
+
+ expect(fetchMock.mock.calls[0]).toMatchObject([
+ "https://deepseek.example.test/v1/user/balance?tenant=example",
+ { headers: { Authorization: "Bearer explicit-key" } },
+ ]);
+ });
+});
diff --git a/packages/opencode-usage-limits/__tests__/providers/helpers.ts b/packages/opencode-usage-limits/__tests__/providers/helpers.ts
index 4e5d2af..34095a3 100644
--- a/packages/opencode-usage-limits/__tests__/providers/helpers.ts
+++ b/packages/opencode-usage-limits/__tests__/providers/helpers.ts
@@ -19,5 +19,6 @@ export const resetFetchMock = () => {
delete process.env.OC_USAGE_LIMITS_ZAI_KEY;
delete process.env.OC_USAGE_LIMITS_SYNTHETIC_KEY;
delete process.env.OC_USAGE_LIMITS_MINIMAX_KEY;
+ delete process.env.DEEPSEEK_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 63b0bd0..7fff9a5 100644
--- a/packages/opencode-usage-limits/__tests__/providers/index.test.ts
+++ b/packages/opencode-usage-limits/__tests__/providers/index.test.ts
@@ -46,6 +46,7 @@ describe("provider manifest", () => {
it("maps OpenCode session providers to plugin providers", () => {
expect([
["openai", pluginProviderForOpenCode("openai")],
+ ["deepseek", pluginProviderForOpenCode("deepseek")],
["zai-coding-plan", pluginProviderForOpenCode("zai-coding-plan")],
["minimax-coding-plan", pluginProviderForOpenCode("minimax-coding-plan")],
["minimax", pluginProviderForOpenCode("minimax")],
@@ -59,6 +60,7 @@ describe("provider manifest", () => {
["anthropic", pluginProviderForOpenCode("anthropic")],
]).toStrictEqual([
["openai", "codex"],
+ ["deepseek", "deepseek"],
["zai-coding-plan", "zai"],
["minimax-coding-plan", "minimax"],
["minimax", "minimax"],
diff --git a/packages/opencode-usage-limits/__tests__/usage.test.ts b/packages/opencode-usage-limits/__tests__/usage.test.ts
index e92a371..24d5618 100644
--- a/packages/opencode-usage-limits/__tests__/usage.test.ts
+++ b/packages/opencode-usage-limits/__tests__/usage.test.ts
@@ -2,17 +2,21 @@ import { Result } from "effect";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
+ balanceQuota,
countQuota,
+ parseUsageBalance,
parseUsageCount,
parseUsagePercentage,
parseUsageResetInstant,
+ quotaUsedPercent,
} from "@/usage.ts";
-import type { Percentage, QuotaCount } from "@/usage.ts";
+import type { BalanceQuota, Percentage, QuotaCount } from "@/usage.ts";
describe("usage domain invariants", () => {
it("keeps refined numeric types nominal", () => {
expectTypeOf().not.toMatchTypeOf();
expectTypeOf().not.toMatchTypeOf();
+ expectTypeOf().not.toMatchTypeOf();
});
it.each([0, 42.5, 100])("accepts finite percentage %s", (value) => {
@@ -35,6 +39,29 @@ describe("usage domain invariants", () => {
).toBeTruthy();
});
+ it("constructs a remaining balance without usage percentages", () => {
+ const remaining = Result.getOrThrow(parseUsageBalance(12.34));
+ const quota = balanceQuota(remaining, "USD");
+
+ expect(quota).toStrictEqual({
+ _tag: "Balance",
+ remaining: 12.34,
+ unit: "USD",
+ });
+ expect(quotaUsedPercent(quota)).toBeNull();
+ });
+
+ it.each([0, 12.5])("accepts finite non-negative balances %s", (value) => {
+ expect(Result.isSuccess(parseUsageBalance(value))).toBeTruthy();
+ });
+
+ it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, "12.5"])(
+ "rejects invalid balance amount %s",
+ (value) => {
+ expect(Result.isFailure(parseUsageBalance(value))).toBeTruthy();
+ }
+ );
+
it("rejects count quotas whose current value exceeds the total", () => {
const current = Result.getOrThrow(parseUsageCount(20));
const total = Result.getOrThrow(parseUsageCount(10));
diff --git a/packages/opencode-usage-limits/examples/usage-limits.jsonc b/packages/opencode-usage-limits/examples/usage-limits.jsonc
index 330f829..c506314 100644
--- a/packages/opencode-usage-limits/examples/usage-limits.jsonc
+++ b/packages/opencode-usage-limits/examples/usage-limits.jsonc
@@ -26,6 +26,17 @@
"authorizationScheme": "bearer",
"baseUrl": "https://chatgpt.com/backend-api",
},
+ "deepseek": {
+ "enabled": true,
+ "label": "DeepSeek",
+ "showSidebarBar": true,
+ "showFooterBar": true,
+ "sidebarWindow": "all",
+ "footerWindow": "auto",
+ "authPath": "~/.config/opencode/auth.json",
+ "apiKey": "{env:DEEPSEEK_API_KEY}", // Optional fallback when OpenCode auth has no DeepSeek key
+ "baseUrl": "https://api.deepseek.com",
+ },
"zai": {
"enabled": true,
"label": "ZAI",
diff --git a/packages/opencode-usage-limits/package.json b/packages/opencode-usage-limits/package.json
index 7081103..1e48bac 100644
--- a/packages/opencode-usage-limits/package.json
+++ b/packages/opencode-usage-limits/package.json
@@ -2,12 +2,13 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "@mynameistito/opencode-usage-limits",
"version": "1.2.2",
- "description": "OpenCode TUI plugin that shows Codex, 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, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, and Qwen usage limits in the sidebar and prompt footer.",
"keywords": [
"ai",
"ai-coding",
"codex",
"commandcode",
+ "deepseek",
"developer-tools",
"minimax",
"minimax-token-plan",
diff --git a/packages/opencode-usage-limits/src/components.tsx b/packages/opencode-usage-limits/src/components.tsx
index 63a8b67..81bc6f4 100644
--- a/packages/opencode-usage-limits/src/components.tsx
+++ b/packages/opencode-usage-limits/src/components.tsx
@@ -5,6 +5,7 @@ import { createMemo, For } from "solid-js";
import type { ConfigDiagnostic } from "@/config.ts";
import {
bottomWindowMainText,
+ formatBalance,
formatPercent,
formatTimestamp,
percentBar,
@@ -94,40 +95,48 @@ const UsageWindowRows = (props: {
windows: readonly UsageWindow[];
}) => (
- {(window) => (
-
-
- {" "}
-
- {window.label}
-
-
- {windowResetText(window)}
- {windowResetTime(window)}
-
-
-
- {" "}
- {props.showBar ? (
+ {(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`;
+ return (
+
+
+ {" "}
+
+ {window.label}
+
+
+ {windowResetText(window)}
+ {windowResetTime(window)}
+
+
+
+ {" "}
+ {showBar ? (
+
+ {percentBar(usedPercent, 12)}
+
+ ) : null}
- {percentBar(quotaUsedPercent(window.quota), 12)}
+ {" "}
+ {quotaText}
- ) : null}
-
- {" "}
- {formatPercent(quotaUsedPercent(window.quota))} used
-
-
-
- )}
+
+
+ );
+ }}
);
@@ -307,16 +316,18 @@ export const BottomUsage = (props: {
if (!props.window) {
return null;
}
+ const usedPercent = quotaUsedPercent(props.window.quota);
+ const showBar = props.showBar && props.window.quota._tag !== "Balance";
return (
- {props.showBar ? (
+ {showBar ? (
- {percentBar(quotaUsedPercent(props.window.quota), 8)}
+ {percentBar(usedPercent, 8)}
) : null}
diff --git a/packages/opencode-usage-limits/src/config-schema.ts b/packages/opencode-usage-limits/src/config-schema.ts
index 51b4d74..eb48445 100644
--- a/packages/opencode-usage-limits/src/config-schema.ts
+++ b/packages/opencode-usage-limits/src/config-schema.ts
@@ -90,6 +90,14 @@ const minimaxProviderConfigSchema = Schema.Struct({
baseUrl: Schema.optionalKey(Schema.String),
});
+/** Schema for DeepSeek provider configuration. */
+const deepSeekProviderConfigSchema = 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);
@@ -118,6 +126,7 @@ const providersSchema = Schema.Struct({
),
codex: Schema.optionalKey(codexProviderConfigSchema),
commandcode: Schema.optionalKey(commandCodeProviderConfigSchema),
+ deepseek: Schema.optionalKey(deepSeekProviderConfigSchema),
minimax: Schema.optionalKey(minimaxProviderConfigSchema),
"opencode-go": Schema.optionalKey(openCodeGoProviderConfigSchema),
qwen: Schema.optionalKey(qwenProviderConfigSchema),
@@ -231,6 +240,7 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => {
}
const minimax = parseAuthEntry(input.minimax);
+ const deepseek = parseAuthEntry(input.deepseek);
const minimaxCodingPlan = parseAuthEntry(input["minimax-coding-plan"]);
const minimaxTokenPlan = parseAuthEntry(input["minimax-token-plan"]);
const openai = parseOpenAIEntry(input.openai);
@@ -246,6 +256,9 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => {
if (minimax) {
auth.minimax = minimax;
}
+ if (deepseek) {
+ auth.deepseek = deepseek;
+ }
if (minimaxCodingPlan) {
auth["minimax-coding-plan"] = minimaxCodingPlan;
}
diff --git a/packages/opencode-usage-limits/src/config.ts b/packages/opencode-usage-limits/src/config.ts
index 5b83276..6cc8c7e 100644
--- a/packages/opencode-usage-limits/src/config.ts
+++ b/packages/opencode-usage-limits/src/config.ts
@@ -92,6 +92,7 @@ export interface OpenCodeAuthLoad {
const AUTH_DECODE_KIND = "auth-decode" as const;
const authEntryNames = new Set([
+ "deepseek",
"minimax",
"minimax-coding-plan",
"minimax-token-plan",
diff --git a/packages/opencode-usage-limits/src/errors-shared.ts b/packages/opencode-usage-limits/src/errors-shared.ts
index c5ab80f..4fe41d4 100644
--- a/packages/opencode-usage-limits/src/errors-shared.ts
+++ b/packages/opencode-usage-limits/src/errors-shared.ts
@@ -6,6 +6,7 @@ export const schemaTaggedError = Schema.TaggedError;
/** Schema for the provider identifiers accepted in structured errors. */
export const ProviderIDSchema = Schema.Literals([
"codex",
+ "deepseek",
"zai",
"synthetic",
"minimax",
@@ -20,6 +21,7 @@ export const credentialMessages = {
"alibaba-token-plan": "missing Bailian console login",
codex: "missing Codex auth",
commandcode: "missing Command Code key",
+ deepseek: "missing DeepSeek key",
minimax: "missing MiniMax key",
"opencode-go": "missing OpenCode GO key",
qwen: "missing Qwen credentials",
diff --git a/packages/opencode-usage-limits/src/errors/response-decode.ts b/packages/opencode-usage-limits/src/errors/response-decode.ts
index dab1c4d..2c2ac25 100644
--- a/packages/opencode-usage-limits/src/errors/response-decode.ts
+++ b/packages/opencode-usage-limits/src/errors/response-decode.ts
@@ -23,6 +23,7 @@ export class ProviderResponseDecodeError extends schemaTaggedError {
export const formatPercent = (value: number | null): string =>
value === null ? "?" : `${Math.round(value)}%`;
+const CURRENCY_SYMBOLS = new Map([
+ ["CNY", "¥"],
+ ["EUR", "€"],
+ ["GBP", "£"],
+ ["JPY", "¥"],
+ ["KRW", "₩"],
+ ["USD", "$"],
+]);
+
+/**
+ * Formats a remaining balance amount without implying a percentage or total.
+ *
+ * @param quota - The remaining balance to render.
+ * @returns A concise amount followed by `remaining`.
+ */
+export const formatBalance = (quota: BalanceQuota): string => {
+ const symbol = CURRENCY_SYMBOLS.get(quota.unit.toUpperCase());
+ const rounded = symbol ? quota.remaining.toFixed(2) : "";
+ const displayAmount =
+ symbol && quota.remaining > 0 && Number(rounded) === 0 ? "<0.01" : rounded;
+ const amount = symbol
+ ? `${symbol}${displayAmount}`
+ : `${quota.remaining} ${quota.unit}`;
+ return `${amount} remaining`;
+};
+
+const quotaMainText = (quota: UsageQuota): string =>
+ quota._tag === "Balance"
+ ? formatBalance(quota)
+ : formatPercent(quotaUsedPercent(quota));
+
/**
* Builds the primary line of text for a usage window in the sidebar panel.
*
* @param window - The provider usage window to summarize.
- * @returns A label and percentage pair such as `daily: 42% used`.
+ * @returns A label and quota summary such as `daily: 42%` or `$12.34 remaining`.
*/
export const windowMainText = (window: UsageWindow): string =>
- `${window.label}: ${formatPercent(quotaUsedPercent(window.quota))}`;
+ `${window.label}: ${quotaMainText(window.quota)}`;
/**
* Builds the compact prompt-footer text for the active provider's primary window.
*
* @param window - The active provider usage window to summarize.
- * @returns A compact percentage label such as `daily 42%`.
+ * @returns A compact quota label such as `daily 42%` or `$12.34 remaining`.
*/
export const bottomWindowMainText = (window: UsageWindow): string =>
- `${window.label} ${formatPercent(quotaUsedPercent(window.quota))}`;
+ `${window.label} ${quotaMainText(window.quota)}`;
/**
* Formats the reset suffix for a usage window.
diff --git a/packages/opencode-usage-limits/src/providers/deepseek.ts b/packages/opencode-usage-limits/src/providers/deepseek.ts
new file mode 100644
index 0000000..cfa3aa2
--- /dev/null
+++ b/packages/opencode-usage-limits/src/providers/deepseek.ts
@@ -0,0 +1,220 @@
+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 { isJsonBoolean, 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 {
+ DeepSeekProviderConfig,
+ OpenCodeAuth,
+ ProviderUsage,
+ UsageWindow,
+} from "@/types.ts";
+import type { QuotaCount } from "@/usage.ts";
+import { balanceQuota, parseUsageBalance } from "@/usage.ts";
+import { isRecord } from "@/utils.ts";
+import type { JsonObject, JsonValue } from "@/utils.ts";
+import { resolveHttpsBaseUrl } from "@/utils/url.ts";
+
+/** Default DeepSeek API origin used for balance requests. */
+const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
+const DEEPSEEK_BALANCE_PATH = "/user/balance";
+const PROVIDER_ID = "deepseek" as const;
+const DECIMAL_STRING = /^\d+(?:\.\d+)?$/u;
+
+interface DeepSeekBalanceInfo {
+ readonly currency: string;
+ readonly totalBalance: QuotaCount;
+}
+
+interface DeepSeekPayload {
+ readonly isAvailable: boolean;
+ readonly balances: readonly DeepSeekBalanceInfo[];
+}
+
+const keyFromDeepSeekAuth = (
+ value: JsonObject,
+ credential: (
+ value: JsonValue | undefined
+ ) => Redacted.Redacted | undefined
+): Redacted.Redacted | undefined => {
+ const direct = credential(value.key) ?? credential(value.apiKey);
+ if (direct) {
+ return direct;
+ }
+ if (!isRecord(value.deepseek)) {
+ return undefined;
+ }
+ return credential(value.deepseek.key) ?? credential(value.deepseek.apiKey);
+};
+
+const keyFromOpenCodeAuth = (
+ value: JsonObject,
+ credential: (
+ value: JsonValue | undefined
+ ) => Redacted.Redacted | undefined
+): Redacted.Redacted | undefined => {
+ if (!isRecord(value.deepseek)) {
+ return undefined;
+ }
+ return credential(value.deepseek.key) ?? credential(value.deepseek.apiKey);
+};
+
+const parseDecimalBalance = (
+ value: JsonValue | undefined
+): QuotaCount | null => {
+ if (!isJsonString(value) || !DECIMAL_STRING.test(value)) {
+ return null;
+ }
+ const parsed = Number(value);
+ if (
+ !Number.isFinite(parsed) ||
+ parsed < 0 ||
+ (parsed === 0 && /[1-9]/u.test(value))
+ ) {
+ return null;
+ }
+ const result = parseUsageBalance(parsed);
+ return Result.isSuccess(result) ? result.success : null;
+};
+
+const parseDeepSeekBalanceInfo = (
+ value: JsonValue | undefined
+): DeepSeekBalanceInfo | null => {
+ if (!isRecord(value) || !isJsonString(value.currency)) {
+ return null;
+ }
+ const currency = value.currency.trim();
+ const totalBalance = parseDecimalBalance(value.total_balance);
+ const grantedBalance = parseDecimalBalance(value.granted_balance);
+ const toppedUpBalance = parseDecimalBalance(value.topped_up_balance);
+ if (
+ currency === "" ||
+ totalBalance === null ||
+ grantedBalance === null ||
+ toppedUpBalance === null
+ ) {
+ return null;
+ }
+ return { currency, totalBalance };
+};
+
+const parseDeepSeekPayload = (value: JsonObject): DeepSeekPayload | null => {
+ if (
+ !isJsonBoolean(value.is_available) ||
+ !Array.isArray(value.balance_infos)
+ ) {
+ return null;
+ }
+ const balances = value.balance_infos.flatMap((entry) => {
+ const parsed = parseDeepSeekBalanceInfo(entry);
+ return parsed ? [parsed] : [];
+ });
+ return { balances, isAvailable: value.is_available };
+};
+
+const deepSeekBalanceUrl = (baseUrl: string): string => {
+ const url = new URL(baseUrl);
+ url.pathname = `${url.pathname.replace(/\/$/u, "")}${DEEPSEEK_BALANCE_PATH}`;
+ url.hash = "";
+ return url.toString();
+};
+
+const balanceWindow = (balance: DeepSeekBalanceInfo): UsageWindow => ({
+ kind: "credits",
+ label: balance.currency,
+ quota: balanceQuota(balance.totalBalance, balance.currency),
+ resetsAt: null,
+});
+
+/** Fetches and normalizes DeepSeek currency balances. */
+const fetchDeepSeekBalanceUsageEffect = (
+ config: DeepSeekProviderConfig | undefined,
+ openCodeAuth: OpenCodeAuth,
+ timeoutMs: number
+): ReturnType["fetch"]> =>
+ Effect.gen(function* runFetchDeepSeekBalanceUsage() {
+ const environment = yield* ProviderEnvironment;
+ const http = yield* ProviderHttpClient;
+ const baseUrl = resolveHttpsBaseUrl(
+ config?.baseUrl,
+ DEFAULT_DEEPSEEK_BASE_URL
+ );
+ const isOfficialOrigin =
+ new URL(baseUrl).origin === DEFAULT_DEEPSEEK_BASE_URL;
+ const authFileKey = yield* readProviderAuthFileCredential(
+ config?.authPath,
+ PROVIDER_ID,
+ keyFromDeepSeekAuth
+ );
+ const configuredKey = environment.resolveCredential(config?.apiKey);
+ const authKey = isRecord(openCodeAuth)
+ ? keyFromOpenCodeAuth(openCodeAuth, environment.credential)
+ : undefined;
+ const apiKey =
+ authFileKey ??
+ (isOfficialOrigin ? (authKey ?? 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: deepSeekBalanceUrl(baseUrl),
+ });
+ const parsedPayload = isRecord(payload)
+ ? parseDeepSeekPayload(payload)
+ : null;
+ if (!parsedPayload || parsedPayload.balances.length === 0) {
+ 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 ?? "DeepSeek",
+ metadata: { isAvailable: parsedPayload.isAvailable },
+ windows: parsedPayload.balances.map(balanceWindow),
+ };
+ });
+
+/** Fetches DeepSeek's available balance windows from its official API. */
+export const fetchDeepSeekBalanceUsage = (
+ config: DeepSeekProviderConfig | undefined,
+ openCodeAuth: OpenCodeAuth,
+ timeoutMs: number
+): Promise> =>
+ Effect.runPromise(
+ fetchDeepSeekBalanceUsageEffect(config, openCodeAuth, timeoutMs).pipe(
+ Effect.provide(ProviderRuntimeLive)
+ )
+ );
+
+/** DeepSeek balance provider adapter and OpenCode provider-ID mapping. */
+export const deepSeekProvider = {
+ defaultLabel: "DeepSeek",
+ displayOrder: 8,
+ fetch: fetchDeepSeekBalanceUsageEffect,
+ footerWindowKind: "credits",
+ id: PROVIDER_ID,
+ openCodeProviderIDs: [PROVIDER_ID],
+} as const satisfies ProviderDefinition<"deepseek">;
diff --git a/packages/opencode-usage-limits/src/providers/index.ts b/packages/opencode-usage-limits/src/providers/index.ts
index ef5e024..7b28208 100644
--- a/packages/opencode-usage-limits/src/providers/index.ts
+++ b/packages/opencode-usage-limits/src/providers/index.ts
@@ -1,6 +1,7 @@
import { alibabaTokenPlanProvider } from "@/providers/alibaba-token-plan.ts";
import { codexProvider } from "@/providers/codex.ts";
import { commandCodeProvider } from "@/providers/commandcode.ts";
+import { deepSeekProvider } from "@/providers/deepseek.ts";
import type { ProviderDefinition } from "@/providers/definition.ts";
import { minimaxProvider } from "@/providers/minimax.ts";
import { openCodeGoProvider } from "@/providers/opencode-go.ts";
@@ -18,6 +19,7 @@ const PROVIDER_MANIFEST: ProviderRegistry = {
"alibaba-token-plan": alibabaTokenPlanProvider,
codex: codexProvider,
commandcode: commandCodeProvider,
+ deepseek: deepSeekProvider,
minimax: minimaxProvider,
"opencode-go": openCodeGoProvider,
qwen: qwenProvider,
diff --git a/packages/opencode-usage-limits/src/types.ts b/packages/opencode-usage-limits/src/types.ts
index 69a221e..0c9fef7 100644
--- a/packages/opencode-usage-limits/src/types.ts
+++ b/packages/opencode-usage-limits/src/types.ts
@@ -24,6 +24,7 @@ export interface ProviderDisplayConfig {
export type ProviderID =
| "alibaba-token-plan"
| "codex"
+ | "deepseek"
| "zai"
| "synthetic"
| "minimax"
@@ -180,6 +181,16 @@ export interface MiniMaxProviderConfig extends CommonProviderConfig {
readonly baseUrl?: string;
}
+/** DeepSeek balance provider configuration. */
+export interface DeepSeekProviderConfig extends CommonProviderConfig {
+ /** DeepSeek 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;
@@ -215,6 +226,8 @@ export interface ProviderConfigMap {
readonly "alibaba-token-plan": AlibabaTokenPlanProviderConfig;
/** Codex settings. */
readonly codex: CodexProviderConfig;
+ /** DeepSeek balance settings. */
+ readonly deepseek: DeepSeekProviderConfig;
/** MiniMax Token Plan settings. */
readonly minimax: MiniMaxProviderConfig;
/** Qwen CLI settings. */
@@ -258,6 +271,8 @@ export interface OpenCodeAuth {
readonly apiKey?: OpenCodeAuthCredential;
/** OpenAI/Codex credentials stored by OpenCode. */
openai?: OpenCodeOpenAIAuthEntry | null;
+ /** DeepSeek credentials stored under the provider's catalog ID. */
+ deepseek?: OpenCodeAuthEntry | null;
/** ZAI Coding Plan credentials stored under OpenCode's provider ID. */
"zai-coding-plan"?: OpenCodeAuthEntry | null;
/** ZAI credentials stored under the plugin's normalized provider ID. */
diff --git a/packages/opencode-usage-limits/src/usage.ts b/packages/opencode-usage-limits/src/usage.ts
index 4d4f694..ec9e00c 100644
--- a/packages/opencode-usage-limits/src/usage.ts
+++ b/packages/opencode-usage-limits/src/usage.ts
@@ -17,6 +17,13 @@ export type Percentage = typeof PercentageSchema.Type;
/** A finite, non-negative quota count. */
export type QuotaCount = typeof QuotaCountSchema.Type;
+/** A remaining balance without a known total or usage percentage. */
+export interface BalanceQuota {
+ readonly _tag: "Balance";
+ readonly remaining: QuotaCount;
+ readonly unit: string;
+}
+
/** A valid absolute reset instant. */
export type ResetInstant = typeof ResetInstantSchema.Type;
@@ -34,6 +41,7 @@ export type UsageQuota =
readonly total: QuotaCount;
readonly usedPercent: Percentage;
}
+ | BalanceQuota
| { readonly _tag: "Unknown" };
/** Schema for finite percentages in the inclusive range `0..100`. */
@@ -63,6 +71,16 @@ export const parseUsagePercentage = (
value: JsonValue
): Result.Result => parsePercentage(value);
+/**
+ * Decodes an unknown value as a finite, non-negative remaining balance.
+ *
+ * @param value - Amount supplied by a provider response.
+ * @returns A validated balance amount or a schema decoding failure.
+ */
+export const parseUsageBalance = (
+ value: JsonValue
+): Result.Result => parseQuotaCount(value);
+
/**
* Decodes an unknown value as a finite, non-negative quota count.
*
@@ -150,15 +168,33 @@ export const countQuota = (
};
};
+/**
+ * Creates a remaining-balance quota without inventing a total or percentage.
+ *
+ * @param remaining - Validated amount still available.
+ * @param unit - Provider-reported unit or currency.
+ * @returns A balance quota preserving the amount and unit.
+ */
+export const balanceQuota = (
+ remaining: QuotaCount,
+ unit: string
+): BalanceQuota => ({
+ _tag: "Balance",
+ remaining: QuotaCountSchema.make(remaining),
+ unit: Schema.String.make(unit),
+});
+
/** Quota form used when a provider cannot report meaningful usage. */
/** Quota sentinel for providers that cannot report meaningful usage. */
export const unknownQuota: UsageQuota = { _tag: "Unknown" };
/**
- * Gets the percentage consumed by a quota when it is known.
+ * Gets the percentage consumed by a quota when it reports consumption.
*
* @param quota - Normalized quota representation.
- * @returns The used percentage, or `null` for an unknown quota.
+ * @returns The used percentage, or `null` for balances and unknown quotas.
*/
export const quotaUsedPercent = (quota: UsageQuota): Percentage | null =>
- quota._tag === "Unknown" ? null : quota.usedPercent;
+ quota._tag === "Percentage" || quota._tag === "Count"
+ ? quota.usedPercent
+ : null;
diff --git a/packages/opencode-usage-limits/usage-limits.schema.json b/packages/opencode-usage-limits/usage-limits.schema.json
index af0f2fa..7b02a90 100644
--- a/packages/opencode-usage-limits/usage-limits.schema.json
+++ b/packages/opencode-usage-limits/usage-limits.schema.json
@@ -17,6 +17,7 @@
"alibaba-token-plan": { "$ref": "#/$defs/alibabaTokenPlanProvider" },
"codex": { "$ref": "#/$defs/codexProvider" },
"commandcode": { "$ref": "#/$defs/commandCodeProvider" },
+ "deepseek": { "$ref": "#/$defs/deepSeekProvider" },
"minimax": { "$ref": "#/$defs/minimaxProvider" },
"opencode-go": { "$ref": "#/$defs/openCodeGoProvider" },
"qwen": { "$ref": "#/$defs/qwenProvider" },
@@ -111,6 +112,35 @@
"baseUrl": { "type": "string" }
}
},
+ "deepSeekProvider": {
+ "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",
+ "description": "Optional override path to a provider auth file. Omit to use auto-discovery from OpenCode auth and provider defaults."
+ },
+ "apiKey": {
+ "type": "string",
+ "description": "Optional literal API key or {env:DEEPSEEK_API_KEY} fallback when auth files do not contain a key."
+ },
+ "baseUrl": { "type": "string" }
+ }
+ },
"commandCodeProvider": {
"type": "object",
"additionalProperties": false,