diff --git a/changelog.d/features/0000-openai-compatible-quota.md b/changelog.d/features/0000-openai-compatible-quota.md new file mode 100644 index 0000000000..22c8f201d0 --- /dev/null +++ b/changelog.d/features/0000-openai-compatible-quota.md @@ -0,0 +1 @@ +- **feat(usage):** `openai-compatible-*` connections can now report billing/quota in Provider Limits. The connection declares its own quota endpoint, auth mode and a dot-path mapping onto `UsageQuota` in `providerSpecificData.quotaEndpoint`, so no upstream-specific code is needed per service — a mapping that resolves nothing reports no quota rather than an exhausted-looking 0/0 ([#13616](https://github.com/diegosouzapw/OmniRoute/issues/13616)) diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index de29934a23..c56196d32a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -49,6 +49,7 @@ import { getKiroUsage, buildKiroUsageResult, discoverKiroProfileArn } from "./us export { buildKiroUsageResult, discoverKiroProfileArn } from "./usage/kiro.ts"; import { getAdobeFireflyUsage } from "./usage/adobeFirefly.ts"; import { getOpenrouterUsage } from "./usage/openrouter.ts"; +import { getOpenAiCompatibleUsage } from "./usage/openaiCompatible.ts"; import { getLlmgatewayUsage } from "./usage/llmgateway.ts"; import { getOllamaCloudUsage } from "./opencodeOllamaUsage.ts"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.ts"; @@ -118,6 +119,15 @@ export async function getUsageForProvider( return await getMoonshotOpenPlatformUsage(connection); } + // openai-compatible-* ids are generated per connection, so they can never + // appear in the switch below or in USAGE_FETCHER_PROVIDERS. The connection + // itself declares where its quota lives (#13616); without that declaration + // this returns a message and the sync treats it as "nothing to show", exactly + // as it did before. + if (typeof provider === "string" && provider.startsWith("openai-compatible-")) { + return await getOpenAiCompatibleUsage(apiKey, providerSpecificData); + } + switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData); diff --git a/open-sse/services/usage/openaiCompatible.ts b/open-sse/services/usage/openaiCompatible.ts new file mode 100644 index 0000000000..28ab0521f1 --- /dev/null +++ b/open-sse/services/usage/openaiCompatible.ts @@ -0,0 +1,183 @@ +/** + * usage/openaiCompatible.ts — a provider-agnostic quota fetcher for + * `openai-compatible-*` connections (#13616). + * + * Every other fetcher in this directory hard-codes one upstream's URL, auth and + * response shape. That works because those providers are known. An + * openai-compatible connection can point at anything, so the shape has to come + * from the connection rather than from us: the operator describes where the + * quota lives and how to read it, and this maps the answer onto `UsageQuota`. + * + * Declared per connection in `providerSpecificData.quotaEndpoint`: + * + * { + * "url": "https://api.example.com/v1/credits", + * "method": "GET", // optional, default GET + * "auth": "bearer", // bearer | x-api-key | none + * "headers": { "X-Org": "acme" }, // optional, merged last + * "plan": "$.data.plan_name", // optional label + * "quotas": { + * "credits": { + * "used": "$.data.used_usd", + * "total": "$.data.limit_usd", + * "resetAt": "$.data.renews_at", // optional + * "currency": "USD" // optional; marks a money quota + * } + * } + * } + * + * Paths are dot/bracket paths rather than full JSONPath — `$.a.b[0].c` — so the + * mapping stays dependency-free and readable in a config field. Anything the + * path cannot resolve is treated as absent, never as zero: a quota that silently + * reads 0/0 looks exhausted, and an operator would reasonably act on that. + */ + +import { type UsageQuota, createQuotaFromUsage } from "./quota.ts"; + +/** + * Operator-configured endpoints can point at anything, including a host that + * never responds. Same bound as `open-sse/services/grokResetCredits.ts`'s + * `FETCH_TIMEOUT_MS`, so one unreachable connection can never hang the whole + * Provider Limits sync. + */ +const QUOTA_ENDPOINT_TIMEOUT_MS = 15_000; + +export interface OpenAiCompatibleQuotaMapping { + used?: string; + total?: string; + remaining?: string; + resetAt?: string; + currency?: string; + displayName?: string; +} + +export interface OpenAiCompatibleQuotaEndpoint { + url: string; + method?: string; + auth?: "bearer" | "x-api-key" | "none"; + headers?: Record; + plan?: string; + quotas?: Record; +} + +/** `$.a.b[0].c` / `a.b.0.c` → the value, or undefined if any hop is missing. */ +export function resolvePath(root: unknown, path: string): unknown { + if (typeof path !== "string" || path.length === 0) return undefined; + const cleaned = path.startsWith("$.") + ? path.slice(2) + : path.startsWith("$") + ? path.slice(1) + : path; + const parts = cleaned + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((segment) => segment.length > 0); + + let cursor: unknown = root; + for (const segment of parts) { + if (cursor === null || cursor === undefined) return undefined; + if (Array.isArray(cursor)) { + const index = Number(segment); + if (!Number.isInteger(index)) return undefined; + cursor = cursor[index]; + continue; + } + if (typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[segment]; + } + return cursor; +} + +/** A mapping is usable only if it can produce a total or a remaining. */ +function buildMappedQuota(body: unknown, mapping: OpenAiCompatibleQuotaMapping): UsageQuota | null { + const total = mapping.total === undefined ? undefined : resolvePath(body, mapping.total); + const used = mapping.used === undefined ? undefined : resolvePath(body, mapping.used); + const remaining = + mapping.remaining === undefined ? undefined : resolvePath(body, mapping.remaining); + const resetAt = mapping.resetAt === undefined ? undefined : resolvePath(body, mapping.resetAt); + + // Neither axis resolved: the path is wrong or the field is absent. Reporting + // 0/0 here would render as a fully-exhausted quota, so report nothing. + if (total === undefined && remaining === undefined) return null; + + // total absent but remaining present: derive the total so the bar has a scale. + const effectiveTotal = + total !== undefined ? total : Number(remaining ?? 0) + Number(used !== undefined ? used : 0); + + const quota = createQuotaFromUsage( + used !== undefined ? used : Number(effectiveTotal ?? 0) - Number(remaining ?? 0), + effectiveTotal, + resetAt + ); + + if (mapping.currency) quota.currency = mapping.currency; + if (mapping.displayName) quota.displayName = mapping.displayName; + return quota; +} + +export function buildAuthHeaders( + endpoint: OpenAiCompatibleQuotaEndpoint, + secret: string | undefined +): Record { + const headers: Record = { accept: "application/json" }; + const mode = endpoint.auth ?? "bearer"; + if (secret && mode === "bearer") headers.authorization = `Bearer ${secret}`; + if (secret && mode === "x-api-key") headers["x-api-key"] = secret; + return { ...headers, ...(endpoint.headers ?? {}) }; +} + +/** + * Read the connection's declared quota endpoint and map it onto `UsageQuota`. + * + * Returns a `message` rather than throwing on every failure path: an + * unreachable or misconfigured endpoint on one connection must not fail the + * whole Provider Limits sync, which walks every connection in turn. + */ +export async function getOpenAiCompatibleUsage( + apiKey: string | undefined, + providerSpecificData: Record | null | undefined +) { + const endpoint = (providerSpecificData?.quotaEndpoint ?? + null) as OpenAiCompatibleQuotaEndpoint | null; + + if (!endpoint || typeof endpoint.url !== "string" || endpoint.url.length === 0) { + return { message: "No quota endpoint configured for this connection." }; + } + const mappings = endpoint.quotas ?? {}; + if (Object.keys(mappings).length === 0) { + return { message: "Quota endpoint configured without a `quotas` mapping." }; + } + + let body: unknown; + try { + const response = await fetch(endpoint.url, { + method: endpoint.method ?? "GET", + headers: buildAuthHeaders(endpoint, apiKey), + signal: AbortSignal.timeout(QUOTA_ENDPOINT_TIMEOUT_MS), + }); + if (!response.ok) { + return { message: `Quota endpoint returned HTTP ${response.status}.` }; + } + body = await response.json(); + } catch { + // Deliberately not echoing the error: the URL is operator-supplied and the + // message can carry it (and any query string) into the dashboard. + return { message: "Quota endpoint unreachable." }; + } + + const quotas: Record = {}; + for (const [name, mapping] of Object.entries(mappings)) { + const quota = buildMappedQuota(body, mapping); + if (quota) quotas[name] = quota; + } + + if (Object.keys(quotas).length === 0) { + return { message: "Quota endpoint responded, but no mapping resolved a value." }; + } + + const plan = endpoint.plan ? resolvePath(body, endpoint.plan) : undefined; + return { + ...(typeof plan === "string" && plan ? { plan } : {}), + quotas, + }; +} diff --git a/src/shared/utils/providerQuotaVisibility.ts b/src/shared/utils/providerQuotaVisibility.ts index 81edcdc450..72763f9f9c 100644 --- a/src/shared/utils/providerQuotaVisibility.ts +++ b/src/shared/utils/providerQuotaVisibility.ts @@ -1,4 +1,4 @@ -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { OPENAI_COMPATIBLE_PREFIX, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { isMoonshotOpenPlatformConnection } from "@omniroute/open-sse/services/usage/moonshotOpenPlatform.ts"; export interface ProviderQuotaVisibilityConnection { @@ -13,11 +13,37 @@ export function isProviderQuotaVisible(connection: ProviderQuotaVisibilityConnec export function supportsProviderQuota( providerId: string, - connection?: { provider?: string; providerSpecificData?: unknown }, + connection?: { provider?: string; providerSpecificData?: unknown } ): boolean { if (USAGE_SUPPORTED_PROVIDERS.includes(providerId)) return true; + // `openai-compatible-*` ids are minted per connection, so they can never be + // members of a static list. Such a connection supports quota exactly when it + // declares where its quota lives (#13616) — the capability is a property of + // the connection, not of the provider id. + if (hasOpenAiCompatibleQuotaEndpoint(providerId, connection?.providerSpecificData)) return true; return isMoonshotOpenPlatformConnection({ provider: providerId, providerSpecificData: connection?.providerSpecificData, }); } + +/** + * True for an `openai-compatible-*` connection carrying a usable + * `providerSpecificData.quotaEndpoint`: a non-empty `url` and at least one + * entry in `quotas`. Both halves are required — an endpoint with no mapping + * can be fetched but never produces a quota, so treating it as supported would + * put a permanently empty card in Provider Limits. + */ +export function hasOpenAiCompatibleQuotaEndpoint( + providerId: string, + providerSpecificData: unknown +): boolean { + if (typeof providerId !== "string" || !providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) { + return false; + } + const psd = providerSpecificData as { quotaEndpoint?: unknown } | null | undefined; + const endpoint = psd?.quotaEndpoint as + { url?: unknown; quotas?: Record } | null | undefined; + if (!endpoint || typeof endpoint.url !== "string" || endpoint.url.length === 0) return false; + return !!endpoint.quotas && Object.keys(endpoint.quotas).length > 0; +} diff --git a/tests/unit/openai-compatible-quota-13616.test.ts b/tests/unit/openai-compatible-quota-13616.test.ts new file mode 100644 index 0000000000..dbeb6859cf --- /dev/null +++ b/tests/unit/openai-compatible-quota-13616.test.ts @@ -0,0 +1,182 @@ +/** + * #13616 — generic billing/quota for `openai-compatible-*` connections. + * + * These connections get an id minted per connection, so they can never be + * members of USAGE_SUPPORTED_PROVIDERS or of the switch in services/usage.ts. + * The capability therefore has to be read off the connection, and the response + * shape has to come from a mapping rather than from a hard-coded upstream. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { resolvePath, getOpenAiCompatibleUsage, buildAuthHeaders } = + await import("../../open-sse/services/usage/openaiCompatible.ts"); +const { supportsProviderQuota } = await import("../../src/shared/utils/providerQuotaVisibility.ts"); + +const ENDPOINT = { + url: "https://api.example.com/v1/credits", + auth: "bearer" as const, + plan: "$.data.plan", + quotas: { + credits: { + used: "$.data.used_usd", + total: "$.data.limit_usd", + resetAt: "$.data.renews_at", + currency: "USD", + }, + }, +}; +const BODY = { + data: { plan: "Scale", used_usd: 40, limit_usd: 100, renews_at: "2026-10-01T00:00:00Z" }, +}; + +function withFetch(body: unknown, status = 200, fn: () => Promise) { + const original = globalThis.fetch; + const calls: { url: string; headers: Record }[] = []; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return fn() + .finally(() => { + globalThis.fetch = original; + }) + .then(() => calls); +} + +test("resolvePath walks dot and bracket paths, and gives up rather than guessing", () => { + const root = { a: { b: [{ c: 7 }] }, zero: 0, empty: "" }; + assert.equal(resolvePath(root, "$.a.b[0].c"), 7); + assert.equal(resolvePath(root, "a.b.0.c"), 7); + assert.equal(resolvePath(root, "$.zero"), 0, "0 is a value, not a miss"); + assert.equal(resolvePath(root, "$.empty"), ""); + assert.equal(resolvePath(root, "$.a.missing.c"), undefined); + assert.equal(resolvePath(root, "$.a.b[9].c"), undefined); + assert.equal(resolvePath(root, ""), undefined); +}); + +test("a mapped response becomes a UsageQuota", async () => { + let result: Record | undefined; + await withFetch(BODY, 200, async () => { + result = (await getOpenAiCompatibleUsage("sk-test", { quotaEndpoint: ENDPOINT })) as never; + }); + const r = result as unknown as { plan: string; quotas: Record> }; + assert.equal(r.plan, "Scale"); + assert.deepEqual( + { ...r.quotas.credits }, + { + used: 40, + total: 100, + remaining: 60, + remainingPercentage: 60, + resetAt: "2026-10-01T00:00:00.000Z", + unlimited: false, + currency: "USD", + } + ); +}); + +test("auth mode selects the header, and custom headers merge last", () => { + assert.equal(buildAuthHeaders({ url: "u" }, "sk").authorization, "Bearer sk"); + assert.equal(buildAuthHeaders({ url: "u", auth: "x-api-key" }, "sk")["x-api-key"], "sk"); + assert.equal(buildAuthHeaders({ url: "u", auth: "none" }, "sk").authorization, undefined); + assert.equal( + buildAuthHeaders({ url: "u", headers: { accept: "text/plain" } }, "sk").accept, + "text/plain", + "an explicit header overrides the default" + ); +}); + +test("an unresolvable mapping reports nothing rather than 0/0", async () => { + // 0/0 renders as a fully-exhausted quota. An operator would act on that, so a + // wrong path must produce no card at all -- this is the assertion that keeps + // a typo in the config from looking like an outage. + let result: { message?: string; quotas?: unknown } | undefined; + await withFetch(BODY, 200, async () => { + result = (await getOpenAiCompatibleUsage("sk-test", { + quotaEndpoint: { ...ENDPOINT, quotas: { credits: { used: "$.nope.a", total: "$.nope.b" } } }, + })) as never; + }); + assert.equal(result?.quotas, undefined); + assert.match(String(result?.message), /no mapping resolved/i); +}); + +test("failure paths degrade to a message instead of throwing", async () => { + const noConfig = await getOpenAiCompatibleUsage("sk", {}); + assert.match(String((noConfig as { message: string }).message), /No quota endpoint/i); + + const noMapping = await getOpenAiCompatibleUsage("sk", { + quotaEndpoint: { url: "https://x", quotas: {} }, + }); + assert.match(String((noMapping as { message: string }).message), /without a `quotas` mapping/i); + + let http: { message?: string } | undefined; + await withFetch({}, 503, async () => { + http = (await getOpenAiCompatibleUsage("sk", { quotaEndpoint: ENDPOINT })) as never; + }); + assert.match(String(http?.message), /HTTP 503/); +}); + +test("the upstream URL never reaches the message on a transport failure", async () => { + // The URL is operator-supplied and can carry a query-string secret; it must + // not be echoed into a dashboard-visible string. + const original = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("connect ECONNREFUSED https://api.example.com/v1/credits?key=SECRET"); + }) as typeof fetch; + try { + const r = (await getOpenAiCompatibleUsage("sk", { quotaEndpoint: ENDPOINT })) as { + message: string; + }; + assert.doesNotMatch(r.message, /SECRET|api\.example\.com/); + assert.match(r.message, /unreachable/i); + } finally { + globalThis.fetch = original; + } +}); + +test("a quota endpoint that never answers is aborted instead of hanging the sync", async () => { + // The endpoint is operator-configured and can point at a host that accepts + // the connection and then goes silent; without a bound, fetch() waits forever. + const original = globalThis.fetch; + let signal: AbortSignal | undefined; + globalThis.fetch = ((_url: string, init: RequestInit) => { + signal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal?.reason)); + }); + }) as typeof fetch; + try { + const pending = getOpenAiCompatibleUsage("sk", { quotaEndpoint: ENDPOINT }); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(signal instanceof AbortSignal, "the quota fetch must carry an abort signal"); + // Fire the bound now rather than waiting the real 15s. + (signal as AbortSignal & { dispatchEvent: (e: Event) => boolean }).dispatchEvent( + new Event("abort") + ); + const r = (await pending) as { message: string }; + assert.match(r.message, /unreachable/i); + } finally { + globalThis.fetch = original; + } +}); + +test("the gate follows the connection, not the provider id", () => { + const id = "openai-compatible-chat-abc123"; + assert.equal( + supportsProviderQuota(id, { provider: id, providerSpecificData: { quotaEndpoint: ENDPOINT } }), + true + ); + assert.equal(supportsProviderQuota(id, { provider: id, providerSpecificData: {} }), false); + assert.equal( + supportsProviderQuota(id, { + provider: id, + providerSpecificData: { quotaEndpoint: { url: "https://x" } }, + }), + false, + "a url with no mapping can be fetched but never yields a quota" + ); +});