From 2144ef85bf8033558efb15ba85dcb410942fd49b Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 21:53:02 +1300 Subject: [PATCH 1/4] feat(usage-limits): add region-isolated Moonshot providers --- .../__tests__/config.test.ts | 25 ++ .../__tests__/format.test.ts | 19 ++ .../__tests__/providers/helpers.ts | 2 + .../__tests__/providers/index.test.ts | 4 + .../__tests__/providers/moonshotai.test.ts | 232 ++++++++++++++++++ .../__tests__/usage.test.ts | 9 + .../src/config-schema.ts | 18 ++ packages/opencode-usage-limits/src/config.ts | 2 + .../src/errors-shared.ts | 4 + .../src/errors/response-decode.ts | 2 + packages/opencode-usage-limits/src/format.ts | 9 +- .../src/providers/index.ts | 6 + .../src/providers/moonshotai.ts | 225 +++++++++++++++++ packages/opencode-usage-limits/src/types.ts | 20 ++ packages/opencode-usage-limits/src/usage.ts | 19 +- 15 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts create mode 100644 packages/opencode-usage-limits/src/providers/moonshotai.ts diff --git a/packages/opencode-usage-limits/__tests__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts index 3aff97c..be2f434 100644 --- a/packages/opencode-usage-limits/__tests__/config.test.ts +++ b/packages/opencode-usage-limits/__tests__/config.test.ts @@ -52,6 +52,7 @@ interface PublishedSchema { commonDisplayFields: PublishedProviderDefinition; deepSeekProvider: PublishedProviderDefinition; minimaxProvider: PublishedProviderDefinition; + moonshotAiProvider: PublishedProviderDefinition; novitaAiProvider: PublishedProviderDefinition; openRouterProvider: PublishedProviderDefinition; openCodeGoProvider: PublishedProviderDefinition; @@ -67,6 +68,8 @@ interface PublishedSchema { commandcode: { $ref: "#/$defs/commandCodeProvider" }; deepseek: { $ref: "#/$defs/deepSeekProvider" }; minimax: { $ref: "#/$defs/minimaxProvider" }; + moonshotai: { $ref: "#/$defs/moonshotAiProvider" }; + "moonshotai-cn": { $ref: "#/$defs/moonshotAiProvider" }; "novita-ai": { $ref: "#/$defs/novitaAiProvider" }; openrouter: { $ref: "#/$defs/openRouterProvider" }; "opencode-go": { $ref: "#/$defs/openCodeGoProvider" }; @@ -103,6 +106,9 @@ describe("configuration parsing", () => { minimax: Object.keys( publishedSchema.$defs.minimaxProvider.properties ).toSorted(), + moonshotai: Object.keys( + publishedSchema.$defs.moonshotAiProvider.properties + ).toSorted(), "novita-ai": Object.keys( publishedSchema.$defs.novitaAiProvider.properties ).toSorted(), @@ -127,6 +133,8 @@ describe("configuration parsing", () => { commandcode: { $ref: "#/$defs/commandCodeProvider" }, deepseek: { $ref: "#/$defs/deepSeekProvider" }, minimax: { $ref: "#/$defs/minimaxProvider" }, + moonshotai: { $ref: "#/$defs/moonshotAiProvider" }, + "moonshotai-cn": { $ref: "#/$defs/moonshotAiProvider" }, "novita-ai": { $ref: "#/$defs/novitaAiProvider" }, "opencode-go": { $ref: "#/$defs/openCodeGoProvider" }, openrouter: { $ref: "#/$defs/openRouterProvider" }, @@ -158,6 +166,7 @@ describe("configuration parsing", () => { providerFields["novita-ai"], providerFields.openrouter, providerFields.minimax, + providerFields.moonshotai, providerFields["opencode-go"], providerFields.synthetic, ]; @@ -217,6 +226,16 @@ describe("configuration parsing", () => { enabled: true, label: "DS", }, + moonshotai: { + apiKey: "moonshot-global-secret", + baseUrl: "https://api.moonshot.ai", + enabled: true, + }, + "moonshotai-cn": { + apiKey: "moonshot-cn-secret", + baseUrl: "https://api.moonshot.cn", + enabled: true, + }, "novita-ai": { apiKey: "novita-secret", authPath: "~/.config/opencode/auth.json", @@ -557,6 +576,8 @@ describe("configuration loading", () => { minimax: { key: "minimax" }, "minimax-coding-plan": { apiKey: "coding" }, "minimax-token-plan": { key: "token-plan" }, + moonshotai: { key: "moonshot-global" }, + "moonshotai-cn": { apiKey: "moonshot-cn" }, "novita-ai": { apiKey: "novita-ai" }, openai: { accountId: "account" }, opencode: { key: "opencode" }, @@ -576,6 +597,8 @@ describe("configuration loading", () => { credentialValue(auth["opencode-go"]?.key), credentialValue(auth.commandcode?.key), credentialValue(auth.deepseek?.key), + credentialValue(auth.moonshotai?.key), + credentialValue(auth["moonshotai-cn"]?.apiKey), credentialValue(auth["novita-ai"]?.apiKey), credentialValue(auth.openrouter?.key), credentialValue(auth.synthetic?.apiKey), @@ -590,6 +613,8 @@ describe("configuration loading", () => { "go", "commandcode", "deepseek", + "moonshot-global", + "moonshot-cn", "novita-ai", "openrouter", "synthetic", diff --git a/packages/opencode-usage-limits/__tests__/format.test.ts b/packages/opencode-usage-limits/__tests__/format.test.ts index 033b64d..c25f777 100644 --- a/packages/opencode-usage-limits/__tests__/format.test.ts +++ b/packages/opencode-usage-limits/__tests__/format.test.ts @@ -19,6 +19,7 @@ import { balanceQuota, countQuota, parseUsageBalance, + parseUsageBalanceAmount, parseUsageCount, parseUsagePercentage, percentageQuota, @@ -118,6 +119,24 @@ describe("format helpers", () => { ); }); + it("renders zero and negative authoritative currency balances accurately", () => { + const negative = balanceQuota( + Result.getOrThrow(parseUsageBalanceAmount(-0.004)), + "USD" + ); + const debt = balanceQuota( + Result.getOrThrow(parseUsageBalanceAmount(-12.5)), + "CNY" + ); + + expect( + windowMainText(usageWindow({ label: "USD balance", quota: negative })) + ).toBe("USD balance: -$<0.01 remaining"); + expect( + windowMainText(usageWindow({ label: "CNY balance", quota: debt })) + ).toBe("CNY balance: -¥12.50 remaining"); + }); + it.each([ [null, ""], [0, " · now"], diff --git a/packages/opencode-usage-limits/__tests__/providers/helpers.ts b/packages/opencode-usage-limits/__tests__/providers/helpers.ts index 8b8562d..ecfa760 100644 --- a/packages/opencode-usage-limits/__tests__/providers/helpers.ts +++ b/packages/opencode-usage-limits/__tests__/providers/helpers.ts @@ -21,5 +21,7 @@ export const resetFetchMock = () => { delete process.env.OC_USAGE_LIMITS_MINIMAX_KEY; delete process.env.DEEPSEEK_API_KEY; delete process.env.OPENROUTER_API_KEY; + delete process.env.MOONSHOT_API_KEY; + delete process.env.MOONSHOT_API_KEY_CN; vi.restoreAllMocks(); }; diff --git a/packages/opencode-usage-limits/__tests__/providers/index.test.ts b/packages/opencode-usage-limits/__tests__/providers/index.test.ts index 1d9954e..abed433 100644 --- a/packages/opencode-usage-limits/__tests__/providers/index.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/index.test.ts @@ -47,6 +47,8 @@ describe("provider manifest", () => { expect([ ["openai", pluginProviderForOpenCode("openai")], ["deepseek", pluginProviderForOpenCode("deepseek")], + ["moonshotai", pluginProviderForOpenCode("moonshotai")], + ["moonshotai-cn", pluginProviderForOpenCode("moonshotai-cn")], ["novita-ai", pluginProviderForOpenCode("novita-ai")], ["openrouter", pluginProviderForOpenCode("openrouter")], ["zai-coding-plan", pluginProviderForOpenCode("zai-coding-plan")], @@ -63,6 +65,8 @@ describe("provider manifest", () => { ]).toStrictEqual([ ["openai", "codex"], ["deepseek", "deepseek"], + ["moonshotai", "moonshotai"], + ["moonshotai-cn", "moonshotai-cn"], ["novita-ai", "novita-ai"], ["openrouter", "openrouter"], ["zai-coding-plan", "zai"], diff --git a/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts b/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts new file mode 100644 index 0000000..18a0f36 --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts @@ -0,0 +1,232 @@ +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, + ProviderTransportError, +} from "@/errors.ts"; +import { + fetchMoonshotAiBalanceUsage, + fetchMoonshotAiCnBalanceUsage, +} from "@/providers/moonshotai.ts"; + +import { installFetchMock, resetFetchMock } from "./helpers.ts"; + +const response = ( + availableBalance: number, + overrides: { readonly code?: number; readonly status?: boolean } = {} +) => + Response.json({ + code: 0, + data: { + available_balance: availableBalance, + cash_balance: -100, + voucher_balance: 200, + }, + scode: "0x0", + status: true, + ...overrides, + }); + +describe("Moonshot/Kimi API balance providers", () => { + afterEach(resetFetchMock); + + it.each([ + [ + "global USD", + "moonshotai", + fetchMoonshotAiBalanceUsage, + "https://api.moonshot.ai/v1/users/me/balance", + "USD", + 49.58894, + ], + [ + "China CNY", + "moonshotai-cn", + fetchMoonshotAiCnBalanceUsage, + "https://api.moonshot.cn/v1/users/me/balance", + "CNY", + 49.58894, + ], + ] as const)( + "fetches the %s balance using available_balance", + async (_name, id, fetchBalance, url, currency, amount) => { + const fetchMock = installFetchMock(response(amount)); + const usage = await fetchBalance({ apiKey: "region-key" }, {}, 1000); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + url, + { + headers: { + Accept: "application/json", + Authorization: "Bearer region-key", + }, + method: "GET", + }, + ]); + expect(usage).toMatchObject({ + id, + windows: [ + { + kind: "credits", + label: `${currency} balance`, + quota: { _tag: "Balance", remaining: amount, unit: currency }, + resetsAt: null, + }, + ], + }); + } + ); + + it.each([0, -3.25])("preserves a %s available balance", async (amount) => { + installFetchMock(response(amount)); + + const usage = await fetchMoonshotAiBalanceUsage( + { apiKey: "global-key" }, + {}, + 1000 + ); + + expect(usage.windows[0]?.quota).toStrictEqual({ + _tag: "Balance", + remaining: amount, + unit: "USD", + }); + }); + + it.each([ + ["missing data", {}], + ["missing available balance", { data: {} }], + ["string balance", { data: { available_balance: "1.25" } }], + [ + "infinite balance", + { data: { available_balance: Number.POSITIVE_INFINITY } }, + ], + ["missing status", { code: 0, data: { available_balance: 1 } }], + [ + "business failure", + { code: 0, data: { available_balance: 1 }, status: false }, + ], + ["nonzero code", { code: 1, data: { available_balance: 1 }, status: true }], + ] as const)("fails closed for %s", async (_label, payload) => { + installFetchMock(Response.json(payload)); + + await expect( + fetchMoonshotAiBalanceUsage({ apiKey: "global-key" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderResponseDecodeError); + }); + + it("classifies HTTP 401 as a safe transport error", async () => { + installFetchMock(Response.json({}, { status: 401 })); + + await expect( + fetchMoonshotAiBalanceUsage({ apiKey: "global-key" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderTransportError); + }); + + it("uses only each provider's matching OpenCode credential", async () => { + const globalMock = installFetchMock(response(1)); + await fetchMoonshotAiBalanceUsage( + {}, + { + moonshotai: { key: "global-key" }, + "moonshotai-cn": { key: "china-key" }, + }, + 1000 + ); + expect(globalMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer global-key" }, + }); + + const chinaMock = installFetchMock(response(2)); + await fetchMoonshotAiCnBalanceUsage( + {}, + { + moonshotai: { key: "global-key" }, + "moonshotai-cn": { key: "china-key" }, + }, + 1000 + ); + expect(chinaMock.mock.calls[0]).toMatchObject([ + "https://api.moonshot.cn/v1/users/me/balance", + { headers: { Authorization: "Bearer china-key" } }, + ]); + }); + + it("reads region-specific credentials from the configured auth file", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-moonshot-${crypto.randomUUID()}.json` + ); + await writeFile( + authPath, + JSON.stringify({ + moonshotai: { key: "auth-file-global" }, + "moonshotai-cn": { apiKey: "auth-file-china" }, + }) + ); + try { + const globalMock = installFetchMock(response(1)); + await fetchMoonshotAiBalanceUsage({ authPath }, {}, 1000); + expect(globalMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer auth-file-global" }, + }); + + const chinaMock = installFetchMock(response(2)); + await fetchMoonshotAiCnBalanceUsage({ authPath }, {}, 1000); + expect(chinaMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer auth-file-china" }, + }); + } finally { + await rm(authPath, { force: true }); + } + }); + + it("does not cross-send a region key when the matching credential is absent", async () => { + await expect( + fetchMoonshotAiBalanceUsage( + {}, + { "moonshotai-cn": { key: "china-key" } }, + 1000 + ) + ).rejects.toThrow("missing Moonshot AI key"); + await expect( + fetchMoonshotAiCnBalanceUsage( + {}, + { moonshotai: { key: "global-key" } }, + 1000 + ) + ).rejects.toThrow("missing Moonshot AI CN key"); + }); + + it.each([ + [ + "global", + fetchMoonshotAiBalanceUsage, + "https://proxy.example.test", + { moonshotai: { key: "discovered" } }, + ], + [ + "China", + fetchMoonshotAiCnBalanceUsage, + "https://api.moonshot.ai", + { "moonshotai-cn": { key: "discovered" } }, + ], + ] as const)( + "requires explicit credentials for custom %s origins", + async (_label, fetchBalance, baseUrl, auth) => { + await expect(fetchBalance({ baseUrl }, auth, 1000)).rejects.toThrow( + "missing Moonshot" + ); + + const fetchMock = installFetchMock(response(1)); + await fetchBalance({ apiKey: "explicit-key", baseUrl }, auth, 1000); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer explicit-key" }, + }); + } + ); +}); diff --git a/packages/opencode-usage-limits/__tests__/usage.test.ts b/packages/opencode-usage-limits/__tests__/usage.test.ts index 24d5618..a2bbb89 100644 --- a/packages/opencode-usage-limits/__tests__/usage.test.ts +++ b/packages/opencode-usage-limits/__tests__/usage.test.ts @@ -5,6 +5,7 @@ import { balanceQuota, countQuota, parseUsageBalance, + parseUsageBalanceAmount, parseUsageCount, parseUsagePercentage, parseUsageResetInstant, @@ -62,6 +63,14 @@ describe("usage domain invariants", () => { } ); + it("accepts signed finite authoritative balance amounts", () => { + expect(Result.isSuccess(parseUsageBalanceAmount(-2.5))).toBeTruthy(); + expect(Result.isSuccess(parseUsageBalanceAmount(0))).toBeTruthy(); + expect( + Result.isFailure(parseUsageBalanceAmount(Number.NEGATIVE_INFINITY)) + ).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/config-schema.ts b/packages/opencode-usage-limits/src/config-schema.ts index a9126b5..cb8c829 100644 --- a/packages/opencode-usage-limits/src/config-schema.ts +++ b/packages/opencode-usage-limits/src/config-schema.ts @@ -98,6 +98,14 @@ const deepSeekProviderConfigSchema = Schema.Struct({ baseUrl: Schema.optionalKey(Schema.String), }); +/** Schema for Moonshot/Kimi API balance provider configuration. */ +const moonshotAiProviderConfigSchema = Schema.Struct({ + ...commonProviderFields, + apiKey: Schema.optionalKey(secret), + authPath: Schema.optionalKey(Schema.String), + baseUrl: Schema.optionalKey(Schema.String), +}); + /** Schema for Novita AI balance provider configuration. */ const novitaAiProviderConfigSchema = Schema.Struct({ ...commonProviderFields, @@ -144,6 +152,8 @@ const providersSchema = Schema.Struct({ commandcode: Schema.optionalKey(commandCodeProviderConfigSchema), deepseek: Schema.optionalKey(deepSeekProviderConfigSchema), minimax: Schema.optionalKey(minimaxProviderConfigSchema), + moonshotai: Schema.optionalKey(moonshotAiProviderConfigSchema), + "moonshotai-cn": Schema.optionalKey(moonshotAiProviderConfigSchema), "novita-ai": Schema.optionalKey(novitaAiProviderConfigSchema), "opencode-go": Schema.optionalKey(openCodeGoProviderConfigSchema), openrouter: Schema.optionalKey(openRouterProviderConfigSchema), @@ -258,6 +268,8 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { } const minimax = parseAuthEntry(input.minimax); + const moonshotai = parseAuthEntry(input.moonshotai); + const moonshotaiCn = parseAuthEntry(input["moonshotai-cn"]); const novitaAi = parseAuthEntry(input["novita-ai"]); const openrouter = parseAuthEntry(input.openrouter); const deepseek = parseAuthEntry(input.deepseek); @@ -276,6 +288,12 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { if (minimax) { auth.minimax = minimax; } + if (moonshotai) { + auth.moonshotai = moonshotai; + } + if (moonshotaiCn) { + auth["moonshotai-cn"] = moonshotaiCn; + } if (novitaAi) { auth["novita-ai"] = novitaAi; } diff --git a/packages/opencode-usage-limits/src/config.ts b/packages/opencode-usage-limits/src/config.ts index ee71fb2..c8ae30f 100644 --- a/packages/opencode-usage-limits/src/config.ts +++ b/packages/opencode-usage-limits/src/config.ts @@ -96,6 +96,8 @@ const authEntryNames = new Set([ "minimax", "minimax-coding-plan", "minimax-token-plan", + "moonshotai", + "moonshotai-cn", "novita-ai", "openai", "opencode", diff --git a/packages/opencode-usage-limits/src/errors-shared.ts b/packages/opencode-usage-limits/src/errors-shared.ts index 4a8b346..b5a36ab 100644 --- a/packages/opencode-usage-limits/src/errors-shared.ts +++ b/packages/opencode-usage-limits/src/errors-shared.ts @@ -10,6 +10,8 @@ export const ProviderIDSchema = Schema.Literals([ "zai", "synthetic", "minimax", + "moonshotai", + "moonshotai-cn", "qwen", "alibaba-token-plan", "opencode-go", @@ -25,6 +27,8 @@ export const credentialMessages = { commandcode: "missing Command Code key", deepseek: "missing DeepSeek key", minimax: "missing MiniMax key", + moonshotai: "missing Moonshot AI key", + "moonshotai-cn": "missing Moonshot AI CN key", "novita-ai": "missing Novita AI key", "opencode-go": "missing OpenCode GO key", openrouter: "missing OpenRouter key", diff --git a/packages/opencode-usage-limits/src/errors/response-decode.ts b/packages/opencode-usage-limits/src/errors/response-decode.ts index 3f3b490..60127b1 100644 --- a/packages/opencode-usage-limits/src/errors/response-decode.ts +++ b/packages/opencode-usage-limits/src/errors/response-decode.ts @@ -25,6 +25,8 @@ export class ProviderResponseDecodeError extends schemaTaggedError { const symbol = CURRENCY_SYMBOLS.get(quota.unit.toUpperCase()); - const rounded = symbol ? quota.remaining.toFixed(2) : ""; + const sign = quota.remaining < 0 ? "-" : ""; + const rounded = symbol ? Math.abs(quota.remaining).toFixed(2) : ""; const displayAmount = - symbol && quota.remaining > 0 && Number(rounded) === 0 ? "<0.01" : rounded; + symbol && quota.remaining !== 0 && Number(rounded) === 0 + ? "<0.01" + : rounded; const amount = symbol - ? `${symbol}${displayAmount}` + ? `${sign}${symbol}${displayAmount}` : `${quota.remaining} ${quota.unit}`; return `${amount} remaining`; }; diff --git a/packages/opencode-usage-limits/src/providers/index.ts b/packages/opencode-usage-limits/src/providers/index.ts index b027802..e795bbe 100644 --- a/packages/opencode-usage-limits/src/providers/index.ts +++ b/packages/opencode-usage-limits/src/providers/index.ts @@ -4,6 +4,10 @@ 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 { + moonshotAiCnProvider, + moonshotAiProvider, +} from "@/providers/moonshotai.ts"; import { novitaAiProvider } from "@/providers/novita-ai.ts"; import { openCodeGoProvider } from "@/providers/opencode-go.ts"; import { openRouterProvider } from "@/providers/openrouter.ts"; @@ -23,6 +27,8 @@ const PROVIDER_MANIFEST: ProviderRegistry = { commandcode: commandCodeProvider, deepseek: deepSeekProvider, minimax: minimaxProvider, + moonshotai: moonshotAiProvider, + "moonshotai-cn": moonshotAiCnProvider, "novita-ai": novitaAiProvider, "opencode-go": openCodeGoProvider, openrouter: openRouterProvider, diff --git a/packages/opencode-usage-limits/src/providers/moonshotai.ts b/packages/opencode-usage-limits/src/providers/moonshotai.ts new file mode 100644 index 0000000..d9c7e8c --- /dev/null +++ b/packages/opencode-usage-limits/src/providers/moonshotai.ts @@ -0,0 +1,225 @@ +import { Clock, Effect, Redacted, Result } from "effect"; + +import { + MissingProviderCredentialsError, + ProviderResponseDecodeError, +} from "@/errors.ts"; +import { readProviderAuthFileCredential } from "@/providers/auth-file.ts"; +import type { ProviderDefinition } from "@/providers/definition.ts"; +import { isJsonNumber } 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 { + MoonshotAiProviderConfig, + OpenCodeAuth, + ProviderUsage, + UsageWindow, +} from "@/types.ts"; +import type { BalanceAmount } from "@/usage.ts"; +import { balanceQuota, parseUsageBalanceAmount } from "@/usage.ts"; +import { isRecord } from "@/utils.ts"; +import type { JsonObject, JsonValue } from "@/utils.ts"; +import { resolveHttpsBaseUrl } from "@/utils/url.ts"; + +interface MoonshotRegion { + readonly baseUrl: string; + readonly currency: "CNY" | "USD"; + readonly id: ID; + readonly label: string; +} + +const regions = { + moonshotai: { + baseUrl: "https://api.moonshot.ai", + currency: "USD", + id: "moonshotai", + label: "Moonshot AI", + }, + "moonshotai-cn": { + baseUrl: "https://api.moonshot.cn", + currency: "CNY", + id: "moonshotai-cn", + label: "Moonshot AI CN", + }, +} as const satisfies { + moonshotai: MoonshotRegion<"moonshotai">; + "moonshotai-cn": MoonshotRegion<"moonshotai-cn">; +}; + +const BALANCE_PATH = "/v1/users/me/balance"; + +const keyFromMoonshotAuth = ( + providerID: "moonshotai" | "moonshotai-cn", + value: JsonObject, + credential: ( + value: JsonValue | undefined + ) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + const direct = credential(value.key) ?? credential(value.apiKey); + if (direct) { + return direct; + } + const entry = value[providerID]; + if (!isRecord(entry)) { + return undefined; + } + return credential(entry.key) ?? credential(entry.apiKey); +}; + +const keyFromOpenCodeAuth = ( + providerID: "moonshotai" | "moonshotai-cn", + value: OpenCodeAuth, + credential: ( + value: JsonValue | Redacted.Redacted | undefined + ) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + if (!isRecord(value)) { + return undefined; + } + const entry = value[providerID]; + if (!isRecord(entry)) { + return undefined; + } + return credential(entry.key) ?? credential(entry.apiKey); +}; + +const balanceUrl = (baseUrl: string): string => { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/$/u, "")}${BALANCE_PATH}`; + url.hash = ""; + return url.toString(); +}; + +const parseMoonshotBalance = (value: JsonValue): BalanceAmount | null => { + if (!isRecord(value) || value.code !== 0 || value.status !== true) { + return null; + } + if (!isRecord(value.data)) { + return null; + } + const amount = value.data.available_balance; + if (!isJsonNumber(amount) || !Number.isFinite(amount)) { + return null; + } + const parsed = parseUsageBalanceAmount(amount); + return Result.isSuccess(parsed) ? parsed.success : null; +}; + +const balanceWindow = ( + amount: BalanceAmount, + region: MoonshotRegion<"moonshotai" | "moonshotai-cn"> +): UsageWindow => ({ + kind: "credits", + label: `${region.currency} balance`, + quota: balanceQuota(amount, region.currency), + resetsAt: null, +}); + +const fetchMoonshotBalanceUsageEffect = < + ID extends "moonshotai" | "moonshotai-cn", +>( + region: MoonshotRegion, + config: MoonshotAiProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): ReturnType["fetch"]> => + Effect.gen(function* runFetchMoonshotBalanceUsage() { + const environment = yield* ProviderEnvironment; + const http = yield* ProviderHttpClient; + const baseUrl = resolveHttpsBaseUrl(config?.baseUrl, region.baseUrl); + const isOfficialOrigin = new URL(baseUrl).origin === region.baseUrl; + const authFileKey = yield* readProviderAuthFileCredential( + config?.authPath, + region.id, + (value, credential) => keyFromMoonshotAuth(region.id, value, credential) + ); + const configuredKey = environment.resolveCredential(config?.apiKey); + const authKey = keyFromOpenCodeAuth( + region.id, + openCodeAuth, + environment.credential + ); + const apiKey = + authFileKey ?? + (isOfficialOrigin ? (authKey ?? configuredKey) : configuredKey); + if (!apiKey) { + return yield* new MissingProviderCredentialsError({ + operation: "fetch-usage", + providerID: region.id, + }); + } + + const payload = yield* http.requestJson({ + headers: { + Accept: "application/json", + Authorization: `Bearer ${Redacted.value(apiKey)}`, + }, + method: "GET", + providerID: region.id, + timeoutMs, + url: balanceUrl(baseUrl), + }); + const amount = parseMoonshotBalance(payload); + if (amount === null) { + return yield* new ProviderResponseDecodeError({ + cause: "schema", + operation: "decode-response", + providerID: region.id, + }); + } + + return { + capturedAt: new Date(yield* Clock.currentTimeMillis), + id: region.id, + label: config?.label ?? region.label, + windows: [balanceWindow(amount, region)], + }; + }); + +const createMoonshotProvider = ( + region: MoonshotRegion +): ProviderDefinition => ({ + defaultLabel: region.label, + displayOrder: region.id === "moonshotai" ? 11 : 12, + fetch: (config, openCodeAuth, timeoutMs) => + fetchMoonshotBalanceUsageEffect(region, config, openCodeAuth, timeoutMs), + footerWindowKind: "credits", + id: region.id, + openCodeProviderIDs: [region.id], +}); + +export const moonshotAiProvider = createMoonshotProvider(regions.moonshotai); +export const moonshotAiCnProvider = createMoonshotProvider( + regions["moonshotai-cn"] +); + +/** Fetches the global Moonshot/Kimi API balance. */ +export const fetchMoonshotAiBalanceUsage = ( + config: MoonshotAiProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): Promise> => + Effect.runPromise( + fetchMoonshotBalanceUsageEffect( + regions.moonshotai, + config, + openCodeAuth, + timeoutMs + ).pipe(Effect.provide(ProviderRuntimeLive)) + ); + +/** Fetches the China Moonshot/Kimi API balance. */ +export const fetchMoonshotAiCnBalanceUsage = ( + config: MoonshotAiProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): Promise> => + Effect.runPromise( + fetchMoonshotBalanceUsageEffect( + regions["moonshotai-cn"], + config, + openCodeAuth, + timeoutMs + ).pipe(Effect.provide(ProviderRuntimeLive)) + ); diff --git a/packages/opencode-usage-limits/src/types.ts b/packages/opencode-usage-limits/src/types.ts index 626e782..3b928cb 100644 --- a/packages/opencode-usage-limits/src/types.ts +++ b/packages/opencode-usage-limits/src/types.ts @@ -28,6 +28,8 @@ export type ProviderID = | "zai" | "synthetic" | "minimax" + | "moonshotai" + | "moonshotai-cn" | "qwen" | "opencode-go" | "commandcode" @@ -193,6 +195,16 @@ export interface DeepSeekProviderConfig extends CommonProviderConfig { readonly baseUrl?: string; } +/** Moonshot/Kimi API balance provider configuration. */ +export interface MoonshotAiProviderConfig extends CommonProviderConfig { + /** 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; +} + /** Novita AI balance provider configuration. */ export interface NovitaAiProviderConfig extends CommonProviderConfig { /** Novita AI API credential override. */ @@ -252,6 +264,10 @@ export interface ProviderConfigMap { readonly deepseek: DeepSeekProviderConfig; /** MiniMax Token Plan settings. */ readonly minimax: MiniMaxProviderConfig; + /** Global Moonshot/Kimi API balance settings. */ + readonly moonshotai: MoonshotAiProviderConfig; + /** China Moonshot/Kimi API balance settings. */ + readonly "moonshotai-cn": MoonshotAiProviderConfig; /** Novita AI balance settings. */ readonly "novita-ai": NovitaAiProviderConfig; /** OpenRouter key spending-limit settings. */ @@ -299,6 +315,10 @@ export interface OpenCodeAuth { openai?: OpenCodeOpenAIAuthEntry | null; /** DeepSeek credentials stored under the provider's catalog ID. */ deepseek?: OpenCodeAuthEntry | null; + /** Global Moonshot/Kimi API credentials. */ + moonshotai?: OpenCodeAuthEntry | null; + /** China Moonshot/Kimi API credentials. */ + "moonshotai-cn"?: 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 996ad4b..63635dc 100644 --- a/packages/opencode-usage-limits/src/usage.ts +++ b/packages/opencode-usage-limits/src/usage.ts @@ -17,10 +17,13 @@ export type Percentage = typeof PercentageSchema.Type; /** A finite, non-negative quota count. */ export type QuotaCount = typeof QuotaCountSchema.Type; +/** A finite signed amount, used when a provider reports a negative balance. */ +export type BalanceAmount = typeof BalanceAmountSchema.Type; + /** A remaining balance without a known total or usage percentage. */ export interface BalanceQuota { readonly _tag: "Balance"; - readonly remaining: QuotaCount; + readonly remaining: BalanceAmount; readonly unit: string; } @@ -54,12 +57,14 @@ const PercentageSchema = Schema.Finite.check( const QuotaCountSchema = Schema.Finite.check( Schema.isGreaterThanOrEqualTo(0) ).pipe(Schema.brand("QuotaCount")); +const BalanceAmountSchema = Schema.Finite.pipe(Schema.brand("BalanceAmount")); /** Schema for valid absolute reset instants. */ const ResetInstantSchema = Schema.Date; const parsePercentage = Schema.decodeUnknownResult(PercentageSchema); const parseQuotaCount = Schema.decodeUnknownResult(QuotaCountSchema); +const parseBalanceAmount = Schema.decodeUnknownResult(BalanceAmountSchema); const parseResetInstant = Schema.decodeUnknownResult(ResetInstantSchema); /** @@ -82,6 +87,12 @@ export const parseUsageBalance = ( value: JsonValue ): Result.Result => parseQuotaCount(value); +/** Decodes a finite signed amount reported as an authoritative balance. */ +export const parseUsageBalanceAmount = ( + value: JsonValue +): Result.Result => + parseBalanceAmount(value); + /** * Decodes an unknown value as a finite, non-negative quota count. * @@ -176,16 +187,16 @@ export const countQuota = ( /** * Creates a remaining-balance quota without inventing a total or percentage. * - * @param remaining - Validated amount still available. + * @param remaining - Validated finite amount still available, possibly negative. * @param unit - Provider-reported unit or currency. * @returns A balance quota preserving the amount and unit. */ export const balanceQuota = ( - remaining: QuotaCount, + remaining: BalanceAmount | QuotaCount, unit: string ): BalanceQuota => ({ _tag: "Balance", - remaining: QuotaCountSchema.make(remaining), + remaining: BalanceAmountSchema.make(remaining), unit: Schema.String.make(unit), }); From 61f39cfbff709b323ae13485172c5ee13fdc04ea Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 21:53:27 +1300 Subject: [PATCH 2/4] docs(usage-limits): document Moonshot balances and config --- .changeset/moonshotai-balance.md | 6 ++++ README.md | 1 + apps/web/docs/index.mdx | 5 +-- apps/web/docs/usage-limits.mdx | 33 +++++++++++++++++++ packages/opencode-usage-limits/README.md | 26 ++++++++++++++- .../examples/usage-limits.jsonc | 20 +++++++++++ packages/opencode-usage-limits/package.json | 4 ++- .../usage-limits.schema.json | 28 ++++++++++++++++ 8 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 .changeset/moonshotai-balance.md diff --git a/.changeset/moonshotai-balance.md b/.changeset/moonshotai-balance.md new file mode 100644 index 0000000..33ade3f --- /dev/null +++ b/.changeset/moonshotai-balance.md @@ -0,0 +1,6 @@ +--- +"@mynameistito/opencode-usage-limits": minor +"@mynameistito/opencode-plugins-docs": patch +--- + +Add isolated global and China Moonshot/Kimi API balance providers. diff --git a/README.md b/README.md index b419d23..7ee98c7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Supported providers: - [Novita AI](https://novita.ai/) - [OpenCode GO](https://opencode.ai/go) - [MiniMax Token Plan](https://platform.minimax.io/subscribe/token-plan) +- [Moonshot/Kimi API](https://platform.kimi.ai/docs/api/balance) (pay-as-you-go global USD / China CNY balances; distinct from Kimi For Coding subscription quota) - [Synthetic](https://synthetic.ai/) - [Qwen](https://qwen.ai/) - [ZAI Coding Plan](https://zai.ai/) diff --git a/apps/web/docs/index.mdx b/apps/web/docs/index.mdx index c20063a..8876a5e 100644 --- a/apps/web/docs/index.mdx +++ b/apps/web/docs/index.mdx @@ -19,8 +19,9 @@ Both packages are published to npm and installed through the OpenCode CLI. They composer hint. - Track Codex, DeepSeek, Novita AI, OpenRouter, Command Code, Z.AI, Synthetic, - MiniMax, Qwen, Alibaba Token Plan, and OpenCode GO windows and balances. + Track Codex, DeepSeek, Moonshot/Kimi API balances, Novita AI, OpenRouter, + Command Code, Z.AI, Synthetic, MiniMax, Qwen, Alibaba Token Plan, and + OpenCode GO windows and balances. diff --git a/apps/web/docs/usage-limits.mdx b/apps/web/docs/usage-limits.mdx index 8de230d..d7e08cf 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -126,6 +126,9 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag MiniMax MiniMax + + Moonshot/Kimi API + Novita AI @@ -254,6 +257,36 @@ The adapter uses `total_balance` directly. It does not add `granted_balance` and Token Plan rolling five-hour and weekly windows. Uses MiniMax auth or `OC_MINIMAX_TOKEN_PLAN_KEY`. See the [MiniMax documentation](https://platform.minimax.io/docs/token-plan/intro). +### Moonshot/Kimi API + +Moonshot/Kimi Open Platform is a pay-as-you-go API balance, separate from Kimi For Coding's subscription-based 5-hour and weekly quota. Choose the provider matching the region where the API key was created: + +| Provider ID | Official balance endpoint | Currency | OpenCode auth ID | +| --- | --- | --- | --- | +| `moonshotai` | `https://api.moonshot.ai/v1/users/me/balance` | USD | `moonshotai` | +| `moonshotai-cn` | `https://api.moonshot.cn/v1/users/me/balance` | CNY | `moonshotai-cn` | + +The adapter displays `data.available_balance` directly as the remaining balance. It does not recompute cash plus voucher balances, convert the amount to a percentage, or infer a reset time. Zero and negative balances remain visible. The response must have `code: 0`, `status: true`, and a finite numeric `data.available_balance`; malformed or unsuccessful responses fail closed. + +Enable either region with the matching OpenCode auth entry, or provide an explicit key: + +```jsonc +{ + "providers": { + "moonshotai": { + "enabled": true, + "apiKey": "{env:MOONSHOT_API_KEY}", // Optional global key + }, + "moonshotai-cn": { + "enabled": true, + "apiKey": "{env:MOONSHOT_API_KEY_CN}", // Optional China key + }, + }, +} +``` + +Global and China keys are independent and are never sent to the other region. OpenCode-discovered credentials are sent only to the exact corresponding official origin. A custom `baseUrl` requires an explicitly configured `authPath` or `apiKey`. + ### Novita AI Displays the current available API balance as a remaining USD amount, not a monthly usage window. Novita's official [`GET /openapi/v1/billing/balance/detail`](https://novita.ai/docs/api-reference/basic-get-user-balance) endpoint returns monetary fields in 1/10,000 USD units; the plugin converts `availableBalance` to USD and does not infer a percentage, balance total, or reset time. diff --git a/packages/opencode-usage-limits/README.md b/packages/opencode-usage-limits/README.md index 3fa9a18..35729ee 100644 --- a/packages/opencode-usage-limits/README.md +++ b/packages/opencode-usage-limits/README.md @@ -1,6 +1,6 @@ # @mynameistito/opencode-usage-limits -OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, Qwen, and Alibaba Token Plan usage limits in the sidebar and prompt footer. +OpenCode TUI plugin that shows Codex, DeepSeek, Moonshot/Kimi API balances, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, Qwen, and Alibaba Token Plan usage limits in the sidebar and prompt footer. ## Features @@ -8,6 +8,7 @@ OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, OpenRouter, Command C - Shows current Codex usage windows from OpenAI/Codex auth. - Shows current DeepSeek currency balances from the official balance API. - Shows the current available Novita AI API balance from its official billing API. +- Shows the remaining pay-as-you-go Moonshot/Kimi API balance for global (USD) and China (CNY) accounts. - Shows the OpenRouter API-key spending limit, not the total account balance. - Shows current ZAI quota windows from ZAI Coding Plan auth. - Shows current Synthetic rolling 5-hour and weekly windows. @@ -157,6 +158,8 @@ The response contract follows the official CLI's [`usage/token-plan.ts`](https:/ | `codex` | ChatGPT Codex usage | — | Bearer | `https://chatgpt.com/backend-api` | | `deepseek` | DeepSeek balances | `DEEPSEEK_API_KEY` | Bearer | `https://api.deepseek.com` | | `novita-ai` | Novita AI available API balance (USD) | `NOVITA_API_KEY` | Bearer | `https://api.novita.ai` | +| `moonshotai` | Moonshot/Kimi API balance (USD) | `MOONSHOT_API_KEY` | Bearer | `https://api.moonshot.ai` | +| `moonshotai-cn` | Moonshot/Kimi API balance (CNY) | `MOONSHOT_API_KEY_CN` | Bearer | `https://api.moonshot.cn` | | `openrouter` | OpenRouter API-key spending limit (USD) | `OPENROUTER_API_KEY` | Bearer | `https://openrouter.ai` | | `zai` | Z.AI Coding Plan quota | `OC_ZAI_API_KEY` | raw / Bearer | `https://api.z.ai` | | `synthetic` | Synthetic quotas | `OC_SYNTHETIC_API_KEY` | Bearer | `https://api.synthetic.new` | @@ -174,6 +177,13 @@ DeepSeek reads `GET https://api.deepseek.com/user/balance`. Each reported curren Novita AI reads `GET https://api.novita.ai/openapi/v1/billing/balance/detail` and displays `availableBalance` as the remaining USD balance. API monetary values are strings in units of 1/10,000 USD (for example, `10000` is `$1.00`). The adapter does not derive the balance from cash, credit, debt, or invoice fields, and does not invent a percentage or reset time. OpenCode-discovered credentials are used only for the exact official Novita origin; a custom `baseUrl` requires an explicit `authPath` or `apiKey`, including `{env:NOVITA_API_KEY}`. +| Provider | Billing model | What we show | +| ----------------- | ------------- | -------------------------------- | +| Kimi For Coding | Subscription | 5-hour and weekly quota | +| Moonshot/Kimi API | Pay-as-you-go | Remaining USD or CNY API balance | + +The global `moonshotai` provider reads `GET https://api.moonshot.ai/v1/users/me/balance` in USD; `moonshotai-cn` reads `GET https://api.moonshot.cn/v1/users/me/balance` in CNY. Both display the API's authoritative `data.available_balance` directly as a remaining balance; cash/voucher fields are not recombined and no percentage or reset is inferred. Global and China API keys are independent and are never sent to the other region. OpenCode credentials are used only for the matching exact official origin; custom `baseUrl` values require an explicitly configured `authPath` or `apiKey`. + OpenRouter reads `GET https://openrouter.ai/api/v1/key` and displays the current API key's finite USD spending limit using `limit` and `limit_remaining`. This is **key-level spending-limit usage**, not total OpenRouter account balance. Reset cadence is shown only when the API reports `daily`, `weekly`, or `monthly`; no absolute reset countdown is inferred. Unbounded or unavailable limits are shown as unknown. Deprecated `rate_limit` and BYOK usage are ignored. OpenCode-discovered credentials are used only for the exact official OpenRouter origin; a custom `baseUrl` requires an explicit `authPath` or `apiKey`, including `{env:OPENROUTER_API_KEY}`. Set `baseUrl` on `minimax` to `https://api.minimaxi.com` when using the mainland-China region. MiniMax always uses `Bearer` auth and ignores `authorizationScheme`. @@ -214,6 +224,14 @@ DeepSeek lookup order: 2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `deepseek`. 3. Config `apiKey`, including `{env:DEEPSEEK_API_KEY}` references. +Moonshot/Kimi lookup order, separately for each provider: + +1. Config `authPath` JSON file (`{ "key": "..." }`, `{ "apiKey": "..." }`, or the matching provider ID nested inside it). +2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `moonshotai` or `moonshotai-cn` matching the configured provider. This is used only for the corresponding official origin. +3. Config `apiKey`, including `{env:MOONSHOT_API_KEY}` for global or `{env:MOONSHOT_API_KEY_CN}` for China. + +The global and China credentials are not interchangeable. A custom origin never receives an automatically discovered OpenCode credential. + Novita AI lookup order: 1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "novita-ai": { "key": "..." } }`). @@ -254,6 +272,10 @@ MiniMax DeepSeek USD: $12.50 remaining CNY: ¥0.00 remaining +Moonshot AI + USD balance: $49.59 remaining +Moonshot AI CN + CNY balance: ¥49.59 remaining Novita AI balance: $100.00 remaining OpenRouter @@ -272,6 +294,8 @@ Provider mapping: - OpenCode provider `openai` -> Codex usage. - OpenCode provider `deepseek` -> DeepSeek balance usage. +- OpenCode provider `moonshotai` -> global Moonshot/Kimi USD balance. +- OpenCode provider `moonshotai-cn` -> China Moonshot/Kimi CNY balance. - OpenCode provider `novita-ai` -> Novita AI available-balance usage. - OpenCode provider `zai-coding-plan` -> ZAI token usage. - OpenCode provider `synthetic` -> Synthetic usage. diff --git a/packages/opencode-usage-limits/examples/usage-limits.jsonc b/packages/opencode-usage-limits/examples/usage-limits.jsonc index e482a25..5b661d2 100644 --- a/packages/opencode-usage-limits/examples/usage-limits.jsonc +++ b/packages/opencode-usage-limits/examples/usage-limits.jsonc @@ -90,6 +90,26 @@ "apiKey": "{env:OC_MINIMAX_TOKEN_PLAN_KEY}", // Optional fallback when OpenCode auth has no MiniMax key "baseUrl": "https://www.minimax.io", }, + "moonshotai": { + "enabled": false, + "label": "Moonshot AI", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", + "apiKey": "{env:MOONSHOT_API_KEY}", // Optional; OpenCode moonshotai auth is discovered by default + "baseUrl": "https://api.moonshot.ai", + }, + "moonshotai-cn": { + "enabled": false, + "label": "Moonshot AI CN", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", + "apiKey": "{env:MOONSHOT_API_KEY_CN}", // Optional; OpenCode moonshotai-cn auth is discovered by default + "baseUrl": "https://api.moonshot.cn", + }, "qwen": { "enabled": false, "label": "Qwen", diff --git a/packages/opencode-usage-limits/package.json b/packages/opencode-usage-limits/package.json index 7eb3e3b..a65e05b 100644 --- a/packages/opencode-usage-limits/package.json +++ b/packages/opencode-usage-limits/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "@mynameistito/opencode-usage-limits", "version": "1.2.2", - "description": "OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, OpenRouter, 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, Moonshot/Kimi API balances, Novita AI, OpenRouter, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, and Qwen usage limits in the sidebar and prompt footer.", "keywords": [ "ai", "ai-coding", @@ -10,8 +10,10 @@ "commandcode", "deepseek", "developer-tools", + "kimi", "minimax", "minimax-token-plan", + "moonshotai", "novita-ai", "openai", "openai-codex", diff --git a/packages/opencode-usage-limits/usage-limits.schema.json b/packages/opencode-usage-limits/usage-limits.schema.json index 519af17..d7a9c6a 100644 --- a/packages/opencode-usage-limits/usage-limits.schema.json +++ b/packages/opencode-usage-limits/usage-limits.schema.json @@ -19,6 +19,8 @@ "commandcode": { "$ref": "#/$defs/commandCodeProvider" }, "deepseek": { "$ref": "#/$defs/deepSeekProvider" }, "minimax": { "$ref": "#/$defs/minimaxProvider" }, + "moonshotai": { "$ref": "#/$defs/moonshotAiProvider" }, + "moonshotai-cn": { "$ref": "#/$defs/moonshotAiProvider" }, "novita-ai": { "$ref": "#/$defs/novitaAiProvider" }, "openrouter": { "$ref": "#/$defs/openRouterProvider" }, "opencode-go": { "$ref": "#/$defs/openCodeGoProvider" }, @@ -201,6 +203,32 @@ "baseUrl": { "type": "string" } } }, + "moonshotAiProvider": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "$ref": "#/$defs/commonDisplayFields/properties/enabled" }, + "showSidebarBar": { + "$ref": "#/$defs/commonDisplayFields/properties/showSidebarBar" + }, + "showFooterBar": { + "$ref": "#/$defs/commonDisplayFields/properties/showFooterBar" + }, + "sidebarWindow": { + "$ref": "#/$defs/commonDisplayFields/properties/sidebarWindow" + }, + "footerWindow": { + "$ref": "#/$defs/commonDisplayFields/properties/footerWindow" + }, + "label": { "$ref": "#/$defs/commonDisplayFields/properties/label" }, + "authPath": { "type": "string" }, + "apiKey": { + "type": "string", + "description": "Optional literal API key or environment reference (for example {env:MOONSHOT_API_KEY} for global and {env:MOONSHOT_API_KEY_CN} for China); custom origins require explicit credentials." + }, + "baseUrl": { "type": "string" } + } + }, "novitaAiProvider": { "type": "object", "additionalProperties": false, From 0c2c6e148873ff7d789e5cccfc8a2ec69cd1728d Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 22:03:56 +1300 Subject: [PATCH 3/4] docs(usage-limits): add Moonshot logo --- apps/web/docs/usage-limits.mdx | 16 ++++++++++++++++ apps/web/theme.css | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/apps/web/docs/usage-limits.mdx b/apps/web/docs/usage-limits.mdx index d7e08cf..f715740 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -127,6 +127,22 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag MiniMax + Moonshot/Kimi API diff --git a/apps/web/theme.css b/apps/web/theme.css index 037d9ac..5518f87 100644 --- a/apps/web/theme.css +++ b/apps/web/theme.css @@ -63,6 +63,10 @@ filter: invert(1); } +:root:not([data-theme="dark"]) .provider-logo-moonshot { + filter: invert(1); +} + .provider-logo-opencode-light { display: none; } From e126062cfd2bbdda2de648928f312f081acc6757 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 22:16:41 +1300 Subject: [PATCH 4/4] test(usage-limits): improve Moonshot coverage --- .../__tests__/providers/index.test.ts | 33 ++++++++++ .../__tests__/providers/moonshotai.test.ts | 60 +++++++++++++++++-- .../src/providers/moonshotai.ts | 7 --- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/opencode-usage-limits/__tests__/providers/index.test.ts b/packages/opencode-usage-limits/__tests__/providers/index.test.ts index abed433..d76f0e4 100644 --- a/packages/opencode-usage-limits/__tests__/providers/index.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/index.test.ts @@ -123,6 +123,39 @@ describe("provider manifest", () => { await expect(result).resolves.toMatchObject({ id: "codex" }); }); + it("dispatches Moonshot provider IDs through the registry definition", async () => { + const fetchMock = installFetchMock( + Response.json({ + code: 0, + data: { available_balance: -2.5 }, + status: true, + }) + ); + + const usage = await Effect.runPromise( + fetchProviderEffect( + "moonshotai-cn", + { apiKey: "china-key" }, + {}, + 1000 + ).pipe(Effect.provide(ProviderRuntimeLive)) + ); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://api.moonshot.cn/v1/users/me/balance", + { headers: { Authorization: "Bearer china-key" } }, + ]); + expect(usage).toMatchObject({ + id: "moonshotai-cn", + windows: [ + { + label: "CNY balance", + quota: { _tag: "Balance", remaining: -2.5, unit: "CNY" }, + }, + ], + }); + }); + it("rejects unknown provider ids asynchronously", async () => { const unknownEffect = fetchProviderEffect("unknown", undefined, {}, 1000); expect(unknownEffect).toBeDefined(); diff --git a/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts b/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts index 18a0f36..54b7b8a 100644 --- a/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/moonshotai.test.ts @@ -98,12 +98,13 @@ describe("Moonshot/Kimi API balance providers", () => { }); it.each([ - ["missing data", {}], - ["missing available balance", { data: {} }], - ["string balance", { data: { available_balance: "1.25" } }], + ["non-object response", null], + ["missing data", { code: 0, status: true }], + ["missing available balance", { code: 0, data: {}, status: true }], + ["non-object data", { code: 0, data: null, status: true }], [ - "infinite balance", - { data: { available_balance: Number.POSITIVE_INFINITY } }, + "string balance", + { code: 0, data: { available_balance: "1.25" }, status: true }, ], ["missing status", { code: 0, data: { available_balance: 1 } }], [ @@ -119,6 +120,19 @@ describe("Moonshot/Kimi API balance providers", () => { ).rejects.toBeInstanceOf(ProviderResponseDecodeError); }); + it("rejects numeric overflow as a non-finite balance", async () => { + installFetchMock( + new Response( + '{"code":0,"status":true,"data":{"available_balance":1e999}}', + { headers: { "content-type": "application/json" } } + ) + ); + + await expect( + fetchMoonshotAiBalanceUsage({ apiKey: "global-key" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderResponseDecodeError); + }); + it("classifies HTTP 401 as a safe transport error", async () => { installFetchMock(Response.json({}, { status: 401 })); @@ -185,6 +199,42 @@ describe("Moonshot/Kimi API balance providers", () => { } }); + it("accepts direct auth-file keys and ignores malformed provider entries", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-moonshot-direct-${crypto.randomUUID()}.json` + ); + await writeFile(authPath, JSON.stringify({ apiKey: "direct-file-key" })); + try { + const fetchMock = installFetchMock(response(1)); + await fetchMoonshotAiBalanceUsage({ authPath }, {}, 1000); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer direct-file-key" }, + }); + } finally { + await rm(authPath, { force: true }); + } + + const malformedPath = path.join( + tmpdir(), + `oc-usage-limits-moonshot-malformed-${crypto.randomUUID()}.json` + ); + await writeFile(malformedPath, JSON.stringify({ moonshotai: null })); + try { + const fetchMock = installFetchMock(response(1)); + await fetchMoonshotAiBalanceUsage( + { apiKey: "explicit-fallback", authPath: malformedPath }, + {}, + 1000 + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer explicit-fallback" }, + }); + } finally { + await rm(malformedPath, { force: true }); + } + }); + it("does not cross-send a region key when the matching credential is absent", async () => { await expect( fetchMoonshotAiBalanceUsage( diff --git a/packages/opencode-usage-limits/src/providers/moonshotai.ts b/packages/opencode-usage-limits/src/providers/moonshotai.ts index d9c7e8c..6d3c889 100644 --- a/packages/opencode-usage-limits/src/providers/moonshotai.ts +++ b/packages/opencode-usage-limits/src/providers/moonshotai.ts @@ -6,7 +6,6 @@ import { } from "@/errors.ts"; import { readProviderAuthFileCredential } from "@/providers/auth-file.ts"; import type { ProviderDefinition } from "@/providers/definition.ts"; -import { isJsonNumber } 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"; @@ -74,9 +73,6 @@ const keyFromOpenCodeAuth = ( value: JsonValue | Redacted.Redacted | undefined ) => Redacted.Redacted | undefined ): Redacted.Redacted | undefined => { - if (!isRecord(value)) { - return undefined; - } const entry = value[providerID]; if (!isRecord(entry)) { return undefined; @@ -99,9 +95,6 @@ const parseMoonshotBalance = (value: JsonValue): BalanceAmount | null => { return null; } const amount = value.data.available_balance; - if (!isJsonNumber(amount) || !Number.isFinite(amount)) { - return null; - } const parsed = parseUsageBalanceAmount(amount); return Result.isSuccess(parsed) ? parsed.success : null; };