mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: implement the feature for checking quota of orbit provider
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<object>} 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}` };
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user