// DORET Prism agent SDK: zero dependencies, works in Node 18+, Deno, Bun and edge runtimes. // // const bank = new AIBankAgent({ apiKey: process.env.AIB_AGENT_KEY!, baseUrl: "https://aibank.example.com" }); // try { await anthropic.messages.create(...) } // catch (e) { // const over = budgetExceeded(e); // if (!over) throw e; // const req = await bank.requestBudget({ amountUsd: "5", purpose: "...", justification: "...", expectedOutcome: "...", durationHours: 4 }); // const decision = await bank.waitForDecision(req.id); // if (decision.status === "approved" || decision.status === "auto_approved") retry(); // } export interface Window { limit_usd: string | null; grants_usd: string; spent_usd: string; reserved_usd: string; remaining_usd: string | null } export interface Budget { per_request_limit_usd: string | null; windows: { day: Window; month: Window }; active_grants: { id: string; amount_usd: string; expires_at: string }[] } export type RequestStatus = "pending" | "auto_approved" | "approved" | "rejected" | "expired" | "cancelled"; export interface BudgetRequest { id: string; status: RequestStatus; amount_usd?: string; duration_hours?: number; decision?: { outcome: string; note: string | null; decided_at: string } | null; grant?: { amount_usd: string; starts_at: string; expires_at: string } | null; reasons?: string[]; } export interface BudgetExceeded { blockingAccount: string; window: "day" | "month" | "request"; limitUsd: string; remainingUsd: string; requiredUsd: string; transactionId?: string } export class AIBankError extends Error { constructor(readonly status: number, readonly type: string, message: string, readonly body: unknown) { super(message); } } export class AIBankAgent { private readonly apiKey: string; private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; constructor(opts: { apiKey: string; baseUrl: string; fetch?: typeof fetch }) { if (!/^aib_(live|test)_/.test(opts.apiKey)) throw new Error("AIBankAgent needs an agent key (aib_live_…)"); this.apiKey = opts.apiKey; this.baseUrl = opts.baseUrl.replace(/\/$/, ""); this.fetchImpl = opts.fetch ?? fetch; } /** Base URLs to give the provider SDKs. */ get anthropicBaseUrl() { return `${this.baseUrl}/gw/anthropic`; } get openaiBaseUrl() { return `${this.baseUrl}/gw/openai/v1`; } /** Limits, spent, reserved and remaining for today and this month. */ budget(): Promise { return this.call("GET", "/v1/agent/me/budget"); } /** * Ask for a temporary increase. The idempotency key defaults to a fresh UUID; pass * your own (e.g. derived from the task) to make retries safe across restarts. */ requestBudget(r: { amountUsd: string | number; purpose: string; justification: string; expectedOutcome: string; durationHours: number; taskId?: string; idempotencyKey?: string }): Promise { return this.call("POST", "/v1/agent/budget-requests", { amount_usd: String(r.amountUsd), purpose: r.purpose, justification: r.justification, expected_outcome: r.expectedOutcome, duration_hours: r.durationHours, task_id: r.taskId, }, r.idempotencyKey ?? crypto.randomUUID()); } getRequest(id: string): Promise { return this.call("GET", `/v1/agent/budget-requests/${encodeURIComponent(id)}`); } cancelRequest(id: string): Promise<{ ok: true }> { return this.call("POST", `/v1/agent/budget-requests/${encodeURIComponent(id)}/cancel`, {}); } /** Poll until the request is decided (or the timeout passes, returning it still pending). */ async waitForDecision(id: string, opts: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal } = {}): Promise { const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000); let interval = opts.intervalMs ?? 2_000; for (;;) { const r = await this.getRequest(id); if (r.status !== "pending" || Date.now() >= deadline) return r; if (opts.signal?.aborted) throw new Error("waitForDecision aborted"); await new Promise((res) => setTimeout(res, Math.min(interval, Math.max(0, deadline - Date.now())))); interval = Math.min(interval * 1.5, 30_000); } } private async call(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise { const res = await this.fetchImpl(this.baseUrl + path, { method, headers: { authorization: `Bearer ${this.apiKey}`, ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); const json = text ? JSON.parse(text) : null; if (!res.ok) throw new AIBankError(res.status, json?.error?.type ?? "error", json?.error?.message ?? `The gateway answered ${res.status}`, json); return json as T; } } /** * Recognize the gateway's 402 in an error thrown by the OpenAI or Anthropic SDK * (or a raw fetch Response body) and return what blocked the call. */ export function budgetExceeded(err: unknown): BudgetExceeded | null { const e = err as any; if (!e || (e.status !== undefined && e.status !== 402)) return null; const error = e.error?.error ?? e.error ?? e.body?.error ?? e?.error; const aib = error?.aib ?? e?.aib; if (!aib || (error?.type ?? e.type) !== "budget_exceeded") return null; return { blockingAccount: aib.blocking_account, window: aib.window, limitUsd: aib.limit_usd, remainingUsd: aib.remaining_usd, requiredUsd: aib.required_usd, transactionId: aib.transaction_id }; } /** * Verify an DORET Prism webhook. `rawBody` must be the exact bytes received. * Rejects signatures older than `toleranceSec` (default 5 minutes). */ export async function verifyWebhook(rawBody: string, signatureHeader: string, secret: string, toleranceSec = 300): Promise { const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=", 2) as [string, string])); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`))); const expected = [...mac].map((b) => b.toString(16).padStart(2, "0")).join(""); if (expected.length !== parts.v1.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i); return diff === 0; }