130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
import { parseUsdDecimal } from "../reseller/money.js";
|
|
|
|
const MAX_KEY_RESPONSE_BYTES = 1024 * 1024;
|
|
|
|
export interface OpenRouterKeyAllowance {
|
|
limitUsdNanos: bigint;
|
|
remainingUsdNanos: bigint;
|
|
reset: string | null;
|
|
checkedAtMs: number;
|
|
}
|
|
|
|
interface OpenRouterKeyClientOptions {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
timeoutMs: number;
|
|
fetchFn?: typeof globalThis.fetch;
|
|
now?: () => number;
|
|
}
|
|
|
|
export class OpenRouterKeyClient {
|
|
private readonly url: string;
|
|
private readonly apiKey: string;
|
|
private readonly timeoutMs: number;
|
|
private readonly fetchFn: typeof globalThis.fetch;
|
|
private readonly now: () => number;
|
|
|
|
constructor(options: OpenRouterKeyClientOptions) {
|
|
this.url = `${options.baseUrl.replace(/\/+$/, "")}/v1/auth/key`;
|
|
this.apiKey = options.apiKey;
|
|
this.timeoutMs = options.timeoutMs;
|
|
this.fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
this.now = options.now ?? Date.now;
|
|
}
|
|
|
|
async readAllowance(): Promise<OpenRouterKeyAllowance> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
try {
|
|
const response = await this.fetchFn(this.url, {
|
|
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
redirect: "error",
|
|
signal: controller.signal,
|
|
});
|
|
const text = await readBoundedText(response, controller);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`OpenRouter key endpoint returned HTTP ${response.status}${
|
|
text ? `: ${text.slice(0, 200).replace(/[\r\n]+/g, " ")}` : ""
|
|
}`,
|
|
);
|
|
}
|
|
let body: unknown;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
throw new Error("OpenRouter key endpoint response was not JSON");
|
|
}
|
|
const root = object(body, "OpenRouter key response");
|
|
const data = object(root.data, "OpenRouter key response.data");
|
|
if (data.limit === null || data.limit_remaining === null) {
|
|
throw new Error("OpenRouter resale key must have a finite spending limit");
|
|
}
|
|
const limit = usdNumberToNanos(data.limit, "OpenRouter key limit");
|
|
const remaining = usdNumberToNanos(
|
|
data.limit_remaining,
|
|
"OpenRouter key limit_remaining",
|
|
);
|
|
const reset = data.limit_reset;
|
|
if (reset !== null && reset !== undefined && typeof reset !== "string") {
|
|
throw new Error("OpenRouter key limit_reset must be a string or null");
|
|
}
|
|
return {
|
|
limitUsdNanos: limit,
|
|
remainingUsdNanos: remaining,
|
|
reset: typeof reset === "string" ? reset : null,
|
|
checkedAtMs: this.now(),
|
|
};
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`OpenRouter allowance check failed: ${detail.replaceAll(this.apiKey, "[redacted]")}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readBoundedText(
|
|
response: Response,
|
|
controller: AbortController,
|
|
): Promise<string> {
|
|
if (!response.body) return "";
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let bytes = 0;
|
|
let text = "";
|
|
try {
|
|
for (;;) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
bytes += chunk.value.byteLength;
|
|
if (bytes > MAX_KEY_RESPONSE_BYTES) {
|
|
controller.abort();
|
|
throw new Error(
|
|
`OpenRouter key response exceeded ${MAX_KEY_RESPONSE_BYTES} bytes`,
|
|
);
|
|
}
|
|
text += decoder.decode(chunk.value, { stream: true });
|
|
}
|
|
return text + decoder.decode();
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
function usdNumberToNanos(value: unknown, field: string): bigint {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
throw new Error(`${field} must be a non-negative finite number`);
|
|
}
|
|
return parseUsdDecimal(value.toFixed(9), "floor");
|
|
}
|
|
|
|
function object(value: unknown, field: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`${field} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|