diff --git a/open-sse/providers/registry/orbit-provider.js b/open-sse/providers/registry/orbit-provider.js index 0c7ffbfe..3dee2191 100644 --- a/open-sse/providers/registry/orbit-provider.js +++ b/open-sse/providers/registry/orbit-provider.js @@ -16,6 +16,9 @@ export default { headers: { "anthropic-version": "2023-06-01", }, + usage: { + url: "https://api.orbit-provider.com/v1/usage", + }, }, models: [ { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, @@ -24,6 +27,10 @@ export default { { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)" }, ], serviceKinds: ["llm"], + features: { + usage: true, + usageApikey: true, + }, thinkingConfig: { options: ["auto", "on", "off"], defaultMode: "auto", diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index dabe53e0..fe51115f 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -12,6 +12,7 @@ import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getGrokCliUsage } from "./usage/grok-cli.js"; +import { getOrbitUsage } from "./usage/orbit.js"; import { getQwenUsage, getIflowUsage, @@ -45,6 +46,7 @@ const USAGE_HANDLERS = { "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + "orbit-provider": (c) => getOrbitUsage(c.apiKey, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/orbit.js b/open-sse/services/usage/orbit.js new file mode 100644 index 00000000..18921b69 --- /dev/null +++ b/open-sse/services/usage/orbit.js @@ -0,0 +1,118 @@ +/** + * Orbit Provider usage handler. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; + +const ORBIT_USAGE_URL = U("orbit-provider").url; + +function clampPercentage(value) { + return Math.min(100, Math.max(0, value)); +} + +function getRemainingPercentage(used, total, upstreamUsagePercent) { + if (total <= 0) return 0; + const usagePercent = Number.isFinite(Number(upstreamUsagePercent)) + ? toFiniteNumber(upstreamUsagePercent) + : (used / total) * 100; + return clampPercentage(100 - usagePercent); +} + +/** + * Normalize Orbit's usage response into quota rows consumed by the dashboard. + * @param {object} responseBody Orbit API response. + * @returns {object} Normalized provider usage. + */ +export function parseOrbitUsage(responseBody) { + if (responseBody?.success !== true || !responseBody?.data || typeof responseBody.data !== "object") { + return { message: "Orbit Provider usage response was invalid." }; + } + + const data = responseBody.data; + const used = Math.max(0, toFiniteNumber(data.tokensUsed)); + const total = Math.max(0, toFiniteNumber(data.tokenLimit)); + const upstreamRemaining = Math.max(0, toFiniteNumber(data.tokensRemaining, Math.max(0, total - used))); + const remaining = data.isExhausted === true ? 0 : Math.min(total, upstreamRemaining); + const remainingPercentage = data.isExhausted === true + ? 0 + : getRemainingPercentage(used, total, data.usagePercent); + const resetPeriod = typeof data.resetPeriod === "string" && data.resetPeriod.trim() + ? data.resetPeriod.trim().toLowerCase() + : "period"; + const resetAt = parseResetTime(data.periodEnd); + const quotas = { + [`Tokens (${resetPeriod})`]: { + used, + total, + remaining, + remainingPercentage, + resetAt, + unlimited: false, + }, + }; + + const credit = data.credit; + if (credit && typeof credit === "object") { + const balance = Math.max(0, toFiniteNumber(credit.balanceUsd)); + const granted = Math.max(0, toFiniteNumber(credit.grantedUsd)); + const spent = Math.max(0, toFiniteNumber(credit.spentUsd)); + const creditTotal = granted > 0 ? granted : balance + spent; + const creditRemainingPercentage = creditTotal > 0 + ? clampPercentage((balance / creditTotal) * 100) + : 0; + + quotas["Credit (USD)"] = { + used: spent, + total: creditTotal, + remaining: balance, + remainingPercentage: creditRemainingPercentage, + resetAt: null, + unlimited: false, + }; + } + + return { + plan: typeof data.plan === "string" && data.plan.trim() ? data.plan.trim() : "Unknown", + quotas, + isExhausted: data.isExhausted === true, + resetPeriod, + }; +} + +/** + * Fetch usage for an Orbit Provider API-key connection. + * @param {string} apiKey Orbit API key. + * @param {object|null} proxyOptions Connection proxy configuration. + * @returns {Promise} Normalized usage data. + */ +export async function getOrbitUsage(apiKey, proxyOptions = null) { + if (!apiKey) { + return { message: "Orbit Provider API key not available." }; + } + + try { + const response = await proxyAwareFetch(ORBIT_USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "Orbit Provider API key invalid or expired." }; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const detail = errorText ? `: ${errorText.slice(0, 200)}` : ""; + return { message: `Orbit Provider usage API error (${response.status})${detail}` }; + } + + const body = await response.json().catch(() => null); + return parseOrbitUsage(body); + } catch (error) { + return { message: `Orbit Provider usage error: ${error.message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 7befe9fb..f338073f 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -492,6 +492,24 @@ export function parseQuotaData(provider, data) { } break; + case "orbit-provider": + // Orbit exposes both token usage and USD credit balance, but only the + // credit balance is useful in the quota dashboard. Keep token usage + // in the server response for diagnostics without rendering its row. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + if (name !== "Credit (USD)") return; + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/tests/unit/orbit-usage.test.js b/tests/unit/orbit-usage.test.js new file mode 100644 index 00000000..5c2c58ec --- /dev/null +++ b/tests/unit/orbit-usage.test.js @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { parseOrbitUsage } from "../../open-sse/services/usage/orbit.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const ORBIT_USAGE_RESPONSE = { + success: true, + data: { + plan: "starter", + usagePercent: 100, + isExhausted: true, + resetPeriod: "monthly", + periodEnd: null, + tokensUsed: 57288213, + tokenLimit: 50000000, + tokensRemaining: 0, + daily: null, + credit: { + balanceMicroUsd: 397891136, + balanceUsd: 397.891136, + balanceUsdFormatted: "$397.89", + grantedMicroUsd: 500000000, + grantedUsd: 500, + spentMicroUsd: 102108864, + spentUsd: 102.108864, + currency: "USD", + source: "ledger", + }, + }, +}; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Orbit Provider usage registry", () => { + it("exposes its usage endpoint and API-key eligibility", () => { + expect(PROVIDERS["orbit-provider"].usage?.url).toBe( + "https://api.orbit-provider.com/v1/usage", + ); + expect(USAGE_SUPPORTED_PROVIDERS).toContain("orbit-provider"); + expect(USAGE_APIKEY_PROVIDERS).toContain("orbit-provider"); + }); +}); + +describe("parseOrbitUsage", () => { + it("normalizes exhausted monthly tokens and USD credit", () => { + const usage = parseOrbitUsage(ORBIT_USAGE_RESPONSE); + + expect(usage).toMatchObject({ + plan: "starter", + isExhausted: true, + resetPeriod: "monthly", + }); + expect(usage.quotas["Tokens (monthly)"]).toMatchObject({ + used: 57288213, + total: 50000000, + remaining: 0, + remainingPercentage: 0, + resetAt: null, + }); + expect(usage.quotas["Credit (USD)"]).toMatchObject({ + used: 102.108864, + total: 500, + remaining: 397.891136, + }); + expect(usage.quotas["Credit (USD)"].remainingPercentage).toBeCloseTo(79.5782272); + }); + + it("rejects an unsuccessful or malformed payload", () => { + expect(parseOrbitUsage({ success: false })).toEqual({ + message: "Orbit Provider usage response was invalid.", + }); + }); +}); + +describe("getUsageForProvider(orbit-provider)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches Orbit usage with the connection API key", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ORBIT_USAGE_RESPONSE)); + const proxyOptions = { connectionProxyEnabled: true }; + + const usage = await getUsageForProvider({ + provider: "orbit-provider", + apiKey: "test-orbit-key", + }, proxyOptions); + + expect(usage.plan).toBe("starter"); + expect(usage.quotas["Tokens (monthly)"].remainingPercentage).toBe(0); + expect(proxyAwareFetch).toHaveBeenCalledWith( + "https://api.orbit-provider.com/v1/usage", + expect.objectContaining({ + method: "GET", + headers: { + Authorization: "Bearer test-orbit-key", + Accept: "application/json", + }, + }), + proxyOptions, + ); + }); + + it("returns an authentication message for an invalid API key", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + + const usage = await getUsageForProvider({ + provider: "orbit-provider", + apiKey: "invalid", + }); + + expect(usage.message).toMatch(/invalid or expired/i); + }); +}); + +describe("parseQuotaData(orbit-provider)", () => { + it("renders only the credit progress bar", () => { + const usage = parseOrbitUsage(ORBIT_USAGE_RESPONSE); + const rows = parseQuotaData("orbit-provider", usage); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "Credit (USD)", + used: 102.108864, + total: 500, + }); + expect(rows[0].remainingPercentage).toBeCloseTo(79.5782272); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 75096d78..097aa83b 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -16,6 +16,7 @@ const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", + "orbit-provider", ]; describe("usage dispatch", () => {