diff --git a/src/auth/jwt-age.test.ts b/src/auth/jwt-age.test.ts new file mode 100644 index 0000000..e0ba163 --- /dev/null +++ b/src/auth/jwt-age.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "bun:test"; +import { inspectJwt } from "./jwt-age.js"; + +function makeJwt(payload: object): string { + const b64 = (s: string) => Buffer.from(s).toString("base64url"); + return `${b64(JSON.stringify({ alg: "HS256", typ: "JWT" }))}.${b64(JSON.stringify(payload))}.sig`; +} + +describe("inspectJwt", () => { + it("decodes iat and computes age", () => { + const iat = Math.floor(Date.now() / 1000) - 3600; // 1h ago + const info = inspectJwt(makeJwt({ iat, user_id: "u1" })); + expect(info).not.toBeNull(); + expect(info!.iat).toBe(iat); + expect(info!.ageHours).toBeGreaterThan(0.9); + expect(info!.ageHours).toBeLessThan(1.1); + expect(info!.userId).toBe("u1"); + }); + + it("returns null for garbage and missing iat", () => { + expect(inspectJwt("not-a-jwt")).toBeNull(); + expect(inspectJwt(makeJwt({ sub: "x" }))).toBeNull(); + }); + + it("reports old tokens as informational (age can exceed days)", () => { + const iat = Math.floor(Date.now() / 1000) - 8 * 24 * 3600; // 8 days ago + const info = inspectJwt(makeJwt({ iat })); + expect(info).not.toBeNull(); + expect(info!.ageHours).toBeGreaterThan(190); + }); +}); diff --git a/src/auth/jwt-age.ts b/src/auth/jwt-age.ts new file mode 100644 index 0000000..84f41bb --- /dev/null +++ b/src/auth/jwt-age.ts @@ -0,0 +1,39 @@ +/** + * JWT age inspection for the stored start-plan token. + * + * Informational only: the token payload carries `iat` but no `exp`, and the + * billing gateway does NOT reject by age — an 8-day-old JWT still serves + * `billing/balance` (verified live). Re-login is only needed when the gateway + * actually returns 401 / 3012. The age is surfaced so operators can see how + * fresh the credential is. + */ +export interface JwtInfo { + /** Unix seconds when the token was issued. */ + iat: number; + /** Token age in hours at the time of inspection. */ + ageHours: number; + userId?: string; +} + +/** Decode a zcode-plan JWT payload without verifying the signature. */ +export function inspectJwt(jwt: string): JwtInfo | null { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + const payload = JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")) as { + iat?: number; + user_id?: string; + sub?: string; + }; + if (typeof payload.iat !== "number") return null; + return { + iat: payload.iat, + ageHours: Math.max(0, (Date.now() / 1000 - payload.iat) / 3600), + ...(payload.user_id ? { userId: payload.user_id } : {}), + }; + } catch { + return null; + } +} diff --git a/src/server/routes-openai.ts b/src/server/routes-openai.ts index cb8ed8b..0157a47 100644 --- a/src/server/routes-openai.ts +++ b/src/server/routes-openai.ts @@ -15,7 +15,36 @@ export async function handleChatCompletions( } /** Handle GET /v1/models — return the model list in OpenAI format. */ -export function handleListModels(): Response { +export function handleListModels(req: Request): Response { + // CLIProxyAPI-style rich catalog: DSH's better-basicfun synchronizer probes + // with ?client_version=pi and parses a top-level `models[]` array with + // slug/context_window/max_tokens/supported_reasoning_levels fields. A plain + // OpenAI request keeps the original `data[]` shape. + const clientVersion = new URL(req.url).searchParams.get("client_version"); + if (clientVersion === "pi") { + const body = { + object: "list", + models: MODELS.map((m) => ({ + slug: m.id, + display_name: m.name, + description: m.name, + context_window: m.contextWindow, + max_context_window: m.contextWindow, + ...(m.maxOutputTokens === undefined ? {} : { max_tokens: m.maxOutputTokens }), + // Heuristic: vision-capable ZCode models embed "v" in their id (e.g. glm-4.6v). + // Revisit if upstream introduces non-vision ids that merely contain "v". + input_modalities: m.id.includes("v") ? ["text", "image"] : ["text"], + supported_reasoning_levels: m.reasoning + ? [{ effort: "low" }, { effort: "medium" }, { effort: "high" }, { effort: "xhigh" }] + : [], + visibility: "list", + })), + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } const list: OpenAIModelList = { object: "list", data: MODELS.map((m) => ({ diff --git a/src/server/routes-quota.test.ts b/src/server/routes-quota.test.ts new file mode 100644 index 0000000..26a2c54 --- /dev/null +++ b/src/server/routes-quota.test.ts @@ -0,0 +1,202 @@ +/** + * Regression tests for the GET /quota billing snapshot (routes-quota.ts). + * + * Covers the review round for PR #41 commit 66959ae: + * - the platform/arch fingerprint must be built from real values with env + * overrides (never `identity.platform/arch` → "undefined-undefined"); + * - empty/whitespace env overrides fall back instead of producing `-x64`; + * - both billing calls share the same fingerprint; + * - upstream snake_case and live-observed camelCase balance fields both map; + * - non-numeric / NaN values never leak into the JSON snapshot. + */ +import { describe, it, expect } from "bun:test"; +import os from "node:os"; +import { collectQuotaSnapshot, handleQuota } from "./routes-quota.js"; +import type { ProxyConfig } from "../config/types.js"; +import type { Credential } from "../auth/types.js"; + +function makeConfig(overrides: Partial = {}): ProxyConfig { + return { + server: { port: 0, host: "127.0.0.1" }, + auth: {}, + provider: "zai", + plan: "coding-plan", + providers: { + zai: { anthropicBase: "https://api.z.ai/api/anthropic", openaiBase: "https://api.z.ai/api/coding/paas/v4" }, + bigmodel: { anthropicBase: "https://open.bigmodel.cn/api/anthropic", openaiBase: "https://open.bigmodel.cn/api/coding/paas/v4" }, + }, + defaultModel: "glm-4.6", + models: ["glm-4.6"], + identity: { appVersion: "test-1.0.0", sourceTitle: "cli", refererOrigin: "https://zcode.z.ai" }, + clientIdentity: { mode: "observe", ttlSeconds: 900, maxSessions: 1024 }, + responses: { enabled: true, storeMaxEntries: 1000, storeTtlMs: 86400000 }, + endpointRouting: { enabled: false, origin: "https://zcode.z.ai" }, + clientSigning: { enabled: false, origin: "https://zcode.z.ai" }, + mcp: { enabled: true, webSearch: true, webReader: false, zread: false }, + async: { + enabled: false, + origin: "https://zcode.z.ai", + pollIntervalMs: 10, + keepAliveIntervalMs: 5, + maxWaitMs: 0, + maxRetries: 3, + settleTimeoutMs: 100, + controlTimeoutMs: 1000, + defaultModel: "", + }, + claim: { enabled: false, auto: true, origin: "https://billing.example", pollIntervalMs: 300000, cooldownMs: 600000, planId: "" }, + logging: { level: "info" }, + ...overrides, + }; +} + +/** Minimal valid start-plan JWT payload (iat only, no exp). */ +const IAT = Math.floor(Date.now() / 1000) - 8 * 24 * 3600; // 8 days old, still valid per jwt-age.ts docs +function makeJwt(): string { + const payload = Buffer.from(JSON.stringify({ iat: IAT })).toString("base64url"); + return `h.${payload}.s`; +} + +const fakeCred: Credential = { apiKey: "key-x.secret-y", provider: "zai", jwt: makeJwt() }; +const loadFake = async (): Promise => fakeCred; +const loadNone = async (): Promise => null; + +interface BillingCall { + url: string; + headers: Record; +} + +/** Mock fetch that records billing calls and answers both endpoints. */ +function makeBillingFetch(opts: { code?: number; body?: unknown } = {}): { fetchImpl: typeof fetch; calls: BillingCall[] } { + const calls: BillingCall[] = []; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit): Promise => { + const u = typeof url === "string" ? url : url.toString(); + if (u.includes("/api/v1/zcode-plan/billing/")) { + calls.push({ url: u, headers: { ...((init?.headers as Record) ?? {}) } }); + const code = opts.code ?? 0; + return new Response(JSON.stringify({ code, msg: "ok", data: opts.body ?? { server_time: 1720000000, balances: [], plans: [] } }), { status: 200 }); + } + return new Response(JSON.stringify({ error: { type: "not_found", message: u } }), { status: 404 }); + }) as typeof fetch; + return { fetchImpl, calls }; +} + +/** Set/restore identity env overrides around a test body. */ +async function withEnv(overrides: Record, fn: () => Promise): Promise { + const saved: Record = {}; + for (const k of ["ZCODE_IDENTITY_PLATFORM", "ZCODE_IDENTITY_ARCH"]) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + await fn(); + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +describe("collectQuotaSnapshot fingerprint", () => { + it("no overrides → real platform/arch, never undefined-undefined", async () => { + await withEnv({ ZCODE_IDENTITY_PLATFORM: undefined, ZCODE_IDENTITY_ARCH: undefined }, async () => { + const { fetchImpl, calls } = makeBillingFetch(); + const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + expect(calls.length).toBe(2); + const expected = `${process.platform}-${os.arch()}`; + expect(snap.errors).toEqual([]); + for (const c of calls) { + const url = new URL(c.url); + expect(url.searchParams.get("platform")).toBe(expected); + expect(url.searchParams.get("app_version")).toBe("test-1.0.0"); + expect(c.headers["X-Platform"]).toBe(expected); + } + expect(calls[0].url).toContain("/billing/balance?"); + expect(calls[1].url).toContain("/billing/preview?"); + }); + }); + + it("valid overrides → both billing calls use the overridden fingerprint", async () => { + await withEnv({ ZCODE_IDENTITY_PLATFORM: "linux", ZCODE_IDENTITY_ARCH: "x64" }, async () => { + const { fetchImpl, calls } = makeBillingFetch(); + await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + for (const c of calls) { + const url = new URL(c.url); + expect(url.searchParams.get("platform")).toBe("linux-x64"); + expect(c.headers["X-Platform"]).toBe("linux-x64"); + } + }); + }); + + it("empty/whitespace overrides fall back to real values (no `-x64` / `linux-`)", async () => { + await withEnv({ ZCODE_IDENTITY_PLATFORM: " ", ZCODE_IDENTITY_ARCH: "" }, async () => { + const { fetchImpl, calls } = makeBillingFetch(); + await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + const expected = `${process.platform}-${os.arch()}`; + for (const c of calls) { + expect(new URL(c.url).searchParams.get("platform")).toBe(expected); + } + }); + }); + + it("no JWT credential → handleQuota returns 503 quota_unavailable envelope", async () => { + const { fetchImpl, calls } = makeBillingFetch(); + const resp = await handleQuota(makeConfig(), fetchImpl, loadNone); + expect(resp.status).toBe(503); + const body = (await resp.json()) as { error: { type: string; message: string } }; + expect(body.error.type).toBe("quota_unavailable"); + expect(calls.length).toBe(0); + }); + + it("upstream nonzero code surfaces in errors, snapshot still 200", async () => { + const { fetchImpl } = makeBillingFetch({ code: 3012 }); + const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + expect(snap.errors.length).toBe(2); + expect(snap.errors[0]).toContain("3012"); + }); +}); + +describe("collectQuotaSnapshot response mapping", () => { + it("snake_case balance fields map (live-observed shape)", async () => { + const body = { + server_time: 1720000100, + balances: [{ show_name: "Free", total_units: 1000, used_units: 250, remaining_units: 750, unit_type: "token", expires_at: 1735689600 }], + }; + const { fetchImpl } = makeBillingFetch({ body }); + const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + expect(snap.balances).toEqual([{ showName: "Free", remainingUnits: 750, totalUnits: 1000, usedUnits: 250, unitType: "token", expiresAt: 1735689600 }]); + expect(snap.serverTime).toBe(1720000100); + }); + + it("camelCase aliases (unitType/expiresAt) map — not silently dropped", async () => { + const body = { + server_time: 1720000100, + balances: [{ show_name: "Free", total_units: 100, used_units: 10, remaining_units: 90, unitType: "token", expiresAt: 1735689600 }], + }; + const { fetchImpl } = makeBillingFetch({ body }); + const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + expect(snap.balances[0].unitType).toBe("token"); + expect(snap.balances[0].expiresAt).toBe(1735689600); + }); + + it("numeric-string timestamps/units coerce; NaN/garbage never reach the JSON", async () => { + const body = { + server_time: "1720000100", + balances: [ + { show_name: "Free", total_units: "100", used_units: "x", remaining_units: "50", expires_at: "1735689600" }, + { show_name: "Bad", total_units: NaN, used_units: null, remaining_units: 7 }, + ], + }; + const { fetchImpl } = makeBillingFetch({ body }); + const snap = await collectQuotaSnapshot(makeConfig(), fetchImpl, loadFake); + expect(snap.serverTime).toBe(1720000100); + expect(snap.balances[0].totalUnits).toBe(100); + expect(snap.balances[0].usedUnits).toBe(0); + expect(snap.balances[0].expiresAt).toBe(1735689600); + expect(snap.balances[1].totalUnits).toBe(0); // NaN → undefined → 0, JSON.stringify would emit null + expect(snap.balances[1].usedUnits).toBe(0); + expect(snap.balances[1].expiresAt).toBeUndefined(); + }); +}); diff --git a/src/server/routes-quota.ts b/src/server/routes-quota.ts new file mode 100644 index 0000000..8a0fb18 --- /dev/null +++ b/src/server/routes-quota.ts @@ -0,0 +1,175 @@ +/** + * GET /quota — live free-quota snapshot from ZCode billing endpoints. + * + * Queries the same control plane the desktop client uses (`billing/balance` + + * `billing/preview` on the configured claim origin) with the stored OAuth JWT + * and the full desktop identity fingerprint. The billing gateway requires a + * stable `X-Device-Mid`, so the config identity is forwarded unchanged. + * + * @see scripts in vibe-coding-labs/zcode-reverse-engineer (header shape) and + * zcode.z.ai desktop bundle `pio()` (identity header semantics). + */ +import os from "node:os"; +import { loadCredential } from "../auth/store.js"; +import { buildIdentityHeaders } from "../proxy/identity.js"; +import { inspectJwt } from "../auth/jwt-age.js"; +import type { ProxyConfig } from "../config/types.js"; +import { errorResponse } from "../proxy/handler.js"; + +export interface QuotaBalanceEntry { + showName: string; + remainingUnits: number; + totalUnits: number; + usedUnits: number; + unitType?: string; + expiresAt?: number; +} + +export interface QuotaPlanEntry { + planId: string; + name: string; + description?: string; + entitlements: Array<{ showName: string; grantUnits: number; unitType: string; effectiveAt?: number }>; +} + +export interface QuotaSnapshot { + provider: string; + serverTime: number; + /** + * Stored start-plan JWT age (informational). The token has no `exp` and is + * not rejected by age — an 8-day-old JWT still serves billing/balance. Only + * a real 401/3012 from the billing gateway indicates re-login is needed, + * which surfaces in `errors`. + */ + jwt: { ageHours: number; issuedAt: number } | null; + balances: QuotaBalanceEntry[]; + claimablePlans: QuotaPlanEntry[]; + errors: string[]; +} + +/** Query one billing URL, tolerating per-endpoint failures. */ +async function fetchBilling( + origin: string, + path: string, + headers: Record, + fetchImpl: typeof fetch, +): Promise<{ code?: number; msg?: string; data?: unknown } | null> { + try { + const resp = await fetchImpl(`${origin.replace(/\/+$/, "")}${path}`, { headers }); + const text = await resp.text(); + try { + return JSON.parse(text) as { code?: number; msg?: string; data?: unknown }; + } catch { + return { code: resp.status, msg: text.slice(0, 120) }; + } + } catch (e) { + return { code: -1, msg: String(e).slice(0, 120) }; + } +} + +/** Coerce an upstream value to a finite number, or undefined (never NaN — JSON.stringify would emit null). */ +function toFiniteNumber(v: unknown): number | undefined { + const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN; + return Number.isFinite(n) ? n : undefined; +} + +/** Build the billing snapshot. Exported for tests. `loadCredentialImpl` is injectable for tests. */ +export async function collectQuotaSnapshot( + config: ProxyConfig, + fetchImpl: typeof fetch = fetch, + loadCredentialImpl: typeof loadCredential = loadCredential, +): Promise { + const cred = await loadCredentialImpl(); + if (!cred?.jwt) { + throw new Error("not logged in — no JWT credential (run: zcode-proxy auth login)"); + } + const jwtInfo = inspectJwt(cred.jwt); + const jwt = jwtInfo + ? { ageHours: Number(jwtInfo.ageHours.toFixed(2)), issuedAt: jwtInfo.iat } + : null; + const identity = config.identity; + const idHeaders = buildIdentityHeaders(identity); + // The claim client drops X-ZCode-Agent for zcode.z.ai control-plane calls; + // the billing gateway follows the same precedent. + delete idHeaders["X-ZCode-Agent"]; + const headers: Record = { ...idHeaders, authorization: `Bearer ${cred.jwt}`, Accept: "application/json" }; + // Billing fingerprint is reconstructed from the observed claim-client format + // (`${platform}-${arch}`). Same env overrides as proxy/identity.ts X-Platform + // (Android seeds linux-x64 via index.ts), with empty-string overrides falling + // back to the real values — an empty override must not yield `-x64`/`linux-`. + // NOTE: ProxyIdentity has no platform/arch fields — do not read them off `identity`. + const platformEnv = process.env.ZCODE_IDENTITY_PLATFORM?.trim(); + const archEnv = process.env.ZCODE_IDENTITY_ARCH?.trim(); + const platform = `${platformEnv || process.platform}-${archEnv || os.arch()}`; + const origin = config.claim.origin || "https://zcode.z.ai"; + const appVersion = identity.appVersion; + + const errors: string[] = []; + const [balance, preview] = await Promise.all([ + fetchBilling(origin, `/api/v1/zcode-plan/billing/balance?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl), + fetchBilling(origin, `/api/v1/zcode-plan/billing/preview?app_version=${encodeURIComponent(appVersion)}&platform=${encodeURIComponent(platform)}`, headers, fetchImpl), + ]); + if (balance && balance.code !== 0) errors.push(`balance: ${balance.code} ${balance.msg ?? ""}`.trim()); + if (preview && preview.code !== 0) errors.push(`preview: ${preview.code} ${preview.msg ?? ""}`.trim()); + + const balances: QuotaBalanceEntry[] = []; + const balanceData = (balance?.data ?? {}) as { balances?: any[]; server_time?: number }; + for (const b of Array.isArray(balanceData.balances) ? balanceData.balances : []) { + // unitType/expiresAt camelCase aliases observed live alongside snake_case; + // accept both so neither casing drops the field. + const expiresAt = toFiniteNumber(b.expires_at ?? b.expiresAt); + const unitType = b.unit_type ?? b.unitType; + balances.push({ + showName: String(b.show_name ?? ""), + remainingUnits: toFiniteNumber(b.remaining_units ?? b.remainingUnits) ?? 0, + totalUnits: toFiniteNumber(b.total_units ?? b.totalUnits) ?? 0, + usedUnits: toFiniteNumber(b.used_units ?? b.usedUnits) ?? 0, + ...(unitType ? { unitType: String(unitType) } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + }); + } + + const claimablePlans: QuotaPlanEntry[] = []; + const previewData = (preview?.data ?? {}) as { plans?: any[] }; + for (const p of Array.isArray(previewData.plans) ? previewData.plans : []) { + claimablePlans.push({ + planId: String(p.plan_id ?? ""), + name: String(p.name ?? p.plan_id ?? ""), + ...(p.description ? { description: String(p.description) } : {}), + entitlements: (Array.isArray(p.entitlements) ? p.entitlements : []).map((e: any) => ({ + showName: String(e.show_name ?? ""), + grantUnits: toFiniteNumber(e.grant_units ?? e.grantUnits) ?? 0, + unitType: String(e.unit_type ?? e.unitType ?? "token"), + ...(toFiniteNumber(e.effective_at ?? e.effectiveAt) !== undefined + ? { effectiveAt: toFiniteNumber(e.effective_at ?? e.effectiveAt) as number } + : {}), + })), + }); + } + + return { + provider: config.provider, + serverTime: toFiniteNumber(balanceData.server_time) ?? Math.floor(Date.now() / 1000), + jwt, + balances, + claimablePlans, + errors, + }; +} + +/** Handle GET /quota — JSON snapshot with the proxy error envelope on failure. `loadCredentialImpl` is injectable for tests. */ +export async function handleQuota( + config: ProxyConfig, + fetchImpl: typeof fetch = fetch, + loadCredentialImpl: typeof loadCredential = loadCredential, +): Promise { + try { + const snapshot = await collectQuotaSnapshot(config, fetchImpl, loadCredentialImpl); + return new Response(JSON.stringify(snapshot, null, 1), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (e) { + return errorResponse(503, "quota_unavailable", `quota query failed: ${(e as Error).message}`); + } +} diff --git a/src/server/server.test.ts b/src/server/server.test.ts index fa637a7..ffd7cdc 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -301,10 +301,41 @@ describe("web UI", () => { describe("route handler exports", () => { it("handleListModels returns model list", () => { - const resp = handleListModels(); + const resp = handleListModels(new Request("http://localhost/v1/models")); expect(resp.status).toBe(200); }); + it("handleListModels rich catalog on client_version=pi", async () => { + const resp = handleListModels(new Request("http://localhost/v1/models?client_version=pi")); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { + models: Array<{ + slug: string; + context_window: number; + max_tokens?: number; + supported_reasoning_levels: Array<{ effort: string }>; + visibility: string; + }>; + data?: unknown; + }; + // Rich shape: top-level models[], no data[]. + expect(Array.isArray(body.models)).toBe(true); + expect(body.data).toBeUndefined(); + const flash = body.models.find((m) => m.slug === "glm-5.3-flash"); + expect(flash).toBeDefined(); + expect(flash!.context_window).toBe(1_000_000); + expect(flash!.max_tokens).toBe(128_000); + expect(flash!.visibility).toBe("list"); + // Reasoning model advertises the Codex-style effort levels the DSH + // better-basicfun bridge maps onto its selector levels. + const efforts = flash!.supported_reasoning_levels.map((l) => l.effort); + expect(efforts).toEqual(["low", "medium", "high", "xhigh"]); + // Vision variants advertise image input; non-reasoning ones have no levels. + const vModel = body.models.find((m) => m.slug === "glm-4.6v"); + expect(vModel).toBeDefined(); + expect(vModel!.supported_reasoning_levels).toEqual([]); + }); + it("handleMessages is a function", () => { expect(typeof handleMessages).toBe("function"); }); diff --git a/src/server/server.ts b/src/server/server.ts index e7b7d8a..1df7e5f 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -16,6 +16,7 @@ import { handleChatCompletions, handleListModels } from "./routes-openai.js"; import { handleMessages } from "./routes-anthropic.js"; import { handleResponsesRoute } from "./routes-responses.js"; import { handleAsyncMessagesRoute, handleAsyncChatRoute, handleAsyncHealthRoute } from "./routes-async.js"; +import { handleQuota } from "./routes-quota.js"; import { errorResponse } from "../proxy/handler.js"; import type { ResponseStore } from "../responses/store.js"; @@ -91,7 +92,11 @@ export function createFetchHandler(opts: ServerOptions): (req: Request) => Promi return handleResponsesRoute(req, responsesOpts); } if (path === "/v1/models" && method === "GET") { - return handleListModels(); + return handleListModels(req); + } + + if (path === "/quota" && method === "GET") { + return handleQuota(config, opts.fetchImpl); } if (path === "/v1/messages" && method === "POST") {