diff --git a/.changeset/646af05b.md b/.changeset/646af05b.md new file mode 100644 index 0000000..d6c750b --- /dev/null +++ b/.changeset/646af05b.md @@ -0,0 +1,5 @@ +--- +"@mynameistito/opencode-usage-limits": minor +--- + +Add Novita AI available-balance provider 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..58242ee 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 + + Novita AI + 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/__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/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", 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,