From f13c030a25c1619603cf43aeb5afbe1d4bcc960d Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 15:52:58 +1300 Subject: [PATCH 1/7] feat(usage-limits): add remaining balance quotas --- .../__tests__/components.test.tsx | 64 +++++++++++++++- .../__tests__/format.test.ts | 26 +++++++ .../__tests__/usage.test.ts | 29 ++++++- .../opencode-usage-limits/src/components.tsx | 75 +++++++++++-------- packages/opencode-usage-limits/src/format.ts | 37 ++++++++- packages/opencode-usage-limits/src/usage.ts | 42 ++++++++++- 6 files changed, 232 insertions(+), 41 deletions(-) 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__/format.test.ts b/packages/opencode-usage-limits/__tests__/format.test.ts index 59a166c..f030cdb 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,30 @@ 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.each([ [null, ""], [0, " · now"], 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/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/format.ts b/packages/opencode-usage-limits/src/format.ts index 183fdac..cae0a9d 100644 --- a/packages/opencode-usage-limits/src/format.ts +++ b/packages/opencode-usage-limits/src/format.ts @@ -1,4 +1,5 @@ import type { UsageWindow } from "@/types.ts"; +import type { BalanceQuota, UsageQuota } from "@/usage.ts"; import { quotaUsedPercent } from "@/usage.ts"; /** @@ -48,23 +49,51 @@ const duration = (seconds: number | null): string => { 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 amount = symbol + ? `${symbol}${quota.remaining.toFixed(2)}` + : `${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/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; From 822cda5dc4c73ddf3f52ca01456706405c44dd69 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:08:42 +1300 Subject: [PATCH 2/7] feat(usage-limits): add DeepSeek balance provider --- .changeset/540e02b2.md | 5 + packages/opencode-usage-limits/README.md | 18 +- .../__tests__/config.test.ts | 28 +- .../__tests__/providers/deepseek.test.ts | 245 ++++++++++++++++++ .../__tests__/providers/helpers.ts | 1 + .../__tests__/providers/index.test.ts | 2 + .../examples/usage-limits.jsonc | 11 + packages/opencode-usage-limits/package.json | 3 +- .../src/config-schema.ts | 13 + packages/opencode-usage-limits/src/config.ts | 1 + .../src/errors-shared.ts | 2 + .../src/errors/response-decode.ts | 1 + .../src/providers/deepseek.ts | 213 +++++++++++++++ .../src/providers/index.ts | 2 + packages/opencode-usage-limits/src/types.ts | 15 ++ .../usage-limits.schema.json | 30 +++ 16 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 .changeset/540e02b2.md create mode 100644 packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts create mode 100644 packages/opencode-usage-limits/src/providers/deepseek.ts 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/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__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts index 8b6c579..a7cb64a 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,17 +196,25 @@ 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; - expect(Result.isSuccess(result)).toBeTruthy(); + const deepSeekApiKey = success?.providers.deepseek?.apiKey; 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", @@ -218,6 +233,12 @@ describe("configuration parsing", () => { enabled: true, label: "CC", }); + expect(success?.providers.deepseek).toMatchObject({ + authPath: "~/.config/opencode/auth.json", + baseUrl: "https://api.deepseek.com", + enabled: true, + label: "DS", + }); }); it.each([ @@ -485,6 +506,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 +526,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 +538,7 @@ describe("configuration loading", () => { "opencode", "go", "commandcode", + "deepseek", "synthetic", "zai", "zai-plan", 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..9d02afd --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts @@ -0,0 +1,245 @@ +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 { ProviderResponseDecodeError } 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 }); + +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("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" } }, + ]); + }); +}); 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/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/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 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 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: `${baseUrl}${DEEPSEEK_BALANCE_PATH}`, + }); + 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/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, From e60a959f9195ecb5c09be9ef314ec653ddd8055a Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:22:58 +1300 Subject: [PATCH 3/7] test(usage-limits): cover DeepSeek HTTP error redaction --- .../__tests__/providers/deepseek.test.ts | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts index 9d02afd..51c0671 100644 --- a/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { ProviderResponseDecodeError } from "@/errors.ts"; +import { + ProviderRateLimitError, + ProviderResponseDecodeError, + ProviderTransportError, +} from "@/errors.ts"; import { fetchDeepSeekBalanceUsage } from "@/providers/deepseek.ts"; import { installFetchMock, resetFetchMock } from "./helpers.ts"; @@ -29,6 +33,37 @@ const response = ( 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); @@ -167,6 +202,40 @@ describe("DeepSeek provider", () => { ).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(), From ea8e791d0a3f6adb749db9b2b59bbb5e5bded766 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:50:32 +1300 Subject: [PATCH 4/7] docs: document DeepSeek usage limits --- .changeset/dee7d71f.md | 5 +++++ README.md | 1 + apps/web/docs/index.mdx | 4 ++-- apps/web/docs/usage-limits.mdx | 40 ++++++++++++++++++++++++++++++++-- apps/web/theme.css | 9 ++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 .changeset/dee7d71f.md 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 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; } From bc6f7bb26485692556864f2e73acc9c7bde25e82 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:58:27 +1300 Subject: [PATCH 5/7] fix(usage-limits): preserve tiny currency balances --- packages/opencode-usage-limits/__tests__/format.test.ts | 9 +++++++++ packages/opencode-usage-limits/src/format.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode-usage-limits/__tests__/format.test.ts b/packages/opencode-usage-limits/__tests__/format.test.ts index f030cdb..a694201 100644 --- a/packages/opencode-usage-limits/__tests__/format.test.ts +++ b/packages/opencode-usage-limits/__tests__/format.test.ts @@ -83,6 +83,15 @@ describe("format helpers", () => { ).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/src/format.ts b/packages/opencode-usage-limits/src/format.ts index cae0a9d..b6d75d0 100644 --- a/packages/opencode-usage-limits/src/format.ts +++ b/packages/opencode-usage-limits/src/format.ts @@ -66,8 +66,11 @@ const CURRENCY_SYMBOLS = new Map([ */ 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}${quota.remaining.toFixed(2)}` + ? `${symbol}${displayAmount}` : `${quota.remaining} ${quota.unit}`; return `${amount} remaining`; }; From 7020c999d7a598bb3408a0477015b8535122aa48 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:58:34 +1300 Subject: [PATCH 6/7] fix(usage-limits): normalize DeepSeek balance URLs --- .../__tests__/providers/deepseek.test.ts | 18 ++++++++++++++++++ .../src/providers/deepseek.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts index 51c0671..5fd131b 100644 --- a/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/deepseek.test.ts @@ -311,4 +311,22 @@ describe("DeepSeek provider", () => { { 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/src/providers/deepseek.ts b/packages/opencode-usage-limits/src/providers/deepseek.ts index d472751..cfa3aa2 100644 --- a/packages/opencode-usage-limits/src/providers/deepseek.ts +++ b/packages/opencode-usage-limits/src/providers/deepseek.ts @@ -119,6 +119,13 @@ const parseDeepSeekPayload = (value: JsonObject): DeepSeekPayload | null => { 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, @@ -168,7 +175,7 @@ const fetchDeepSeekBalanceUsageEffect = ( method: "GET", providerID: PROVIDER_ID, timeoutMs, - url: `${baseUrl}${DEEPSEEK_BALANCE_PATH}`, + url: deepSeekBalanceUrl(baseUrl), }); const parsedPayload = isRecord(payload) ? parseDeepSeekPayload(payload) From bf76cf380e54974769cf3cd8d329816a7a8ba483 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 16:58:40 +1300 Subject: [PATCH 7/7] test(usage-limits): assert config decoding succeeds --- .../__tests__/config.test.ts | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/opencode-usage-limits/__tests__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts index a7cb64a..035ab43 100644 --- a/packages/opencode-usage-limits/__tests__/config.test.ts +++ b/packages/opencode-usage-limits/__tests__/config.test.ts @@ -210,34 +210,37 @@ describe("configuration parsing", () => { 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), 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.deepseek).toMatchObject({ - authPath: "~/.config/opencode/auth.json", - baseUrl: "https://api.deepseek.com", - enabled: true, - label: "DS", + 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", + }, }); });