import type { FetchLike, UsageCommandRequest, UsageCommandRunner, UsageCredential, UsageFeed, UsageHttpRequest, UsageLimit, UsageReport, UsageReportBody, UsageRequest, UsageResponse, UsageState, UsageStatus, } from "./types.js"; export const unknownReport = (message: string): UsageReportBody => ({ limits: [], error: message }); /** Absent means the provider reports no percentage for that window, never "zero used". */ export function statusForPercent(usedPercent: number | undefined): UsageStatus { if (usedPercent === undefined) return "unknown"; if (usedPercent >= 100) return "exhausted"; if (usedPercent >= 90) return "warning"; return "ok"; } export function makeLimit( label: string, usedPercent: number | undefined, options: { windowLabel?: string; resetsAt?: number; detail?: string; status?: UsageStatus } = {}, ): UsageLimit { const clamped = usedPercent === undefined ? undefined : Math.max(0, Math.min(100, usedPercent)); return { label, ...(clamped === undefined ? {} : { usedPercent: clamped }), ...(options.windowLabel === undefined ? {} : { windowLabel: options.windowLabel }), ...(options.resetsAt === undefined ? {} : { resetsAt: options.resetsAt }), ...(options.detail === undefined ? {} : { detail: options.detail }), status: options.status ?? statusForPercent(clamped), }; } /** ISO-8601 (with or without fractional seconds) to epoch milliseconds. */ export function isoToEpochMs(value: unknown): number | undefined { if (typeof value !== "string" || value.trim() === "") return undefined; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : undefined; } export function epochToWindowLabel(resetsAt: number | undefined, nowMs: number): string | undefined { if (resetsAt === undefined) return undefined; const seconds = Math.max(1, Math.round((resetsAt - nowMs) / 1000)); if (seconds >= 86_400) return `${Math.round(seconds / 86_400)}d window`; return `${Math.max(1, Math.round(seconds / 3_600))}h window`; } /** * Narrow a step's request to the HTTP variant, loudly. Tests (and hosts) that only speak HTTP want * this rather than a silent `undefined` when a feed asks for a command instead. */ export function httpRequest(request: UsageRequest): UsageHttpRequest { if (request.kind === "command") { throw new Error(`expected an HTTP request, got a command request (${request.command})`); } return request; } export function createUsageState(credential: UsageCredential, nowMs: number): UsageState { return { credential, context: { nowMs }, responses: [] }; } /** JSON body of a response, or undefined when it is not JSON. */ export function jsonBody(response: UsageResponse): T | undefined { try { return JSON.parse(response.body) as T; } catch { return undefined; } } export function reportFrom(feed: UsageFeed, body: UsageReportBody, fetchedAt: number, error?: string): UsageReport { return { provider: feed.provider, providerName: feed.providerName, fetchedAt, ...body, limits: body.limits ?? [], error: error ?? body.error ?? null, }; } export interface UsageHostOptions { fetchImpl?: FetchLike; /** * How to answer a `command` request. A host that omits it is saying "I only speak HTTP", and the * feed gets a non-zero status it can report or route around — never an exception it cannot see. */ runCommand?: UsageCommandRunner; signal?: AbortSignal; nowMs?: number; maxRounds?: number; } /** The answer a feed gets when its host will not run commands. */ export const unsupportedCommandResponse = (request: UsageCommandRequest): UsageResponse => ({ status: 127, body: `this host does not run commands (${request.command})`, }); /** * The host loop: ask for requests, send them with the host's own HTTP stack (or run them, for a * command request), hand the responses back, repeat until the feed reports. A provider that needs a * value from one response to build the next request just takes another round. */ export async function runUsageFeed( feed: UsageFeed, credential: UsageCredential, options: UsageHostOptions = {}, ): Promise { const nowMs = options.nowMs ?? Date.now(); const fetchImpl = options.fetchImpl ?? fetch; const maxRounds = options.maxRounds ?? 4; let state = createUsageState(credential, nowMs); for (let round = 0; round < maxRounds; round += 1) { const step = feed.advance(state, state.responses); if ("report" in step) return reportFrom(feed, step.report, nowMs); const responses: UsageResponse[] = []; for (const request of step.requests) { responses.push(await answer(request, { fetchImpl, runCommand: options.runCommand, signal: options.signal })); } state = { ...state, responses }; } const message = `usage feed for ${feed.provider} did not finish in ${maxRounds} rounds`; return reportFrom(feed, unknownReport(message), nowMs, message); } async function answer( request: UsageRequest, host: { fetchImpl: FetchLike; runCommand?: UsageCommandRunner; signal?: AbortSignal }, ): Promise { if (request.kind === "command") { if (!host.runCommand) return unsupportedCommandResponse(request); try { return await host.runCommand(request); } catch (error) { return { status: 1, body: error instanceof Error ? error.message : String(error) }; } } try { const response = await host.fetchImpl(request.url, { method: request.method, headers: request.headers, body: request.body, signal: host.signal, }); return { status: response.status, body: await response.text() }; } catch (error) { // A transport failure is data for the feed: it becomes a response with status 0 rather than // abandoning the whole collection. const message = host.signal?.aborted ? "cancelled" : error instanceof Error ? error.message : String(error); return { status: 0, body: message }; } }