feat(usage): generic billing/quota for openai-compatible connections (#13673)

* feat(usage): generic billing/quota for openai-compatible connections (#13616)

Every other fetcher in services/usage hard-codes one upstream's URL, auth
and response shape, which works because those providers are known. An
openai-compatible connection can point at anything, and its id is minted
per connection -- so it can never be a member of USAGE_SUPPORTED_PROVIDERS
or a case in the dispatcher switch.

So the shape comes from the connection instead. `providerSpecificData.
quotaEndpoint` declares the url, auth mode, optional headers, and a mapping
of dot-paths onto UsageQuota:

    { "url": "...", "auth": "bearer",
      "quotas": { "credits": { "used": "$.data.used_usd",
                               "total": "$.data.limit_usd",
                               "currency": "USD" } } }

Dot/bracket paths (`$.a.b[0].c`) rather than full JSONPath, so the mapping
stays dependency-free and legible in a config field.

Three decisions worth stating:

- **An unresolvable mapping reports nothing, never 0/0.** A quota reading
  0 of 0 renders as fully exhausted, and an operator would act on that. A
  typo'd path must produce no card, not a fake outage.
- **The transport error is not echoed.** The url is operator-supplied and
  can carry a query-string secret; the message says "unreachable" and
  nothing more.
- **The capability is read off the connection, not the id.**
  `supportsProviderQuota` already takes the connection and already has a
  connection-shaped check (moonshot), so the gate goes there. A declared
  url with no `quotas` mapping does NOT count as supported: it can be
  fetched but can never yield a quota, and would leave a permanently empty
  card in Provider Limits.

Verified: 7 new tests; mutations each killed by the right one --
  let an unresolved mapping fall through to 0/0 -> that test alone fails
  echo the transport error                       -> that test alone fails
121 tests pass across this file, usage-families-split, provider-plugin-
manifest, provider-limits* and the quota-visibility suites (the drift
guard from #13134 included). eslint clean on all four files; the three
no-unused-vars errors in usage.ts are byte-identical on the base branch.

* fix(usage): bound the openai-compatible quota fetch with a timeout

An operator-configured quotaEndpoint that never responds would hang
getOpenAiCompatibleUsage()'s fetch() indefinitely, stalling that
connection's Provider Limits sync. Same 15s bound as the other
fetchers in this directory (grokResetCredits.ts's FETCH_TIMEOUT_MS).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* test(usage): pin the openai-compatible quota fetch timeout

A quota endpoint that accepts the connection and never answers must be
aborted by the fetch signal instead of hanging the Provider Limits sync.
Fails without the AbortSignal.timeout() bound, passes with it.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: abhisheksharma2411 <abhisheksharma2411@users.noreply.github.com>
This commit is contained in:
Abhishek Sharma
2026-09-18 09:14:06 -07:00
committed by GitHub
parent c74cea3d35
commit 95b2e53727
5 changed files with 404 additions and 2 deletions

View File

@@ -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))

View File

@@ -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);

View File

@@ -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<string, string>;
plan?: string;
quotas?: Record<string, OpenAiCompatibleQuotaMapping>;
}
/** `$.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<string, unknown>)[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<string, string> {
const headers: Record<string, string> = { 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<string, unknown> | 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<string, UsageQuota> = {};
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,
};
}

View File

@@ -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<string, unknown> } | null | undefined;
if (!endpoint || typeof endpoint.url !== "string" || endpoint.url.length === 0) return false;
return !!endpoint.quotas && Object.keys(endpoint.quotas).length > 0;
}

View File

@@ -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<void>) {
const original = globalThis.fetch;
const calls: { url: string; headers: Record<string, string> }[] = [];
globalThis.fetch = (async (url: string, init: RequestInit) => {
calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
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<string, never> | 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<string, Record<string, unknown>> };
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<Response>((_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"
);
});