From 1dbbb99f8382685093843878694cb767928f4c47 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 17:40:27 +1300 Subject: [PATCH 1/6] feat(usage-limits): add Novita AI balance provider --- .../__tests__/config.test.ts | 27 +- .../__tests__/providers/index.test.ts | 2 + .../__tests__/providers/novita-ai.test.ts | 270 ++++++++++++++++++ .../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/index.ts | 2 + .../src/providers/novita-ai.ts | 178 ++++++++++++ packages/opencode-usage-limits/src/types.ts | 17 +- .../usage-limits.schema.json | 30 ++ 11 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 packages/opencode-usage-limits/__tests__/providers/novita-ai.test.ts create mode 100644 packages/opencode-usage-limits/src/providers/novita-ai.ts diff --git a/packages/opencode-usage-limits/__tests__/config.test.ts b/packages/opencode-usage-limits/__tests__/config.test.ts index 035ab43..c894c02 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; + novitaAiProvider: PublishedProviderDefinition; openCodeGoProvider: PublishedProviderDefinition; qwenProvider: PublishedProviderDefinition; syntheticProvider: PublishedProviderDefinition; @@ -65,6 +66,7 @@ interface PublishedSchema { commandcode: { $ref: "#/$defs/commandCodeProvider" }; deepseek: { $ref: "#/$defs/deepSeekProvider" }; minimax: { $ref: "#/$defs/minimaxProvider" }; + "novita-ai": { $ref: "#/$defs/novitaAiProvider" }; "opencode-go": { $ref: "#/$defs/openCodeGoProvider" }; qwen: { $ref: "#/$defs/qwenProvider" }; synthetic: { $ref: "#/$defs/syntheticProvider" }; @@ -99,6 +101,9 @@ describe("configuration parsing", () => { minimax: Object.keys( publishedSchema.$defs.minimaxProvider.properties ).toSorted(), + "novita-ai": Object.keys( + publishedSchema.$defs.novitaAiProvider.properties + ).toSorted(), "opencode-go": Object.keys( publishedSchema.$defs.openCodeGoProvider.properties ).toSorted(), @@ -117,6 +122,7 @@ describe("configuration parsing", () => { commandcode: { $ref: "#/$defs/commandCodeProvider" }, deepseek: { $ref: "#/$defs/deepSeekProvider" }, minimax: { $ref: "#/$defs/minimaxProvider" }, + "novita-ai": { $ref: "#/$defs/novitaAiProvider" }, "opencode-go": { $ref: "#/$defs/openCodeGoProvider" }, qwen: { $ref: "#/$defs/qwenProvider" }, synthetic: { $ref: "#/$defs/syntheticProvider" }, @@ -143,6 +149,7 @@ describe("configuration parsing", () => { const apiKeyProviders = [ providerFields.commandcode, providerFields.deepseek, + providerFields["novita-ai"], providerFields.minimax, providerFields["opencode-go"], providerFields.synthetic, @@ -203,6 +210,13 @@ describe("configuration parsing", () => { enabled: true, label: "DS", }, + "novita-ai": { + apiKey: "novita-secret", + authPath: "~/.config/opencode/auth.json", + baseUrl: "https://api.novita.ai", + enabled: true, + label: "Novita", + }, }, }); @@ -210,12 +224,14 @@ describe("configuration parsing", () => { const apiKey = success?.providers.codex?.apiKey; const commandCodeApiKey = success?.providers.commandcode?.apiKey; const deepSeekApiKey = success?.providers.deepseek?.apiKey; + const novitaAiApiKey = success?.providers["novita-ai"]?.apiKey; expect(Result.isSuccess(result)).toBeTruthy(); expect([ Redacted.isRedacted(apiKey), Redacted.isRedacted(commandCodeApiKey), Redacted.isRedacted(deepSeekApiKey), - ]).toStrictEqual([true, true, true]); + Redacted.isRedacted(novitaAiApiKey), + ]).toStrictEqual([true, true, true, true]); expect(String(apiKey)).not.toContain("do-not-log"); expect(success?.providers).toMatchObject({ codex: { @@ -241,6 +257,12 @@ describe("configuration parsing", () => { enabled: true, label: "DS", }, + "novita-ai": { + authPath: "~/.config/opencode/auth.json", + baseUrl: "https://api.novita.ai", + enabled: true, + label: "Novita", + }, }); }); @@ -513,6 +535,7 @@ describe("configuration loading", () => { minimax: { key: "minimax" }, "minimax-coding-plan": { apiKey: "coding" }, "minimax-token-plan": { key: "token-plan" }, + "novita-ai": { apiKey: "novita-ai" }, openai: { accountId: "account" }, opencode: { key: "opencode" }, "opencode-go": { key: "go" }, @@ -530,6 +553,7 @@ describe("configuration loading", () => { credentialValue(auth["opencode-go"]?.key), credentialValue(auth.commandcode?.key), credentialValue(auth.deepseek?.key), + credentialValue(auth["novita-ai"]?.apiKey), credentialValue(auth.synthetic?.apiKey), credentialValue(auth.zai?.key), credentialValue(auth["zai-coding-plan"]?.key), @@ -542,6 +566,7 @@ describe("configuration loading", () => { "go", "commandcode", "deepseek", + "novita-ai", "synthetic", "zai", "zai-plan", diff --git a/packages/opencode-usage-limits/__tests__/providers/index.test.ts b/packages/opencode-usage-limits/__tests__/providers/index.test.ts index 7fff9a5..a01c2f3 100644 --- a/packages/opencode-usage-limits/__tests__/providers/index.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/index.test.ts @@ -47,6 +47,7 @@ describe("provider manifest", () => { expect([ ["openai", pluginProviderForOpenCode("openai")], ["deepseek", pluginProviderForOpenCode("deepseek")], + ["novita-ai", pluginProviderForOpenCode("novita-ai")], ["zai-coding-plan", pluginProviderForOpenCode("zai-coding-plan")], ["minimax-coding-plan", pluginProviderForOpenCode("minimax-coding-plan")], ["minimax", pluginProviderForOpenCode("minimax")], @@ -61,6 +62,7 @@ describe("provider manifest", () => { ]).toStrictEqual([ ["openai", "codex"], ["deepseek", "deepseek"], + ["novita-ai", "novita-ai"], ["zai-coding-plan", "zai"], ["minimax-coding-plan", "minimax"], ["minimax", "minimax"], diff --git a/packages/opencode-usage-limits/__tests__/providers/novita-ai.test.ts b/packages/opencode-usage-limits/__tests__/providers/novita-ai.test.ts new file mode 100644 index 0000000..65145cd --- /dev/null +++ b/packages/opencode-usage-limits/__tests__/providers/novita-ai.test.ts @@ -0,0 +1,270 @@ +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 { + MissingProviderCredentialsError, + ProviderResponseDecodeError, + ProviderTransportError, +} from "@/errors.ts"; +import { fetchNovitaAiBalanceUsage } from "@/providers/novita-ai.ts"; + +import { installFetchMock, resetFetchMock } from "./helpers.ts"; + +const response = (availableBalance: string | undefined) => + Response.json(availableBalance === undefined ? {} : { availableBalance }); + +describe("Novita AI provider", () => { + afterEach(() => { + resetFetchMock(); + delete process.env.NOVITA_API_KEY; + }); + + it("requests the balance endpoint and converts $100 to USD", async () => { + const fetchMock = installFetchMock(response("1000000")); + + const usage = await fetchNovitaAiBalanceUsage( + { apiKey: "novita-key" }, + {}, + 1000 + ); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://api.novita.ai/openapi/v1/billing/balance/detail", + { + headers: { + Accept: "application/json", + Authorization: "Bearer novita-key", + }, + method: "GET", + }, + ]); + expect(usage).toMatchObject({ + id: "novita-ai", + label: "Novita AI", + windows: [ + { + kind: "credits", + label: "balance", + quota: { _tag: "Balance", remaining: 100, unit: "USD" }, + resetsAt: null, + }, + ], + }); + }); + + it("converts whole-dollar and sub-cent amounts without using other fields", async () => { + installFetchMock( + Response.json({ + availableBalance: "1", + cashBalance: "999999999", + creditBalance: "999999999", + debtBalance: "999999999", + }) + ); + + const usage = await fetchNovitaAiBalanceUsage({ apiKey: "key" }, {}, 1000); + + expect(usage.windows[0]?.quota).toStrictEqual({ + _tag: "Balance", + remaining: 0.0001, + unit: "USD", + }); + + installFetchMock(response("10000")); + const dollarUsage = await fetchNovitaAiBalanceUsage( + { apiKey: "key" }, + {}, + 1000 + ); + expect(dollarUsage.windows[0]?.quota).toMatchObject({ + remaining: 1, + unit: "USD", + }); + + installFetchMock(response("00010000")); + const paddedUsage = await fetchNovitaAiBalanceUsage( + { apiKey: "key" }, + {}, + 1000 + ); + expect(paddedUsage.windows[0]?.quota).toMatchObject({ + remaining: 1, + unit: "USD", + }); + }); + + it("preserves a zero balance", async () => { + installFetchMock(response("0")); + + const usage = await fetchNovitaAiBalanceUsage({ apiKey: "key" }, {}, 1000); + + expect(usage.windows[0]?.quota).toStrictEqual({ + _tag: "Balance", + remaining: 0, + unit: "USD", + }); + }); + + it.each([ + ["missing field", undefined], + ["empty string", ""], + ["negative amount", "-1"], + ["fractional minor unit", "1.5"], + ["exponent", "1e4"], + ["nonnumeric", "NaN"], + ["oversized decimal amount", "10000000000000000"], + ["unsafe numeric amount", "9007199254740992"], + ] as const)("rejects %s", async (_name, amount) => { + installFetchMock(response(amount)); + + await expect( + fetchNovitaAiBalanceUsage({ apiKey: "key" }, {}, 1000) + ).rejects.toBeInstanceOf(ProviderResponseDecodeError); + }); + + it("accepts the largest safe integer amount", async () => { + installFetchMock(response("9007199254740991")); + + const usage = await fetchNovitaAiBalanceUsage({ apiKey: "key" }, {}, 1000); + + expect(usage.windows[0]?.quota).toMatchObject({ + remaining: Number.MAX_SAFE_INTEGER / 10_000, + unit: "USD", + }); + }); + + it("uses auto-discovered OpenCode auth on the exact official origin", async () => { + const fetchMock = installFetchMock(response("10000")); + + await fetchNovitaAiBalanceUsage( + {}, + { "novita-ai": { key: "open-code-key" } }, + 1000 + ); + + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer open-code-key" }, + }); + }); + + it("does not send auto-discovered auth to a custom origin", async () => { + await expect( + fetchNovitaAiBalanceUsage( + { baseUrl: "https://novita.example.test" }, + { "novita-ai": { key: "open-code-key" } }, + 1000 + ) + ).rejects.toBeInstanceOf(MissingProviderCredentialsError); + + const fetchMock = installFetchMock(response("10000")); + await fetchNovitaAiBalanceUsage( + { + apiKey: "explicit-key", + baseUrl: "https://novita.example.test/v1?tenant=example#balance", + }, + { "novita-ai": { key: "open-code-key" } }, + 1000 + ); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://novita.example.test/v1/openapi/v1/billing/balance/detail?tenant=example", + { headers: { Authorization: "Bearer explicit-key" } }, + ]); + }); + + it("supports authPath and explicit environment credentials", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-novita-ai-${crypto.randomUUID()}.json` + ); + await writeFile( + authPath, + JSON.stringify({ "novita-ai": { apiKey: "file-key" } }) + ); + try { + const fetchMock = installFetchMock(response("10000")); + await fetchNovitaAiBalanceUsage( + { apiKey: "config-key", authPath }, + {}, + 1000 + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer file-key" }, + }); + } finally { + await rm(authPath, { force: true }); + } + + process.env.NOVITA_API_KEY = "environment-key"; + const fetchMock = installFetchMock(response("10000")); + await fetchNovitaAiBalanceUsage( + { apiKey: "{env:NOVITA_API_KEY}" }, + {}, + 1000 + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer environment-key" }, + }); + }); + + it("accepts flat auth-file credentials and ignores unrelated auth data", async () => { + const authPath = path.join( + tmpdir(), + `oc-usage-limits-novita-ai-flat-${crypto.randomUUID()}.json` + ); + await writeFile(authPath, JSON.stringify({ key: "file-key" })); + try { + const fetchMock = installFetchMock(response("10000")); + await fetchNovitaAiBalanceUsage({ authPath }, {}, 1000); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer file-key" }, + }); + } finally { + await rm(authPath, { force: true }); + } + + const emptyAuthPath = path.join( + tmpdir(), + `oc-usage-limits-novita-ai-empty-${crypto.randomUUID()}.json` + ); + await writeFile(emptyAuthPath, JSON.stringify({ ignored: true })); + try { + const fetchMock = installFetchMock(response("10000")); + await fetchNovitaAiBalanceUsage( + { apiKey: "config-key", authPath: emptyAuthPath }, + {}, + 1000 + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer config-key" }, + }); + } finally { + await rm(emptyAuthPath, { force: true }); + } + }); + + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [500, "http"], + ] as const)( + "classifies Novita AI HTTP %d as a safe transport error", + async (status, cause) => { + installFetchMock(Response.json({ error: "secret-key" }, { status })); + const request = fetchNovitaAiBalanceUsage( + { apiKey: "secret-key" }, + {}, + 1000 + ); + await expect(request).rejects.toBeInstanceOf(ProviderTransportError); + await expect(request).rejects.not.toThrow("secret-key"); + await expect(request).rejects.toMatchObject({ + cause, + providerID: "novita-ai", + }); + } + ); +}); diff --git a/packages/opencode-usage-limits/src/config-schema.ts b/packages/opencode-usage-limits/src/config-schema.ts index eb48445..4d98421 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 Novita AI balance provider configuration. */ +const novitaAiProviderConfigSchema = 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); @@ -128,6 +136,7 @@ const providersSchema = Schema.Struct({ commandcode: Schema.optionalKey(commandCodeProviderConfigSchema), deepseek: Schema.optionalKey(deepSeekProviderConfigSchema), minimax: Schema.optionalKey(minimaxProviderConfigSchema), + "novita-ai": Schema.optionalKey(novitaAiProviderConfigSchema), "opencode-go": Schema.optionalKey(openCodeGoProviderConfigSchema), qwen: Schema.optionalKey(qwenProviderConfigSchema), synthetic: Schema.optionalKey(syntheticProviderConfigSchema), @@ -240,6 +249,7 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { } const minimax = parseAuthEntry(input.minimax); + const novitaAi = parseAuthEntry(input["novita-ai"]); const deepseek = parseAuthEntry(input.deepseek); const minimaxCodingPlan = parseAuthEntry(input["minimax-coding-plan"]); const minimaxTokenPlan = parseAuthEntry(input["minimax-token-plan"]); @@ -256,6 +266,9 @@ export const parseOpenCodeAuth = (input: JsonValue): OpenCodeAuth => { if (minimax) { auth.minimax = minimax; } + if (novitaAi) { + auth["novita-ai"] = novitaAi; + } if (deepseek) { auth.deepseek = deepseek; } diff --git a/packages/opencode-usage-limits/src/config.ts b/packages/opencode-usage-limits/src/config.ts index 6cc8c7e..ee71fb2 100644 --- a/packages/opencode-usage-limits/src/config.ts +++ b/packages/opencode-usage-limits/src/config.ts @@ -96,6 +96,7 @@ const authEntryNames = new Set([ "minimax", "minimax-coding-plan", "minimax-token-plan", + "novita-ai", "openai", "opencode", "opencode-go", diff --git a/packages/opencode-usage-limits/src/errors-shared.ts b/packages/opencode-usage-limits/src/errors-shared.ts index 4fe41d4..88d89d4 100644 --- a/packages/opencode-usage-limits/src/errors-shared.ts +++ b/packages/opencode-usage-limits/src/errors-shared.ts @@ -14,6 +14,7 @@ export const ProviderIDSchema = Schema.Literals([ "alibaba-token-plan", "opencode-go", "commandcode", + "novita-ai", ]); /** Safe, user-facing missing-credential message for each provider. */ @@ -23,6 +24,7 @@ export const credentialMessages = { commandcode: "missing Command Code key", deepseek: "missing DeepSeek key", minimax: "missing MiniMax key", + "novita-ai": "missing Novita AI key", "opencode-go": "missing OpenCode GO key", qwen: "missing Qwen credentials", synthetic: "missing Synthetic key", diff --git a/packages/opencode-usage-limits/src/errors/response-decode.ts b/packages/opencode-usage-limits/src/errors/response-decode.ts index 2c2ac25..021a7c2 100644 --- a/packages/opencode-usage-limits/src/errors/response-decode.ts +++ b/packages/opencode-usage-limits/src/errors/response-decode.ts @@ -25,6 +25,7 @@ export class ProviderResponseDecodeError extends schemaTaggedError | undefined; + +const keyFromNovitaAiAuth = ( + value: JsonObject, + credential: ( + value: JsonValue | undefined + ) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + const direct = credential(value.key) ?? credential(value.apiKey); + if (direct) { + return direct; + } + if (!isRecord(value[PROVIDER_ID])) { + return undefined; + } + return ( + credential(value[PROVIDER_ID].key) ?? credential(value[PROVIDER_ID].apiKey) + ); +}; + +const keyFromOpenCodeAuth = ( + value: OpenCodeAuth, + credential: (value: CredentialInput) => Redacted.Redacted | undefined +): Redacted.Redacted | undefined => { + const auth = isRecord(value) ? value : undefined; + if (!auth || !isRecord(auth[PROVIDER_ID])) { + return undefined; + } + return ( + credential(auth[PROVIDER_ID].key) ?? credential(auth[PROVIDER_ID].apiKey) + ); +}; + +/** Converts Novita's integer ten-thousandths-of-a-dollar amount to USD. */ +const parseAvailableBalance = ( + value: JsonValue | undefined +): QuotaCount | null => { + if (!isJsonString(value) || !MINOR_UNIT_STRING.test(value)) { + return null; + } + const normalized = value.replace(/^0+/u, "") || "0"; + if (normalized.length > String(Number.MAX_SAFE_INTEGER).length) { + return null; + } + const minorUnits = BigInt(normalized); + if (minorUnits > MAX_SAFE_MINOR_UNITS) { + return null; + } + const parsed = parseUsageBalance(Number(minorUnits) / 10_000); + return Result.isSuccess(parsed) ? parsed.success : null; +}; + +const novitaAiBalanceUrl = (baseUrl: string): string => { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/$/u, "")}${NOVITA_AI_BALANCE_PATH}`; + url.hash = ""; + return url.toString(); +}; + +const novitaAiBalanceWindow = (amount: QuotaCount): UsageWindow => ({ + kind: "credits", + label: "balance", + quota: balanceQuota(amount, "USD"), + resetsAt: null, +}); + +/** Fetches and normalizes Novita AI's available USD balance. */ +const fetchNovitaAiBalanceUsageEffect = ( + config: NovitaAiProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): ReturnType["fetch"]> => + Effect.gen(function* runFetchNovitaAiBalanceUsage() { + const environment = yield* ProviderEnvironment; + const http = yield* ProviderHttpClient; + const baseUrl = resolveHttpsBaseUrl( + config?.baseUrl, + DEFAULT_NOVITA_AI_BASE_URL + ); + const isOfficialOrigin = + new URL(baseUrl).origin === DEFAULT_NOVITA_AI_BASE_URL; + const authFileKey = yield* readProviderAuthFileCredential( + config?.authPath, + PROVIDER_ID, + keyFromNovitaAiAuth + ); + const configuredKey = environment.resolveCredential(config?.apiKey); + const authKey = keyFromOpenCodeAuth(openCodeAuth, environment.credential); + 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: novitaAiBalanceUrl(baseUrl), + }); + const amount = isRecord(payload) + ? parseAvailableBalance(payload.availableBalance) + : null; + if (amount === null) { + 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 ?? "Novita AI", + windows: [novitaAiBalanceWindow(amount)], + }; + }); + +/** Fetches Novita AI's available balance from its official billing API. */ +export const fetchNovitaAiBalanceUsage = ( + config: NovitaAiProviderConfig | undefined, + openCodeAuth: OpenCodeAuth, + timeoutMs: number +): Promise> => + Effect.runPromise( + fetchNovitaAiBalanceUsageEffect(config, openCodeAuth, timeoutMs).pipe( + Effect.provide(ProviderRuntimeLive) + ) + ); + +/** Novita AI balance adapter and OpenCode provider-ID mapping. */ +export const novitaAiProvider = { + defaultLabel: "Novita AI", + displayOrder: 9, + fetch: fetchNovitaAiBalanceUsageEffect, + footerWindowKind: "credits", + id: PROVIDER_ID, + openCodeProviderIDs: [PROVIDER_ID], +} as const satisfies ProviderDefinition<"novita-ai">; diff --git a/packages/opencode-usage-limits/src/types.ts b/packages/opencode-usage-limits/src/types.ts index 0c9fef7..a4c316e 100644 --- a/packages/opencode-usage-limits/src/types.ts +++ b/packages/opencode-usage-limits/src/types.ts @@ -30,7 +30,8 @@ export type ProviderID = | "minimax" | "qwen" | "opencode-go" - | "commandcode"; + | "commandcode" + | "novita-ai"; /** Sensitive string accepted by parsed config and legacy provider boundaries. */ type Credential = Redacted.Redacted | string; @@ -191,6 +192,16 @@ export interface DeepSeekProviderConfig extends CommonProviderConfig { readonly baseUrl?: string; } +/** Novita AI balance provider configuration. */ +export interface NovitaAiProviderConfig extends CommonProviderConfig { + /** Novita AI 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; @@ -230,6 +241,8 @@ export interface ProviderConfigMap { readonly deepseek: DeepSeekProviderConfig; /** MiniMax Token Plan settings. */ readonly minimax: MiniMaxProviderConfig; + /** Novita AI balance settings. */ + readonly "novita-ai": NovitaAiProviderConfig; /** Qwen CLI settings. */ readonly qwen: QwenProviderConfig; /** Synthetic settings. */ @@ -289,6 +302,8 @@ export interface OpenCodeAuth { "opencode-go"?: OpenCodeAuthEntry | null; /** Command Code credentials stored under OpenCode's provider ID. */ commandcode?: OpenCodeAuthEntry | null; + /** Novita AI credentials stored under the provider's catalog ID. */ + "novita-ai"?: OpenCodeAuthEntry | null; /** OpenCode Zen credentials stored under the legacy provider ID. */ opencode?: OpenCodeAuthEntry | null; } diff --git a/packages/opencode-usage-limits/usage-limits.schema.json b/packages/opencode-usage-limits/usage-limits.schema.json index 7b02a90..8383015 100644 --- a/packages/opencode-usage-limits/usage-limits.schema.json +++ b/packages/opencode-usage-limits/usage-limits.schema.json @@ -19,6 +19,7 @@ "commandcode": { "$ref": "#/$defs/commandCodeProvider" }, "deepseek": { "$ref": "#/$defs/deepSeekProvider" }, "minimax": { "$ref": "#/$defs/minimaxProvider" }, + "novita-ai": { "$ref": "#/$defs/novitaAiProvider" }, "opencode-go": { "$ref": "#/$defs/openCodeGoProvider" }, "qwen": { "$ref": "#/$defs/qwenProvider" }, "synthetic": { "$ref": "#/$defs/syntheticProvider" }, @@ -199,6 +200,35 @@ "baseUrl": { "type": "string" } } }, + "novitaAiProvider": { + "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 OpenCode auth or provider defaults." + }, + "apiKey": { + "type": "string", + "description": "Optional literal API key or {env:NOVITA_API_KEY} fallback when auth files do not contain a key." + }, + "baseUrl": { "type": "string" } + } + }, "openCodeGoProvider": { "type": "object", "additionalProperties": false, From 4c1c40e7f8a0c9648b8b5c2fcf57af9b1b8cb34f Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 17:40:47 +1300 Subject: [PATCH 2/6] docs(usage-limits): document Novita AI balance support --- .changeset/646af05b.md | 5 +++++ packages/opencode-usage-limits/README.md | 17 +++++++++++++++-- .../examples/usage-limits.jsonc | 10 ++++++++++ packages/opencode-usage-limits/package.json | 3 ++- 4 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .changeset/646af05b.md diff --git a/.changeset/646af05b.md b/.changeset/646af05b.md new file mode 100644 index 0000000..b8251e1 --- /dev/null +++ b/.changeset/646af05b.md @@ -0,0 +1,5 @@ +--- +"@mynameistito/opencode-usage-limits": patch +--- + +Add Novita AI available-balance provider diff --git a/packages/opencode-usage-limits/README.md b/packages/opencode-usage-limits/README.md index 6397ee2..2e0b09a 100644 --- a/packages/opencode-usage-limits/README.md +++ b/packages/opencode-usage-limits/README.md @@ -1,12 +1,13 @@ # @mynameistito/opencode-usage-limits -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. +OpenCode TUI plugin that shows Codex, DeepSeek, Novita AI, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, Qwen, and Alibaba Token Plan usage limits in the sidebar and prompt footer. ## Features - Adds a `Usage Limits` block under the sidebar `Context` section. - Shows current Codex usage windows from OpenAI/Codex auth. - Shows current DeepSeek currency balances from the official balance API. +- Shows the current available Novita AI API balance from its official billing API. - Shows 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. @@ -14,7 +15,7 @@ OpenCode TUI plugin that shows Codex, DeepSeek, Command Code, OpenCode GO, ZAI, - Shows current Alibaba Token Plan 5-hour and weekly windows from the local `bl` CLI. - Shows current OpenCode GO rolling, weekly, and monthly windows. - Displays current Command Code 5-hour, weekly, and derived monthly credit usage. -- Adds compact prompt-footer usage when the current session uses an OpenAI, DeepSeek, Command Code, OpenCode GO, ZAI Coding Plan, Synthetic, MiniMax Token Plan, or Qwen Token Plan model. +- Adds compact prompt-footer usage when the current session uses an OpenAI, DeepSeek, Novita AI, 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. @@ -154,6 +155,7 @@ The response contract follows the official CLI's [`usage/token-plan.ts`](https:/ | --- | --- | --- | --- | --- | | `codex` | ChatGPT Codex usage | — | Bearer | `https://chatgpt.com/backend-api` | | `deepseek` | DeepSeek balances | `DEEPSEEK_API_KEY` | Bearer | `https://api.deepseek.com` | +| `novita-ai` | Novita AI available API balance (USD) | `NOVITA_API_KEY` | Bearer | `https://api.novita.ai` | | `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` | @@ -168,6 +170,8 @@ 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}`. +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}`. + 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 @@ -206,6 +210,12 @@ DeepSeek lookup order: 2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `deepseek`. 3. Config `apiKey`, including `{env:DEEPSEEK_API_KEY}` references. +Novita AI lookup order: + +1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "novita-ai": { "key": "..." } }`). +2. OpenCode auth at `~/.local/share/opencode/auth.json`, provider `novita-ai`. +3. Config `apiKey`, including `{env:NOVITA_API_KEY}` references. + Command Code lookup order: 1. Config `authPath` JSON file (`{ "key": "..." }` / `{ "apiKey": "..." }` / `{ "commandcode": { "key": "..." } }`). @@ -234,6 +244,8 @@ MiniMax DeepSeek USD: $12.50 remaining CNY: ¥0.00 remaining +Novita AI + balance: $100.00 remaining ``` Prompt footer shows compact usage when the current session model belongs to a supported provider: @@ -248,6 +260,7 @@ Provider mapping: - OpenCode provider `openai` -> Codex usage. - OpenCode provider `deepseek` -> DeepSeek balance usage. +- OpenCode provider `novita-ai` -> Novita AI available-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/examples/usage-limits.jsonc b/packages/opencode-usage-limits/examples/usage-limits.jsonc index c506314..06f773e 100644 --- a/packages/opencode-usage-limits/examples/usage-limits.jsonc +++ b/packages/opencode-usage-limits/examples/usage-limits.jsonc @@ -37,6 +37,16 @@ "apiKey": "{env:DEEPSEEK_API_KEY}", // Optional fallback when OpenCode auth has no DeepSeek key "baseUrl": "https://api.deepseek.com", }, + "novita-ai": { + "enabled": false, + "label": "Novita AI", + "showSidebarBar": true, + "showFooterBar": true, + "sidebarWindow": "all", + "footerWindow": "auto", + "apiKey": "{env:NOVITA_API_KEY}", // Optional fallback when OpenCode auth has no Novita AI key + "baseUrl": "https://api.novita.ai", + }, "zai": { "enabled": true, "label": "ZAI", diff --git a/packages/opencode-usage-limits/package.json b/packages/opencode-usage-limits/package.json index 1e48bac..6db7eca 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, 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, Novita AI, Command Code, OpenCode GO, ZAI, Synthetic, MiniMax Token Plan, and Qwen usage limits in the sidebar and prompt footer.", "keywords": [ "ai", "ai-coding", @@ -12,6 +12,7 @@ "developer-tools", "minimax", "minimax-token-plan", + "novita-ai", "openai", "openai-codex", "opencode", From 4e2c603ebd79567808cfec997eec240435deb464 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 17:41:02 +1300 Subject: [PATCH 3/6] docs(site): add Novita AI usage guide --- .changeset/b908f8ed.md | 5 +++++ README.md | 1 + apps/web/docs/index.mdx | 4 ++-- apps/web/docs/usage-limits.mdx | 23 +++++++++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .changeset/b908f8ed.md diff --git a/.changeset/b908f8ed.md b/.changeset/b908f8ed.md new file mode 100644 index 0000000..f5b1bf6 --- /dev/null +++ b/.changeset/b908f8ed.md @@ -0,0 +1,5 @@ +--- +"@mynameistito/opencode-plugins-docs": patch +--- + +Document Novita AI balance support diff --git a/README.md b/README.md index 30f6a65..b419d23 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Supported providers: - [ChatGPT](https://chatgpt.com/) - [Command Code](https://commandcode.ai/) - [DeepSeek](https://www.deepseek.com/) +- [Novita AI](https://novita.ai/) - [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 83f397f..774c248 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, DeepSeek, Command Code, Z.AI, Synthetic, MiniMax, Qwen, Alibaba - Token Plan, and OpenCode GO windows and balances. + Track Codex, DeepSeek, Novita AI, 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 76643fc..d8df528 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -126,6 +126,10 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag MiniMax MiniMax + + Novita AI + Novita AI + Date: Mon, 5 Oct 2026 17:50:06 +1300 Subject: [PATCH 4/6] fix(docs): remove unavailable Novita logo image --- apps/web/docs/usage-limits.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/docs/usage-limits.mdx b/apps/web/docs/usage-limits.mdx index d8df528..448a34f 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -127,7 +127,6 @@ The top-level `enabled` flag disables the plugin. Each provider's `enabled` flag MiniMax - Novita AI Novita AI From c9c71b8394391c9b90802442ed401d2aae4d43b6 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 17:52:24 +1300 Subject: [PATCH 5/6] chore(usage-limits): classify Novita support as minor --- .changeset/646af05b.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/646af05b.md b/.changeset/646af05b.md index b8251e1..d6c750b 100644 --- a/.changeset/646af05b.md +++ b/.changeset/646af05b.md @@ -1,5 +1,5 @@ --- -"@mynameistito/opencode-usage-limits": patch +"@mynameistito/opencode-usage-limits": minor --- Add Novita AI available-balance provider From 0fc6b5bc7e07fac9ecf2ff53a2569cb805690a07 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Mon, 5 Oct 2026 17:53:42 +1300 Subject: [PATCH 6/6] docs(site): clarify Novita balance unit conversion --- apps/web/docs/usage-limits.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/docs/usage-limits.mdx b/apps/web/docs/usage-limits.mdx index 448a34f..58242ee 100644 --- a/apps/web/docs/usage-limits.mdx +++ b/apps/web/docs/usage-limits.mdx @@ -252,7 +252,7 @@ Token Plan rolling five-hour and weekly windows. Uses MiniMax auth or `OC_MINIMA ### 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 uses `availableBalance` directly and does not infer a percentage, balance total, or reset time. +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. Enable the `novita-ai` provider using OpenCode's saved credential, or configure a key explicitly: