@blockrun/llm
Version:
TypeScript SDK for BlockRun - every frontier model behind one OpenAI-compatible client. Pay with a BlockRun API key or per-call in USDC. Smart routing picks the cheapest capable model. No rate limits.
14,870 lines • 545 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
APIError: () => APIError,
API_KEY_URL: () => API_KEY_URL,
ARC_CHAIN_ID: () => ARC_CHAIN_ID,
AnthropicClient: () => AnthropicClient,
BASE_CHAIN_ID: () => BASE_CHAIN_ID,
BLOCKRUN_SOL_OPERATOR: () => BLOCKRUN_SOL_OPERATOR,
BatchChannelLockedError: () => BatchChannelLockedError,
BatchCloseDeferredError: () => BatchCloseDeferredError,
BatchPaymentUnresolvedError: () => BatchPaymentUnresolvedError,
BlockrunClient: () => BlockrunClient,
BlockrunError: () => BlockrunError,
DEFAULT_BATCH_IN_DOUBT: () => DEFAULT_BATCH_IN_DOUBT,
DEFAULT_BATCH_LOCK_WAIT_MS: () => DEFAULT_BATCH_LOCK_WAIT_MS,
DEFAULT_BATCH_RATE_LIMIT: () => DEFAULT_BATCH_RATE_LIMIT,
EVM_NETWORKS: () => EVM_NETWORKS,
ImageClient: () => ImageClient,
KNOWN_PROVIDERS: () => KNOWN_PROVIDERS,
LLMClient: () => LLMClient,
MusicClient: () => MusicClient,
NETWORK_ALIASES: () => NETWORK_ALIASES,
OpenAI: () => OpenAI,
PHONE_PRICES: () => PHONE_PRICES,
PORTAL_URL: () => PORTAL_URL,
PORTRAIT_ENROLLMENT_PRICE_USD: () => PORTRAIT_ENROLLMENT_PRICE_USD,
PaymentError: () => PaymentError,
PhoneClient: () => PhoneClient,
PortraitClient: () => PortraitClient,
PriceClient: () => PriceClient,
RPC_PRICE_USD: () => RPC_PRICE_USD,
RetiredEndpointError: () => RetiredEndpointError,
RpcClient: () => RpcClient,
SOLANA_NETWORK: () => SOLANA_NETWORK,
SOLANA_WALLET_FILE_PATH: () => SOLANA_WALLET_FILE,
SUPPORTED_NETWORKS: () => SUPPORTED_NETWORKS,
SearchClient: () => SearchClient,
SolanaBatchPayer: () => SolanaBatchPayer,
SolanaLLMClient: () => SolanaLLMClient,
SpeechClient: () => SpeechClient,
SurfClient: () => SurfClient,
USDC_ARC: () => USDC_ARC,
USDC_BASE: () => USDC_BASE,
USDC_BASE_CONTRACT: () => USDC_BASE_CONTRACT,
USDC_SOLANA: () => USDC_SOLANA,
VideoClient: () => VideoClient,
VoiceClient: () => VoiceClient,
WALLET_DIR_PATH: () => WALLET_DIR_PATH,
WALLET_FILE_PATH: () => WALLET_FILE_PATH,
clearCache: () => clearCache,
createPaymentPayload: () => createPaymentPayload,
createSolanaPaymentPayload: () => createSolanaPaymentPayload,
createSolanaWallet: () => createSolanaWallet,
createWallet: () => createWallet,
default: () => client_default,
evmNetwork: () => evmNetwork,
extractPaymentDetails: () => extractPaymentDetails,
formatFundingMessageCompact: () => formatFundingMessageCompact,
formatNeedsFundingMessage: () => formatNeedsFundingMessage,
formatSolanaWalletMigrationNotice: () => formatSolanaWalletMigrationNotice,
formatWalletCreatedMessage: () => formatWalletCreatedMessage,
formatWalletMigrationNotice: () => formatWalletMigrationNotice,
getCached: () => getCached,
getCachedByRequest: () => getCachedByRequest,
getCostLogSummary: () => getCostLogSummary,
getCostSummary: () => getCostSummary,
getEip681Uri: () => getEip681Uri,
getOrCreateSolanaWallet: () => getOrCreateSolanaWallet,
getOrCreateWallet: () => getOrCreateWallet,
getPaymentLinks: () => getPaymentLinks,
getWalletAddress: () => getWalletAddress,
importSolanaWallet: () => importSolanaWallet,
importWallet: () => importWallet,
listDiscoveredSolanaWallets: () => listDiscoveredSolanaWallets,
listDiscoveredWallets: () => listDiscoveredWallets2,
loadSolanaWallet: () => loadSolanaWallet,
loadWallet: () => loadWallet,
logCost: () => logCost,
parsePaymentRequired: () => parsePaymentRequired,
readSettlement: () => readSettlement,
retryDisposition: () => retryDisposition,
saveSolanaWallet: () => saveSolanaWallet,
saveToCache: () => saveToCache,
saveWallet: () => saveWallet,
scanSolanaWallets: () => scanSolanaWallets,
scanWallets: () => scanWallets2,
setCache: () => setCache,
setupAgentClient: () => setupAgentClient,
setupAgentSolanaWallet: () => setupAgentSolanaWallet,
setupAgentWallet: () => setupAgentWallet,
solanaClient: () => solanaClient,
solanaKeyToBytes: () => solanaKeyToBytes,
solanaPublicKey: () => solanaPublicKey,
status: () => status,
toWireSearchParameters: () => toWireSearchParameters,
toWireTools: () => toWireTools,
validateMaxTokens: () => validateMaxTokens,
validateModel: () => validateModel,
validateTemperature: () => validateTemperature,
validateTopP: () => validateTopP
});
module.exports = __toCommonJS(index_exports);
// src/types.ts
var RETRY_DISPOSITION = /* @__PURE__ */ Symbol.for("@blockrun/llm/retry-disposition/v1");
function retryDisposition(err) {
if (!err || typeof err !== "object" && typeof err !== "function") return void 0;
const value = err[RETRY_DISPOSITION];
return value === "unpaid" || value === "paid-or-in-doubt" ? value : void 0;
}
function withDisposition(err, disposition) {
if (!err || typeof err !== "object") return err;
if (retryDisposition(err) === "paid-or-in-doubt") return err;
try {
Object.defineProperty(err, RETRY_DISPOSITION, { value: disposition, configurable: true, writable: true, enumerable: false });
} catch {
}
return err;
}
var BlockrunError = class extends Error {
constructor(message) {
super(message);
this.name = "BlockrunError";
}
};
var RetiredEndpointError = class extends BlockrunError {
constructor(message) {
super(message);
this.name = "RetiredEndpointError";
}
};
var PaymentError = class extends BlockrunError {
constructor(message) {
super(message);
this.name = "PaymentError";
}
};
var APIError = class extends BlockrunError {
statusCode;
/** Retry-After header returned by the account API. */
retryAfter;
response;
constructor(message, statusCode, response) {
super(message);
this.name = "APIError";
this.statusCode = statusCode;
this.response = response;
}
};
// src/validation.ts
var LOCALHOST_DOMAINS = ["localhost", "127.0.0.1"];
var KNOWN_PROVIDERS = /* @__PURE__ */ new Set([
"openai",
"anthropic",
"google",
"deepseek",
"mistralai",
"meta-llama",
"together",
"xai",
"moonshot",
"nvidia",
"minimax",
"zai"
]);
function validateModel(model) {
if (!model || typeof model !== "string") {
throw new Error("Model must be a non-empty string");
}
}
function validateMaxTokens(maxTokens) {
if (maxTokens === void 0 || maxTokens === null) {
return;
}
if (typeof maxTokens !== "number" || !Number.isInteger(maxTokens)) {
throw new Error("maxTokens must be an integer");
}
if (maxTokens < 1) {
throw new Error("maxTokens must be positive (minimum: 1)");
}
}
function validateTemperature(temperature) {
if (temperature === void 0 || temperature === null) {
return;
}
if (typeof temperature !== "number") {
throw new Error("temperature must be a number");
}
if (temperature < 0 || temperature > 2) {
throw new Error("temperature must be between 0 and 2");
}
}
function validateTopP(topP) {
if (topP === void 0 || topP === null) {
return;
}
if (typeof topP !== "number") {
throw new Error("topP must be a number");
}
if (topP < 0 || topP > 1) {
throw new Error("topP must be between 0 and 1");
}
}
function validatePrivateKey(key) {
if (typeof key !== "string") {
throw new Error("Private key must be a string");
}
if (!key.startsWith("0x")) {
throw new Error("Private key must start with 0x");
}
if (key.length !== 66) {
throw new Error(
"Private key must be 66 characters (0x + 64 hexadecimal characters)"
);
}
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
throw new Error(
"Private key must contain only hexadecimal characters (0-9, a-f, A-F)"
);
}
}
function validateApiUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error("Invalid API URL format");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid protocol: ${parsed.protocol}. Use http:// or https://`
);
}
const isLocalhost = LOCALHOST_DOMAINS.includes(parsed.hostname);
if (parsed.protocol !== "https:" && !isLocalhost) {
throw new Error(
`API URL must use HTTPS for non-localhost endpoints. Use https:// instead of ${parsed.protocol}//`
);
}
}
function sanitizeErrorResponse(errorBody) {
if (typeof errorBody !== "object" || errorBody === null) {
return { message: "API request failed" };
}
const body = errorBody;
const detail = typeof body.message === "string" && body.message !== body.error ? body.message : void 0;
return {
message: typeof body.error === "string" ? body.error : "API request failed",
code: typeof body.code === "string" ? body.code : void 0,
...detail ? { detail } : {},
...typeof body.hint === "string" ? { hint: body.hint } : {}
};
}
function validateResourceUrl(url, baseUrl) {
try {
const parsed = new URL(url);
const baseParsed = new URL(baseUrl);
if (parsed.hostname !== baseParsed.hostname) {
console.warn(
`Resource URL hostname mismatch: ${parsed.hostname} vs ${baseParsed.hostname}. Using safe default instead.`
);
return `${baseUrl}/v1/chat/completions`;
}
if (parsed.protocol !== baseParsed.protocol) {
console.warn(
`Resource URL protocol mismatch: ${parsed.protocol} vs ${baseParsed.protocol}. Using safe default instead.`
);
return `${baseUrl}/v1/chat/completions`;
}
return url;
} catch {
console.warn(`Invalid resource URL format: ${url}. Using safe default.`);
return `${baseUrl}/v1/chat/completions`;
}
}
// src/api-key.ts
var TRANSIENT_STATUS = /* @__PURE__ */ new Set([502, 503, 504, 522, 524]);
var TRANSIENT_RETRIES = 2;
var TRANSIENT_BACKOFF_MS = 1e3;
function retryDelay(ms, signal) {
signal?.throwIfAborted();
return new Promise((resolve2, reject) => {
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve2();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
var API_KEY_URL = "https://api.blockrun.ai";
var PORTAL_URL = "https://user.blockrun.ai";
function resolveApiKeyAuth(options) {
if (options.apiKey !== void 0 && options.privateKey !== void 0) {
throw new Error("Pass either apiKey or privateKey, not both.");
}
const key = options.apiKey ?? (options.privateKey === void 0 && typeof process !== "undefined" ? process.env?.BLOCKRUN_API_KEY : void 0);
if (key === void 0) return void 0;
if (!/^brk_[A-Za-z0-9_-]+$/.test(key.trim())) {
throw new Error(`Invalid BlockRun API key. Create one at ${PORTAL_URL}/dashboard/keys.`);
}
const base = options.apiUrl ?? (typeof process !== "undefined" ? process.env?.BLOCKRUN_API_BASE_URL : void 0) ?? API_KEY_URL;
return new ApiKeyAuth(key.trim(), base);
}
function accountErrorDisposition(err) {
if (!(err instanceof APIError)) return "paid-or-in-doubt";
if (err.statusCode >= 400 && err.statusCode < 500) return "unpaid";
if (err.statusCode >= 500 && hasGatewayErrorEnvelope(err.response)) return "unpaid";
return "paid-or-in-doubt";
}
function hasGatewayErrorEnvelope(response) {
return !!response && typeof response === "object" && typeof response.message === "string" && response.message.length > 0;
}
function requireWallet(value) {
if (value === void 0) throw new Error("This operation requires a wallet; the client uses API key account billing.");
return value;
}
var ApiKeyAuth = class {
apiUrl;
#key;
constructor(key, base) {
validateApiUrl(base);
const url = new URL(base);
if (url.username || url.password || url.search || url.hash) {
throw new Error("API base URL must not contain credentials, a query, or a fragment.");
}
this.apiUrl = base.replace(/\/+$/, "").replace(/\/v1$/, "");
this.#key = key;
}
/** Resolve gateway poll paths without forwarding credentials to another origin. */
resolveUrl(path7) {
const url = new URL(path7, this.apiUrl + "/");
if (url.origin !== new URL(this.apiUrl).origin || url.username || url.password) {
throw new Error("Refusing to send a BlockRun API key to a different origin.");
}
if (new URL(this.apiUrl).pathname === "/" && url.pathname.startsWith("/api/v1/")) url.pathname = url.pathname.slice(4);
return url.href;
}
async fetch(input, init, raiseErrors = true) {
const request = input instanceof Request ? input : void 0;
const url = this.resolveUrl(request?.url ?? String(input));
const headers = new Headers(init?.headers ?? request?.headers);
for (const name of [...headers.keys()]) {
if (name.toLowerCase().includes("payment") || name.toLowerCase() === "x-api-key") headers.delete(name);
}
headers.set("authorization", `Bearer ${this.#key}`);
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
const retries = method === "GET" || method === "HEAD" ? TRANSIENT_RETRIES : 0;
const signal = init?.signal ?? request?.signal;
let response;
for (let attempt = 0; ; attempt++) {
response = await globalThis.fetch(request ? new Request(url, request) : url, {
...init,
headers,
redirect: "error"
});
if (attempt >= retries || !TRANSIENT_STATUS.has(response.status)) break;
await response.body?.cancel();
await retryDelay(TRANSIENT_BACKOFF_MS * (attempt + 1), signal);
}
if (raiseErrors && !response.ok) {
let body;
try {
body = await response.json();
} catch {
body = void 0;
}
const outer = body && typeof body === "object" ? body : {};
const detail = outer.error && typeof outer.error === "object" ? outer.error : outer;
const safe = Object.fromEntries(["message", "code", "type", "param"].flatMap((name) => typeof detail[name] === "string" ? [[name, detail[name].split(this.#key).join("[REDACTED]")]] : []));
const hint = response.status === 402 ? ` Top up at ${PORTAL_URL}/dashboard/credits.` : response.status === 401 ? ` Check your key at ${PORTAL_URL}/dashboard/keys.` : "";
const error = new APIError(`BlockRun account API error: ${response.status}.${hint}`, response.status, safe);
error.retryAfter = response.headers.get("retry-after") ?? void 0;
throw error;
}
return response;
}
/** Poll an account-owned async job without ever entering the x402 signing path. */
async poll(response, timeout, interval) {
const deadline = Date.now() + timeout;
let data = await response.json();
if (["failed", "cancelled", "canceled"].includes(String(data.status))) {
throw new APIError("Account API job failed or was cancelled", 502);
}
const pollPath = data.poll_url;
if (typeof pollPath !== "string") {
if (response.status === 202 || ["queued", "in_progress", "processing"].includes(String(data.status))) {
throw new APIError("Async response missing poll_url", response.status);
}
return data;
}
const url = this.resolveUrl(pollPath);
while (Date.now() < deadline) {
if (data.status === "completed") return data;
if (["failed", "cancelled", "canceled"].includes(String(data.status))) {
throw new APIError("Account API job failed or was cancelled", 502, sanitizeErrorResponse(data));
}
await new Promise((resolve2) => setTimeout(resolve2, Math.min(interval, Math.max(0, deadline - Date.now()))));
const remaining = deadline - Date.now();
if (remaining <= 0) break;
try {
const res = await this.fetch(url, { signal: AbortSignal.timeout(remaining) });
data = await res.json();
} catch (err) {
if (!(err instanceof APIError) || !TRANSIENT_STATUS.has(err.statusCode)) throw err;
}
}
if (data.status === "completed") return data;
throw new APIError("Account API job polling timed out; check the job before submitting again.", 504, { poll_url: url });
}
};
// src/search-wire.ts
function put(out, key, value) {
if (value !== void 0) out[key] = value;
}
function handles(list) {
return list?.map((h) => h.trim().replace(/^@+/, ""));
}
function sourceToWire(source) {
const raw = source;
const out = { ...raw };
for (const key of Object.keys(out)) if (/[A-Z]/.test(key)) delete out[key];
switch (source.type) {
case "x":
put(out, "included_x_handles", handles(source.includedXHandles) ?? out.included_x_handles);
put(out, "excluded_x_handles", handles(source.excludedXHandles) ?? out.excluded_x_handles);
put(out, "post_favorite_count", source.postFavoriteCount);
put(out, "post_view_count", source.postViewCount);
put(out, "enable_image_understanding", source.enableImageUnderstanding);
put(out, "enable_video_understanding", source.enableVideoUnderstanding);
break;
case "web":
put(out, "country", source.country);
put(out, "allowed_websites", source.allowedWebsites);
put(out, "excluded_websites", source.excludedWebsites);
put(out, "safe_search", source.safeSearch);
put(out, "enable_image_understanding", source.enableImageUnderstanding);
break;
case "news":
put(out, "country", source.country);
put(out, "allowed_websites", source.allowedWebsites);
put(out, "excluded_websites", source.excludedWebsites);
put(out, "safe_search", source.safeSearch);
break;
case "rss":
put(out, "links", source.links);
break;
}
return out;
}
function toWireSearchParameters(sp) {
const raw = sp;
const out = {};
for (const [key, value] of Object.entries(raw)) if (!/[A-Z]/.test(key) && key !== "sources") out[key] = value;
put(out, "mode", sp.mode);
if (sp.sources) out.sources = sp.sources.map(sourceToWire);
put(out, "return_citations", sp.returnCitations);
put(out, "from_date", sp.fromDate);
put(out, "to_date", sp.toDate);
put(out, "max_search_results", sp.maxSearchResults);
put(out, "max_turns", sp.maxTurns);
return out;
}
function toWireTools(tools) {
return tools.map((tool) => {
if (tool.type === "x_search") {
const out = { type: "x_search" };
put(out, "allowed_x_handles", handles(tool.allowedXHandles));
put(out, "excluded_x_handles", handles(tool.excludedXHandles));
put(out, "from_date", tool.fromDate);
put(out, "to_date", tool.toDate);
put(out, "enable_image_understanding", tool.enableImageUnderstanding);
put(out, "enable_video_understanding", tool.enableVideoUnderstanding);
return out;
}
if (tool.type === "web_search") {
const out = { type: "web_search" };
put(out, "allowed_domains", tool.allowedDomains);
put(out, "excluded_domains", tool.excludedDomains);
put(out, "enable_image_understanding", tool.enableImageUnderstanding);
return out;
}
return tool;
});
}
// src/receipt.ts
function atomicToUsd(amount) {
return typeof amount === "string" && /^\d+$/.test(amount) ? Number(amount) / 1e6 : void 0;
}
function receiptChargedUsd(receipt) {
return atomicToUsd(receipt?.extra?.chargedAmount) ?? atomicToUsd(receipt?.amount);
}
function decode(header) {
try {
const json = typeof Buffer !== "undefined" ? Buffer.from(header, "base64").toString("utf8") : atob(header);
const parsed = JSON.parse(json);
return parsed && typeof parsed === "object" ? parsed : void 0;
} catch {
return void 0;
}
}
function readSettlement(response, quotedUsd, scheme) {
const header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
const receipt = header ? decode(header) : void 0;
const extra = receipt?.extra;
const costUsd = receiptChargedUsd({
amount: receipt?.amount,
extra: extra && typeof extra === "object" ? extra : void 0
}) ?? quotedUsd;
const receiptTx = typeof receipt?.transaction === "string" && receipt.transaction ? receipt.transaction : void 0;
const transaction = receiptTx ?? (response.headers.get("X-Payment-Receipt")?.trim() || void 0);
const settlement = {
scheme,
quotedUsd,
...transaction ? { transaction } : {},
...typeof receipt?.network === "string" ? { network: receipt.network } : {},
...receipt?.success === false ? { success: false } : {}
};
return { costUsd, settlement };
}
function settlementSkipReason(response) {
const headers = response.headers;
if (typeof headers?.get !== "function") return void 0;
const reason = [headers.get("X-Settlement-Skipped"), headers.get("X-Payment-Waived")].map((value) => value?.trim()).find((value) => !!value && !NOT_A_SKIP.test(value));
if (reason === void 0) return void 0;
const evidence = settlementEvidence(response);
if (evidence !== void 0) {
console.error(
`[@blockrun/llm] settlement: the gateway named a skipped settlement (${JSON.stringify(reason)}) on a response that also carries ${evidence}; booked as charged`
);
return void 0;
}
return reason;
}
var NOT_A_SKIP = /^(false|0|no|none)$/i;
function settlementEvidence(response) {
const header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
const receipt = header ? decode(header) : void 0;
if (receipt && receipt.success !== false) {
if (typeof receipt.transaction === "string" && receipt.transaction) return "a settlement receipt with a transaction";
const extra = receipt.extra;
const charged = receiptChargedUsd({
amount: receipt.amount,
extra: extra && typeof extra === "object" ? extra : void 0
});
if (charged !== void 0 && charged > 0) return "a settlement receipt with a charge";
}
if (response.headers.get("X-Payment-Receipt")?.trim()) return "an X-Payment-Receipt transaction";
return void 0;
}
function settlementSkipped(response) {
return settlementSkipReason(response) !== void 0;
}
function withCost(body, response, bookedUsd, scheme) {
const skipped = settlementSkipReason(response);
if (skipped !== void 0) {
body.costUsd = 0;
body.settlement = { ...readSettlement(response, bookedUsd, scheme).settlement, skipped };
return body;
}
body.costUsd = bookedUsd;
body.settlement = readSettlement(response, bookedUsd, scheme).settlement;
return body;
}
// src/client.ts
var import_accounts2 = require("viem/accounts");
// node_modules/.pnpm/@blockrun+router-core@https+++codeload.github.com+BlockRunAI+router-core+tar.gz+fd6d03d035ff6_meidvh7c7i3y7wjjl6r2ymc4py/node_modules/@blockrun/router-core/dist/index.js
var UNSEGMENTED_SCRIPT = /[-ۿ -ヿ㐀-䶿一-鿿가-豈--]/;
var WORD_CHAR = /[\p{L}\p{N}]/u;
var ASCII_PHRASE = /^[a-z0-9' -]+$/;
var SEGMENTED_WORD_CHAR = "\\p{Script=Latin}\\p{Script=Cyrillic}\\p{Script=Greek}\\p{N}";
var BOUNDARY_BEFORE = `(?<![${SEGMENTED_WORD_CHAR}])`;
var BOUNDARY_AFTER = `(?![${SEGMENTED_WORD_CHAR}])`;
var BOUNDED_LAST_WORD_LENGTH = 6;
var SHORT_SUFFIXES = "(?:s|ed|ing)?";
var SIBILANT_PLURAL = "es";
var MID_SUFFIXES = "(?:s|es|ed|d|ing|er|ers|ly|ment|ments|ation|ations|e|en|n|a|as|o|os|em|et|ung|ungen)?";
var DROPPED_E_SUFFIXES = "(?:ing|ed|es|er|ers|ation|ations)";
var DOUBLED_CONSONANT_SUFFIXES = "(?:ing|ed|er|ers)";
var matcherCache = /* @__PURE__ */ new Map();
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function keywordMatcher(keyword) {
const cached = matcherCache.get(keyword);
if (cached) return cached;
const kw = keyword.toLowerCase();
let matcher;
if (UNSEGMENTED_SCRIPT.test(kw) || kw.length === 0) {
matcher = { needle: kw, pattern: null };
} else {
const lead = WORD_CHAR.test(kw[0]) ? BOUNDARY_BEFORE : "";
const escaped = escapeRegExp(kw);
let body = escaped;
let trail = "";
let needle = kw;
const lastWord = kw.slice(kw.lastIndexOf(" ") + 1);
if (WORD_CHAR.test(kw[kw.length - 1]) && ASCII_PHRASE.test(kw) && lastWord.length <= BOUNDED_LAST_WORD_LENGTH) {
const forms = [];
if (/\d$/.test(kw)) {
forms.push(escaped);
} else if (lastWord.length <= 3) {
forms.push(`${escaped}${SHORT_SUFFIXES}`);
if (/(?:x|s|z|ch|sh)$/.test(kw)) forms.push(`${escaped}${SIBILANT_PLURAL}`);
} else {
forms.push(`${escaped}${MID_SUFFIXES}`);
if (kw.endsWith("e")) {
forms.push(`${escaped.slice(0, -1)}${DROPPED_E_SUFFIXES}`);
needle = kw.slice(0, -1);
} else if (kw.endsWith("y")) {
forms.push(`${escaped.slice(0, -1)}(?:ies|ied)`);
needle = kw.slice(0, -1);
} else if (/[aeiou][bdgmnprt]$/.test(kw)) {
forms.push(`${escaped}${kw[kw.length - 1]}${DOUBLED_CONSONANT_SUFFIXES}`);
}
}
body = `(?:${forms.join("|")})`;
trail = BOUNDARY_AFTER;
}
matcher = { needle, pattern: new RegExp(`${lead}${body}${trail}`, "u") };
}
matcherCache.set(keyword, matcher);
return matcher;
}
function matchesKeyword(text, keyword) {
const { needle, pattern } = keywordMatcher(keyword);
if (!text.includes(needle)) return false;
return pattern === null ? true : pattern.test(text);
}
function scoreTokenCount(estimatedTokens, thresholds) {
if (estimatedTokens < thresholds.simple) {
return { name: "tokenCount", score: -1, signal: `short (${estimatedTokens} tokens)` };
}
if (estimatedTokens > thresholds.complex) {
return { name: "tokenCount", score: 1, signal: `long (${estimatedTokens} tokens)` };
}
return { name: "tokenCount", score: 0, signal: null };
}
function scoreKeywordMatch(text, keywords, name, signalLabel, thresholds, scores) {
const matches = keywords.filter((kw) => matchesKeyword(text, kw));
if (matches.length >= thresholds.high) {
return {
name,
score: scores.high,
signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`
};
}
if (matches.length >= thresholds.low) {
return {
name,
score: scores.low,
signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`
};
}
return { name, score: scores.none, signal: null };
}
function scoreMultiStep(text) {
const patterns = [/first.*then/i, /step \d/i, /\d\.\s/];
const hits = patterns.filter((p) => p.test(text));
if (hits.length > 0) {
return { name: "multiStepPatterns", score: 0.5, signal: "multi-step" };
}
return { name: "multiStepPatterns", score: 0, signal: null };
}
function scoreQuestionComplexity(prompt) {
const count = (prompt.match(/\?/g) || []).length;
if (count > 3) {
return { name: "questionComplexity", score: 0.5, signal: `${count} questions` };
}
return { name: "questionComplexity", score: 0, signal: null };
}
function scoreAgenticTask(text, keywords) {
let matchCount = 0;
const signals = [];
for (const keyword of keywords) {
if (matchesKeyword(text, keyword)) {
matchCount++;
if (signals.length < 3) {
signals.push(keyword);
}
}
}
if (matchCount >= 4) {
return {
dimensionScore: {
name: "agenticTask",
score: 1,
signal: `agentic (${signals.join(", ")})`
},
agenticScore: 1
};
} else if (matchCount >= 3) {
return {
dimensionScore: {
name: "agenticTask",
score: 0.6,
signal: `agentic (${signals.join(", ")})`
},
agenticScore: 0.6
};
} else if (matchCount >= 1) {
return {
dimensionScore: {
name: "agenticTask",
score: 0.2,
signal: `agentic-light (${signals.join(", ")})`
},
agenticScore: 0.2
};
}
return {
dimensionScore: { name: "agenticTask", score: 0, signal: null },
agenticScore: 0
};
}
function classifyByRules(prompt, systemPrompt, estimatedTokens, config) {
const userText = prompt.toLowerCase();
const dimensions = [
// Token count uses total estimated tokens (system + user) — context size matters for model selection
scoreTokenCount(estimatedTokens, config.tokenCountThresholds),
scoreKeywordMatch(
userText,
config.codeKeywords,
"codePresence",
"code",
{ low: 1, high: 2 },
{ none: 0, low: 0.5, high: 1 }
),
scoreKeywordMatch(
userText,
config.reasoningKeywords,
"reasoningMarkers",
"reasoning",
{ low: 1, high: 2 },
{ none: 0, low: 0.7, high: 1 }
),
scoreKeywordMatch(
userText,
config.technicalKeywords,
"technicalTerms",
"technical",
{ low: 2, high: 4 },
{ none: 0, low: 0.5, high: 1 }
),
scoreKeywordMatch(
userText,
config.creativeKeywords,
"creativeMarkers",
"creative",
{ low: 1, high: 2 },
{ none: 0, low: 0.5, high: 0.7 }
),
scoreKeywordMatch(
userText,
config.simpleKeywords,
"simpleIndicators",
"simple",
{ low: 1, high: 2 },
{ none: 0, low: -1, high: -1 }
),
scoreMultiStep(userText),
scoreQuestionComplexity(prompt),
// 6 new dimensions
scoreKeywordMatch(
userText,
config.imperativeVerbs,
"imperativeVerbs",
"imperative",
{ low: 1, high: 2 },
{ none: 0, low: 0.3, high: 0.5 }
),
scoreKeywordMatch(
userText,
config.constraintIndicators,
"constraintCount",
"constraints",
{ low: 1, high: 3 },
{ none: 0, low: 0.3, high: 0.7 }
),
scoreKeywordMatch(
userText,
config.outputFormatKeywords,
"outputFormat",
"format",
{ low: 1, high: 2 },
{ none: 0, low: 0.4, high: 0.7 }
),
scoreKeywordMatch(
userText,
config.referenceKeywords,
"referenceComplexity",
"references",
{ low: 1, high: 2 },
{ none: 0, low: 0.3, high: 0.5 }
),
scoreKeywordMatch(
userText,
config.negationKeywords,
"negationComplexity",
"negation",
{ low: 2, high: 3 },
{ none: 0, low: 0.3, high: 0.5 }
),
scoreKeywordMatch(
userText,
config.domainSpecificKeywords,
"domainSpecificity",
"domain-specific",
{ low: 1, high: 2 },
{ none: 0, low: 0.5, high: 0.8 }
)
];
const agenticResult = scoreAgenticTask(userText, config.agenticTaskKeywords);
dimensions.push(agenticResult.dimensionScore);
const agenticScore = agenticResult.agenticScore;
const signals = dimensions.filter((d) => d.signal !== null).map((d) => d.signal);
const weights = config.dimensionWeights;
let weightedScore = 0;
for (const d of dimensions) {
const w = weights[d.name] ?? 0;
weightedScore += d.score * w;
}
const reasoningMatches = config.reasoningKeywords.filter((kw) => matchesKeyword(userText, kw));
if (reasoningMatches.length >= 2) {
const confidence2 = calibrateConfidence(
Math.max(weightedScore, 0.3),
// ensure positive for confidence calc
config.confidenceSteepness
);
return {
score: weightedScore,
tier: "REASONING",
confidence: Math.max(confidence2, 0.85),
signals,
agenticScore,
dimensions
};
}
const { simpleMedium, mediumComplex, complexReasoning } = config.tierBoundaries;
let tier;
let distanceFromBoundary;
if (weightedScore < simpleMedium) {
tier = "SIMPLE";
distanceFromBoundary = simpleMedium - weightedScore;
} else if (weightedScore < mediumComplex) {
tier = "MEDIUM";
distanceFromBoundary = Math.min(weightedScore - simpleMedium, mediumComplex - weightedScore);
} else if (weightedScore < complexReasoning) {
tier = "COMPLEX";
distanceFromBoundary = Math.min(
weightedScore - mediumComplex,
complexReasoning - weightedScore
);
} else {
tier = "REASONING";
distanceFromBoundary = weightedScore - complexReasoning;
}
const confidence = calibrateConfidence(distanceFromBoundary, config.confidenceSteepness);
if (confidence < config.confidenceThreshold) {
return { score: weightedScore, tier: null, confidence, signals, agenticScore, dimensions };
}
return { score: weightedScore, tier, confidence, signals, agenticScore, dimensions };
}
function calibrateConfidence(distance, steepness) {
return 1 / (1 + Math.exp(-steepness * distance));
}
var BASELINE_MODEL_ID = "anthropic/claude-opus-4.7";
var BASELINE_INPUT_PRICE = 5;
var BASELINE_OUTPUT_PRICE = 25;
function selectModel(tier, confidence, method, reasoning, tierConfigs, modelPricing, estimatedInputTokens, maxOutputTokens, routingProfile, agenticScore) {
const tierConfig = tierConfigs[tier];
const model = tierConfig.primary;
const pricing = modelPricing.get(model);
let costEstimate;
if (pricing?.flatPrice !== void 0) {
costEstimate = pricing.flatPrice;
} else {
const inputPrice = pricing?.inputPrice ?? 0;
const outputPrice = pricing?.outputPrice ?? 0;
costEstimate = estimatedInputTokens / 1e6 * inputPrice + maxOutputTokens / 1e6 * outputPrice;
}
const opusPricing = modelPricing.get(BASELINE_MODEL_ID);
const opusInputPrice = opusPricing?.inputPrice ?? BASELINE_INPUT_PRICE;
const opusOutputPrice = opusPricing?.outputPrice ?? BASELINE_OUTPUT_PRICE;
const baselineInput = estimatedInputTokens / 1e6 * opusInputPrice;
const baselineOutput = maxOutputTokens / 1e6 * opusOutputPrice;
const baselineCost = baselineInput + baselineOutput;
const savings = routingProfile === "premium" || pricing === void 0 ? 0 : baselineCost > 0 ? Math.max(0, (baselineCost - costEstimate) / baselineCost) : 0;
return {
model,
tier,
confidence,
method,
reasoning,
costEstimate,
baselineCost,
savings,
...agenticScore !== void 0 && { agenticScore }
};
}
function getFallbackChain(tier, tierConfigs) {
const config = tierConfigs[tier];
return [config.primary, ...config.fallback];
}
var SERVER_MARGIN_PERCENT = 5;
var MIN_PAYMENT_USD = 1e-3;
function calculateModelCost(model, modelPricing, estimatedInputTokens, maxOutputTokens, routingProfile) {
const pricing = modelPricing.get(model);
let costEstimate;
if (pricing?.flatPrice !== void 0) {
costEstimate = Math.max(pricing.flatPrice * (1 + SERVER_MARGIN_PERCENT / 100), MIN_PAYMENT_USD);
} else {
const inputPrice = pricing?.inputPrice ?? 0;
const outputPrice = pricing?.outputPrice ?? 0;
const inputCost = estimatedInputTokens / 1e6 * inputPrice;
const outputCost = maxOutputTokens / 1e6 * outputPrice;
costEstimate = Math.max(
(inputCost + outputCost) * (1 + SERVER_MARGIN_PERCENT / 100),
MIN_PAYMENT_USD
);
}
const opusPricing = modelPricing.get(BASELINE_MODEL_ID);
const opusInputPrice = opusPricing?.inputPrice ?? BASELINE_INPUT_PRICE;
const opusOutputPrice = opusPricing?.outputPrice ?? BASELINE_OUTPUT_PRICE;
const baselineInput = estimatedInputTokens / 1e6 * opusInputPrice;
const baselineOutput = maxOutputTokens / 1e6 * opusOutputPrice;
const baselineCost = baselineInput + baselineOutput;
const savings = routingProfile === "premium" ? 0 : baselineCost > 0 ? Math.max(0, (baselineCost - costEstimate) / baselineCost) : 0;
return { costEstimate, baselineCost, savings };
}
function filterCandidatesByCapacity(models, estimatedInputTokens, requestedOutputTokens, getCapabilities) {
const filtered = models.filter((modelId) => {
const capabilities = getCapabilities(modelId);
if (!capabilities) return true;
return capabilities.contextWindow >= (estimatedInputTokens + requestedOutputTokens) * 1.1 && capabilities.maxOutput >= requestedOutputTokens;
});
return filtered;
}
var CJK_CHARS = /[ -ヿ㐀-䶿一-鿿가-豈--]/g;
function estimateTokens(text) {
if (text.length === 0) return 0;
const cjk = text.match(CJK_CHARS)?.length ?? 0;
return cjk + Math.ceil((text.length - cjk) / 4);
}
function scanLimit(promptTruncationChars) {
return Math.max(1, Math.min(8e3, promptTruncationChars));
}
function sampleForScan(value, limit) {
if (value.length <= limit) return value;
const prefixLength = Math.ceil(limit / 2);
return `${value.slice(0, prefixLength)}
${value.slice(-(limit - prefixLength))}`;
}
function applyPromotions(tierConfigs, promotions, profile, now = /* @__PURE__ */ new Date()) {
if (!promotions || promotions.length === 0) return tierConfigs;
let result = tierConfigs;
for (const promo of promotions) {
const start = new Date(promo.startDate);
const end = new Date(promo.endDate);
if (now < start || now >= end) continue;
if (promo.profiles && !promo.profiles.includes(profile)) continue;
if (result === tierConfigs) {
result = { ...tierConfigs };
for (const t of Object.keys(result)) {
result[t] = { ...result[t] };
}
}
for (const [tier, override] of Object.entries(promo.tierOverrides)) {
if (!result[tier]) continue;
if (override.primary) result[tier].primary = override.primary;
if (override.fallback) result[tier].fallback = override.fallback;
}
}
return result;
}
function applyUnavailableModels(tierConfigs, unavailableModels) {
if (!unavailableModels || unavailableModels.length === 0) return tierConfigs;
const dead = new Set(unavailableModels);
let result = tierConfigs;
for (const tier of Object.keys(tierConfigs)) {
const config = tierConfigs[tier];
const alive = [config.primary, ...config.fallback].filter((model) => !dead.has(model));
if (alive.length === 0 || alive[0] === config.primary && alive.length === config.fallback.length + 1) {
continue;
}
if (result === tierConfigs) result = { ...tierConfigs };
result[tier] = { primary: alive[0], fallback: alive.slice(1) };
}
return result;
}
var RulesStrategy = class {
name = "rules";
route(prompt, systemPrompt, maxOutputTokens, options) {
const { config, modelPricing } = options;
const fullText = `${systemPrompt ?? ""} ${prompt}`;
const estimatedTokens = estimateTokens(fullText);
const limit = scanLimit(config.classifier.promptTruncationChars);
const scannedPrompt = sampleForScan(prompt, limit);
const scannedSystemPrompt = systemPrompt ? sampleForScan(systemPrompt, limit) : void 0;
const ruleResult = classifyByRules(
scannedPrompt,
scannedSystemPrompt,
estimatedTokens,
config.scoring
);
const { routingProfile } = options;
let tierConfigs;
let profileSuffix;
let profile;
if (routingProfile === "eco") {
tierConfigs = config.ecoTiers ?? config.tiers;
profileSuffix = config.ecoTiers ? " | eco" : " | eco (default tiers)";
profile = "eco";
} else if (routingProfile === "premium") {
tierConfigs = config.premiumTiers ?? config.tiers;
profileSuffix = config.premiumTiers ? " | premium" : " | premium (default tiers)";
profile = "premium";
} else {
const agenticScore = ruleResult.agenticScore ?? 0;
const isAutoAgentic = agenticScore >= 0.5;
const agenticModeSetting = config.overrides.agenticMode;
const hasToolsInRequest = options.requiresTools ?? options.hasTools ?? false;
let useAgenticTiers;
if (agenticModeSetting === false) {
useAgenticTiers = false;
} else if (agenticModeSetting === true) {
useAgenticTiers = config.agenticTiers != null;
} else {
useAgenticTiers = (hasToolsInRequest || isAutoAgentic) && config.agenticTiers != null;
}
tierConfigs = useAgenticTiers ? config.agenticTiers : config.tiers;
profileSuffix = useAgenticTiers ? ` | agentic${hasToolsInRequest ? " (tools)" : ""}` : "";
profile = useAgenticTiers ? "agentic" : "auto";
}
tierConfigs = applyPromotions(tierConfigs, config.promotions, profile, options.now);
tierConfigs = applyUnavailableModels(tierConfigs, options.unavailableModels);
const agenticScoreValue = ruleResult.agenticScore;
if (estimatedTokens > config.overrides.maxTokensForceComplex) {
const decision2 = selectModel(
"COMPLEX",
0.95,
"rules",
`Input exceeds ${config.overrides.maxTokensForceComplex} tokens${profileSuffix}`,
tierConfigs,
modelPricing,
estimatedTokens,
maxOutputTokens,
routingProfile,
agenticScoreValue
);
return { ...decision2, tierConfigs, profile };
}
const hasStructuredOutput = options.requiresStructuredOutput === true;
let tier;
let confidence;
const method = "rules";
let reasoning = `score=${ruleResult.score.toFixed(2)} | ${ruleResult.signals.join(", ")}`;
if (ruleResult.tier !== null) {
tier = ruleResult.tier;
confidence = ruleResult.confidence;
} else {
tier = config.overrides.ambiguousDefaultTier;
confidence = 0.5;
reasoning += ` | ambiguous -> default: ${tier}`;
}
if (hasStructuredOutput) {
const tierRank = { SIMPLE: 0, MEDIUM: 1, COMPLEX: 2, REASONING: 3 };
const minTier = config.overrides.structuredOutputMinTier;
if (tierRank[tier] < tierRank[minTier]) {
reasoning += ` | upgraded to ${minTier} (structured output)`;
tier = minTier;
}
}
reasoning += profileSuffix;
const decision = selectModel(
tier,
confidence,
method,
reasoning,
tierConfigs,
modelPricing,
estimatedTokens,
maxOutputTokens,
routingProfile,
agenticScoreValue
);
return { ...decision, tierConfigs, profile };
}
};
var registry = /* @__PURE__ */ new Map();
registry.set("rules", new RulesStrategy());
function getStrategy(name) {
const strategy = registry.get(name);
if (!strategy) {
throw new Error(`Unknown routing strategy: ${name}`);
}
return strategy;
}
function registerStrategy(strategy) {
registry.set(strategy.name, strategy);
}
var DEFAULT_MODEL_CAPABILITIES = Object.freeze({
"anthropic/claude-fable-5": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-fable-5.1": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-haiku-4.5": {
// override: The public catalog's `categories` omit "vision" for this Anthropic model even though the gateway accepts image input for it (the prior hand-maintained snapshot had it, and Anthropic's model card lists it). Without this the vision filter would silently drop it — reported against the catalog; remove once the categories carry vision.
contextWindow: 2e5,
maxOutputTokens: 64e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-opus-4.5": {
contextWindow: 2e5,
maxOutputTokens: 64e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-opus-4.7": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-opus-4.8": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-opus-5": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-opus-5.5": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-sonnet-4.5": {
contextWindow: 2e5,
maxOutputTokens: 64e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-sonnet-4.6": {
// override: The public catalog's `categories` omit "vision" for this Anthropic model even though the gateway accepts image input for it (the prior hand-maintained snapshot had it, and Anthropic's model card lists it). Without this the vision filter would silently drop it — reported against the catalog; remove once the categories carry vision.
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-sonnet-5": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"anthropic/claude-sonnet-5.5": {
contextWindow: 1e6,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"cohere/north-mini-code": {
// supportsTools: not probed — fails closed
contextWindow: 256e3,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"deepseek/deepseek-chat": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"deepseek/deepseek-reasoner": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"deepseek/deepseek-v4-flash-vision-exp": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: false,
supportsVision: true
},
"deepseek/deepseek-v4-pro": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"deepseek/deepseek-v4.1-flash": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"google/gemini-2.5-flash": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-2.5-flash-lite": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"google/gemini-2.5-pro": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-3-flash-preview": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-3.1-flash-lite": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"google/gemini-3.1-pro": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-3.5-flash": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-3.5-flash-lite": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"google/gemini-3.6-flash": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-3.8-flash": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"google/gemini-4-argon": {
// supportsTools: not probed — fails closed
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: false,
supportsVision: true
},
"minimax/minimax-m2.7": {
contextWindow: 204800,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: false
},
"minimax/minimax-m3": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"mistral/mistral-large-4": {
// supportsTools: not probed — fails closed
contextWindow: 524288,
maxOutputTokens: 262144,
supportsTools: false,
supportsVision: true
},
"moonshot/kimi-k3": {
contextWindow: 1048576,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: true
},
"nvidia/llama-3.2-11b-vision": {
// supportsTools: not probed — fails closed
contextWindow: 128e3,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: true
},
"nvidia/muse-glimmer-30b": {
// supportsTools: not probed — fails closed
contextWindow: 131072,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": {
// override: The catalog tags this model "vision", but a correctly sized probe does not hold up (ClawRouter, 2026-08-31): a 64x64 solid-red PNG was named correctly 1 of 4 times on Base, and on Solana the image was silently dropped and a text model answered "white". An HTTP 200 with a confident wrong answer gives the caller nothing to branch on, so image turns must not be routed here. It is ecoTiers.SIMPLE.fallback[0] since nemotron-3-nano-30b was delisted (2026-09-08); remove once a probe of this size comes back right on both chains.
contextWindow: 256e3,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"nvidia/nemotron-3-ultra-550b": {
// supportsTools: not probed — fails closed
contextWindow: 1e6,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"nvidia/nemotron-3.5-lightning": {
// supportsTools: not probed — fails closed
contextWindow: 1e6,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"openai/chat-latest": {
contextWindow: 128e3,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-4.1": {
contextWindow: 128e3,
maxOutputTokens: 32768,
supportsTools: true,
supportsVision: true
},
"openai/gpt-4.1-mini": {
contextWindow: 128e3,
maxOutputTokens: 32768,
supportsTools: true,
supportsVision: false
},
"openai/gpt-4.1-nano": {
contextWindow: 128e3,
maxOutputTokens: 32768,
supportsTools: true,
supportsVision: false
},
"openai/gpt-4o": {
contextWindow: 128e3,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: true
},
"openai/gpt-4o-mini": {
contextWindow: 128e3,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: false
},
"openai/gpt-5-mini": {
contextWindow: 2e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"openai/gpt-5.1": {
contextWindow: 4e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.2": {
contextWindow: 4e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.2-pro": {
// supportsTools: not probed — fails closed
contextWindow: 4e5,
maxOutputTokens: 128e3,
supportsTools: false,
supportsVision: true
},
"openai/gpt-5.3-codex": {
// supportsTools: gateway unavailable at probe time — fails closed; override: 2026-08-29 probe: every request (6 plain + 3 tool attempts) returned a gateway 500, so the probe measured an incident, not the model. Codex's function calling is established by the 2026-07 Terminal-Bench / tau2 calibration trajectories in portfolio.ts. Hosts observing the 500s should drop it with RouterOptions.unavailableModels rather than this snapshot claiming the model cannot call tools.
contextWindow: 4e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"openai/gpt-5.4": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.4-mini": {
contextWindow: 4e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.4-nano": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"openai/gpt-5.4-pro": {
// supportsTools: not probed — fails closed
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: false,
supportsVision: true
},
"openai/gpt-5.5": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.5-pro": {
// supportsTools: not probed — fails closed
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: false,
supportsVision: true
},
"openai/gpt-5.6-luna": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.6-luna-pro": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: false,
supportsVision: true
},
"openai/gpt-5.6-sol": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.6-sol-pro": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.6-terra": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-5.6-terra-pro": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-6-astra": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-6-luna": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-6-sol": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/gpt-6.1-sol": {
contextWindow: 105e4,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: true
},
"openai/o1": {
contextWindow: 2e5,
maxOutputTokens: 1e5,
supportsTools: true,
supportsVision: false
},
"openai/o3": {
contextWindow: 2e5,
maxOutputTokens: 1e5,
supportsTools: true,
supportsVision: false
},
"openai/o3-mini": {
contextWindow: 128e3,
maxOutputTokens: 1e5,
supportsTools: true,
supportsVision: false
},
"openai/o4-mini": {
contextWindow: 128e3,
maxOutputTokens: 1e5,
supportsTools: true,
supportsVision: false
},
"poolside/laguna-s-2.1": {
// supportsTools: not probed — fails closed
contextWindow: 1048576,
maxOutputTokens: 131072,
supportsTools: false,
supportsVision: false
},
"poolside/laguna-xs-2.1": {
// supportsTools: not probed — fails closed
contextWindow: 131072,
maxOutputTokens: 16384,
supportsTools: false,
supportsVision: false
},
"qwen/qwen3.7-flash": {
contextWindow: 1e6,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"qwen/qwen3.7-max": {
contextWindow: 1e6,
maxOutputTokens: 65536,
supportsTools: true,
supportsVision: false
},
"qwen/qwen3.7-plus": {
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: false
},
"qwen/qwen3.8-flash": {
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: false,
supportsVision: true
},
"qwen/qwen3.8-max": {
// supportsTools: not probed — fails closed
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: false,
supportsVision: false
},
"tencent/hy4-preview": {
// supportsTools: not probed — fails closed
contextWindow: 1048576,
maxOutputTokens: 64e3,
supportsTools: false,
supportsVision: false
},
"xai/grok-4.3": {
contextWindow: 1e6,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: true
},
"xai/grok-4.5": {
contextWindow: 5e5,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: true
},
"xai/grok-4.6": {
contextWindow: 5e5,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: true
},
"xai/grok-4.7": {
contextWindow: 5e5,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: true
},
"xai/grok-build-0.1": {
contextWindow: 256e3,
maxOutputTokens: 16384,
supportsTools: true,
supportsVision: false
},
"xiaomi/mimo-v2.5": {
contextWindow: 1048576,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: true
},
"xiaomi/mimo-v2.5-pro": {
contextWindow: 1048576,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: false
},
"zai/glm-5": {
contextWindow: 2e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"zai/glm-5-turbo": {
contextWindow: 2e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"zai/glm-5.1": {
contextWindow: 2e5,
maxOutputTokens: 128e3,
supportsTools: true,
supportsVision: false
},
"zai/glm-5.2": {
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: false
},
"zai/glm-5.3": {
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: false
},
"zai/glm-5.3-flash": {
contextWindow: 1e6,
maxOutputTokens: 131072,
supportsTools: true,
supportsVision: true
}
});
var model_profiles_generated_default = {
"anthropic/claude-fable-5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 9298.5,
p95LatencyMs: 9873.4,
outputTokensPerSecond: 55.17,
errorRate: 0,
samples: 3
},
"anthropic/claude-haiku-4.5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3157.4,
p95LatencyMs: 3170.7,
outputTokensPerSecond: 162.16,
errorRate: 0,
samples: 3
},
"anthropic/claude-opus-4.5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6497.7,
p95LatencyMs: 6953.7,
outputTokensPerSecond: 78.99,
errorRate: 0,
samples: 3
},
"anthropic/claude-opus-4.7": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5316.5,
p95LatencyMs: 6121.5,
outputTokensPerSecond: 97.34,
errorRate: 0,
samples: 3
},
"anthropic/claude-opus-4.8": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6216.1,
p95LatencyMs: 6847.7,
outputTokensPerSecond: 82.81,
errorRate: 0,
samples: 3
},
"anthropic/claude-opus-5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 7309,
p95LatencyMs: 7745.2,
outputTokensPerSecond: 70.17,
errorRate: 0,
samples: 3
},
"anthropic/claude-sonnet-4.5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6330.4,
p95LatencyMs: 6631.6,
outputTokensPerSecond: 81.03,
errorRate: 0,
samples: 3
},
"anthropic/claude-sonnet-4.6": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6508,
p95LatencyMs: 6698.3,
outputTokensPerSecond: 78.6,
errorRate: 0,
samples: 3
},
"anthropic/claude-sonnet-5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6165.4,
p95LatencyMs: 6582.9,
outputTokensPerSecond: 83.62,
errorRate: 0,
samples: 3
},
"deepseek/deepseek-chat": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4351.4,
p95LatencyMs: 4543.7,
outputTokensPerSecond: 117.78,
errorRate: 0,
samples: 3
},
"deepseek/deepseek-reasoner": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5201.2,
p95LatencyMs: 6079.6,
outputTokensPerSecond: 99.77,
errorRate: 0,
samples: 3
},
"deepseek/deepseek-v4-pro": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 8781.2,
p95LatencyMs: 9881.1,
outputTokensPerSecond: 58.98,
errorRate: 0,
samples: 3
},
"deepseek/deepseek-v4.1-flash": {
measuredAt: "2026-10-20T18:12:51Z",
latencyMs: 2383.5,
p95LatencyMs: 2547.8,
outputTokensPerSecond: 216.53,
errorRate: 0,
samples: 3
},
"google/gemini-2.5-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5416.4,
p95LatencyMs: 6442.8,
outputTokensPerSecond: 213.07,
errorRate: 0,
samples: 3
},
"google/gemini-2.5-flash-lite": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5002.6,
p95LatencyMs: 5780.3,
outputTokensPerSecond: 408.43,
errorRate: 0,
samples: 3
},
"google/gemini-2.5-pro": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 28169.5,
p95LatencyMs: 29491.4,
outputTokensPerSecond: 147.3,
errorRate: 0,
samples: 3
},
"google/gemini-3-flash-preview": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4717.1,
p95LatencyMs: 5037.1,
outputTokensPerSecond: 198.71,
errorRate: 0,
samples: 3
},
"google/gemini-3.1-flash-lite": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 2855.8,
p95LatencyMs: 3172.7,
outputTokensPerSecond: 286.91,
errorRate: 0,
samples: 3
},
"google/gemini-3.1-pro": {
measuredAt: "2026-08-29T16:59:54Z",
latencyMs: 24194.1,
p95LatencyMs: 27269.6,
outputTokensPerSecond: 109.47,
errorRate: 0,
samples: 3
},
"google/gemini-3.5-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5320.6,
p95LatencyMs: 5429.8,
outputTokensPerSecond: 226.21,
errorRate: 0,
samples: 3
},
"google/gemini-3.5-flash-lite": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3515.8,
p95LatencyMs: 4363.4,
outputTokensPerSecond: 248.9,
errorRate: 0,
samples: 3
},
"google/gemini-3.6-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 13020,
p95LatencyMs: 15383.1,
outputTokensPerSecond: 187.87,
errorRate: 0,
samples: 3
},
"minimax/minimax-m2.7": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 8761.1,
p95LatencyMs: 10199.3,
outputTokensPerSecond: 59.18,
errorRate: 0,
samples: 3
},
"minimax/minimax-m3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 11101.9,
p95LatencyMs: 26087.1,
outputTokensPerSecond: 101.12,
errorRate: 0,
samples: 3
},
"moonshot/kimi-k3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 24498.9,
p95LatencyMs: 40365.3,
outputTokensPerSecond: 25.11,
errorRate: 0,
samples: 3
},
"nvidia/mistral-nemotron": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 7349.6,
p95LatencyMs: 9932.3,
outputTokensPerSecond: 79.48,
errorRate: 0.3333,
samples: 3
},
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": {
measuredAt: "2026-08-29T16:59:54Z",
latencyMs: 9324.6,
p95LatencyMs: 12992,
outputTokensPerSecond: 64.96,
errorRate: 0.3333,
samples: 3
},
"nvidia/nemotron-nano-12b-v2-vl": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5846.9,
p95LatencyMs: 5846.9,
outputTokensPerSecond: 87.57,
errorRate: 0.6667,
samples: 3
},
"nvidia/nemotron-nano-9b-v2": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5282.5,
p95LatencyMs: 5282.5,
outputTokensPerSecond: 96.92,
errorRate: 0.6667,
samples: 3
},
"nvidia/step-3.7-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4617.4,
p95LatencyMs: 5237.4,
outputTokensPerSecond: 112.92,
errorRate: 0.3333,
samples: 3
},
"openai/chat-latest": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3690.9,
p95LatencyMs: 4344,
outputTokensPerSecond: 111.85,
errorRate: 0,
samples: 3
},
"openai/gpt-4.1": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3527.9,
p95LatencyMs: 3831.7,
outputTokensPerSecond: 141.27,
errorRate: 0,
samples: 3
},
"openai/gpt-4.1-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4268.2,
p95LatencyMs: 5101.5,
outputTokensPerSecond: 103.42,
errorRate: 0,
samples: 3
},
"openai/gpt-4.1-nano": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3088.3,
p95LatencyMs: 3369.2,
outputTokensPerSecond: 150.31,
errorRate: 0,
samples: 3
},
"openai/gpt-4o": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 2995.2,
p95LatencyMs: 3174.2,
outputTokensPerSecond: 171.32,
errorRate: 0,
samples: 3
},
"openai/gpt-4o-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4751.5,
p95LatencyMs: 4930.4,
outputTokensPerSecond: 107.84,
errorRate: 0,
samples: 3
},
"openai/gpt-5-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4558.1,
p95LatencyMs: 5081.9,
outputTokensPerSecond: 113.25,
errorRate: 0,
samples: 3
},
"openai/gpt-5.2": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5436.6,
p95LatencyMs: 5928.8,
outputTokensPerSecond: 95.47,
errorRate: 0,
samples: 3
},
"openai/gpt-5.3-codex": {
measuredAt: "2026-08-29T16:59:54Z",
latencyMs: 15290.4,
p95LatencyMs: 15290.4,
outputTokensPerSecond: 33.49,
errorRate: 0.6667,
samples: 3
},
"openai/gpt-5.4": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5596,
p95LatencyMs: 5919.4,
outputTokensPerSecond: 91.67,
errorRate: 0,
samples: 3
},
"openai/gpt-5.4-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3377.8,
p95LatencyMs: 3646.8,
outputTokensPerSecond: 138.08,
errorRate: 0,
samples: 3
},
"openai/gpt-5.4-nano": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4040.4,
p95LatencyMs: 4205.9,
outputTokensPerSecond: 118.52,
errorRate: 0,
samples: 3
},
"openai/gpt-5.5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6367.8,
p95LatencyMs: 7330.7,
outputTokensPerSecond: 81.29,
errorRate: 0,
samples: 3
},
"openai/gpt-5.6-luna": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6064.5,
p95LatencyMs: 7347.3,
outputTokensPerSecond: 87.93,
errorRate: 0,
samples: 3
},
"openai/gpt-5.6-luna-pro": {
measuredAt: "2026-08-29T16:59:54Z",
latencyMs: 13914.9,
p95LatencyMs: 13914.9,
outputTokensPerSecond: 36.79,
errorRate: 0.6667,
samples: 3
},
"openai/gpt-5.6-sol": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 7720.2,
p95LatencyMs: 9108.2,
outputTokensPerSecond: 67.47,
errorRate: 0,
samples: 3
},
"openai/gpt-5.6-sol-pro": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 11442.7,
p95LatencyMs: 13363.1,
outputTokensPerSecond: 148.75,
errorRate: 0,
samples: 3
},
"openai/gpt-5.6-terra": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4941,
p95LatencyMs: 5095.3,
outputTokensPerSecond: 103.69,
errorRate: 0,
samples: 3
},
"openai/gpt-5.6-terra-pro": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3574.1,
p95LatencyMs: 4126.3,
outputTokensPerSecond: 133.59,
errorRate: 0,
samples: 3
},
"openai/gpt-6.1-sol": {
measuredAt: "2026-10-20T18:12:51Z",
latencyMs: 11324,
p95LatencyMs: 12747.6,
outputTokensPerSecond: 45.11,
errorRate: 0,
samples: 3
},
"openai/o1": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4324.9,
p95LatencyMs: 5838.1,
outputTokensPerSecond: 125.86,
errorRate: 0,
samples: 3
},
"openai/o3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 5463.4,
p95LatencyMs: 5613.1,
outputTokensPerSecond: 93.8,
errorRate: 0,
samples: 3
},
"openai/o3-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 2912.7,
p95LatencyMs: 3092.1,
outputTokensPerSecond: 176.49,
errorRate: 0,
samples: 3
},
"openai/o4-mini": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 4958.7,
p95LatencyMs: 5313,
outputTokensPerSecond: 103.81,
errorRate: 0,
samples: 3
},
"qwen/qwen3.7-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 3385.5,
p95LatencyMs: 4042.7,
outputTokensPerSecond: 153.94,
errorRate: 0,
samples: 3
},
"qwen/qwen3.7-max": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 9387.1,
p95LatencyMs: 10490.2,
outputTokensPerSecond: 54.92,
errorRate: 0,
samples: 3
},
"qwen/qwen3.7-plus": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 9766.6,
p95LatencyMs: 9798.2,
outputTokensPerSecond: 52.42,
errorRate: 0,
samples: 3
},
"tencent/hy3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6062.3,
p95LatencyMs: 7070.2,
outputTokensPerSecond: 87.3,
errorRate: 0,
samples: 3
},
"xai/grok-4.3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 9467.7,
p95LatencyMs: 10087.9,
outputTokensPerSecond: 48.36,
errorRate: 0,
samples: 3
},
"xai/grok-4.5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 13564.8,
p95LatencyMs: 17351.9,
outputTokensPerSecond: 60.71,
errorRate: 0,
samples: 3
},
"xai/grok-build-0.1": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 16394.8,
p95LatencyMs: 18035.4,
outputTokensPerSecond: 96.86,
errorRate: 0,
samples: 3
},
"xiaomi/mimo-v2.5-pro": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 12070.7,
p95LatencyMs: 12386.8,
outputTokensPerSecond: 42.44,
errorRate: 0,
samples: 3
},
"zai/glm-5": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 6839.7,
p95LatencyMs: 7261.4,
outputTokensPerSecond: 75.16,
errorRate: 0,
samples: 3
},
"zai/glm-5-turbo": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 55348.5,
p95LatencyMs: 114086.6,
outputTokensPerSecond: 14.64,
errorRate: 0,
samples: 3
},
"zai/glm-5.1": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 15658.4,
p95LatencyMs: 17307.1,
outputTokensPerSecond: 32.9,
errorRate: 0,
samples: 3
},
"zai/glm-5.2": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 10308.5,
p95LatencyMs: 15127.6,
outputTokensPerSecond: 54.87,
errorRate: 0,
samples: 3
},
"zai/glm-5.3": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 7272.4,
p95LatencyMs: 7998.1,
outputTokensPerSecond: 71.09,
errorRate: 0,
samples: 3
},
"zai/glm-5.3-flash": {
measuredAt: "2026-08-29T16:51:33Z",
latencyMs: 10545.3,
p95LatencyMs: 11672.4,
outputTokensPerSecond: 49.01,
errorRate: 0,
samples: 3
}
};
var LIVE_MODEL_PROFILES = Object.freeze(
model_profiles_generated_default
);
var HISTORICAL_MODEL_PROFILES = Object.freeze({
"anthropic/claude-haiku-4.5": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 2305,
outputTokensPerSecond: 140.6
},
"anthropic/claude-sonnet-4.6": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 2110,
outputTokensPerSecond: 121.3
},
"deepseek/deepseek-chat": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 1431,
outputTokensPerSecond: 179.2,
intelligenceIndex: 32
},
"google/gemini-2.5-flash": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 1238,
outputTokensPerSecond: 207.6,
intelligenceIndex: 20
},
"google/gemini-2.5-flash-lite": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 1353,
outputTokensPerSecond: 192.5,
intelligenceIndex: 20
},
"google/gemini-2.5-pro": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 1294,
outputTokensPerSecond: 197.8
},
"google/gemini-3.1-pro": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 1609,
outputTokensPerSecond: 167.2
},
"openai/gpt-4o-mini": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 2764,
outputTokensPerSecond: 92.8
},
"openai/gpt-5.3-codex": {
measuredAt: "2026-03-16T13:50:48Z",
latencyMs: 7935,
outputTokensPerSecond: 32.3
}
});
function inferToolRequirement(prompt, _systemPrompt, toolChoice) {
if (toolChoice === "none") return false;
if (toolChoice === "required") return true;
if (typeof toolChoice === "object" && toolChoice !== null && toolChoice.type === "function") {
return true;
}
const text = prompt;
const explicitTool = /\b(?:use|call|invoke)\s+(?:the\s+)?[\w.-]+\s+(?:tool|function|api)\b|\btool[_ -]?call\b|使用.{0,20}(?:工具|函数|接口)|调用.{0,20}(?:工具|函数|接口)/i;
const codeEnvironment = /\b(?:run|execute)\s+(?:the\s+)?(?:tests?|command|script|build|linter)|\b(?:edit|modify|patch|create|write|save|delete|rename|move|inspect|read)\b.{0,60}\b(?:file|repository|repo|codebase|directory|folder)\b|\b(?:terminal|shell|bash|zsh|pytest|npm test|pnpm test|git\s+(?:status|diff|commit)|docker)\b|(?:运行|执行).{0,20}(?:测试|命令|脚本|构建)|(?:修改|编辑|修复|创建|读取|检查|保存).{0,30}(?:文件|仓库|代码库|目录)/i;
const webAction = /\b(?:browse|search|look up|fetch|open)\b.{0,80}\b(?:web|website|url|online|documentation|docs|news|weather|price)\b|(?:浏览|搜索|查询|打开).{0,30}(?:网页|网站|链接|文档|新闻|天气|价格)/i;
const statefulAction = /\b(?:refund|cancel|book|reserve|purchase|buy|return|exchange|transfer|update|change)\b.{0,80}\b(?:order|booking|reservation|account|address|payment|subscription|ticket|flight|item)\b|(?:退款|取消|预订|购买|退货|换货|转账|更新|修改).{0,30}(?:订单|预订|账户|地址|付款|订阅|票|航班|商品)/i;
return explicitTool.test(text) || codeEnvironment.test(text) || webAction.test(text) || statefulAction.test(text);
}
var DEFAULT_PORTFOLIO_WEIGHTS = {
auto: {
quality: 0.47,
capability: 0.2,
cost: 0.18,
speed: 0.07,
reliability: 0.03,
legacy: 0.05
},
eco: { quality: 0.36, capability: 0.2, cost: 0.28, speed: 0.1, reliability: 0.04, legacy: 0.02 },
premium: {
quality: 0.58,
capability: 0.2,
cost: 0.08,
speed: 0.06,
reliability: 0.06,
legacy: 0.02
},
highStakesBoost: { quality: 0.08, reliability: 0.05 },
latencySensitiveSpeedBoost: 0.08,
affinityFloorGap: { auto: 0.1, eco: 0.22, premium: 0.05 }
};
function likelyNeedsParallelToolCalls(prompt, needsTools, toolCount, toolNames) {
if (!needsTools || toolCount === void 0 || toolCount < 1) return false;
const text = prompt.trim();
const explicitRepeat = /\b(?:in parallel|simultaneously|concurrently|for each|each of|every one|both|(?:two|three|multiple|several)\s+(?:cities|locations|items|tasks|orders|users|files))\b|并行|同时|分别|每个|各自|(?:两个|三个|多个)(?:城市|地点|项目|任务|订单|用户|文件)|cada uno|para cada|simult[aá]neamente/i.test(
text
);
if (explicitRepeat) return true;
const sentenceClauses = text.split(/[.!?。!?]+/).map((part) => part.trim()).filter((part) => part.length >= 8);
if (/\b(?:also|additionally|furthermore)\b|另外|此外|그리고/i.test(text) && sentenceClauses.length >= 2 || /\band\s+(?:also|for the)\b/i.test(text))
return true;
const pairedQuantity = /\b\d+(?:\.\d+)?\s+(?:and|or)\s+\d+(?:\.\d+)?\s*(?:gb|mb|tb|kg|g|ml|oz|cups?|cores?|cpus?)\b/i.test(
text
);
if (pairedQuantity) return true;
const operationTokens = /* @__PURE__ */ new Set([
"add",
"delete",
"remove",
"cancel",
"return",
"exchange",
"modify",
"book",
"transfer",
"send",
"upload",
"download",
"create",
"close"
]);
const lowered = text.toLowerCase();
const matchedOperationTokens = new Set(
(toolNames ?? []).flatMap((name) => name.toLowerCase().split(/[^a-z0-9\u3400-\u9fff]+/)).filter((token) => operationTokens.has(token) && lowered.includes(token))
);
if (matchedOperationTokens.size >= 2) return true;
const nonEmptyLines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const quantityMentions = text.match(
/\b(?:\d+(?:\.\d+)?|one|two|three|four|five|six|seven|eight|nine|ten)\s*(?:oz|ounce|ounces|g|gram|grams|kg|ml|cups?|pieces?|tablespoons?)\b/gi
) ?? [];
if (nonEmptyLines.length >= 2 && quantityMentions.length >= 2) return true;
const repeatedLookup = /\b(?:weather|climate|clima|tiempo|temperature|snow|news|report)\b|天气|气象|温度|降雪|新闻|报告/i.test(
text
);
const multiLocationConnector = /\b(?:and also|both|y|e)\b|还有|以及|和|、/i.test(text);
const commaSeparatedLocations = (text.match(/[,,]/g) ?? []).length >= 2;
if (repeatedLookup && (multiLocationConnector || commaSeparatedLocations)) return true;
const distinctOrderParts = /\b(?:food|meal)\b[\s\S]*\bdrink\b|\bdrink\b[\s\S]*\b(?:food|meal)\b/i.test(text);
const koreanParallelClauses = (text.match(/,/g) ?? []).length >= 3 && /하고|그리고/.test(text);
return distinctOrderParts || koreanParallelClauses;
}
function classifyTask(prompt, systemPrompt, options) {
const fullText = `${systemPrompt ?? ""} ${prompt}`;
const estimatedInputTokens = estimateTokens(fullText);
const limit = scanLimit(options.config.classifier.promptTruncationChars);
const scannedPrompt = sampleForScan(prompt, limit);
const scannedSystemPrompt = sampleForScan(systemPrompt ?? "", limit);
const scannedFullText = `${scannedSystemPrompt} ${scannedPrompt}`;
const text = scannedPrompt.toLowerCase();
const explicitCodeSignal = /```|\b(?:typescript|javascript|python|rust|java|sql|stack trace|traceback|exception)\b|\.(?:ts|tsx|js|py|go|rs)\b/i.test(
scannedPrompt
);
const codeConstructSignal = /\b(?:implement|refactor|debug|write|edit|modify|create|define|review|fix)\b[\s\S]{0,48}\b(?:api|function|class|method)\b|\b(?:api|function|class|method)\b[\s\S]{0,48}\b(?:code|implementation|typescript|javascript|python|rust|java)\b/i.test(
scannedPrompt
);
const nativeCodeSignal = /\b(?:programmed|written|implemented?|code)\s+(?:in|using)\s+(?:c\+\+|c|rust|go)\b/i.test(
scannedPrompt
);
const errorClassSignal = /\b[A-Z][A-Za-z]*(?:Error|Exception)\b/.test(scannedPrompt);
const hasCode = explicitCodeSignal || codeConstructSignal || nativeCodeSignal || errorClassSignal;
const codeArtifact = /```|\b(?:stack trace|traceback)\b|\.(?:ts|tsx|js|py|go|rs)\b/i.test(scannedPrompt) || codeConstructSignal || nativeCodeSignal || errorClassSignal;
const toolsAvailable = options.hasTools ?? false;
const needsTools = options.requiresTools ?? (toolsAvailable && inferToolRequirement(scannedPrompt, scannedSystemPrompt));
const likelyParallelToolCalls = likelyNeedsParallelToolCalls(
scannedPrompt,
needsTools,
options.toolCount,
options.toolNames
);
const normalizedToolNames = (options.toolNames ?? []).map((name) => name.toLowerCase());
const airlineToolSignal = normalizedToolNames.some(
(name) => /(?:flight|reservation|airport|baggage|passenger)/.test(name)
);
const retailToolSignal = normalizedToolNames.some(
(name) => /(?:order|product|item|return|exchange|address)/.test(name)
);
const webResearchToolSignal = normalizedToolNames.some(
(name) => /^(?:web_?search|web_?fetch)$/.test(name)
);
const agentDomain = airlineToolSignal && !retailToolSignal ? "airline" : retailToolSignal && !airlineToolSignal ? "retail" : webResearchToolSignal ? "web_research" : "other";
const clueConnectors = scannedFullText.match(
/\b(?:after|before|while|where|whose|which|in \d{4}|as of|over \d+|another|also|furthermore)\b|(?:之后|之前|其中|截至|超过|另一个|此外)/gi
) ?? [];
const entityResolutionSignal = /\b(?:identify|who (?:is|was)|what (?:is|was) the name|which (?:person|player|company|country|city)|find the (?:person|player|name|entity))\b|(?:找出|识别|是谁|哪位|名称是什么)/i.test(
scannedFullText
);
const exactAnswerSignal = /\b(?:exact answer|single best-supported answer|following clues|multiple public sources)\b|(?:精确答案|根据.*线索|多个公开来源)/i.test(
scannedFullText
);
const deepWebResearch = agentDomain === "web_research" && (exactAnswerSignal || entityResolutionSignal && (clueConnectors.length >= 3 || prompt.length >= 320));
const globalOptimizationSignal = /\b(?:cheapest|lowest[- ]price|least expensive|most expensive|highest(?:[- ]priced)?|largest|smallest|maximum|minimum|best available|closest|not (?:cost|exceed))\b|最便宜|最低价|最贵|最高价|最大|最小/i.test(
scannedPrompt
);
const globalScopeSignal = /\b(?:everything|all (?:(?:my|your|their|the) )?(?:future |upcoming )?(?:items|orders|passengers|flights|reservations|bookings)|every (?:item|order|passenger|flight|reservation|booking))\b|全部|所有|每个/i.test(
scannedPrompt
);
const globalChoiceSignal = globalOptimizationSignal || globalScopeSignal;
const crossRecordSignal = /\b(?:another|other|different|previous)\s+(?:order|reservation|booking|account|address)\b|另一(?:个)?(?:订单|预订|账户|地址)|其他(?:订单|预订|账户|地址)/i.test(
scannedPrompt
);
const reservationIds = scannedPrompt.match(/\b[A-Z0-9]{6}\b/g) ?? [];
const crossReservationBatchSignal = agentDomain === "airline" && (/\b(?:two|three|multiple|several)(?:\s+of\s+(?:my|our|the))?\s+(?:upcoming\s+)?(?:reservations?|bookings?)\b|\b(?:a\s+)?(?:second|third)\s+(?:reservation|booking)\b/i.test(
scannedPrompt
) || new Set(reservationIds).size >= 2);
const conditionalGlobalWorkflowSignal = agentDomain === "airline" && globalScopeSignal && /\b(?:if|that (?:contain|have)|longer than|shorter than|under|over|at (?:most|least)|wherever possible)\b|如果|超过|少于|不超过|尽可能/i.test(
scannedPrompt
) && /\b(?:cancel|change|upgrade|move|book)\b[\s\S]*\b(?:cancel|change|upgrade|move|book)\b|取消[\s\S]*(?:升级|更改)|升级[\s\S]*(?:取消|更改)/i.test(
scannedPrompt
);
const policyExceptionSignal = agentDomain === "retail" && /\b(?:return|refund|send back|get (?:my |the )?money back)\b|退货|退款|退回/i.test(
scannedPrompt
) && /\b(?:amex|american express|visa|mastercard|credit card|debit card|different card|another card|other card)\b|信用卡|借记卡|其他卡|另一张卡/i.test(
scannedPrompt
);
const singleSelectedPolicyException = policyExceptionSignal && /\b(?:return|refund|send back)\b[^.!?。!?]{0,96}\b(?:the )?(?:pricier|cheaper|more expensive|less expensive|costlier|one)\b/i.test(
scannedPrompt
);
const negotiatedWorkflowSignal = agentDomain === "retail" && /\b(?:return|exchange)\b|退货|退回|换货|交换/i.test(scannedPrompt);
const numberedSteps = (scannedPrompt.match(/(?:^|\s)\d+(?:\.\d+)*[.)]\s+/g) ?? []).length;
const complexMultiToolPlan = likelyParallelToolCalls && ((options.toolCount ?? 0) >= 6 || numberedSteps >= 3 || prompt.length > 1200);
let agentRisk = needsTools && singleSelectedPolicyException ? "policy_exception_simple" : needsTools && policyExceptionSignal ? "policy_exception" : (
// Airline prompts that require a global optimum (for example the
// cheapest itinerary across several candidates) are materially harder
// than applying one change to every passenger in a known reservation.
// Full-session evidence supports Sonnet for the former, while upgrading
// the latter merely because it says "all passengers" caused a large cost
// increase without a quality gain.
needsTools && agentDomain === "airline" && (globalOptimizationSignal || conditionalGlobalWorkflowSignal) ? "complex_high" : needsTools && (likelyParallelToolCalls || globalChoiceSignal || crossRecordSignal || crossReservationBatchSignal || negotiatedWorkflowSignal) ? "high" : "standard"
);
const needsVision = options.hasVision ?? false;
const needsStructuredOutput = options.requiresStructuredOutput ?? false;
const latencySensitive = /\b(?:urgent|asap|fast|quick|low latency|real[- ]time)\b|尽快|马上|快速|低延迟/i.test(
scannedPrompt
);
const highStakes = /\b(?:production|security|payment|legal|medical|financial|audit)\b|生产|安全|支付|法律|医疗|财务|审计/i.test(
scannedPrompt
);
const terminalToolSignal = normalizedToolNames.some(
(name) => /^(?:terminalexec|terminalinspect|terminalsendkeys)$/.test(name)
);
const simpleTerminalArtifact = /\b(?:create|write|convert|generate|build|implement|run|fix|repair|debug|make)\b[\s\S]{0,120}\b(?:file|script|csv|parquet|json|txt|server|endpoint)\b/i.test(
scannedPrompt
);
const terminalComplexRepair = terminalToolSignal && /\b(?:multiple|several)\s+(?:scripts?|files?|components?)\b|\b(?:pipeline|dependencies)\b[\s\S]{0,100}\b(?:fail|issue|fix|repair|run|execute)\b|\b(?:identify|find|fix|repair)\s+(?:and\s+)?(?:fix\s+)?all\s+(?:the\s+)?issues\b/i.test(
scannedPrompt
);
const mentionedTerminalRuntimes = new Set(
(scannedPrompt.match(/\b(?:gcc|clang|rustc|javac|go\s+build|node|python)\b/gi) ?? []).map(
(name) => name.toLowerCase().replace(/\s+/g, " ")
)
);
const terminalCrossRuntimeArtifact = terminalToolSignal && (/\bpolyglot\b/i.test(scannedPrompt) || /\b(?:both|each)\b[\s\S]{0,120}\b(?:compilers?|runtimes?|toolchains?)\b/i.test(
scannedPrompt
) || mentionedTerminalRuntimes.size >= 2 && /\b(?:compile|build|run|execute)\b/i.test(scannedPrompt));
const terminalFrameworkToNativeArtifact = terminalToolSignal && /\b(?:pytorch|tensorflow|jax|onnx|state[_ -]?dict|checkpoint|safetensors?)\b|\.(?:pth|pt|onnx)\b/i.test(
scannedPrompt
) && /\b(?:pure|native|programmed|written|implemented?)\s+(?:in|using)\s+(?:c\+\+|c|rust|go)\b|\b(?:c\+\+|c|rust|go)\s+(?:program|binary|executable|cli|tool|implementation)\b/i.test(
scannedPrompt
) && /\b(?:inference|model|weights?|tensor|export|convert|load)\b/i.test(scannedPrompt);
if (needsTools && (terminalComplexRepair || terminalCrossRuntimeArtifact || terminalFrameworkToNativeArtifact) && (agentRisk === "standard" || agentRisk === "high"))
agentRisk = "complex_high";
const complexTerminalOperation = /\b(?:git|ssh|nginx|https|certificate|authentication|credential|deploy|production|encrypt|gpg|shred|securely delete|decommission|benchmark|evaluate|embedding|chess|image|search the web|schema|statistical|statistics|aggregate|join|multiple inputs?)\b/i.test(
scannedPrompt
);
const terminalCredentialSignal = /\b(?:ssh|nginx|certificate|authentication|credentials?|passwords?|api keys?|deploy|production|encrypt|gpg|shred|securely delete|decommission)\b/i.test(
scannedPrompt
) || /\b(?:access|auth|authentication|bearer|secret|api)\s+tokens?\b|\btokens?\s+(?:secret|credential|authentication)\b/i.test(
scannedPrompt
);
const terminalSafetySensitive = terminalToolSignal && (highStakes || terminalCredentialSignal);
const implicitTerminalCode = needsTools && terminalToolSignal && agentRisk === "standard" && !highStakes && !complexTerminalOperation && numberedSteps < 3 && prompt.length <= 1e3 && simpleTerminalArtifact;
const language = /[\u3400-\u9fff]/.test(scannedPrompt) ? "zh" : "other";
const multipleChoiceSignals = (scannedPrompt.match(/(?:^|\n)\s*[A-D][.)]\s+/gim) ?? []).length;
const numericSignals = (scannedPrompt.match(/-?\d+(?:[.,]\d+)?/g) ?? []).length;
const compactMathProblem = !hasCode && prompt.length < 2500 && numericSignals >= 2 && (/[+×÷=%$€£¥]|\b(?:total|each|per|times|half|twice|percent|how many|how much|calculate)\b/i.test(
scannedPrompt
) || /[??]\s*$/.test(scannedPrompt.trim()) || numericSignals >= 3);
let taskType = "chat";
if (needsVision) taskType = "vision";
else if (estimatedInputTokens > 8e4) taskType = "long_context";
else if (needsTools && (hasCode || implicitTerminalCode)) taskType = "code_agent";
else if (needsTools && likelyParallelToolCalls && !complexMultiToolPlan)
taskType = "tool_agent_parallel";
else if (needsTools) taskType = "tool_agent";
else if (multipleChoiceSignals >= 3) taskType = "reasoning_mcq";
else if (compactMathProblem) taskType = "reasoning_math";
else if (errorClassSignal || /\b(?:bug|debug|error|failure|failing|regression|crash|修复|报错|错误|调试)\b/i.test(text))
taskType = "debug";
else if (hasCode || /\b(?:refactor|implement|patch|edit|rewrite|重构|实现|修改)\b/i.test(text))
taskType = "code_edit";
else if (needsStructuredOutput || /\b(?:extract|json|schema|csv|字段|提取)\b/i.test(text))
taskType = "extraction";
else if (/\b(?:prove|derive|theorem|formal|mathematical|reasoning|证明|推导|定理|数学)\b/i.test(text))
taskType = "reasoning";
return {
taskType,
estimatedInputTokens,
hasCode,
needsTools,
toolsAvailable,
needsVision,
needsStructuredOutput,
latencySensitive,
highStakes,
language,
likelyParallelToolCalls,
complexMultiToolPlan,
agentDomain,
deepWebResearch,
agentRisk,
terminalToolSignal,
terminalSafetySensitive,
implicitTerminalCode,
codeArtifact
};
}
function affinity(modelId, task, language = "other", agentDomain = "other", deepWebResearch = false, agentRisk = "standard", terminalToolSignal = false, terminalSafetySensitive = false) {
const id = modelId.toLowerCase();
const modelName = id.slice(id.indexOf("/") + 1);
const match = (values, score) => values.some((value) => modelName === value) ? score : 0;
const base = 0.68;
switch (task) {
case "code_agent":
if (terminalToolSignal && agentRisk === "complex_high") {
return Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5.3-codex"], 0.87),
match(["gpt-5-mini"], 0.78),
match(["gemini-3.5-flash"], 0.76)
);
}
return Math.max(
base,
match(["gpt-5.3-codex"], 1),
match(["claude-sonnet-5"], 0.98),
match(["gpt-5-mini"], 0.96),
match(["gemini-3.5-flash"], 0.92),
match(["kimi-k3"], 0.9),
match(["deepseek-v4-pro", "glm-5.2"], 0.88)
);
case "tool_agent":
if (terminalToolSignal && agentRisk === "complex_high") {
return Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5.3-codex"], 0.87),
match(["gpt-5-mini"], 0.78),
match(["gemini-3.5-flash"], 0.76)
);
}
if (terminalToolSignal && !terminalSafetySensitive) {
return Math.max(
base,
match(["gpt-5-mini"], 1),
match(["gpt-5.3-codex"], 0.98),
match(["claude-sonnet-5"], 0.9),
match(["gemini-3.5-flash"], 0.89)
);
}
if (terminalToolSignal && terminalSafetySensitive) {
return Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["claude-opus-4.8"], 0.9),
match(["gpt-5.3-codex"], 0.84)
);
}
if (agentDomain === "web_research") {
return deepWebResearch ? Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.88),
match(["gemini-3.5-flash"], 0.84),
match(["claude-opus-5"], 0.8),
match(["claude-opus-4.8"], 0.78)
) : Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.88),
match(["gemini-3.5-flash"], 0.86),
match(["claude-opus-5"], 0.84),
match(["claude-opus-4.8"], 0.82)
);
}
if (agentDomain === "retail") {
if (agentRisk === "standard") {
return Math.max(
base,
match(["gpt-5-mini"], 1),
match(["claude-sonnet-5"], 0.88),
match(["gemini-3.5-flash"], 0.82),
match(["gpt-5.3-codex"], 0.81),
match(["kimi-k3"], 0.78),
match(["deepseek-v4-pro"], 0.76)
);
}
if (agentRisk === "policy_exception") {
return Math.max(
base,
match(["gpt-4.1"], 1),
match(["claude-sonnet-5"], 0.9),
match(["deepseek-v4-pro"], 0.82),
match(["gpt-5-mini"], 0.8),
match(["gpt-4o-mini"], 0.76)
);
}
if (agentRisk === "policy_exception_simple") {
return Math.max(
base,
match(["gpt-5-mini"], 1),
match(["gpt-4.1"], 0.86),
match(["deepseek-v4-pro"], 0.82),
match(["gpt-4o-mini"], 0.8)
);
}
return Math.max(
base,
match(["deepseek-v4-pro"], 1),
match(["claude-sonnet-5"], 0.88),
match(["gemini-3.5-flash"], 0.82),
match(["gpt-5.3-codex"], 0.81),
match(["kimi-k3"], 0.78),
match(["gpt-5-mini"], 0.76)
);
}
if (agentDomain === "airline") {
if (agentRisk === "complex_high") {
return Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.78),
match(["gemini-3.5-flash"], 0.76),
match(["deepseek-v4-pro"], 0.74)
);
}
return Math.max(
base,
match(["gpt-5-mini"], 1),
match(["claude-sonnet-5"], 0.9),
match(["gemini-3.5-flash"], 0.8),
match(["deepseek-v4-pro"], 0.76)
);
}
return Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gemini-3.5-flash"], 0.88),
match(["gpt-5.3-codex"], 0.87),
match(["gpt-5-mini"], 0.84),
match(["kimi-k3"], 0.85),
match(["deepseek-v4-pro"], 0.82)
);
case "tool_agent_parallel":
if (terminalToolSignal) {
return terminalSafetySensitive ? Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["claude-opus-4.8"], 0.9),
match(["gpt-5.3-codex"], 0.86)
) : Math.max(
base,
match(["gpt-5-mini"], 1),
match(["gpt-5.3-codex"], 0.98),
match(["claude-sonnet-5"], 0.92),
match(["gemini-3.5-flash"], 0.88)
);
}
if (agentDomain === "web_research") {
return deepWebResearch ? Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.88),
match(["gemini-3.5-flash"], 0.84),
match(["claude-opus-5"], 0.8),
match(["claude-opus-4.8"], 0.78)
) : Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.88),
match(["gemini-3.5-flash"], 0.86),
match(["claude-opus-5"], 0.84),
match(["claude-opus-4.8"], 0.82)
);
}
if (agentDomain === "retail") {
if (agentRisk === "policy_exception") {
return Math.max(
base,
match(["gpt-4.1"], 1),
match(["claude-sonnet-5"], 0.9),
match(["deepseek-v4-pro"], 0.82),
match(["gpt-5-mini"], 0.8),
match(["gpt-4o-mini"], 0.76)
);
}
if (agentRisk === "policy_exception_simple") {
return Math.max(
base,
match(["gpt-5-mini"], 1),
match(["gpt-4.1"], 0.86),
match(["deepseek-v4-pro"], 0.82),
match(["gpt-4o-mini"], 0.8)
);
}
return Math.max(
base,
match(["deepseek-v4-pro"], 1),
match(["claude-sonnet-5"], 0.88),
match(["claude-opus-4.8"], 0.84),
match(["gpt-5-mini"], 0.78),
match(["gemini-3.5-flash"], 0.76)
);
}
if (agentDomain === "airline") {
return agentRisk === "complex_high" ? Math.max(
base,
match(["claude-sonnet-5"], 1),
match(["gpt-5-mini"], 0.78),
match(["claude-opus-4.8"], 0.76),
match(["gemini-3.5-flash"], 0.74)
) : Math.max(
base,
match(["gpt-5-mini"], 1),
match(["claude-sonnet-5"], 0.9),
match(["gemini-3.5-flash"], 0.8)
);
}
return Math.max(
base,
match(["claude-opus-4.8"], 1),
match(["claude-sonnet-5"], 0.84),
match(["grok-4.5"], 0.82),
match(["gemini-3.5-flash"], 0.8),
match(["deepseek-v4-pro"], 0.78)
);
case "code_edit":
case "debug":
return Math.max(
base,
match(["gpt-5.3-codex"], 1),
match(["claude-sonnet-4.6"], 0.94),
match(["glm-5.2"], 0.9),
match(["deepseek-v4-pro"], 0.86)
);
case "reasoning":
return Math.max(
base,
match(["claude-sonnet-5", "claude-sonnet-4.6"], 0.98),
match(["deepseek-v4-pro"], 0.95),
match(["grok-4.5"], 0.94),
match(["gemini-3.1-pro", "gemini-3.5-flash"], 0.92)
);
case "reasoning_mcq":
return Math.max(
base,
match(["gemini-3-flash-preview"], 1),
match(["gemini-3.5-flash"], 0.91),
match(["grok-4.5"], 0.9),
match(["claude-sonnet-5"], 0.88),
match(["deepseek-v4-pro"], 0.84)
);
case "reasoning_math":
return Math.max(
base,
match(["gemini-3.5-flash"], 1),
match(["grok-4.5"], 0.93),
match(["claude-sonnet-5", "deepseek-v4-pro", "kimi-k3"], 0.9)
);
case "vision":
return Math.max(
base,
match(["gemini-3.1-pro"], 0.96),
match(["qwen3.7-max", "claude-sonnet-4.6", "kimi-k3", "grok-4.3"], 0.9)
);
case "long_context":
return Math.max(
base,
match(["gemini-3.1-pro"], 1),
match(["qwen3.7-max", "glm-5.2"], 0.89),
match(["gemini-3.5-flash"], 0.88),
match(["deepseek-v4-pro"], 0.85)
);
case "extraction": {
const kimiExtractionAffinity = language === "zh" ? 1 : 0.9;
const otherExtractionAffinity = language === "zh" ? 0.88 : 0.9;
return Math.max(
base,
match(["gemini-3.5-flash", "gemini-2.5-flash", "gpt-4o-mini"], otherExtractionAffinity),
match(["claude-sonnet-5", "claude-sonnet-4.6"], otherExtractionAffinity),
match(["kimi-k3"], kimiExtractionAffinity)
);
}
default:
return Math.max(
base,
match(["gemini-3.5-flash", "gemini-2.5-flash", "kimi-k3"], 0.86)
);
}
}
function evidenceCandidates(task) {
if (task === "code_agent") {
return [
"openai/gpt-5.3-codex",
"anthropic/claude-sonnet-5",
"openai/gpt-5-mini",
"google/gemini-3.5-flash",
"moonshot/kimi-k3",
"deepseek/deepseek-v4-pro"
];
}
if (task === "tool_agent") {
return [
"anthropic/claude-sonnet-5",
"anthropic/claude-opus-5",
"openai/gpt-5-mini",
"openai/gpt-4.1",
"openai/gpt-4o-mini",
"google/gemini-3.5-flash",
"openai/gpt-5.3-codex",
"moonshot/kimi-k3",
"deepseek/deepseek-v4-pro"
];
}
if (task === "tool_agent_parallel") {
return [
"anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-5",
"openai/gpt-5-mini",
"openai/gpt-4.1",
"openai/gpt-4o-mini",
"xai/grok-4.5",
"google/gemini-3.5-flash",
"deepseek/deepseek-v4-pro"
];
}
if (task === "long_context") {
return [
"google/gemini-3.1-pro",
"deepseek/deepseek-v4-pro",
"qwen/qwen3.7-max",
"zai/glm-5.2",
"google/gemini-3.5-flash"
];
}
if (task === "reasoning_mcq") {
return [
"google/gemini-3-flash-preview",
"google/gemini-3.5-flash",
"xai/grok-4.5",
"anthropic/claude-sonnet-5",
"deepseek/deepseek-v4-pro"
];
}
if (task === "extraction") {
return ["moonshot/kimi-k3", "google/gemini-3.5-flash", "anthropic/claude-sonnet-5"];
}
if (task === "reasoning_math") {
return [
"google/gemini-3.5-flash",
"xai/grok-4.5",
"anthropic/claude-sonnet-5",
"deepseek/deepseek-v4-pro",
"moonshot/kimi-k3"
];
}
return [];
}
var VERIFIED_TOOL_FALLBACKS = [
"anthropic/claude-fable-5.1",
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"deepseek/deepseek-v4.1-flash",
"google/gemini-3.8-flash",
"openai/gpt-5.1",
"openai/gpt-6-astra",
"openai/gpt-6-luna",
"openai/gpt-6-sol",
"openai/gpt-6.1-sol",
"xai/grok-4.6",
"xai/grok-4.7",
"xiaomi/mimo-v2.5"
];
function isEligible(modelId, features, maxOutputTokens, options) {
const model = options.modelCapabilities?.[modelId] ?? DEFAULT_MODEL_CAPABILITIES[modelId];
if (!model) return true;
if (features.needsTools && !model.supportsTools) return false;
if (features.needsVision && !model.supportsVision) return false;
if (features.needsStructuredOutput && !model.supportsTools) return false;
if (model.maxOutputTokens < maxOutputTokens) return false;
return model.contextWindow >= (features.estimatedInputTokens + maxOutputTokens) * 1.1;
}
function estimatedCost(modelId, options, inputTokens, outputTokens) {
const price2 = options.modelPricing.get(modelId);
if (!price2) return Number.POSITIVE_INFINITY;
if (price2.flatPrice !== void 0) return price2.flatPrice;
return (inputTokens * price2.inputPrice + outputTokens * price2.outputPrice) / 1e6;
}
function profileScore(modelId, options, now) {
const profile = options.modelPerformance?.[modelId] ?? LIVE_MODEL_PROFILES[modelId] ?? HISTORICAL_MODEL_PROFILES[modelId];
if (!profile) return void 0;
const measuredAt = Date.parse(profile.measuredAt);
if (!Number.isFinite(measuredAt)) return void 0;
const ageDays = Math.max(0, (now.getTime() - measuredAt) / 864e5);
const sampleConfidence = profile.samples === void 0 ? 1 : Math.min(1, Math.max(0, profile.samples) / 10);
const freshness = Math.pow(0.5, ageDays / 30) * sampleConfidence;
const quality = profile.intelligenceIndex === void 0 ? void 0 : Math.min(1, profile.intelligenceIndex / 50);
const speed = Math.min(
1,
(2e3 / Math.max(500, profile.latencyMs) + profile.outputTokensPerSecond / 250) / 2
);
const tailSpeed = Math.min(1, 3e3 / Math.max(750, profile.p95LatencyMs ?? profile.latencyMs));
const reliability = Math.max(0, 1 - (profile.errorRate ?? 0));
return { quality, speed, tailSpeed, reliability, freshness };
}
var PortfolioStrategy = class {
name = "portfolio";
// Rules set the capability tier; the strategy is stateless, so one instance serves every request.
rules = new RulesStrategy();
route(prompt, systemPrompt, maxOutputTokens, options) {
const features = classifyTask(prompt, systemPrompt, options);
const base = this.rules.route(prompt, systemPrompt, maxOutputTokens, {
...options,
requiresTools: features.needsTools
});
const tierConfigs = base.tierConfigs;
if (!tierConfigs) return base;
const targetTier = (features.taskType === "reasoning_mcq" || features.taskType === "reasoning_math") && (base.tier === "SIMPLE" || base.tier === "MEDIUM") ? "REASONING" : (features.taskType === "debug" || features.taskType === "code_edit") && features.codeArtifact && base.tier === "SIMPLE" ? "MEDIUM" : base.tier;
const tierConfig = tierConfigs[targetTier];
const configuredCandidates = tierConfig ? getFallbackChain(targetTier, tierConfigs) : [];
const unavailable = new Set(options.unavailableModels ?? []);
const verifiedToolFallbacks = features.needsTools ? VERIFIED_TOOL_FALLBACKS.filter((model2) => options.modelPricing.has(model2)) : [];
if (options.routingProfile !== "premium") {
const fallbackCost = (model2) => estimatedCost(model2, options, features.estimatedInputTokens, maxOutputTokens);
verifiedToolFallbacks.sort((a, b) => fallbackCost(a) - fallbackCost(b));
}
const chain = [
.../* @__PURE__ */ new Set([
...configuredCandidates,
...evidenceCandidates(features.taskType),
...verifiedToolFallbacks
])
].filter(
(model2) => typeof model2 === "string" && model2.length > 0 && !unavailable.has(model2)
);
const eligible = new Set(
chain.filter((model2) => isEligible(model2, features, maxOutputTokens, options))
);
const eligibleCandidates = eligible.size > 0 ? [...eligible] : chain;
if (eligibleCandidates.length === 0) return base;
const profileName = options.routingProfile === "eco" ? "eco" : options.routingProfile === "premium" ? "premium" : "auto";
const portfolio = options.config.portfolio ?? DEFAULT_PORTFOLIO_WEIGHTS;
const getAffinity = (model2) => affinity(
model2,
features.taskType,
features.language,
features.agentDomain,
features.deepWebResearch,
features.agentRisk,
features.terminalToolSignal,
features.terminalSafetySensitive
);
const bestAffinity = Math.max(...eligibleCandidates.map(getAffinity));
const specificAffinity = eligibleCandidates.filter((model2) => getAffinity(model2) > 0.68);
const affinityPool = specificAffinity.length > 0 ? specificAffinity : [eligibleCandidates[0]];
const affinityFloorGap = features.terminalToolSignal ? Math.max(
portfolio.affinityFloorGap[profileName],
features.terminalSafetySensitive ? 0.15 : 0.12
) : portfolio.affinityFloorGap[profileName];
const candidates = affinityPool.filter(
(model2) => getAffinity(model2) >= bestAffinity - affinityFloorGap
);
const costs = candidates.map(
(model2) => estimatedCost(model2, options, features.estimatedInputTokens, maxOutputTokens)
);
const finiteCosts = costs.filter(Number.isFinite);
const minCost = finiteCosts.length > 0 ? Math.min(...finiteCosts) : 0;
const maxCost = finiteCosts.length > 0 ? Math.max(...finiteCosts) : 1;
const now = options.now ?? /* @__PURE__ */ new Date();
const profileWeights = portfolio[profileName];
const rankedEntries = candidates.map((model2, index) => {
const cost = estimatedCost(model2, options, features.estimatedInputTokens, maxOutputTokens);
const costScore = Number.isFinite(cost) && maxCost > minCost ? 1 - (cost - minCost) / (maxCost - minCost) : 0.5;
const capabilityScore = eligible.has(model2) ? 1 : 0;
const profile = profileScore(model2, options, now);
const observedQuality = profile?.quality === void 0 ? getAffinity(model2) : getAffinity(model2) * (1 - profile.freshness) + profile.quality * profile.freshness;
const observedSpeed = profile ? profile.speed * profile.freshness + 0.5 * (1 - profile.freshness) : 0.5;
const observedTailSpeed = profile ? profile.tailSpeed * profile.freshness + 0.5 * (1 - profile.freshness) : 0.5;
const observedReliability = profile ? profile.reliability * profile.freshness + (1 - profile.freshness) : 1;
const legacyScore = 1 - index / Math.max(1, candidates.length - 1);
const qualityWeight = profileWeights.quality + (features.highStakes ? portfolio.highStakesBoost.quality : 0);
const speedScore = features.latencySensitive ? observedTailSpeed : observedSpeed;
const speedWeight = profileWeights.speed + (features.latencySensitive ? portfolio.latencySensitiveSpeedBoost : 0);
const reliabilityWeight = profileWeights.reliability + (features.highStakes ? portfolio.highStakesBoost.reliability : 0);
const score = observedQuality * qualityWeight + capabilityScore * profileWeights.capability + costScore * profileWeights.cost + speedScore * speedWeight + observedReliability * reliabilityWeight + legacyScore * profileWeights.legacy;
return {
model: model2,
score,
quality: observedQuality,
cost: costScore,
speed: speedScore,
reliability: observedReliability
};
}).sort((a, b) => b.score - a.score);
const scoredModels = rankedEntries.map((item) => item.model);
const webResearchFallbackOrder = [
"anthropic/claude-sonnet-5",
"openai/gpt-5-mini",
"google/gemini-3.5-flash",
"anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
"openai/gpt-5.3-codex"
];
const ranked = features.agentDomain === "web_research" ? [
...scoredModels,
...webResearchFallbackOrder.filter(
(model2) => eligibleCandidates.includes(model2) && !scoredModels.includes(model2)
),
...eligibleCandidates.filter(
(model2) => !scoredModels.includes(model2) && !webResearchFallbackOrder.includes(model2)
)
] : [...scoredModels, ...eligibleCandidates.filter((model2) => !scoredModels.includes(model2))];
const model = ranked[0] ?? base.model;
const selectedTierConfigs = {
...tierConfigs,
[targetTier]: { primary: model, fallback: ranked.slice(1) }
};
const decision = selectModel(
targetTier,
base.confidence,
"portfolio",
`${base.reasoning} | v3 task=${features.taskType} agentRisk=${features.agentRisk} deepWebResearch=${features.deepWebResearch} terminalCode=${features.implicitTerminalCode} terminalSafety=${features.terminalSafetySensitive} candidates=${ranked.length}`,
selectedTierConfigs,
options.modelPricing,
features.estimatedInputTokens,
maxOutputTokens,
options.routingProfile,
base.agenticScore
);
return {
...decision,
tierConfigs: selectedTierConfigs,
profile: base.profile,
candidates: ranked,
candidateScores: rankedEntries.map(({ model: model2, score, quality, cost, speed, reliability }) => ({
model: model2,
score,
quality,
cost,
speed,
reliability
})),
taskType: features.taskType,
routerVersion: "v3-portfolio"
};
}
};
var DEFAULT_ROUTING_CONFIG = {
version: "3.5",
strategy: "portfolio",
portfolio: {
auto: {
quality: 0.47,
capability: 0.2,
cost: 0.18,
speed: 0.07,
reliability: 0.03,
legacy: 0.05
},
eco: {
quality: 0.36,
capability: 0.2,
cost: 0.28,
speed: 0.1,
reliability: 0.04,
legacy: 0.02
},
premium: {
quality: 0.58,
capability: 0.2,
cost: 0.08,
speed: 0.06,
reliability: 0.06,
legacy: 0.02
},
highStakesBoost: { quality: 0.08, reliability: 0.05 },
latencySensitiveSpeedBoost: 0.08,
affinityFloorGap: { auto: 0.1, eco: 0.22, premium: 0.05 }
},
classifier: {
llmModel: "google/gemini-2.5-flash",
llmMaxTokens: 10,
llmTemperature: 0,
promptTruncationChars: 500,
cacheTtlMs: 36e5
// 1 hour
},
scoring: {
tokenCountThresholds: { simple: 50, complex: 500 },
// Multilingual keywords: EN + ZH + JA + RU + DE + ES + PT + KO + AR
codeKeywords: [
// English
"function",
"class",
"import",
"def",
"SELECT",
"async",
"await",
"const",
"let",
"var",
"return",
"```",
// Chinese
"\u51FD\u6570",
"\u7C7B",
"\u5BFC\u5165",
"\u5B9A\u4E49",
"\u67E5\u8BE2",
"\u5F02\u6B65",
"\u7B49\u5F85",
"\u5E38\u91CF",
"\u53D8\u91CF",
"\u8FD4\u56DE",
// Japanese
"\u95A2\u6570",
"\u30AF\u30E9\u30B9",
"\u30A4\u30F3\u30DD\u30FC\u30C8",
"\u975E\u540C\u671F",
"\u5B9A\u6570",
"\u5909\u6570",
// Russian
"\u0444\u0443\u043D\u043A\u0446\u0438\u044F",
"\u043A\u043B\u0430\u0441\u0441",
"\u0438\u043C\u043F\u043E\u0440\u0442",
"\u043E\u043F\u0440\u0435\u0434\u0435\u043B",
"\u0437\u0430\u043F\u0440\u043E\u0441",
"\u0430\u0441\u0438\u043D\u0445\u0440\u043E\u043D\u043D\u044B\u0439",
"\u043E\u0436\u0438\u0434\u0430\u0442\u044C",
"\u043A\u043E\u043D\u0441\u0442\u0430\u043D\u0442\u0430",
"\u043F\u0435\u0440\u0435\u043C\u0435\u043D\u043D\u0430\u044F",
"\u0432\u0435\u0440\u043D\u0443\u0442\u044C",
// German
"funktion",
"klasse",
"importieren",
"definieren",
"abfrage",
"asynchron",
"erwarten",
"konstante",
"variable",
"zur\xFCckgeben",
// Spanish
"funci\xF3n",
"clase",
"importar",
"definir",
"consulta",
"as\xEDncrono",
"esperar",
"constante",
"variable",
"retornar",
// Portuguese
"fun\xE7\xE3o",
"classe",
"importar",
"definir",
"consulta",
"ass\xEDncrono",
"aguardar",
"constante",
"vari\xE1vel",
"retornar",
// Korean
"\uD568\uC218",
"\uD074\uB798\uC2A4",
"\uAC00\uC838\uC624\uAE30",
"\uC815\uC758",
"\uCFFC\uB9AC",
"\uBE44\uB3D9\uAE30",
"\uB300\uAE30",
"\uC0C1\uC218",
"\uBCC0\uC218",
"\uBC18\uD658",
// Arabic
"\u062F\u0627\u0644\u0629",
"\u0641\u0626\u0629",
"\u0627\u0633\u062A\u064A\u0631\u0627\u062F",
"\u062A\u0639\u0631\u064A\u0641",
"\u0627\u0633\u062A\u0639\u0644\u0627\u0645",
"\u063A\u064A\u0631 \u0645\u062A\u0632\u0627\u0645\u0646",
"\u0627\u0646\u062A\u0638\u0627\u0631",
"\u062B\u0627\u0628\u062A",
"\u0645\u062A\u063A\u064A\u0631",
"\u0625\u0631\u062C\u0627\u0639"
],
reasoningKeywords: [
// English
"prove",
"theorem",
"derive",
"step by step",
"chain of thought",
"formally",
"mathematical",
"proof",
"logically",
// Chinese
"\u8BC1\u660E",
"\u5B9A\u7406",
"\u63A8\u5BFC",
"\u9010\u6B65",
"\u601D\u7EF4\u94FE",
"\u5F62\u5F0F\u5316",
"\u6570\u5B66",
"\u903B\u8F91",
// Japanese
"\u8A3C\u660E",
"\u5B9A\u7406",
"\u5C0E\u51FA",
"\u30B9\u30C6\u30C3\u30D7\u30D0\u30A4\u30B9\u30C6\u30C3\u30D7",
"\u8AD6\u7406\u7684",
// Russian
"\u0434\u043E\u043A\u0430\u0437\u0430\u0442\u044C",
"\u0434\u043E\u043A\u0430\u0436\u0438",
"\u0434\u043E\u043A\u0430\u0437\u0430\u0442\u0435\u043B\u044C\u0441\u0442\u0432",
"\u0442\u0435\u043E\u0440\u0435\u043C\u0430",
"\u0432\u044B\u0432\u0435\u0441\u0442\u0438",
"\u0448\u0430\u0433 \u0437\u0430 \u0448\u0430\u0433\u043E\u043C",
"\u043F\u043E\u0448\u0430\u0433\u043E\u0432\u043E",
"\u043F\u043E\u044D\u0442\u0430\u043F\u043D\u043E",
"\u0446\u0435\u043F\u043E\u0447\u043A\u0430 \u0440\u0430\u0441\u0441\u0443\u0436\u0434\u0435\u043D\u0438\u0439",
"\u0440\u0430\u0441\u0441\u0443\u0436\u0434\u0435\u043D\u0438",
"\u0444\u043E\u0440\u043C\u0430\u043B\u044C\u043D\u043E",
"\u043C\u0430\u0442\u0435\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438",
"\u043B\u043E\u0433\u0438\u0447\u0435\u0441\u043A\u0438",
// German
"beweisen",
"beweis",
"theorem",
"ableiten",
"schritt f\xFCr schritt",
"gedankenkette",
"formal",
"mathematisch",
"logisch",
// Spanish
"demostrar",
"teorema",
"derivar",
"paso a paso",
"cadena de pensamiento",
"formalmente",
"matem\xE1tico",
"prueba",
"l\xF3gicamente",
// Portuguese
"provar",
"teorema",
"derivar",
"passo a passo",
"cadeia de pensamento",
"formalmente",
"matem\xE1tico",
"prova",
"logicamente",
// Korean
"\uC99D\uBA85",
"\uC815\uB9AC",
"\uB3C4\uCD9C",
"\uB2E8\uACC4\uBCC4",
"\uC0AC\uACE0\uC758 \uC5F0\uC1C4",
"\uD615\uC2DD\uC801",
"\uC218\uD559\uC801",
"\uB17C\uB9AC\uC801",
// Arabic
"\u0625\u062B\u0628\u0627\u062A",
"\u0646\u0638\u0631\u064A\u0629",
"\u0627\u0634\u062A\u0642\u0627\u0642",
"\u062E\u0637\u0648\u0629 \u0628\u062E\u0637\u0648\u0629",
"\u0633\u0644\u0633\u0644\u0629 \u0627\u0644\u062A\u0641\u0643\u064A\u0631",
"\u0631\u0633\u0645\u064A\u0627\u064B",
"\u0631\u064A\u0627\u0636\u064A",
"\u0628\u0631\u0647\u0627\u0646",
"\u0645\u0646\u0637\u0642\u064A\u0627\u064B"
],
simpleKeywords: [
// English
"what is",
"define",
"translate",
"hello",
"yes or no",
"capital of",
"how old",
"who is",
"when was",
// Chinese
"\u4EC0\u4E48\u662F",
"\u5B9A\u4E49",
"\u7FFB\u8BD1",
"\u4F60\u597D",
"\u662F\u5426",
"\u9996\u90FD",
"\u591A\u5927",
"\u8C01\u662F",
"\u4F55\u65F6",
// Japanese
"\u3068\u306F",
"\u5B9A\u7FA9",
"\u7FFB\u8A33",
"\u3053\u3093\u306B\u3061\u306F",
"\u306F\u3044\u304B\u3044\u3044\u3048",
"\u9996\u90FD",
"\u8AB0",
// Russian
"\u0447\u0442\u043E \u0442\u0430\u043A\u043E\u0435",
"\u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\u043D\u0438\u0435",
"\u043F\u0435\u0440\u0435\u0432\u0435\u0441\u0442\u0438",
"\u043F\u0435\u0440\u0435\u0432\u0435\u0434\u0438",
"\u043F\u0440\u0438\u0432\u0435\u0442",
"\u0434\u0430 \u0438\u043B\u0438 \u043D\u0435\u0442",
"\u0441\u0442\u043E\u043B\u0438\u0446\u0430",
"\u0441\u043A\u043E\u043B\u044C\u043A\u043E \u043B\u0435\u0442",
"\u043A\u0442\u043E \u0442\u0430\u043A\u043E\u0439",
"\u043A\u043E\u0433\u0434\u0430",
"\u043E\u0431\u044A\u044F\u0441\u043D\u0438",
// German
"was ist",
"definiere",
"\xFCbersetze",
"hallo",
"ja oder nein",
"hauptstadt",
"wie alt",
"wer ist",
"wann",
"erkl\xE4re",
// Spanish
"qu\xE9 es",
"definir",
"traducir",
"hola",
"s\xED o no",
"capital de",
"cu\xE1ntos a\xF1os",
"qui\xE9n es",
"cu\xE1ndo",
// Portuguese
"o que \xE9",
"definir",
"traduzir",
"ol\xE1",
"sim ou n\xE3o",
"capital de",
"quantos anos",
"quem \xE9",
"quando",
// Korean
"\uBB34\uC5C7",
"\uC815\uC758",
"\uBC88\uC5ED",
"\uC548\uB155\uD558\uC138\uC694",
"\uC608 \uB610\uB294 \uC544\uB2C8\uC624",
"\uC218\uB3C4",
"\uB204\uAD6C",
"\uC5B8\uC81C",
// Arabic
"\u0645\u0627 \u0647\u0648",
"\u062A\u0639\u0631\u064A\u0641",
"\u062A\u0631\u062C\u0645",
"\u0645\u0631\u062D\u0628\u0627",
"\u0646\u0639\u0645 \u0623\u0648 \u0644\u0627",
"\u0639\u0627\u0635\u0645\u0629",
"\u0645\u0646 \u0647\u0648",
"\u0645\u062A\u0649"
],
technicalKeywords: [
// English
"algorithm",
"optimize",
"architecture",
"distributed",
"kubernetes",
"microservice",
"database",
"infrastructure",
// Chinese
"\u7B97\u6CD5",
"\u4F18\u5316",
"\u67B6\u6784",
"\u5206\u5E03\u5F0F",
"\u5FAE\u670D\u52A1",
"\u6570\u636E\u5E93",
"\u57FA\u7840\u8BBE\u65BD",
// Japanese
"\u30A2\u30EB\u30B4\u30EA\u30BA\u30E0",
"\u6700\u9069\u5316",
"\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3",
"\u5206\u6563",
"\u30DE\u30A4\u30AF\u30ED\u30B5\u30FC\u30D3\u30B9",
"\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9",
// Russian
"\u0430\u043B\u0433\u043E\u0440\u0438\u0442\u043C",
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0430\u0446\u0438",
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0438\u0440\u0443\u0439",
"\u0430\u0440\u0445\u0438\u0442\u0435\u043A\u0442\u0443\u0440\u0430",
"\u0440\u0430\u0441\u043F\u0440\u0435\u0434\u0435\u043B\u0451\u043D\u043D\u044B\u0439",
"\u043C\u0438\u043A\u0440\u043E\u0441\u0435\u0440\u0432\u0438\u0441",
"\u0431\u0430\u0437\u0430 \u0434\u0430\u043D\u043D\u044B\u0445",
"\u0438\u043D\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0430",
// German
"algorithmus",
"optimieren",
"architektur",
"verteilt",
"kubernetes",
"mikroservice",
"datenbank",
"infrastruktur",
// Spanish
"algoritmo",
"optimizar",
"arquitectura",
"distribuido",
"microservicio",
"base de datos",
"infraestructura",
// Portuguese
"algoritmo",
"otimizar",
"arquitetura",
"distribu\xEDdo",
"microsservi\xE7o",
"banco de dados",
"infraestrutura",
// Korean
"\uC54C\uACE0\uB9AC\uC998",
"\uCD5C\uC801\uD654",
"\uC544\uD0A4\uD14D\uCC98",
"\uBD84\uC0B0",
"\uB9C8\uC774\uD06C\uB85C\uC11C\uBE44\uC2A4",
"\uB370\uC774\uD130\uBCA0\uC774\uC2A4",
"\uC778\uD504\uB77C",
// Arabic
"\u062E\u0648\u0627\u0631\u0632\u0645\u064A\u0629",
"\u062A\u062D\u0633\u064A\u0646",
"\u0628\u0646\u064A\u0629",
"\u0645\u0648\u0632\u0639",
"\u062E\u062F\u0645\u0629 \u0645\u0635\u063A\u0631\u0629",
"\u0642\u0627\u0639\u062F\u0629 \u0628\u064A\u0627\u0646\u0627\u062A",
"\u0628\u0646\u064A\u0629 \u062A\u062D\u062A\u064A\u0629"
],
creativeKeywords: [
// English
"story",
"poem",
"compose",
"brainstorm",
"creative",
"imagine",
"write a",
// Chinese
"\u6545\u4E8B",
"\u8BD7",
"\u521B\u4F5C",
"\u5934\u8111\u98CE\u66B4",
"\u521B\u610F",
"\u60F3\u8C61",
"\u5199\u4E00\u4E2A",
// Japanese
"\u7269\u8A9E",
"\u8A69",
"\u4F5C\u66F2",
"\u30D6\u30EC\u30A4\u30F3\u30B9\u30C8\u30FC\u30E0",
"\u5275\u9020\u7684",
"\u60F3\u50CF",
// Russian
"\u0438\u0441\u0442\u043E\u0440\u0438\u044F",
"\u0440\u0430\u0441\u0441\u043A\u0430\u0437",
"\u0441\u0442\u0438\u0445\u043E\u0442\u0432\u043E\u0440\u0435\u043D\u0438\u0435",
"\u0441\u043E\u0447\u0438\u043D\u0438\u0442\u044C",
"\u0441\u043E\u0447\u0438\u043D\u0438",
"\u043C\u043E\u0437\u0433\u043E\u0432\u043E\u0439 \u0448\u0442\u0443\u0440\u043C",
"\u0442\u0432\u043E\u0440\u0447\u0435\u0441\u043A\u0438\u0439",
"\u043F\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u0438\u0442\u044C",
"\u043F\u0440\u0438\u0434\u0443\u043C\u0430\u0439",
"\u043D\u0430\u043F\u0438\u0448\u0438",
// German
"geschichte",
"gedicht",
"komponieren",
"brainstorming",
"kreativ",
"vorstellen",
"schreibe",
"erz\xE4hlung",
// Spanish
"historia",
"poema",
"componer",
"lluvia de ideas",
"creativo",
"imaginar",
"escribe",
// Portuguese
"hist\xF3ria",
"poema",
"compor",
"criativo",
"imaginar",
"escreva",
// Korean
"\uC774\uC57C\uAE30",
"\uC2DC",
"\uC791\uACE1",
"\uBE0C\uB808\uC778\uC2A4\uD1A0\uBC0D",
"\uCC3D\uC758\uC801",
"\uC0C1\uC0C1",
"\uC791\uC131",
// Arabic
"\u0642\u0635\u0629",
"\u0642\u0635\u064A\u062F\u0629",
"\u062A\u0623\u0644\u064A\u0641",
"\u0639\u0635\u0641 \u0630\u0647\u0646\u064A",
"\u0625\u0628\u062F\u0627\u0639\u064A",
"\u062A\u062E\u064A\u0644",
"\u0627\u0643\u062A\u0628"
],
// New dimension keyword lists (multilingual)
imperativeVerbs: [
// English
"build",
"create",
"implement",
"design",
"develop",
"construct",
"generate",
"deploy",
"configure",
"set up",
// Chinese
"\u6784\u5EFA",
"\u521B\u5EFA",
"\u5B9E\u73B0",
"\u8BBE\u8BA1",
"\u5F00\u53D1",
"\u751F\u6210",
"\u90E8\u7F72",
"\u914D\u7F6E",
"\u8BBE\u7F6E",
// Japanese
"\u69CB\u7BC9",
"\u4F5C\u6210",
"\u5B9F\u88C5",
"\u8A2D\u8A08",
"\u958B\u767A",
"\u751F\u6210",
"\u30C7\u30D7\u30ED\u30A4",
"\u8A2D\u5B9A",
// Russian
"\u043F\u043E\u0441\u0442\u0440\u043E\u0438\u0442\u044C",
"\u043F\u043E\u0441\u0442\u0440\u043E\u0439",
"\u0441\u043E\u0437\u0434\u0430\u0442\u044C",
"\u0441\u043E\u0437\u0434\u0430\u0439",
"\u0440\u0435\u0430\u043B\u0438\u0437\u043E\u0432\u0430\u0442\u044C",
"\u0440\u0435\u0430\u043B\u0438\u0437\u0443\u0439",
"\u0441\u043F\u0440\u043E\u0435\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
"\u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u0430\u0442\u044C",
"\u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u0430\u0439",
"\u0441\u043A\u043E\u043D\u0441\u0442\u0440\u0443\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
"\u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
"\u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u0443\u0439",
"\u0440\u0430\u0437\u0432\u0435\u0440\u043D\u0443\u0442\u044C",
"\u0440\u0430\u0437\u0432\u0435\u0440\u043D\u0438",
"\u043D\u0430\u0441\u0442\u0440\u043E\u0438\u0442\u044C",
"\u043D\u0430\u0441\u0442\u0440\u043E\u0439",
// German
"erstellen",
"bauen",
"implementieren",
"entwerfen",
"entwickeln",
"konstruieren",
"generieren",
"bereitstellen",
"konfigurieren",
"einrichten",
// Spanish
"construir",
"crear",
"implementar",
"dise\xF1ar",
"desarrollar",
"generar",
"desplegar",
"configurar",
// Portuguese
"construir",
"criar",
"implementar",
"projetar",
"desenvolver",
"gerar",
"implantar",
"configurar",
// Korean
"\uAD6C\uCD95",
"\uC0DD\uC131",
"\uAD6C\uD604",
"\uC124\uACC4",
"\uAC1C\uBC1C",
"\uBC30\uD3EC",
"\uC124\uC815",
// Arabic
"\u0628\u0646\u0627\u0621",
"\u0625\u0646\u0634\u0627\u0621",
"\u062A\u0646\u0641\u064A\u0630",
"\u062A\u0635\u0645\u064A\u0645",
"\u062A\u0637\u0648\u064A\u0631",
"\u062A\u0648\u0644\u064A\u062F",
"\u0646\u0634\u0631",
"\u0625\u0639\u062F\u0627\u062F"
],
constraintIndicators: [
// English
"under",
"at most",
"at least",
"within",
"no more than",
"o(",
"maximum",
"minimum",
"limit",
"budget",
// Chinese
"\u4E0D\u8D85\u8FC7",
"\u81F3\u5C11",
"\u6700\u591A",
"\u5728\u5185",
"\u6700\u5927",
"\u6700\u5C0F",
"\u9650\u5236",
"\u9884\u7B97",
// Japanese
"\u4EE5\u4E0B",
"\u6700\u5927",
"\u6700\u5C0F",
"\u5236\u9650",
"\u4E88\u7B97",
// Russian
"\u043D\u0435 \u0431\u043E\u043B\u0435\u0435",
"\u043D\u0435 \u043C\u0435\u043D\u0435\u0435",
"\u043A\u0430\u043A \u043C\u0438\u043D\u0438\u043C\u0443\u043C",
"\u0432 \u043F\u0440\u0435\u0434\u0435\u043B\u0430\u0445",
"\u043C\u0430\u043A\u0441\u0438\u043C\u0443\u043C",
"\u043C\u0438\u043D\u0438\u043C\u0443\u043C",
"\u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0435\u043D\u0438\u0435",
"\u0431\u044E\u0434\u0436\u0435\u0442",
// German
"h\xF6chstens",
"mindestens",
"innerhalb",
"nicht mehr als",
"maximal",
"minimal",
"grenze",
"budget",
// Spanish
"como m\xE1ximo",
"al menos",
"dentro de",
"no m\xE1s de",
"m\xE1ximo",
"m\xEDnimo",
"l\xEDmite",
"presupuesto",
// Portuguese
"no m\xE1ximo",
"pelo menos",
"dentro de",
"n\xE3o mais que",
"m\xE1ximo",
"m\xEDnimo",
"limite",
"or\xE7amento",
// Korean
"\uC774\uD558",
"\uC774\uC0C1",
"\uCD5C\uB300",
"\uCD5C\uC18C",
"\uC81C\uD55C",
"\uC608\uC0B0",
// Arabic
"\u0639\u0644\u0649 \u0627\u0644\u0623\u0643\u062B\u0631",
"\u0639\u0644\u0649 \u0627\u0644\u0623\u0642\u0644",
"\u0636\u0645\u0646",
"\u0644\u0627 \u064A\u0632\u064A\u062F \u0639\u0646",
"\u0623\u0642\u0635\u0649",
"\u0623\u062F\u0646\u0649",
"\u062D\u062F",
"\u0645\u064A\u0632\u0627\u0646\u064A\u0629"
],
outputFormatKeywords: [
// English
"json",
"yaml",
"xml",
"table",
"csv",
"markdown",
"schema",
"format as",
"structured",
// Chinese
"\u8868\u683C",
"\u683C\u5F0F\u5316\u4E3A",
"\u7ED3\u6784\u5316",
// Japanese
"\u30C6\u30FC\u30D6\u30EB",
"\u30D5\u30A9\u30FC\u30DE\u30C3\u30C8",
"\u69CB\u9020\u5316",
// Russian
"\u0442\u0430\u0431\u043B\u0438\u0446\u0430",
"\u0444\u043E\u0440\u043C\u0430\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043A\u0430\u043A",
"\u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0439",
// German
"tabelle",
"formatieren als",
"strukturiert",
// Spanish
"tabla",
"formatear como",
"estructurado",
// Portuguese
"tabela",
"formatar como",
"estruturado",
// Korean
"\uD14C\uC774\uBE14",
"\uD615\uC2DD",
"\uAD6C\uC870\uD654",
// Arabic
"\u062C\u062F\u0648\u0644",
"\u062A\u0646\u0633\u064A\u0642",
"\u0645\u0646\u0638\u0645"
],
referenceKeywords: [
// English
"above",
"below",
"previous",
"following",
"the docs",
"the api",
"the code",
"earlier",
"attached",
// Chinese
"\u4E0A\u9762",
"\u4E0B\u9762",
"\u4E4B\u524D",
"\u63A5\u4E0B\u6765",
"\u6587\u6863",
"\u4EE3\u7801",
"\u9644\u4EF6",
// Japanese
"\u4E0A\u8A18",
"\u4E0B\u8A18",
"\u524D\u306E",
"\u6B21\u306E",
"\u30C9\u30AD\u30E5\u30E1\u30F3\u30C8",
"\u30B3\u30FC\u30C9",
// Russian
"\u0432\u044B\u0448\u0435",
"\u043D\u0438\u0436\u0435",
"\u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0438\u0439",
"\u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u0439",
"\u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u0430\u0446\u0438\u044F",
"\u043A\u043E\u0434",
"\u0440\u0430\u043D\u0435\u0435",
"\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435",
// German
"oben",
"unten",
"vorherige",
"folgende",
"dokumentation",
"der code",
"fr\xFCher",
"anhang",
// Spanish
"arriba",
"abajo",
"anterior",
"siguiente",
"documentaci\xF3n",
"el c\xF3digo",
"adjunto",
// Portuguese
"acima",
"abaixo",
"anterior",
"seguinte",
"documenta\xE7\xE3o",
"o c\xF3digo",
"anexo",
// Korean
"\uC704",
"\uC544\uB798",
"\uC774\uC804",
"\uB2E4\uC74C",
"\uBB38\uC11C",
"\uCF54\uB4DC",
"\uCCA8\uBD80",
// Arabic
"\u0623\u0639\u0644\u0627\u0647",
"\u0623\u062F\u0646\u0627\u0647",
"\u0627\u0644\u0633\u0627\u0628\u0642",
"\u0627\u0644\u062A\u0627\u0644\u064A",
"\u0627\u0644\u0648\u062B\u0627\u0626\u0642",
"\u0627\u0644\u0643\u0648\u062F",
"\u0645\u0631\u0641\u0642"
],
negationKeywords: [
// English
"don't",
"do not",
"avoid",
"never",
"without",
"except",
"exclude",
"no longer",
// Chinese
"\u4E0D\u8981",
"\u907F\u514D",
"\u4ECE\u4E0D",
"\u6CA1\u6709",
"\u9664\u4E86",
"\u6392\u9664",
// Japanese
"\u3057\u306A\u3044\u3067",
"\u907F\u3051\u308B",
"\u6C7A\u3057\u3066",
"\u306A\u3057\u3067",
"\u9664\u304F",
// Russian
"\u043D\u0435 \u0434\u0435\u043B\u0430\u0439",
"\u043D\u0435 \u043D\u0430\u0434\u043E",
"\u043D\u0435\u043B\u044C\u0437\u044F",
"\u0438\u0437\u0431\u0435\u0433\u0430\u0442\u044C",
"\u043D\u0438\u043A\u043E\u0433\u0434\u0430",
"\u0431\u0435\u0437",
"\u043A\u0440\u043E\u043C\u0435",
"\u0438\u0441\u043A\u043B\u044E\u0447\u0438\u0442\u044C",
"\u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435",
// German
"nicht",
"vermeide",
"niemals",
"ohne",
"au\xDFer",
"ausschlie\xDFen",
"nicht mehr",
// Spanish
"no hagas",
"evitar",
"nunca",
"sin",
"excepto",
"excluir",
// Portuguese
"n\xE3o fa\xE7a",
"evitar",
"nunca",
"sem",
"exceto",
"excluir",
// Korean
"\uD558\uC9C0 \uB9C8",
"\uD53C\uD558\uB2E4",
"\uC808\uB300",
"\uC5C6\uC774",
"\uC81C\uC678",
// Arabic
"\u0644\u0627 \u062A\u0641\u0639\u0644",
"\u062A\u062C\u0646\u0628",
"\u0623\u0628\u062F\u0627\u064B",
"\u0628\u062F\u0648\u0646",
"\u0628\u0627\u0633\u062A\u062B\u0646\u0627\u0621",
"\u0627\u0633\u062A\u0628\u0639\u0627\u062F"
],
domainSpecificKeywords: [
// English
"quantum",
"fpga",
"vlsi",
"risc-v",
"asic",
"photonics",
"genomics",
"proteomics",
"topological",
"homomorphic",
"zero-knowledge",
"lattice-based",
// Chinese
"\u91CF\u5B50",
"\u5149\u5B50\u5B66",
"\u57FA\u56E0\u7EC4\u5B66",
"\u86CB\u767D\u8D28\u7EC4\u5B66",
"\u62D3\u6251",
"\u540C\u6001",
"\u96F6\u77E5\u8BC6",
"\u683C\u5BC6\u7801",
// Japanese
"\u91CF\u5B50",
"\u30D5\u30A9\u30C8\u30CB\u30AF\u30B9",
"\u30B2\u30CE\u30DF\u30AF\u30B9",
"\u30C8\u30DD\u30ED\u30B8\u30AB\u30EB",
// Russian
"\u043A\u0432\u0430\u043D\u0442\u043E\u0432\u044B\u0439",
"\u0444\u043E\u0442\u043E\u043D\u0438\u043A\u0430",
"\u0433\u0435\u043D\u043E\u043C\u0438\u043A\u0430",
"\u043F\u0440\u043E\u0442\u0435\u043E\u043C\u0438\u043A\u0430",
"\u0442\u043E\u043F\u043E\u043B\u043E\u0433\u0438\u0447\u0435\u0441\u043A\u0438\u0439",
"\u0433\u043E\u043C\u043E\u043C\u043E\u0440\u0444\u043D\u044B\u0439",
"\u0441 \u043D\u0443\u043B\u0435\u0432\u044B\u043C \u0440\u0430\u0437\u0433\u043B\u0430\u0448\u0435\u043D\u0438\u0435\u043C",
"\u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u0435 \u0440\u0435\u0448\u0451\u0442\u043E\u043A",
// German
"quanten",
"photonik",
"genomik",
"proteomik",
"topologisch",
"homomorph",
"zero-knowledge",
"gitterbasiert",
// Spanish
"cu\xE1ntico",
"fot\xF3nica",
"gen\xF3mica",
"prote\xF3mica",
"topol\xF3gico",
"homom\xF3rfico",
// Portuguese
"qu\xE2ntico",
"fot\xF4nica",
"gen\xF4mica",
"prote\xF4mica",
"topol\xF3gico",
"homom\xF3rfico",
// Korean
"\uC591\uC790",
"\uD3EC\uD1A0\uB2C9\uC2A4",
"\uC720\uC804\uCCB4\uD559",
"\uC704\uC0C1",
"\uB3D9\uD615",
// Arabic
"\u0643\u0645\u064A",
"\u0636\u0648\u0626\u064A\u0627\u062A",
"\u062C\u064A\u0646\u0648\u0645\u064A\u0627\u062A",
"\u0637\u0648\u0628\u0648\u0644\u0648\u062C\u064A",
"\u062A\u0645\u0627\u062B\u0644\u064A"
],
// Agentic task keywords - file ops, execution, multi-step, iterative work
// Pruned: removed overly common words like "then", "first", "run", "test", "build"
agenticTaskKeywords: [
// English - File operations (clearly agentic)
"read file",
"read the file",
"look at",
"check the",
"open the",
"edit",
"modify",
"update the",
"change the",
"write to",
"create file",
// English - Execution (specific commands only)
"execute",
"deploy",
"install",
"npm",
"pip",
"compile",
// English - Multi-step patterns (specific only)
"after that",
"and also",
"once done",
"step 1",
"step 2",
// English - Iterative work
"fix",
"debug",
"until it works",
"keep trying",
"iterate",
"make sure",
"verify",
"confirm",
// Chinese (keep specific ones)
"\u8BFB\u53D6\u6587\u4EF6",
"\u67E5\u770B",
"\u6253\u5F00",
"\u7F16\u8F91",
"\u4FEE\u6539",
"\u66F4\u65B0",
"\u521B\u5EFA",
"\u6267\u884C",
"\u90E8\u7F72",
"\u5B89\u88C5",
"\u7B2C\u4E00\u6B65",
"\u7B2C\u4E8C\u6B65",
"\u4FEE\u590D",
"\u8C03\u8BD5",
"\u76F4\u5230",
"\u786E\u8BA4",
"\u9A8C\u8BC1",
// Spanish
"leer archivo",
"editar",
"modificar",
"actualizar",
"ejecutar",
"desplegar",
"instalar",
"paso 1",
"paso 2",
"arreglar",
"depurar",
"verificar",
// Portuguese
"ler arquivo",
"editar",
"modificar",
"atualizar",
"executar",
"implantar",
"instalar",
"passo 1",
"passo 2",
"corrigir",
"depurar",
"verificar",
// Korean
"\uD30C\uC77C \uC77D\uAE30",
"\uD3B8\uC9D1",
"\uC218\uC815",
"\uC5C5\uB370\uC774\uD2B8",
"\uC2E4\uD589",
"\uBC30\uD3EC",
"\uC124\uCE58",
"\uB2E8\uACC4 1",
"\uB2E8\uACC4 2",
"\uB514\uBC84\uADF8",
"\uD655\uC778",
// Arabic
"\u0642\u0631\u0627\u0621\u0629 \u0645\u0644\u0641",
"\u062A\u062D\u0631\u064A\u0631",
"\u062A\u0639\u062F\u064A\u0644",
"\u062A\u062D\u062F\u064A\u062B",
"\u062A\u0646\u0641\u064A\u0630",
"\u0646\u0634\u0631",
"\u062A\u062B\u0628\u064A\u062A",
"\u0627\u0644\u062E\u0637\u0648\u0629 1",
"\u0627\u0644\u062E\u0637\u0648\u0629 2",
"\u0625\u0635\u0644\u0627\u062D",
"\u062A\u0635\u062D\u064A\u062D",
"\u062A\u062D\u0642\u0642"
],
// Dimension weights (sum to 1.0)
dimensionWeights: {
tokenCount: 0.08,
codePresence: 0.15,
reasoningMarkers: 0.18,
technicalTerms: 0.1,
creativeMarkers: 0.05,
simpleIndicators: 0.02,
// Reduced from 0.12 to make room for agenticTask
multiStepPatterns: 0.12,
questionComplexity: 0.05,
imperativeVerbs: 0.03,
constraintCount: 0.04,
outputFormat: 0.03,
referenceComplexity: 0.02,
negationComplexity: 0.01,
domainSpecificity: 0.02,
agenticTask: 0.04
// Reduced - agentic signals influence tier selection, not dominate it
},
// Tier boundaries on weighted score axis
tierBoundaries: {
simpleMedium: 0,
mediumComplex: 0.3,
// Raised from 0.18 - prevent simple tasks from reaching expensive COMPLEX tier
complexReasoning: 0.5
// Raised from 0.4 - reserve for true reasoning tasks
},
// Sigmoid steepness for confidence calibration
confidenceSteepness: 12,
// Below this confidence → ambiguous (null tier)
confidenceThreshold: 0.7
},
// ─── Tier chains ───
//
// Catalog refresh 2026-08-29 (V3.5). Every chain below names only models
// the public catalog lists (GET https://blockrun.ai/api/v1/models). Ids the
// gateway withholds (`hidden: true`) — kimi-k2.5/k2.6/k2.7, the grok-4-fast
// and grok-4-1-fast pairs, grok-4-0709, claude-opus-4.6, gemini-3-pro-preview,
// the whole `free/*` namespace — were removed everywhere, including fallback
// rungs, so a routed model is always one a user can find on blockrun.ai/models.
//
// Primaries moved only where portfolio.ts already carries calibration
// evidence for the successor (Sonnet 5 over Sonnet 4.6, GPT-5 Mini for
// agentic MEDIUM, Gemini 3.5 Flash where Kimi K2.7 was). Newcomers with no
// trajectory evidence yet (gemini-3.6-flash, glm-5.3, glm-5.3-flash,
// gpt-5.6-luna, grok-4.3, minimax-m3, qwen3.7-plus) enter as fallback rungs;
// promotion waits for a calibration run, because version recency is not a
// quality signal.
//
// Latency figures in comments are the 2026-08-29 gateway probe
// (model-profiles.generated.json); prices are the catalog list.
// Auto (balanced) tier configs - current default smart routing
tiers: {
SIMPLE: {
primary: "google/gemini-2.5-flash",
// $0.30/$2.50 — 60% retention (best) in the 2026-03 run; still the fastest quality answer
fallback: [
"google/gemini-3-flash-preview",
// $0.50/$3 — GPQA 5/6 in the 2026-07 calibration
"google/gemini-3.5-flash-lite",
// $0.30/$2.50, 1M ctx, thinking mode — same price as 2.5 Flash, newer generation
"deepseek/deepseek-chat",
// $0.14/$0.28, 1M ctx
"google/gemini-3.1-flash-lite",
// $0.25/$1.50, 1M ctx
"openai/gpt-5.6-luna",
// $0.20/$1.20, 1M ctx — GPT-5.6 cost tier (cut 2026-07-30)
"openai/gpt-5.4-nano",
// $0.20/$1.25, 1M ctx
"google/gemini-2.5-flash-lite",
// $0.10/$0.40
"nvidia/nemotron-3.5-lightning"
// FREE backstop — NVIDIA free tier (probed 2026-08-30)
]
},
MEDIUM: {
// Was moonshot/kimi-k2.7 (hidden 2026-08). Gemini 3.5 Flash is the
// calibrated successor: MGSM 5/5, GPQA 4/6, extraction band (portfolio.ts).
primary: "google/gemini-3.5-flash",
// $1.50/$9, 1M ctx, vision + tools
fallback: [
"google/gemini-3.6-flash",
// $1.50/$7.50 — newest Flash, output 17% cheaper than 3.5; awaiting calibration
"zai/glm-5.3-flash",
// $0.15/$0.50, 1M ctx, vision + tools verified live 2026-08-27
"openai/gpt-5.6-terra",
// $2/$12, 1M ctx — GPT-5.6 balanced tier
"google/gemini-3-flash-preview",
// $0.50/$3
"deepseek/deepseek-chat",
// $0.14/$0.28
"google/gemini-2.5-flash",
// $0.30/$2.50
"minimax/minimax-m3",
// $0.30/$1.20, 1M ctx
"google/gemini-3.1-flash-lite",
// $0.25/$1.50
"openai/gpt-5.6-luna",
// $0.20/$1.20
"google/gemini-2.5-flash-lite"
// $0.10/$0.40
]
},
COMPLEX: {
primary: "google/gemini-3.1-pro",
// $2/$12 — proven long-context flagship (portfolio.ts long_context lead)
fallback: [
"google/gemini-3.6-flash",
// $1.50/$7.50 — Pro-level quality at Flash price (Google's claim; uncalibrated here)
"google/gemini-3.5-flash",
// $1.50/$9 — calibrated
"anthropic/claude-sonnet-5",
// $3/$15 — near-Opus quality, tau2 + Terminal-Bench calibrated
"xai/grok-4.5",
// $2.50/$9 — 503-resistant, independent infra (was grok-4-0709, now hidden)
"google/gemini-2.5-pro",
// $1.25/$10
"anthropic/claude-sonnet-4.6",
// $3/$15
"openai/gpt-5.6-terra",
// $2/$12 — GPT-5.6 balanced tier (Sol excluded: #202)
"openai/gpt-5.5",
// $5/$30 — prior OpenAI flagship
"openai/gpt-5.4",
// $2.50/$15 — previous flagship, benchmarked
"zai/glm-5.3",
// $1.40/$4.40, 1M ctx, always-on thinking — verified live 2026-08-19
"moonshot/kimi-k3",
// $3/$15, 1M ctx — Moonshot flagship (K2.7 successor)
"deepseek/deepseek-v4-pro",
// $0.435/$0.87 — strongest open-weight reasoner
"deepseek/deepseek-chat",
// $0.14/$0.28 — cheap last resort
"google/gemini-2.5-flash"
// $0.30/$2.50
]
},
REASONING: {
// Was xai/grok-4-1-fast-reasoning ($0.20/$0.50, hidden 2026-08). DeepSeek
// Reasoner is the cheapest listed reasoner at the same 1M context.
primary: "deepseek/deepseek-reasoner",
// $0.14/$0.28, 1M ctx
fallback: [
"deepseek/deepseek-v4-pro",
// $0.435/$0.87 — calibrated reasoning band 0.95
"xai/grok-4.3",
// $1.50/$4, 1M ctx — xAI reasoning model, vision
"qwen/qwen3.7-plus",
// $0.32/$1.28, 1M ctx — reasoning; needs a generous max_tokens (thinking is billed)
"google/gemini-3.5-flash",
// $1.50/$9 — MGSM 5/5
"openai/o4-mini",
// $1.10/$4.40
"openai/o3"
// $2/$8
]
}
},
// Eco tier configs - absolute cheapest (blockrun/eco)
ecoTiers: {
SIMPLE: {
primary: "nvidia/nemotron-3.5-lightning",
// FREE — NVIDIA free tier flagship, 1M ctx
fallback: [
// Was nvidia/nemotron-3-nano-30b until blockrun delisted it on 2026-09-08
// (NVIDIA deprovisioned it for the account — a structured per-account
// 404). nano-omni is blockrun's own redirect target for that id, so this
// follows the same rule as the head: the router and the gateway name the
// same model.
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
// FREE — nano-30b's successor, 256K ctx
// The free head keeps rotting with NVIDIA's hosting (deepseek-v4-flash
// 410 2026-08-12, seed-oss-36b 410 2026-08-03, gpt-oss-120b/20b 400
// 2026-08-21, and on 2026-08-30 FOUR of the five visible free models at
// once — step-3.7-flash, nemotron-nano-9b-v2 and nemotron-nano-12b-v2-vl
// all 410, mistral-nemotron hung). Each retirement retargets the two
// free rungs to the current free tier; the paid rungs below never move.
// The head follows blockrun's own redirect of the model it replaces, so
// the router and the gateway never name different models.
"google/gemini-2.5-flash-lite",
// $0.10/$0.40 — cheapest paid rung
"zai/glm-5.3-flash",
// $0.15/$0.50, 1M ctx, vision + tools
"openai/gpt-5.6-luna",
// $0.20/$1.20, 1M ctx
"openai/gpt-5.4-nano",
// $0.20/$1.25
"google/gemini-3.1-flash-lite"
// $0.25/$1.50
]
},
MEDIUM: {
primary: "zai/glm-5.3-flash",
// $0.15/$0.50, 1M ctx, vision + tools verified live — cheapest full-capability model
fallback: [
"deepseek/deepseek-chat",
// $0.14/$0.28
"google/gemini-3.1-flash-lite",
// $0.25/$1.50
"openai/gpt-5.6-luna",
// $0.20/$1.20
"openai/gpt-5.4-nano",
// $0.20/$1.25
"google/gemini-2.5-flash-lite",
// $0.10/$0.40
"google/gemini-2.5-flash"
// $0.30/$2.50
]
},
COMPLEX: {
primary: "zai/glm-5.3-flash",
// $0.15/$0.50, 1M ctx
fallback: [
"deepseek/deepseek-chat",
// $0.14/$0.28, 1M ctx
"minimax/minimax-m3",
// $0.30/$1.20, 1M ctx
"deepseek/deepseek-v4-pro",
// $0.435/$0.87
"google/gemini-3.1-flash-lite",
// $0.25/$1.50
"google/gemini-2.5-flash"
// $0.30/$2.50
]
},
REASONING: {
primary: "deepseek/deepseek-reasoner",
// $0.14/$0.28, 1M ctx — cheapest listed reasoner
fallback: [
"deepseek/deepseek-v4-pro",
// $0.435/$0.87
"qwen/qwen3.7-plus",
// $0.32/$1.28 — reasoning
"minimax/minimax-m3",
// $0.30/$1.20 — reasoning + coding
"zai/glm-5.3-flash"
// $0.15/$0.50 — reasoning tokens alongside content
]
}
},
// Premium tier configs - best quality (blockrun/premium)
// codex=complex coding, flash=simple coding, sonnet=reasoning/instructions, fable/opus=architecture/PM/audits
premiumTiers: {
SIMPLE: {
// Was moonshot/kimi-k2.7 (hidden 2026-08).
primary: "google/gemini-3.5-flash",
// $1.50/$9, 1M ctx, vision + tools — calibrated
fallback: [
"google/gemini-3.6-flash",
// $1.50/$7.50 — newest Flash
"anthropic/claude-haiku-4.5",
// $1/$5
"zai/glm-5.3",
// $1.40/$4.40, 1M ctx
"google/gemini-2.5-flash",
// $0.30/$2.50
"google/gemini-3.5-flash-lite",
// $0.30/$2.50
"deepseek/deepseek-chat"
// $0.14/$0.28
]
},
MEDIUM: {
primary: "openai/gpt-5.3-codex",
// $1.75/$14 - 400K context, 128K output — code_edit/debug lead (portfolio.ts)
fallback: [
"anthropic/claude-sonnet-5",
// $3/$15 — code_agent band 0.98
"moonshot/kimi-k3",
// $3/$15, 1M ctx — Moonshot flagship
"zai/glm-5.3",
// $1.40/$4.40 — long-horizon coding
"google/gemini-3.6-flash",
// $1.50/$7.50
"google/gemini-3.5-flash",
// $1.50/$9
"google/gemini-2.5-pro",
// $1.25/$10
"xai/grok-4.5",
// $2.50/$9
"anthropic/claude-sonnet-4.6",
// $3/$15
"openai/gpt-5.6-terra"
// $2/$12
]
},
COMPLEX: {
// fable-5 was promoted here 2026-06-11, force-reverted 2026-06-13 when Anthropic
// withdrew the offer, and restored 2026-07-14 now that BlockRun has relisted it.
primary: "anthropic/claude-fable-5",
// Best quality for complex tasks — Mythos-class flagship above Opus ($10/$50, 1M ctx, always-on thinking)
// Fallback chain de-Gemini'd 2026-04-22: when Anthropic 503s, Gemini is
// also prone to "high demand" 503s (correlated failure — everyone falls
// back to Google at the same time). Prefer in-family → xAI → Moonshot →
// OpenAI flagship → Z.AI → DeepSeek → NVIDIA free instead.
fallback: [
"anthropic/claude-opus-5",
// in-family hot swap first (half the price, 1M ctx + adaptive thinking)
"anthropic/claude-opus-4.8",
// in-family hot swap (identical cost to 5)
"anthropic/claude-opus-4.7",
// in-family hot swap (identical cost to 4.8)
"anthropic/claude-sonnet-5",
// Sonnet-tier drop-down, near-Opus quality
"anthropic/claude-sonnet-4.6",
"xai/grok-4.5",
// xAI flagship — 503-resistant, direct-xAI SKU
"moonshot/kimi-k3",
// Moonshot flagship, independent infra
"openai/gpt-5.6-terra",
// GPT-5.6 balanced tier — stable (Sol excluded: #202)
"openai/gpt-5.5",
// Prior OpenAI flagship — 1M+ ctx, native agent + computer use
"openai/gpt-5.4",
// Previous flagship (slow but stable, benchmarked at 6,213ms)
"openai/gpt-5.3-codex",
"zai/glm-5.3",
// Z.AI flagship, 1M ctx
"deepseek/deepseek-v4-pro",
// strongest open-weight reasoner
"deepseek/deepseek-chat",
// Cheap, reliable
"nvidia/nemotron-3.5-lightning"
// NVIDIA free ultimate backstop
]
},
REASONING: {
// Sonnet 5 promoted over Sonnet 4.6 (same price; reasoning band 0.98 for both,
// plus Sonnet 5's tau2/BrowseComp trajectory evidence).
primary: "anthropic/claude-sonnet-5",
// $3/$15, 1M ctx, adaptive thinking
fallback: [
"anthropic/claude-sonnet-4.6",
// in-family hot swap — same cost
"anthropic/claude-opus-5",
// Newest flagship Opus w/ adaptive thinking
"anthropic/claude-opus-4.8",
// Prior flagship Opus — identical cost to 5
"anthropic/claude-opus-4.7",
// Flagship Opus w/ adaptive thinking
"xai/grok-4.5",
// reasoning band 0.94
"deepseek/deepseek-v4-pro",
// reasoning band 0.95
"xai/grok-4.3",
// $1.50/$4 — xAI reasoning model
"openai/o4-mini",
// $1.10/$4.40
"openai/o3"
// $2/$8
]
}
},
// Agentic tier configs - models that excel at multi-step autonomous tasks
agenticTiers: {
SIMPLE: {
primary: "openai/gpt-4o-mini",
// $0.15/$0.60 - best tool compliance at lowest cost
fallback: [
"openai/gpt-5.6-luna",
// $0.20/$1.20 — lightweight agentic tier of GPT-5.6
"zai/glm-5.3-flash",
// $0.15/$0.50 — tool calls verified live 2026-08-27
"anthropic/claude-haiku-4.5",
// $1/$5
"google/gemini-2.5-flash"
// $0.30/$2.50
]
},
MEDIUM: {
// Was moonshot/kimi-k2.7 (hidden 2026-08). GPT-5 Mini carries the
// Terminal-Bench and tau2 trajectory evidence in portfolio.ts.
primary: "openai/gpt-5-mini",
// $0.25/$2 — 4/7 Terminal-Bench, 5/6 tau2 airline
fallback: [
"google/gemini-3.5-flash",
// $1.50/$9 — tool_agent band 0.88
"zai/glm-5.3-flash",
// $0.15/$0.50 — tools verified
"openai/gpt-5.6-terra",
// $2/$12
"openai/gpt-4o-mini",
// $0.15/$0.60 — reliable tool calling
"anthropic/claude-haiku-4.5",
// $1/$5
"deepseek/deepseek-chat",
// $0.14/$0.28
"moonshot/kimi-k3"
// $3/$15 — tool_agent band 0.85
]
},
COMPLEX: {
// Sonnet 5 promoted over Sonnet 4.6: tau2 airline + retail reward 1.0,
// Terminal-Bench safety band lead (portfolio.ts).
primary: "anthropic/claude-sonnet-5",
// $3/$15 — best agentic quality per trajectory evidence
// Fallback chain de-Gemini'd 2026-04-22: Gemini's "high demand" 503s
// correlate with Anthropic outages (everyone falls back together).
// Prefer 503-resistant providers first.
fallback: [
"anthropic/claude-sonnet-4.6",
// in-family hot swap — same cost
"anthropic/claude-opus-5",
// Newest flagship Opus — in-family hot swap
"anthropic/claude-opus-4.8",
// Prior flagship Opus — identical cost to 5
"anthropic/claude-opus-4.7",
// Flagship Opus — in-family hot swap
"xai/grok-4.5",
// xAI flagship — strong tool use, independent infra
"moonshot/kimi-k3",
// Moonshot flagship — independent infra
"openai/gpt-5.6-terra",
// GPT-5.6 balanced tier — stable (Sol excluded: #202)
"openai/gpt-5.5",
// Prior flagship — native agent + computer use (exactly the agentic-tier use case)
"openai/gpt-5.4",
// Previous flagship — reliable
"openai/gpt-5.3-codex",
// code_agent lead
"zai/glm-5.3",
// long-horizon coding
"deepseek/deepseek-v4-pro",
// retail high-risk 3/3
"deepseek/deepseek-chat",
// cheap, reliable
"nvidia/nemotron-3.5-lightning"
// NVIDIA free ultimate backstop
]
},
REASONING: {
primary: "anthropic/claude-sonnet-5",
// $3/$15 — strong tool use + adaptive thinking
fallback: [
"anthropic/claude-sonnet-4.6",
// in-family hot swap — same cost
"anthropic/claude-opus-5",
// Newest flagship Opus w/ adaptive thinking
"anthropic/claude-opus-4.8",
// Prior flagship Opus — identical cost to 5
"anthropic/claude-opus-4.7",
// Flagship Opus w/ adaptive thinking
"xai/grok-4.5",
// reasoning band 0.94
"deepseek/deepseek-v4-pro",
// reasoning band 0.95
"deepseek/deepseek-reasoner"
// $0.14/$0.28
]
}
},
// Time-windowed promotions — auto-applied when active, ignored when expired.
// The GLM-5.1 launch promo (2026-04-01 → 2026-05-01) was the last entry and
// has expired; the list is kept empty so the mechanism stays wired.
promotions: [],
overrides: {
maxTokensForceComplex: 1e5,
structuredOutputMinTier: "MEDIUM",
ambiguousDefaultTier: "MEDIUM"
// agenticMode left undefined → auto-detect via tools/agenticScore.
// Set to `true` to force agentic tiers; `false` to disable them entirely.
}
};
registerStrategy(new PortfolioStrategy());
function route(prompt, systemPrompt, maxOutputTokens, options) {
const strategy = getStrategy(options.config.strategy ?? "portfolio");
return strategy.route(prompt, systemPrompt, maxOutputTokens, options);
}
// src/router-adapter.ts
var AUTO_ROUTING_PROFILES = {
"blockrun/auto": "auto",
"blockrun/eco": "eco",
"blockrun/premium": "premium"
};
var BASE_MINIMUM_PAYMENT_USD = 2e-3;
var SOLANA_MINIMUM_PAYMENT_USD = 1e-3;
function isTransientError(err) {
if (retryDisposition(err) !== "unpaid") return false;
if (err instanceof PaymentError) return false;
if (err instanceof APIError) {
return [429, 502, 503, 504, 522, 524].includes(err.statusCode);
}
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if (err.name === "TypeError" && /fetch|network/i.test(err.message)) return true;
}
return false;
}
function errSummary(err) {
if (err instanceof APIError) return `APIError ${err.statusCode}`;
if (err instanceof Error) {
const msg = err.message.length > 80 ? err.message.slice(0, 80) : err.message;
return `${err.name}: ${msg}`;
}
return String(err).slice(0, 100);
}
function requestsSearch(options) {
if (options.tools?.some((tool) => tool.type === "x_search" || tool.type === "web_search")) return true;
if (options.searchParameters !== void 0) return options.searchParameters.mode !== "off";
return options.search === true;
}
var searchModelsByCatalog = /* @__PURE__ */ new WeakMap();
function registerSearchModels(pricing, models) {
const xaiChat = models.filter(
(m) => m.id.startsWith("xai/") && m.available !== false && Array.isArray(m.categories) && m.categories.includes("chat") && pricing.has(m.id)
);
const tagged = xaiChat.filter((m) => m.categories.includes("search"));
searchModelsByCatalog.set(pricing, (tagged.length > 0 ? tagged : xaiChat).map((m) => m.id));
}
function searchFallbackChain(modelPricing, routingProfile) {
const pool = searchModelsByCatalog.get(modelPricing) ?? [...modelPricing.keys()].filter((id) => id.startsWith("xai/"));
const price = (id) => {
const p = modelPricing.get(id);
return p ? p.inputPrice + p.outputPrice : Number.POSITIVE_INFINITY;
};
const sorted = [...pool].sort((a, b) => price(a) - price(b) || b.localeCompare(a, void 0, { numeric: true }));
if (routingProfile !== "premium") return sorted;
return [...pool].sort((a, b) => price(b) - price(a) || b.localeCompare(a, void 0, { numeric: true }));
}
function routingProfileForModel(model) {
return AUTO_ROUTING_PROFILES[model.toLowerCase()];
}
function routingText(messages) {
const systemPrompt = messages.filter((message) => message.role === "system" && typeof message.content === "string").map((message) => message.content).join("\n") || void 0;
const lastUser = [...messages].reverse().find((message) => message.role === "user" && typeof message.content === "string");
const lastText = [...messages].reverse().find((message) => typeof message.content === "string");
let conversationChars = 0;
let hasVision = false;
for (const message of messages) {
if (typeof message.content === "string") {
conversationChars += message.content.length;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part?.type === "image_url" || part?.type === "image") hasVision = true;
if (typeof part?.text === "string") conversationChars += part.text.length;
}
}
}
return {
prompt: lastUser?.content ?? lastText?.content ?? "",
systemPrompt,
conversationChars,
hasVision
};
}
function routeWithCatalog(prompt, systemPrompt, maxOutputTokens, modelPricing, options = {}) {
const tools = (options.tools ?? []).filter((tool) => tool.type === "function");
const requiresTools = options.toolChoice === "none" ? false : options.toolChoice === "required" || typeof options.toolChoice === "object" ? true : void 0;
const decision = route(prompt, systemPrompt, maxOutputTokens, {
config: DEFAULT_ROUTING_CONFIG,
modelPricing,
routingProfile: options.routingProfile,
hasTools: tools.length > 0,
toolCount: tools.length,
toolNames: tools.map((tool) => tool.function.name),
requiresTools,
hasVision: options.hasVision,
requiresStructuredOutput: options.requiresStructuredOutput
});
const tierConfigs = decision.tierConfigs ?? DEFAULT_ROUTING_CONFIG.tiers;
const ranked = decision.candidates?.length ? decision.candidates : [decision.model, ...getFallbackChain(decision.tier, tierConfigs)];
const callable = [];
for (const id of ranked) {
const resolved = !id.startsWith("free/") ? id : modelPricing.has(`nvidia/${id.slice(5)}`) ? `nvidia/${id.slice(5)}` : null;
if (resolved && !callable.includes(resolved)) callable.push(resolved);
}
let searchFallback = false;
if (requestsSearch(options)) {
let searchable = callable.filter((id) => id.startsWith("xai/"));
if (searchable.length === 0) {
searchable = searchFallbackChain(modelPricing, options.routingProfile);
searchFallback = true;
}
if (searchable.length === 0) {
throw new Error(
`Search (x_search / web_search tools or searchParameters) runs on xai/* models only, and the catalog lists no available xai/* chat model. Call an xai/* model directly or drop the search options.`
);
}
callable.splice(0, callable.length, ...searchable);
}
const estimatedInputTokens = Math.ceil(
Math.max(options.conversationChars ?? 0, `${systemPrompt ?? ""} ${prompt}`.length) / 4
);
const fitting = filterCandidatesByCapacity(
callable,
estimatedInputTokens,
maxOutputTokens,
(id) => {
const caps = DEFAULT_MODEL_CAPABILITIES[id];
return caps ? { contextWindow: caps.contextWindow, maxOutput: caps.maxOutputTokens } : void 0;
}
);
const availableCandidates = fitting.length > 0 ? fitting : callable;
const model = availableCandidates[0] ?? decision.model;
const costs = calculateModelCost(
model,
modelPricing,
estimatedInputTokens,
maxOutputTokens,
options.routingProfile
);
const minimumPaymentUsd = options.minimumPaymentUsd ?? SOLANA_MINIMUM_PAYMENT_USD;
const entry = modelPricing.get(model);
const isFree = entry !== void 0 && entry.inputPrice === 0 && entry.outputPrice === 0 && !entry.flatPrice;
const costEstimate = isFree ? 0 : Math.max(costs.costEstimate, minimumPaymentUsd);
const savings = options.routingProfile === "premium" || costs.baselineCost <= 0 ? 0 : entry !== void 0 ? Math.max(0, (costs.baselineCost - costEstimate) / costs.baselineCost) : decision.savings;
return {
...decision,
...costs,
costEstimate,
savings,
model,
reasoning: searchFallback ? `${decision.reasoning} | search runs on xai/* only; the route had none, so the ${options.routingProfile === "premium" ? "most capable" : "cheapest"} search-capable xAI model: ${model}` : model === decision.model ? decision.reasoning : `${decision.reasoning} | catalog fallback: ${model}`,
candidates: availableCandidates,
candidateScores: decision.candidateScores?.filter(
(score) => availableCandidates.includes(score.model)
),
fallbacks: availableCandidates.slice(1)
};
}
// src/x402.ts
var import_accounts = require("viem/accounts");
// src/solana-deps.ts
var INSTALL_HINT = "npm install @solana/web3.js @solana/spl-token (or pnpm add / yarn add)";
function missing(pkg, what, cause) {
return new Error(
`@blockrun/llm: ${what} requires the optional peer dependency "${pkg}", which is not installed.
${INSTALL_HINT}
Solana packages are optional peers so that consumers who only use Base/EVM payments do not inherit them. If you only make Base payments, you should not be reaching this code path \u2014 check which chain you passed.
Original error: ${cause instanceof Error ? cause.message : String(cause)}`
);
}
async function loadSolanaWeb3(what) {
try {
return await import("@solana/web3.js");
} catch (err) {
throw missing("@solana/web3.js", what, err);
}
}
async function loadSplToken(what) {
try {
return await import("@solana/spl-token");
} catch (err) {
throw missing("@solana/spl-token", what, err);
}
}
// src/x402.ts
var BASE_CHAIN_ID = 8453;
var USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
var ARC_CHAIN_ID = 5042;
var USDC_ARC = "0x3600000000000000000000000000000000000000";
var EVM_NETWORKS = {
"eip155:8453": {
name: "Base",
chainId: BASE_CHAIN_ID,
usdc: USDC_BASE,
domain: { name: "USD Coin", version: "2", chainId: BASE_CHAIN_ID, verifyingContract: USDC_BASE }
},
"eip155:5042": {
name: "Arc",
chainId: ARC_CHAIN_ID,
usdc: USDC_ARC,
domain: { name: "USDC", version: "2", chainId: ARC_CHAIN_ID, verifyingContract: USDC_ARC }
},
"eip155:84532": {
name: "Base Sepolia",
chainId: 84532,
usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
domain: {
name: "USDC",
version: "2",
chainId: 84532,
verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
}
}
};
function evmNetwork(network) {
const net = EVM_NETWORKS[network];
if (!net) {
throw new Error(
`Unsupported x402 network "${network}": this SDK signs USDC payments on ${Object.keys(EVM_NETWORKS).join(", ")}`
);
}
return net;
}
var SOLANA_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
var USDC_SOLANA = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
var SOLANA_USDC_DECIMALS = 6;
var DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
var DEFAULT_COMPUTE_UNIT_LIMIT = 8e3;
var BLOCKHASH_TTL_MS = 1e4;
var MAX_FEE_NONCE_STEPS = 64;
var MAX_TRACKED_BLOCKHASHES = 8;
var MAX_TRACKED_ENDPOINTS = 8;
var blockhashCache = /* @__PURE__ */ new Map();
function trimOldest(map, limit) {
while (map.size > limit) {
const oldest = map.keys().next().value;
map.delete(oldest);
}
}
var issuedByBlockhash = /* @__PURE__ */ new Map();
function issuedFor(blockhash) {
let issued = issuedByBlockhash.get(blockhash);
if (!issued) {
issued = /* @__PURE__ */ new Set();
issuedByBlockhash.set(blockhash, issued);
trimOldest(issuedByBlockhash, MAX_TRACKED_BLOCKHASHES);
}
return issued;
}
async function getBlockhashEntry(connection, rpcUrl, forceRefresh) {
const now = Date.now();
const cached = blockhashCache.get(rpcUrl);
if (!forceRefresh && cached && now - cached.fetchedAt < BLOCKHASH_TTL_MS) {
return cached;
}
const { blockhash } = await connection.getLatestBlockhash();
const current = blockhashCache.get(rpcUrl);
if (current?.blockhash === blockhash) {
current.fetchedAt = now;
return current;
}
const entry = { blockhash, fetchedAt: now };
blockhashCache.set(rpcUrl, entry);
trimOldest(blockhashCache, MAX_TRACKED_ENDPOINTS);
return entry;
}
var TRANSFER_TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" }
]
};
function createNonce() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return `0x${Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
var BLOCKRUN_SERVICE_CODE = "blockrun";
function withBuilderCodeServiceCode(extensions) {
const merged = { ...extensions || {} };
const existing = merged["builder-code"] || {};
merged["builder-code"] = {
...existing,
info: { ...existing.info || {}, s: [BLOCKRUN_SERVICE_CODE] }
};
return merged;
}
async function createPaymentPayload(privateKey, fromAddress, recipient, amount, network = "eip155:8453", options = {}) {
const now = Math.floor(Date.now() / 1e3);
const validAfter = now - 600;
const validBefore = now + (options.maxTimeoutSeconds || 300);
const nonce = createNonce();
const net = evmNetwork(network);
if (options.asset && options.asset.toLowerCase() !== net.usdc.toLowerCase()) {
throw new Error(
`x402 asset mismatch: the 402 asks for ${options.asset} on ${network}, but this SDK only pays USDC there (${net.usdc})`
);
}
const domain = net.domain;
const signature = await (0, import_accounts.signTypedData)({
privateKey,
domain,
types: TRANSFER_TYPES,
primaryType: "TransferWithAuthorization",
message: {
from: fromAddress,
to: recipient,
value: BigInt(amount),
validAfter: BigInt(validAfter),
validBefore: BigInt(validBefore),
nonce
}
});
const paymentData = {
x402Version: 2,
resource: {
url: options.resourceUrl || "https://blockrun.ai/api/v1/chat/completions",
description: options.resourceDescription || "BlockRun AI API call",
mimeType: "application/json"
},
accepted: {
scheme: "exact",
network,
amount,
asset: net.usdc,
payTo: recipient,
maxTimeoutSeconds: options.maxTimeoutSeconds || 300,
extra: { name: net.domain.name, version: net.domain.version }
},
payload: {
signature,
authorization: {
from: fromAddress,
to: recipient,
value: amount,
validAfter: validAfter.toString(),
validBefore: validBefore.toString(),
nonce
}
},
extensions: withBuilderCodeServiceCode(options.extensions)
};
return btoa(JSON.stringify(paymentData));
}
async function createSolanaPaymentPayload(secretKey, fromAddress, recipient, amount, feePayer, options = {}) {
const {
Connection,
PublicKey,
TransactionMessage,
VersionedTransaction,
ComputeBudgetProgram,
Keypair
} = await loadSolanaWeb3("createSolanaPaymentPayload()");
const { getAssociatedTokenAddress, createTransferCheckedInstruction } = await loadSplToken(
"createSolanaPaymentPayload()"
);
const rpcUrl = options.rpcUrl || "https://sol.blockrun.ai/api/v1/solana/rpc";
const connection = options.rpcHeaders ? new Connection(rpcUrl, { httpHeaders: options.rpcHeaders }) : new Connection(rpcUrl);
const keypair = Keypair.fromSecretKey(secretKey);
const feePayerPubkey = new PublicKey(feePayer);
const ownerPubkey = keypair.publicKey;
const tokenMint = new PublicKey(USDC_SOLANA);
const payToPubkey = new PublicKey(recipient);
const sourceATA = await getAssociatedTokenAddress(tokenMint, ownerPubkey, false);
const destinationATA = await getAssociatedTokenAddress(tokenMint, payToPubkey, false);
const transferIx = createTransferCheckedInstruction(
sourceATA,
tokenMint,
destinationATA,
ownerPubkey,
BigInt(amount),
SOLANA_USDC_DECIMALS
);
const buildSignedTx = (blockhash, unitPriceMicroLamports) => {
const messageV0 = new TransactionMessage({
payerKey: feePayerPubkey,
recentBlockhash: blockhash,
instructions: [
ComputeBudgetProgram.setComputeUnitLimit({ units: DEFAULT_COMPUTE_UNIT_LIMIT }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: unitPriceMicroLamports }),
transferIx
]
}).compileToV0Message();
const transaction = new VersionedTransaction(messageV0);
transaction.sign([keypair]);
return Buffer.from(transaction.serialize()).toString("base64");
};
const findDistinctTx = (candidate) => {
const issued = issuedFor(candidate.blockhash);
for (let step = 0; step <= MAX_FEE_NONCE_STEPS; step++) {
const tx = buildSignedTx(
candidate.blockhash,
DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS + step
);
if (!issued.has(tx)) return tx;
}
return null;
};
let entry = await getBlockhashEntry(connection, rpcUrl, options.forceFreshBlockhash ?? false);
let serializedTx = findDistinctTx(entry);
if (serializedTx === null) {
entry = await getBlockhashEntry(connection, rpcUrl, true);
serializedTx = findDistinctTx(entry);
}
if (serializedTx === null) {
throw new Error(
`Solana payment: ${MAX_FEE_NONCE_STEPS + 1} identical payments already issued against blockhash ${entry.blockhash}. Retry once it rotates.`
);
}
issuedFor(entry.blockhash).add(serializedTx);
const paymentData = {
x402Version: 2,
resource: {
url: options.resourceUrl || "https://blockrun.ai/api/v1/chat/completions",
description: options.resourceDescription || "BlockRun AI API call",
mimeType: "application/json"
},
accepted: {
scheme: "exact",
network: SOLANA_NETWORK,
amount,
asset: USDC_SOLANA,
payTo: recipient,
maxTimeoutSeconds: options.maxTimeoutSeconds || 300,
extra: options.extra || { feePayer }
},
payload: {
transaction: serializedTx
},
extensions: withBuilderCodeServiceCode(options.extensions)
};
return btoa(JSON.stringify(paymentData));
}
function parsePaymentRequired(headerValue) {
try {
const decoded = atob(headerValue);
const parsed = JSON.parse(decoded);
if (!parsed.accepts || !Array.isArray(parsed.accepts)) {
throw new Error("Invalid payment required structure: missing or invalid 'accepts' field");
}
return parsed;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid payment required structure")) {
throw error;
}
throw new Error("Failed to parse payment required header: invalid format", {
cause: error
});
}
throw new Error("Failed to parse payment required header", { cause: error });
}
}
function extractPaymentDetails(paymentRequired, preferredNetwork) {
const accepts = paymentRequired.accepts || [];
if (accepts.length === 0) {
throw new Error("No payment options in payment required response");
}
let option = null;
if (preferredNetwork) {
option = accepts.find((opt) => opt.network === preferredNetwork) || null;
}
if (!option) {
option = accepts[0];
}
const amount = option.amount || option.maxAmountRequired;
if (!amount) {
throw new Error("No amount found in payment requirements");
}
return {
amount,
recipient: option.payTo,
network: option.network,
asset: option.asset,
scheme: option.scheme,
maxTimeoutSeconds: option.maxTimeoutSeconds || 300,
extra: option.extra,
resource: paymentRequired.resource
};
}
// src/cost-log.ts
var fs = __toESM(require("fs"), 1);
var path = __toESM(require("path"), 1);
var os = __toESM(require("os"), 1);
var BLOCKRUN_DIR = path.join(os.homedir(), ".blockrun");
var COST_LOG_FILE = path.join(BLOCKRUN_DIR, "cost_log.jsonl");
function logCost(entry) {
try {
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
} catch {
}
try {
fs.appendFileSync(COST_LOG_FILE, JSON.stringify(entry) + "\n");
} catch {
}
}
function getCostSummary() {
if (!fs.existsSync(COST_LOG_FILE)) {
return { totalUsd: 0, calls: 0, byModel: {}, byEndpoint: {} };
}
let totalUsd = 0;
let calls = 0;
const byModel = {};
const byEndpoint = {};
try {
const content = fs.readFileSync(COST_LOG_FILE, "utf-8").trim();
if (!content) return { totalUsd: 0, calls: 0, byModel: {}, byEndpoint: {} };
for (const line of content.split("\n")) {
if (!line) continue;
try {
const raw = JSON.parse(line);
const cost = typeof raw.cost_usd === "number" ? raw.cost_usd : raw.costUsd ?? 0;
if (!cost) continue;
totalUsd += cost;
calls += 1;
if (raw.model) byModel[raw.model] = (byModel[raw.model] || 0) + cost;
if (raw.endpoint) byEndpoint[raw.endpoint] = (byEndpoint[raw.endpoint] || 0) + cost;
} catch {
}
}
} catch {
}
return { totalUsd, calls, byModel, byEndpoint };
}
// src/version.ts
var SDK_VERSION = "3.25.0";
var USER_AGENT = `blockrun-ts/${SDK_VERSION}`;
// src/client.ts
function mapRawToModel(m) {
return {
id: m.id,
name: m.name || m.id,
provider: m.provider || m.owned_by || "",
description: m.description || "",
inputPrice: m.inputPrice ?? m.input_price ?? m.pricing?.input ?? 0,
outputPrice: m.outputPrice ?? m.output_price ?? m.pricing?.output ?? 0,
contextWindow: m.contextWindow ?? m.context_window ?? 0,
maxOutput: m.maxOutput ?? m.max_output ?? 0,
categories: m.categories || [],
available: true,
billingMode: m.billingMode ?? m.billing_mode,
flatPrice: m.flatPrice ?? m.flat_price ?? m.pricing?.flat,
hidden: m.hidden
};
}
function mapRawToImageModel(m) {
return {
id: m.id,
name: m.name || m.id,
provider: m.provider || m.owned_by || "",
description: m.description || "",
pricePerImage: m.pricePerImage ?? m.price_per_image ?? m.pricing?.per_image ?? m.pricing?.perImage ?? m.pricing?.flat ?? m.flatPrice ?? m.flat_price ?? 0,
supportedSizes: m.supportedSizes ?? m.supported_sizes,
maxPromptLength: m.maxPromptLength ?? m.max_prompt_length,
available: true
};
}
var DEFAULT_API_URL = "https://blockrun.ai/api";
var DEFAULT_MAX_TOKENS = 1024;
function resolveDefaultTimeout() {
const envVal = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_CHAT_TIMEOUT : void 0;
if (envVal !== void 0) {
const seconds = Number(envVal);
if (Number.isFinite(seconds) && seconds > 0) {
return seconds * 1e3;
}
}
return 6e5;
}
var DEFAULT_TIMEOUT = resolveDefaultTimeout();
var LLMClient = class _LLMClient {
static DEFAULT_API_URL = DEFAULT_API_URL;
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
modelPricingCache = null;
modelPricingPromise = null;
// Pre-auth cache: avoids the 402 round-trip on repeat requests to the same model.
// Key = "endpoint:model", value = cached payment header + timestamp + the
// length of the request body it was quoted for.
// TTL: 1 hour — pre-auth quotes are stable server-side on that horizon.
//
// A quote prices ONE request (input size + max_tokens), and exact settles
// the whole signed amount, so reusing it for a smaller request overpaid and
// for a larger one underpaid. The gateway refuses both with a 402 (an
// over-signed payment since blockrun#874), so a reuse is only attempted for
// a body the same size as the one that was quoted.
preAuthCache = /* @__PURE__ */ new Map();
static PRE_AUTH_TTL_MS = 36e5;
/**
* Initialize the BlockRun LLM client.
*
* @param options - Client configuration options (optional if BLOCKRUN_API_KEY, BASE_CHAIN_WALLET_KEY or BLOCKRUN_WALLET_KEY is set)
*/
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BASE_CHAIN_WALLET_KEY || process.env.BLOCKRUN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BASE_CHAIN_WALLET_KEY (or BLOCKRUN_WALLET_KEY) environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts2.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT;
}
/**
* Simple 1-line chat interface.
*
* @param model - Model ID (e.g., 'openai/gpt-5.2', 'anthropic/claude-sonnet-4.6')
* @param prompt - User message
* @param options - Optional chat parameters
* @returns Assistant's response text
*
* @example
* const response = await client.chat('gpt-5.2', 'What is the capital of France?');
* console.log(response); // 'The capital of France is Paris.'
*/
async chat(model, prompt, options) {
const messages = [];
if (options?.system) {
messages.push({ role: "system", content: options.system });
}
messages.push({ role: "user", content: prompt });
const result = await this.chatCompletion(model, messages, {
maxTokens: options?.maxTokens,
temperature: options?.temperature,
topP: options?.topP,
search: options?.search,
searchParameters: options?.searchParameters,
responseFormat: options?.responseFormat,
stop: options?.stop,
fallbackModels: options?.fallbackModels
});
return result.choices[0].message.content || "";
}
async makeRoutingDecision(prompt, systemPrompt, maxOutputTokens, options = {}) {
return routeWithCatalog(
prompt,
systemPrompt,
maxOutputTokens,
await this.getModelPricing(),
{ ...options, minimumPaymentUsd: this.apiAuth ? 0 : BASE_MINIMUM_PAYMENT_USD }
);
}
/**
* Inspect a local routing decision without making or paying for a model call.
* The first invocation may fetch the public model catalog for current prices.
*/
async route(prompt, options) {
return this.makeRoutingDecision(
prompt,
options?.system,
options?.maxOutputTokens ?? options?.maxTokens ?? DEFAULT_MAX_TOKENS,
{
routingProfile: options?.routingProfile,
requiresStructuredOutput: options?.responseFormat !== void 0,
search: options?.search,
searchParameters: options?.searchParameters
}
);
}
/**
* Smart chat with automatic model routing.
*
* Uses BlockRun's product-neutral Router Core portfolio strategy: it
* classifies the task shape locally (no
* extra model call), enforces capability constraints as hard filters, and
* ranks an ordered candidate portfolio — the cheapest model that can handle
* the request wins, and the rest become the transient-error fallback chain.
*
* @param prompt - User message
* @param options - Optional chat and routing parameters
* @returns SmartChatResponse with response text, selected model, and routing metadata
*
* @example Simple usage (auto profile)
* ```ts
* const result = await client.smartChat('What is 2+2?');
* console.log(result.response); // '4'
* console.log(result.model); // 'google/gemini-3.5-flash'
* console.log(result.routing.method); // 'portfolio'
* console.log(result.routing.savings); // 0.78 (78% savings)
* ```
*
* @example With routing profile
* ```ts
* // Eco mode (budget optimized)
* const result = await client.smartChat('Explain quantum computing', { routingProfile: 'eco' });
*
* // Premium mode (best quality)
* const result = await client.smartChat('Write a business plan', { routingProfile: 'premium' });
* ```
*/
async smartChat(prompt, options) {
const decision = await this.route(prompt, options);
const response = await this.chat(decision.model, prompt, {
system: options?.system,
maxTokens: options?.maxTokens,
temperature: options?.temperature,
topP: options?.topP,
search: options?.search,
searchParameters: options?.searchParameters,
responseFormat: options?.responseFormat,
stop: options?.stop,
// An explicit caller-supplied chain wins over the routed one.
fallbackModels: options?.fallbackModels ?? decision.fallbacks
});
return {
response,
model: decision.model,
routing: decision
};
}
/** Route a full Agent/tool conversation while preserving its complete response. */
async smartChatCompletion(messages, options = {}) {
const { prompt, systemPrompt, conversationChars, hasVision } = routingText(messages);
const decision = await this.makeRoutingDecision(
prompt,
systemPrompt,
options.maxOutputTokens ?? options.maxTokens ?? DEFAULT_MAX_TOKENS,
{
routingProfile: options.routingProfile,
requiresStructuredOutput: options.responseFormat !== void 0,
tools: options.tools,
toolChoice: options.toolChoice,
search: options.search,
searchParameters: options.searchParameters,
conversationChars,
hasVision
}
);
const response = await this.chatCompletion(decision.model, messages, {
maxTokens: options.maxTokens,
temperature: options.temperature,
topP: options.topP,
search: options.search,
searchParameters: options.searchParameters,
tools: options.tools,
toolChoice: options.toolChoice,
responseFormat: options.responseFormat,
stop: options.stop,
timeout: options.timeout,
// An explicit caller-supplied chain wins over the routed one.
fallbackModels: options.fallbackModels ?? decision.fallbacks
});
response.routing = decision;
return { response, model: decision.model, routing: decision };
}
/**
* Get model pricing map (cached).
* Fetches from API on first call, then returns cached result.
*/
async getModelPricing() {
if (this.modelPricingCache) {
return this.modelPricingCache;
}
if (this.modelPricingPromise) {
return this.modelPricingPromise;
}
this.modelPricingPromise = this.fetchModelPricing();
try {
this.modelPricingCache = await this.modelPricingPromise;
return this.modelPricingCache;
} finally {
this.modelPricingPromise = null;
}
}
/** Fetch model pricing from the live catalog, preserving flat billing. */
async fetchModelPricing() {
const models = await this.listModels();
const pricing = /* @__PURE__ */ new Map();
for (const model of models) {
if (model.available === false) continue;
const inputPrice = Number.isFinite(model.inputPrice) ? model.inputPrice : 0;
const outputPrice = Number.isFinite(model.outputPrice) ? model.outputPrice : 0;
if (model.billingMode === "flat" && Number.isFinite(model.flatPrice) && model.flatPrice > 0) {
pricing.set(model.id, { inputPrice, outputPrice, flatPrice: model.flatPrice });
} else {
pricing.set(model.id, { inputPrice, outputPrice });
}
}
registerSearchModels(pricing, models);
return pricing;
}
/**
* Full chat completion interface (OpenAI-compatible).
*
* When `fallbackModels` is set, transient failures (timeouts, network
* errors, 429, 5xx) on the primary model trigger a retry against the next
* model in the list before raising, but only while no payment has been
* sent for it (the error's {@link RetryDisposition} is `"unpaid"`). Once a
* payment was sent, any failure propagates: the next model would be a
* second charge. 4xx errors and PaymentError also propagate immediately —
* those aren't "swap upstream and retry" situations. Each fallback hop
* logs one stderr line.
*
* @param model - Primary model ID
* @param messages - Array of messages with role and content
* @param options - Optional completion parameters
* @returns ChatResponse object with choices and usage
*/
async chatCompletion(model, messages, options) {
const routingProfile = routingProfileForModel(model);
if (routingProfile) {
return (await this.smartChatCompletion(messages, {
...options,
routingProfile
})).response;
}
validateMaxTokens(options?.maxTokens);
const buildBody = (m) => {
const body = {
model: m,
messages,
max_tokens: options?.maxTokens || DEFAULT_MAX_TOKENS
};
if (options?.temperature !== void 0) body.temperature = options.temperature;
if (options?.topP !== void 0) body.top_p = options.topP;
if (options?.searchParameters !== void 0) {
body.search_parameters = toWireSearchParameters(options.searchParameters);
} else if (options?.search === true) {
body.search_parameters = { mode: "on" };
}
if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
if (options?.stop !== void 0) body.stop = options.stop;
return body;
};
const chain = [model, ...options?.fallbackModels ?? []];
let lastErr;
for (let i = 0; i < chain.length; i++) {
const candidate = chain[i];
try {
return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
} catch (err) {
lastErr = err;
const next = chain[i + 1];
if (!next || !isTransientError(err)) throw err;
console.error(
`[@blockrun/llm] ${candidate} -> ${next} (${errSummary(err)})`
);
}
}
throw lastErr;
}
/**
* Write a canonical cost_log entry after a settled x402 payment.
* Best-effort: failures here must never break a successful API call.
* Mirrors what Franklin's AgentClient writes via src/agent/llm.ts so
* cost_log.jsonl is a single source of truth regardless of caller.
*/
recordCost(url, costUsd, opts) {
try {
let endpoint = "";
try {
endpoint = new URL(url).pathname;
} catch {
endpoint = "";
}
const model = opts?.body && typeof opts.body.model === "string" ? opts.body.model : void 0;
logCost({
ts: Date.now() / 1e3,
endpoint,
cost_usd: costUsd,
model,
wallet: this.account.address,
network: opts?.network,
client_kind: "LLMClient"
});
} catch {
}
}
/**
* Parse the chat response JSON and attach `fallback` metadata when the
* gateway signalled a transparent free-fallback substitution. The
* gateway sets X-Fallback-Used / X-Fallback-Model / X-Settlement-Skipped
* on the response when it served a paid request from a free model
* (route.ts createPaymentResponseHeader path). Without surfacing these
* to the caller, the user gets a different model than requested with
* no visibility — silent quality drop and no clue why the on-chain
* balance didn't change.
*/
async parseChatResponse(response) {
const body = await response.json();
const used = response.headers.get("X-Fallback-Used") === "true";
if (used) {
body.fallback = {
used: true,
model: response.headers.get("X-Fallback-Model") || void 0,
settlementSkipped: response.headers.get("X-Settlement-Skipped") === "free-fallback"
};
}
return body;
}
/**
* Make a request with automatic x402 payment handling.
*
* Every error it throws carries a {@link RetryDisposition}, which is what
* {@link LLMClient.chatCompletion}'s fallback walk honours: errors of the
* unpaid first request (and its one 502/503 retry), of the 402 challenge
* and of signing are `"unpaid"`; anything after the signed payment was sent
* is `"paid-or-in-doubt"`. With an API key the request itself is billed, so
* only the account API's explicit 4xx refusal is `"unpaid"`.
*/
async requestWithPayment(endpoint, body, timeoutMs) {
const url = `${this.apiUrl}${endpoint}`;
const unpaid = {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: JSON.stringify(body)
};
const response = await this.sendUnpaid(url, unpaid, timeoutMs);
if (response.status === 502 || response.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp = await this.sendUnpaid(url, unpaid, timeoutMs);
if (retryResp.status !== 502 && retryResp.status !== 503) {
if (retryResp.status === 402) return this.handlePaymentAndRetry(url, body, retryResp, timeoutMs);
if (!retryResp.ok) {
let errorBody;
try {
errorBody = await retryResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw withDisposition(
new APIError(`API error: ${retryResp.status}`, retryResp.status, sanitizeErrorResponse(errorBody)),
"unpaid"
);
}
return this.parseUnpaidChatResponse(retryResp);
}
}
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response, timeoutMs);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw withDisposition(
new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
),
"unpaid"
);
}
return this.parseUnpaidChatResponse(response);
}
/**
* Send a request that carries no payment, marking what it throws with its
* {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
* {@link accountErrorDisposition} (the request itself is billed).
*/
async sendUnpaid(url, init, timeoutMs) {
try {
return await this.fetchWithTimeout(url, init, timeoutMs);
} catch (err) {
throw withDisposition(err, this.apiAuth ? accountErrorDisposition(err) : "unpaid");
}
}
/**
* Parse a response to a request that carried no payment: the free tier,
* or, with an API key, the billed request itself.
*
* With a wallet nothing was paid, so `costUsd` is 0 (a free model). With an
* API key the call is billed to the account and `costUsd` stays undefined.
*/
async parseUnpaidChatResponse(response) {
let served;
try {
served = await this.parseChatResponse(response);
} catch (err) {
throw withDisposition(err, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
}
if (!this.apiAuth) {
served.costUsd = 0;
served.settlement = { scheme: "free", quotedUsd: 0 };
}
return served;
}
/**
* Handle 402 response: parse requirements, sign payment, retry.
*
* Anything thrown before the signed payment is sent is `"unpaid"`; anything
* after it, `"paid-or-in-doubt"` (see {@link RetryDisposition}).
*/
async handlePaymentAndRetry(url, body, response, timeoutMs) {
let signed;
try {
signed = await this.signFrom402(url, response);
} catch (err) {
throw withDisposition(err, "unpaid");
}
try {
return await this.sendPaid(url, body, signed.paymentPayload, signed.details, timeoutMs);
} catch (err) {
throw withDisposition(err, "paid-or-in-doubt");
}
}
/** Read a 402's requirements and sign the exact payment for them. Sends nothing. */
async signFrom402(url, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch (parseError) {
console.debug("Failed to parse payment header from response body", parseError);
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || `${this.apiUrl}/v1/chat/completions`,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
return { paymentPayload, details };
}
/** Send the signed payment and read the answer. The payment may settle from here on. */
async sendPaid(url, body, paymentPayload, details, timeoutMs) {
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
}, timeoutMs);
if (retryResponse.status === 502 || retryResponse.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp2 = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
}, timeoutMs);
if (retryResp2.status !== 502 && retryResp2.status !== 503) {
if (retryResp2.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
if (!retryResp2.ok) {
let errorBody;
try {
errorBody = await retryResp2.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResp2.status}`, retryResp2.status, sanitizeErrorResponse(errorBody));
}
return this.bookPaid(url, body, details, retryResp2);
}
}
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retryResponse.status}`,
retryResponse.status,
sanitizeErrorResponse(errorBody)
);
}
return this.bookPaid(url, body, details, retryResponse);
}
/**
* Book a served, paid chat call and attach its cost. A response whose
* settlement the gateway skipped ({@link settlementSkipped}: e.g.
* `X-Settlement-Skipped: free-fallback`) moved nothing on chain, so it is
* not added to `getSpending()` or the cost log, and reports `costUsd: 0`.
*/
async bookPaid(url, body, details, response) {
const costUsd = parseFloat(details.amount) / 1e6;
if (!settlementSkipped(response)) {
this.sessionCalls += 1;
this.sessionTotalUsd += costUsd;
this.recordCost(url, costUsd, { body, network: details.network });
}
return withCost(await this.parseChatResponse(response), response, costUsd, "exact");
}
/**
* Book a served, paid raw (non-chat) call: the signed amount goes to
* `getSpending()` and the cost log, unless the gateway skipped settlement
* ({@link settlementSkipped}: e.g. `X-Settlement-Skipped: empty-result` on an
* empty prediction-market search, `unexecuted` on a JSON-RPC call that never
* ran, or `X-Payment-Waived`), in which case nothing moved and nothing is
* booked. `X-Payment-Settled: false` alone is still booked: it is owed.
*/
bookPaidRaw(url, details, response, body) {
if (settlementSkipped(response)) return;
const costUsd = parseFloat(details.amount) / 1e6;
this.sessionCalls += 1;
this.sessionTotalUsd += costUsd;
this.recordCost(url, costUsd, body ? { body, network: details.network } : { network: details.network });
}
/**
* Sign a payment header and return the PAYMENT-SIGNATURE value.
* Extracted to share logic between streaming and non-streaming flows.
*/
async signPayment(paymentHeader) {
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || `${this.apiUrl}/v1/chat/completions`,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
const costUsd = parseFloat(details.amount) / 1e6;
return { paymentPayload, costUsd };
}
/**
* Streaming chat completion with automatic x402 payment.
*
* Uses a pre-auth cache so repeat calls to the same model skip the 402
* round-trip (~200ms savings). Falls back to the normal 402 flow on cache
* miss or if the pre-signed payment is rejected.
*
* @returns Raw fetch Response with a streaming SSE body.
*/
async chatCompletionStream(model, messages, options) {
validateMaxTokens(options?.maxTokens);
let requestModel = model;
const routingProfile = routingProfileForModel(model);
if (routingProfile) {
const { prompt, systemPrompt, conversationChars, hasVision } = routingText(messages);
const decision = await this.makeRoutingDecision(
prompt,
systemPrompt,
options?.maxTokens ?? DEFAULT_MAX_TOKENS,
{
routingProfile,
requiresStructuredOutput: options?.responseFormat !== void 0,
tools: options?.tools,
toolChoice: options?.toolChoice,
conversationChars,
hasVision
}
);
requestModel = decision.model;
}
const url = `${this.apiUrl}/v1/chat/completions`;
const body = {
model: requestModel,
messages,
max_tokens: options?.maxTokens ?? DEFAULT_MAX_TOKENS,
stream: true
};
if (options?.temperature !== void 0) body.temperature = options.temperature;
if (options?.topP !== void 0) body.top_p = options.topP;
if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
if (options?.stop !== void 0) body.stop = options.stop;
const cacheKey2 = `/v1/chat/completions:${requestModel}`;
const requestBody = JSON.stringify(body);
const cached = this.preAuthCache.get(cacheKey2);
const now = Date.now();
if (cached && now - cached.cachedAt < _LLMClient.PRE_AUTH_TTL_MS && cached.bodyLength === requestBody.length) {
let signed;
try {
signed = await this.signPayment(cached.paymentHeader);
} catch {
this.preAuthCache.delete(cacheKey2);
}
if (signed) {
let preAuthResp;
try {
preAuthResp = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": signed.paymentPayload
},
body: JSON.stringify(body)
});
} catch (err) {
this.preAuthCache.delete(cacheKey2);
throw withDisposition(err, "paid-or-in-doubt");
}
if (preAuthResp.ok) {
if (!settlementSkipped(preAuthResp)) {
this.sessionCalls += 1;
this.sessionTotalUsd += signed.costUsd;
}
return preAuthResp;
}
this.preAuthCache.delete(cacheKey2);
if (preAuthResp.status !== 402) {
let errorBody;
try {
errorBody = await preAuthResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw withDisposition(
new APIError(`API error after payment: ${preAuthResp.status}`, preAuthResp.status, sanitizeErrorResponse(errorBody)),
"paid-or-in-doubt"
);
}
}
}
const firstResp = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: JSON.stringify(body)
});
if (firstResp.status !== 402) {
if (!firstResp.ok) {
let errorBody;
try {
errorBody = await firstResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${firstResp.status}`, firstResp.status, sanitizeErrorResponse(errorBody));
}
return firstResp;
}
let paymentHeader = firstResp.headers.get("payment-required");
if (!paymentHeader) {
try {
const rb = await firstResp.json();
if (rb.x402 || rb.accepts) paymentHeader = btoa(JSON.stringify(rb));
} catch {
}
}
if (!paymentHeader) throw new PaymentError("402 response but no payment requirements found");
this.preAuthCache.set(cacheKey2, { paymentHeader, cachedAt: now, bodyLength: requestBody.length });
const { paymentPayload, costUsd } = await this.signPayment(paymentHeader);
const streamResp = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (streamResp.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
if (!streamResp.ok) {
let errorBody;
try {
errorBody = await streamResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${streamResp.status}`, streamResp.status, sanitizeErrorResponse(errorBody));
}
if (!settlementSkipped(streamResp)) {
this.sessionCalls += 1;
this.sessionTotalUsd += costUsd;
}
return streamResp;
}
/**
* Make a request with automatic x402 payment handling, returning raw JSON.
* Used for non-ChatResponse endpoints (search, image edit, etc.).
*/
async requestWithPaymentRaw(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: JSON.stringify(body)
});
if (response.status === 502 || response.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: JSON.stringify(body)
});
if (retryResp.status !== 502 && retryResp.status !== 503) {
if (retryResp.status === 402) return this.handlePaymentAndRetryRaw(url, body, retryResp);
if (!retryResp.ok) {
let errorBody;
try {
errorBody = await retryResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${retryResp.status}`, retryResp.status, sanitizeErrorResponse(errorBody));
}
return retryResp.json();
}
}
if (response.status === 402) {
return this.handlePaymentAndRetryRaw(url, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
if (this.apiAuth && endpoint.startsWith("/v1/images/")) return this.apiAuth.poll(response, this.timeout, 2e3);
return response.json();
}
/**
* Handle 402 response for raw endpoints: parse requirements, sign payment, retry.
*/
async handlePaymentAndRetryRaw(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
console.debug("Failed to parse payment header from response body");
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || url,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 502 || retryResponse.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp2 = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResp2.status !== 502 && retryResp2.status !== 503) {
if (retryResp2.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
if (!retryResp2.ok) {
let errorBody;
try {
errorBody = await retryResp2.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResp2.status}`, retryResp2.status, sanitizeErrorResponse(errorBody));
}
this.bookPaidRaw(url, details, retryResp2, body);
return retryResp2.json();
}
}
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retryResponse.status}`,
retryResponse.status,
sanitizeErrorResponse(errorBody)
);
}
this.bookPaidRaw(url, details, retryResponse, body);
return retryResponse.json();
}
/**
* GET with automatic x402 payment handling, returning raw JSON.
* Used for Predexon prediction market endpoints that use GET + query params.
*/
async getWithPaymentRaw(endpoint, params) {
const query = params ? "?" + new URLSearchParams(params).toString() : "";
const url = `${this.apiUrl}${endpoint}${query}`;
const response = await this.fetchWithTimeout(url, {
method: "GET",
headers: { "User-Agent": USER_AGENT }
});
if (response.status === 502 || response.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp = await this.fetchWithTimeout(url, {
method: "GET",
headers: { "User-Agent": USER_AGENT }
});
if (retryResp.status !== 502 && retryResp.status !== 503) {
if (retryResp.status === 402) return this.handleGetPaymentAndRetryRaw(url, endpoint, params, retryResp);
if (!retryResp.ok) {
let errorBody;
try {
errorBody = await retryResp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${retryResp.status}`, retryResp.status, sanitizeErrorResponse(errorBody));
}
return retryResp.json();
}
}
if (response.status === 402) {
return this.handleGetPaymentAndRetryRaw(url, endpoint, params, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
return response.json();
}
/**
* Handle 402 response for GET endpoints: parse requirements, sign payment, retry with GET.
*/
async handleGetPaymentAndRetryRaw(url, endpoint, params, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
console.debug("Failed to parse payment header from response body");
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || url,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
const query = params ? "?" + new URLSearchParams(params).toString() : "";
const retryUrl = `${this.apiUrl}${endpoint}${query}`;
const retryResponse = await this.fetchWithTimeout(retryUrl, {
method: "GET",
headers: {
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
}
});
if (retryResponse.status === 502 || retryResponse.status === 503) {
await new Promise((r) => setTimeout(r, 1e3));
const retryResp2 = await this.fetchWithTimeout(retryUrl, {
method: "GET",
headers: {
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
}
});
if (retryResp2.status !== 502 && retryResp2.status !== 503) {
if (retryResp2.status === 402) throw new PaymentError("Payment was rejected. Check your wallet balance.");
if (!retryResp2.ok) {
let errorBody;
try {
errorBody = await retryResp2.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResp2.status}`, retryResp2.status, sanitizeErrorResponse(errorBody));
}
this.bookPaidRaw(url, details, retryResp2);
return retryResp2.json();
}
}
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retryResponse.status}`,
retryResponse.status,
sanitizeErrorResponse(errorBody)
);
}
this.bookPaidRaw(url, details, retryResponse);
return retryResponse.json();
}
/**
* Fetch with timeout: `timeoutMs` (a per-call override), else the client's `timeout`.
*/
async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* List available models with pricing.
*
* Returns the full `/v1/models` unified catalog (chat + image + music).
* The shape preserves backwards compatibility — image/music rows have
* `inputPrice = outputPrice = 0` since those fields don't apply, and
* their per-call price surfaces via `flatPrice`.
*/
async listModels() {
const raw = await this.fetchRawModels();
return raw.map((m) => mapRawToModel(m));
}
/**
* List available image generation models with pricing.
*
* The dedicated `/v1/images/models` endpoint was deprecated server-side;
* image models live in the unified `/v1/models` catalog under
* `categories: ["image", ...]`. This method filters that catalog so
* existing callers keep working.
*/
async listImageModels() {
const raw = await this.fetchRawModels();
return raw.filter((m) => Array.isArray(m.categories) && m.categories.includes("image")).map((m) => mapRawToImageModel(m));
}
/**
* List all available models (chat, image, music, etc.) with pricing.
*
* @returns Array of all models with `type` field set from category
* (`llm` for chat, `image` / `music` for media). Backwards-compat:
* chat models always report `type: "llm"`.
*/
async listAllModels() {
const raw = await this.fetchRawModels();
const out = [];
for (const m of raw) {
const cats = Array.isArray(m.categories) ? m.categories : [];
if (cats.includes("image")) {
const model = mapRawToImageModel(m);
model.type = "image";
out.push(model);
} else {
const model = mapRawToModel(m);
model.type = "llm";
out.push(model);
}
}
return out;
}
/**
* Internal: fetch the raw `/v1/models` catalog without normalising shape.
* Used by listImageModels / listAllModels so each can pick category-
* specific fields.
*/
async fetchRawModels() {
const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/models`, {
method: "GET"
});
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`Failed to list models: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
const data = await response.json();
return data.data || [];
}
/**
* Edit an image using img2img, optionally fusing multiple source images.
*
* @param prompt - Text description of the desired edit
* @param image - A base64 `data:image/...` URI, or an array of 2–4 such URIs
* to fuse (image fusion). Caps: `openai/*` up to 4, `google/*` up to 3.
* A `mask` cannot be combined with multiple source images.
* @param options - Optional edit parameters
* @returns ImageResponse with edited image URLs
*/
async imageEdit(prompt, image, options) {
const body = {
model: options?.model || "openai/gpt-image-2",
prompt,
image,
size: options?.size || "1024x1024",
n: options?.n || 1
};
if (options?.mask !== void 0) {
body.mask = options.mask;
}
const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
return data;
}
/**
* Standalone search over web and news. For X/Twitter, use chat on an
* `xai/*` model with an `x_search` tool.
*
* @param query - Search query
* @param options - Optional search parameters
* @returns SearchResult with summary and citations
*/
async search(query, options) {
const body = {
query,
max_results: options?.maxResults || 10
};
if (options?.sources !== void 0) body.sources = options.sources;
if (options?.fromDate !== void 0) body.from_date = options.fromDate;
if (options?.toDate !== void 0) body.to_date = options.toDate;
const data = await this.requestWithPaymentRaw("/v1/search", body);
return data;
}
/**
* Generic Exa endpoint proxy (POST). Useful when you need an Exa API
* surface that the typed wrappers below don't expose.
*
* @param path - Exa endpoint segment: "search" | "find-similar" | "contents" | "answer"
* @param body - Request body (see Exa API docs)
*
* @example
* const results = await client.exa("search", { query: "latest AI research", numResults: 5 });
*/
async exa(path7, body) {
return this.requestWithPaymentRaw(`/v1/exa/${path7}`, body);
}
/**
* Neural web search via Exa. Returns semantically relevant URLs and metadata.
* Understands meaning, not just keywords.
*
* @param query - Natural language search query
* @param options - Optional filters (numResults, category, date range, domains)
*/
async exaSearch(query, options) {
const body = { query };
if (options?.numResults !== void 0) body.numResults = options.numResults;
if (options?.category !== void 0) body.category = options.category;
if (options?.startPublishedDate !== void 0) body.startPublishedDate = options.startPublishedDate;
if (options?.endPublishedDate !== void 0) body.endPublishedDate = options.endPublishedDate;
if (options?.includeDomains !== void 0) body.includeDomains = options.includeDomains;
if (options?.excludeDomains !== void 0) body.excludeDomains = options.excludeDomains;
const data = await this.requestWithPaymentRaw("/v1/exa/search", body);
return data.data;
}
/**
* Ask a question and get a cited, synthesized answer grounded in real web sources.
* No hallucinations — every claim is backed by a citation.
*
* @param query - The question to answer
*/
async exaAnswer(query) {
const data = await this.requestWithPaymentRaw("/v1/exa/answer", { query });
return data.data;
}
/**
* Fetch full Markdown text content from a list of URLs. Billed per URL.
* Returns clean text ready to feed into an LLM context window.
*
* @param urls - Array of URLs to fetch (up to 100)
*/
async exaContents(urls) {
const data = await this.requestWithPaymentRaw("/v1/exa/contents", { urls });
return data.data;
}
/**
* Find pages semantically similar to a given URL.
* Useful for discovering competitors, alternatives, and related resources.
*
* @param url - Reference URL
* @param options - Optional filters (numResults, excludeSourceDomain)
*/
async exaFindSimilar(url, options) {
const body = { url };
if (options?.numResults !== void 0) body.numResults = options.numResults;
if (options?.excludeSourceDomain !== void 0) body.excludeSourceDomain = options.excludeSourceDomain;
const data = await this.requestWithPaymentRaw("/v1/exa/find-similar", body);
return data.data;
}
/**
* Mint a one-time Coinbase Onramp link to fund this wallet with USDC on Base.
*
* FREE — the x402 signature only authenticates the wallet (no payment is
* charged). Because the funding address must equal the signing wallet, this
* mints a `pay.coinbase.com` link prefilled for `address` so a card/bank can
* top it up (60+ fiat currencies → Base USDC). Base / USDC only.
*
* The returned URL is single-use and expires in ~5 minutes — mint it at click
* time and never cache it. The funding `address` must match the signing wallet.
*
* @param address - Destination Base wallet address (0x + 40 hex chars). Must
* equal the signing wallet that funds it.
* @returns `{ url }` — a one-time https://pay.coinbase.com/... link.
*
* @example
* const { url } = await client.onramp(client.getWalletAddress());
* console.log(`Fund your wallet: ${url}`);
*/
async onramp(address) {
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
throw new Error("address must be a 0x-prefixed 40-hex-character Base address");
}
const data = await this.requestWithPaymentRaw("/v1/onramp/token", {
address,
network: "base",
asset: "USDC"
});
const url = typeof data.url === "string" ? data.url : "";
if (!url.startsWith("https://pay.coinbase.com/")) {
throw new APIError("gateway returned no onramp url", 502, { message: "gateway returned no onramp url" });
}
return { url };
}
/**
* Get USDC balance on Base mainnet.
*
* @returns USDC balance as a float (6 decimal places normalized)
*
* @example
* const balance = await client.getBalance();
* console.log(`Balance: $${balance.toFixed(2)} USDC`);
*/
async getBalance() {
const usdcContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const configuredRpc = typeof process !== "undefined" && process.env ? process.env.BASE_RPC_URL : void 0;
const rpcs = [
configuredRpc,
"https://base-rpc.publicnode.com",
"https://mainnet.base.org",
"https://base.llamarpc.com"
].filter((rpc) => Boolean(rpc));
const selector = "0x70a08231";
const paddedAddress = this.account.address.slice(2).toLowerCase().padStart(64, "0");
const data = selector + paddedAddress;
const payload = {
jsonrpc: "2.0",
method: "eth_call",
params: [{ to: usdcContract, data }, "latest"],
id: 1
};
let lastError;
for (const rpc of rpcs) {
try {
const response = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`Base RPC returned ${response.status}`);
const result = await response.json();
if (!result.result || result.error) throw new Error("Base RPC returned no balance result");
const balanceRaw = parseInt(result.result || "0x0", 16);
return balanceRaw / 1e6;
} catch (e) {
lastError = e;
}
}
throw lastError || new Error("All RPCs failed");
}
/**
* Query Predexon prediction market data (GET endpoints).
*
* Access real-time data from Polymarket, Kalshi, Limitless, Opinion, Predict.Fun, Binance Futures, UMA and sports markets.
* Powered by Predexon. Billed per request.
*
* @param path - Endpoint path, e.g. "polymarket/events", "kalshi/markets/12345"
* @param params - Query parameters passed to the endpoint
*
* @example
* const events = await client.pm("polymarket/events");
* const market = await client.pm("kalshi/markets/KXBTC-25MAR14");
* const results = await client.pm("polymarket/search", { q: "bitcoin" });
*/
async pm(path7, params) {
return this.getWithPaymentRaw(`/v1/pm/${path7}`, params);
}
/**
* Structured query for Predexon prediction market data (POST endpoints).
*
* For endpoints that require a JSON body, e.g. bulk wallet identity lookup.
* Tier 1 =, Tier 2 =
*
* @param path - Endpoint path, e.g. "polymarket/wallet/identities"
* @param query - JSON body for the structured query
*
* @example
* const batch = await client.pmQuery("polymarket/wallet/identities", {
* addresses: ["0xabc...", "0xdef..."],
* });
*/
async pmQuery(path7, query) {
return this.requestWithPaymentRaw(`/v1/pm/${path7}`, query);
}
// ── PM convenience helpers (Predexon v2) ─────────────────────────────────
// Thin wrappers over pm() / pmQuery() for the most common v2 endpoints.
/** RETIRED — `/v1/pm/markets` no longer exists.
*
* Predexon sunset market matching on 2026-07-20 and the whole canonical layer
* went with it, so this path returns 410 upstream. Use
* `pm("markets/search", { q })` for cross-venue lookups.
*
* Kept as a throwing stub rather than deleted so upgrading does not break
* property access; it throws before any network I/O.
*
* @throws {RetiredEndpointError} always. */
async pmMarkets(_params) {
throw new RetiredEndpointError(
'/v1/pm/markets was sunset by Predexon on 2026-07-20 (upstream 410). Use pm("markets/search", { q }) for cross-venue lookups.'
);
}
/** RETIRED — `/v1/pm/markets/listings` no longer exists (410, sunset
* 2026-07-20 with market matching). Use `pm("markets/search", { q })`.
*
* @throws {RetiredEndpointError} always. */
async pmListings(_params) {
throw new RetiredEndpointError(
'/v1/pm/markets/listings was sunset by Predexon on 2026-07-20 (upstream 410). Use pm("markets/search", { q }) for cross-venue lookups.'
);
}
/** RETIRED — `/v1/pm/outcomes/{predexonId}` no longer exists (410, sunset
* 2026-07-20 with market matching). Use `pm("markets/search", { q })`.
*
* @throws {RetiredEndpointError} always. */
async pmOutcome(_predexonId) {
throw new RetiredEndpointError(
'/v1/pm/outcomes/{predexon_id} was sunset by Predexon on 2026-07-20 (upstream 410). Use pm("markets/search", { q }) for cross-venue lookups.'
);
}
/** Polymarket markets with cursor-based keyset pagination (use pagination_key).
* Tier 1. */
async pmPolymarketMarketsKeyset(params) {
return this.pm("polymarket/markets/keyset", params);
}
/** Polymarket events with cursor-based keyset pagination (use pagination_key).
* Tier 1. */
async pmPolymarketEventsKeyset(params) {
return this.pm("polymarket/events/keyset", params);
}
/** List available sports categories. Tier 1.
*
* NOTE: upstream returns 500 for every `sports/*` path as of 2026-08-04.
* The route still resolves, so this works again the moment Predexon restores
* it, but do not build on it yet. */
async pmSportsCategories() {
return this.pm("sports/categories");
}
/** List sports markets grouped by game. Filter with league, sport_type,
* status, venue. Tier 1.
*
* NOTE: upstream returns 500 for every `sports/*` path as of 2026-08-04. */
async pmSportsMarkets(params) {
return this.pm("sports/markets", params);
}
/** Fetch identity + profile metadata for one wallet (ENS, Twitter, portfolio,
* etc.). Tier 2. */
async pmWalletIdentity(wallet) {
return this.pm(`polymarket/wallet/identity/${wallet}`);
}
/** Bulk identity lookup for up to 200 wallet addresses (POST). Tier 2. */
async pmWalletIdentities(addresses) {
return this.pmQuery("polymarket/wallet/identities", { addresses });
}
/** Discover wallets connected to a seed address via on-chain transfers and
* identity proofs. Tier 2. */
async pmWalletCluster(address) {
return this.pm(`polymarket/wallet/${address}/cluster`);
}
// ============================================================
// DefiLlama (DeFi protocols / TVL / yields / prices)
// ============================================================
/**
* Query DefiLlama DeFi data (GET passthrough). Powered by DefiLlama.
* for protocols / protocol/{slug} / chains / yields;
* for prices/{coins}.
*
* @param path - e.g. "protocols", "protocol/aave", "chains", "yields",
* "prices/coingecko:bitcoin,base:0x..."
* @param params - Query parameters passed through to DefiLlama
*/
async defi(path7, params) {
return this.getWithPaymentRaw(`/v1/defillama/${path7}`, params);
}
/** All DeFi protocols with TVL. */
async defiProtocols() {
return this.defi("protocols");
}
/** Single protocol details + historical TVL. */
async defiProtocol(slug) {
return this.defi(`protocol/${slug}`);
}
/** Current TVL of every chain. */
async defiChains() {
return this.defi("chains");
}
/** Yield pools with APY/TVL. */
async defiYields(params) {
return this.defi("yields", params);
}
/** Token price lookup. Coins like "coingecko:bitcoin" or "{chain}:{address}". */
async defiPrices(coins) {
const joined = Array.isArray(coins) ? coins.join(",") : coins;
return this.defi(`prices/${joined}`);
}
// ============================================================
// 0x DEX (swap quotes + gasless) — free passthrough
// ============================================================
/**
* Query the 0x Swap / Gasless APIs (free — no x402 payment; BlockRun
* takes an on-chain affiliate fee on executed swaps instead).
*
* @param path - "price", "quote", "gasless/price", "gasless/quote",
* "gasless/submit" (POST), "gasless/status/{hash}",
* "gasless/approval-tokens", "gasless/chains", "swap/chains"
* @param params - Query params (chainId, sellToken, buyToken, sellAmount, taker, ...)
* @param body - JSON body — pass to switch to POST (gasless/submit only)
*/
async dex(path7, params, body) {
if (body) return this.requestWithPaymentRaw(`/v1/zerox/${path7}`, body);
return this.getWithPaymentRaw(`/v1/zerox/${path7}`, params);
}
/** Indicative Permit2 swap price — no commitment (free). */
async dexPrice(params) {
return this.dex("price", params);
}
/** Firm Permit2 swap quote with permit2.eip712 + tx data (free). */
async dexQuote(params) {
return this.dex("quote", params);
}
/** Gasless indicative price quote (free). */
async dexGaslessPrice(params) {
return this.dex("gasless/price", params);
}
/** Gasless firm quote — returns trade.eip712 to sign (free). */
async dexGaslessQuote(params) {
return this.dex("gasless/quote", params);
}
/** Submit a signed gasless trade; the 0x relayer pays gas (free). */
async dexGaslessSubmit(body) {
return this.dex("gasless/submit", void 0, body);
}
/** Poll a gasless trade's status by tradeHash (free). */
async dexGaslessStatus(tradeHash) {
return this.dex(`gasless/status/${tradeHash}`);
}
/** Chains where the Swap API is supported (free). */
async dexChains() {
return this.dex("swap/chains");
}
/** Chains where the Gasless API is supported (free). */
async dexGaslessChains() {
return this.dex("gasless/chains");
}
// ============================================================
// Modal Sandbox (pay-per-call cloud compute)
// ============================================================
/**
* Call the Modal sandbox compute API (POST passthrough).
*
* @param path - "sandbox/create" (CPU / GPU tiers), "sandbox/exec",
* "sandbox/status", "sandbox/terminate"
* @param body - JSON body for the endpoint
*/
async modal(path7, body) {
return this.requestWithPaymentRaw(`/v1/modal/${path7}`, body ?? {});
}
/** Create a sandboxed compute environment (CPU / GPU tiers). */
async modalSandboxCreate(body) {
return this.modal("sandbox/create", body);
}
/** Execute a command in a sandbox; returns stdout/stderr. */
async modalSandboxExec(sandboxId, command, extra) {
return this.modal("sandbox/exec", { sandbox_id: sandboxId, command, ...extra });
}
/** Check a sandbox's status. */
async modalSandboxStatus(sandboxId) {
return this.modal("sandbox/status", { sandbox_id: sandboxId });
}
/** Terminate a sandbox. */
async modalSandboxTerminate(sandboxId) {
return this.modal("sandbox/terminate", { sandbox_id: sandboxId });
}
/**
* Get current session spending.
*
* @returns Object with totalUsd and calls count
*
* @example
* const spending = client.getSpending();
* console.log(`Spent $${spending.totalUsd.toFixed(4)} across ${spending.calls} calls`);
*/
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return {
totalUsd: this.sessionTotalUsd,
calls: this.sessionCalls
};
}
/**
* Get the wallet address being used for payments.
*/
getWalletAddress() {
return this.account.address;
}
};
var client_default = LLMClient;
// src/image.ts
var import_accounts3 = require("viem/accounts");
var DEFAULT_API_URL2 = "https://blockrun.ai/api";
var DEFAULT_MODEL = "google/nano-banana";
var DEFAULT_SIZE = "1024x1024";
var DEFAULT_TIMEOUT2 = 2e5;
var POLL_INTERVAL_MS = 3e3;
var POLL_MAX_DURATION_MS = 3e5;
var ImageClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
/**
* Initialize the BlockRun Image client.
*
* @param options - Client configuration options
*/
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts3.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL2;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT2;
}
/**
* Generate an image from a text prompt.
*
* @param prompt - Text description of the image to generate
* @param options - Optional generation parameters
* @returns ImageResponse with generated image URLs
*
* @example
* const result = await client.generate('A sunset over mountains');
* console.log(result.data[0].url);
*/
async generate(prompt, options) {
const body = {
model: options?.model || DEFAULT_MODEL,
prompt,
size: options?.size || DEFAULT_SIZE,
n: options?.n || 1
};
if (options?.quality) {
body.quality = options.quality;
}
return this.requestWithPayment("/v1/images/generations", body);
}
/**
* Edit an image using img2img, optionally fusing multiple source images.
*
* Pass a single `data:image/...` base64 data URI to edit one image, or an
* array of 2–4 data URIs to fuse them (image fusion) — e.g. a reference photo
* plus a brand logo. The server caps fusion per provider: `openai/*` accepts
* up to 4 source images, `google/*` (Nano Banana) up to 3. A `mask` cannot be
* combined with multiple source images.
*
* Edit-capable models: `openai/gpt-image-1`, `openai/gpt-image-2`,
* `openai/gpt-image-2.5-sunburst`, `google/nano-banana`, `google/nano-banana-2`,
* `google/nano-banana-pro`. `mask` is OpenAI-only.
*
* @param prompt - Text description of the desired edit
* @param image - A base64 `data:image/...` URI, or an array of 2–4 such URIs to fuse
* @param options - Optional edit parameters
* @returns ImageResponse with edited image URLs
*
* @example
* // Single-image edit
* const result = await client.edit('Make it a painting', imageDataUri);
*
* @example
* // Multi-image fusion (Nano Banana): blend a subject with a brand logo
* const fused = await client.edit(
* 'Place the logo on the t-shirt',
* [subjectDataUri, logoDataUri],
* { model: 'google/nano-banana' }
* );
*/
async edit(prompt, image, options) {
const body = {
model: options?.model || "openai/gpt-image-2",
prompt,
image,
size: options?.size || "1024x1024",
n: options?.n || 1
};
if (options?.mask !== void 0) {
body.mask = options.mask;
}
return this.requestWithPayment("/v1/images/image2image", body);
}
/**
* List available image generation models with pricing.
*
* The dedicated `/v1/images/models` endpoint was deprecated server-side;
* image models live in the unified `/v1/models` catalog under
* `categories: ["image", ...]`. This method filters that catalog so
* existing callers keep working.
*/
async listImageModels() {
const response = await this.fetchWithTimeout(
`${this.apiUrl}/v1/models`,
{ method: "GET" }
);
if (!response.ok) {
throw new APIError(
`Failed to list image models: ${response.status}`,
response.status
);
}
const data = await response.json();
return (data.data || []).filter((m) => Array.isArray(m.categories) && m.categories.includes("image")).map((m) => ({
id: m.id,
name: m.name || m.id,
provider: m.provider || m.owned_by || "",
description: m.description || "",
pricePerImage: m.pricePerImage ?? m.price_per_image ?? m.pricing?.per_image ?? m.pricing?.perImage ?? m.pricing?.flat ?? m.flatPrice ?? m.flat_price ?? 0,
supportedSizes: m.supportedSizes ?? m.supported_sizes,
maxPromptLength: m.maxPromptLength ?? m.max_prompt_length,
available: true
}));
}
/**
* Make a request with automatic x402 payment handling.
*/
async requestWithPayment(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
if (this.apiAuth) return this.apiAuth.poll(response, POLL_MAX_DURATION_MS, POLL_INTERVAL_MS);
return response.json();
}
/**
* Handle 402 response: parse requirements, sign payment, retry.
*/
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || `${this.apiUrl}/v1/images/generations`,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun Image Generation",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 402) {
throw new PaymentError(
"Payment was rejected. Check your wallet balance."
);
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retryResponse.status}`,
retryResponse.status,
sanitizeErrorResponse(errorBody)
);
}
const responseBody = await retryResponse.json();
if (retryResponse.status === 202 || responseBody.status === "queued" || responseBody.status === "in_progress") {
if (!responseBody.poll_url) {
throw new APIError(
`Server returned ${retryResponse.status} but no poll_url to follow.`,
retryResponse.status,
sanitizeErrorResponse(responseBody)
);
}
return this.pollImageJob(responseBody.poll_url, paymentPayload);
}
return responseBody;
}
/**
* Poll the async image generation endpoint until the job reaches a terminal
* state (completed/failed). Used when the POST returns 202 + poll_url for
* slow models that exceeded the server's inline window.
*
* Sends the SAME payment header on every poll — the server binds the payer
* to the job id and re-verifies on each call. Settlement happens server-side
* on the first poll where status=completed.
*/
async pollImageJob(pollPath, paymentHeader) {
const pollUrl = pollPath.startsWith("/api/") ? `${this.apiUrl.replace(/\/api$/, "")}${pollPath}` : `${this.apiUrl}${pollPath.startsWith("/") ? "" : "/"}${pollPath}`;
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_MAX_DURATION_MS) {
await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
const pollResp = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: {
"PAYMENT-SIGNATURE": paymentHeader,
"X-PAYMENT": paymentHeader
}
});
if (pollResp.status === 202) {
continue;
}
const pollBody = await pollResp.json().catch(() => ({}));
if (!pollResp.ok) {
throw new APIError(
`API error during poll: ${pollResp.status}`,
pollResp.status,
sanitizeErrorResponse(pollBody)
);
}
if (pollBody.status === "failed") {
throw new APIError(
pollBody.error || "Upstream image generation failed",
200,
sanitizeErrorResponse(pollBody)
);
}
if (pollBody.data) {
return pollBody;
}
if (pollBody.status === "queued" || pollBody.status === "in_progress") {
continue;
}
}
throw new APIError(
`Image generation poll timed out after ${POLL_MAX_DURATION_MS / 1e3}s`,
504,
{ error: "poll_timeout", poll_url: pollPath }
);
}
/**
* Fetch with timeout.
*/
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get the wallet address being used for payments.
*/
getWalletAddress() {
return this.account.address;
}
/**
* Get session spending information.
*/
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return {
totalUsd: this.sessionTotalUsd,
calls: this.sessionCalls
};
}
};
// src/music.ts
var import_accounts4 = require("viem/accounts");
var DEFAULT_API_URL3 = "https://blockrun.ai/api";
var DEFAULT_MODEL2 = "minimax/music-2.5+";
var DEFAULT_TIMEOUT3 = 21e4;
var MusicClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts4.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL3;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT3;
}
/**
* Generate a music track from a text prompt.
*
* Takes 1-3 minutes. Returns a CDN URL valid for ~24h.
*
* @param prompt - Music style, mood, or description
* @param options - Optional generation parameters
* @returns MusicResponse with track URL and metadata
*
* @example
* const result = await client.generate('chill lo-fi beats with piano');
* console.log(result.data[0].url); // Download this URL — expires in 24h
*
* @example With lyrics
* const result = await client.generate('upbeat pop song', {
* instrumental: false,
* lyrics: 'Hello world, this is my song...'
* });
*/
async generate(prompt, options) {
const instrumental = options?.instrumental ?? true;
const lyrics = options?.lyrics?.trim();
if (instrumental && lyrics) {
throw new Error("Cannot specify lyrics when instrumental is true");
}
const body = {
model: options?.model || DEFAULT_MODEL2,
prompt,
instrumental
};
if (lyrics) body.lyrics = lyrics;
return this.requestWithPayment("/v1/audio/generations", body);
}
async requestWithPayment(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
if (this.apiAuth) return this.apiAuth.poll(response, this.timeout, 5e3);
return response.json();
}
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || `${this.apiUrl}/v1/audio/generations`,
resourceDescription: details.resource?.description || "BlockRun Music Generation",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
}
const data = await retryResponse.json();
if (!settlementSkipped(retryResponse)) {
this.sessionCalls++;
this.sessionTotalUsd += 0.1575;
}
const txHash = retryResponse.headers.get("x-payment-receipt") || retryResponse.headers.get("X-Payment-Receipt");
if (txHash) data.txHash = txHash;
return data;
}
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
// src/speech.ts
var import_accounts5 = require("viem/accounts");
var DEFAULT_API_URL4 = "https://blockrun.ai/api";
var DEFAULT_MODEL3 = "elevenlabs/flash-v2.5";
var DEFAULT_SOUNDFX_MODEL = "elevenlabs/sound-effects";
var DEFAULT_TIMEOUT4 = 12e4;
var SpeechClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts5.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL4;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT4;
}
/**
* Synthesize speech from text (OpenAI-compatible TTS).
*
* Price scales with character count: (chars / 1000) x model rate,
* minimum Synthesis is synchronous.
*
* @param input - Text to synthesize. Per-model character caps apply
* (flash/turbo 40k, multilingual-v2 10k, v3 5k).
* @param options - Optional model / voice / format / speed
* @returns SpeechResponse with audio URL, format, and character count
*
* @example
* const result = await client.generate('Welcome to BlockRun.', { voice: 'george' });
* console.log(result.data[0].url); // audio URL (mp3 by default)
*/
async generate(input, options) {
const body = {
model: options?.model || DEFAULT_MODEL3,
input
};
if (options?.voice) body.voice = options.voice;
if (options?.responseFormat) body.response_format = options.responseFormat;
if (options?.speed !== void 0) body.speed = options.speed;
return this.requestWithPayment("/v1/audio/speech", body);
}
/** OpenAI-style alias for {@link generate}. */
speak(input, options) {
return this.generate(input, options);
}
/**
* Generate a cinematic sound effect from a text prompt.
*
*, up to 22 seconds of audio.
*
* @param text - Sound effect description (max 1000 chars).
* E.g. 'rain on a tin roof', 'sci-fi door whoosh'
* @param options - Optional duration / prompt influence / format
* @returns SpeechResponse with audio URL and format
*
* @example
* const fx = await client.soundEffect('crackling campfire at night');
* console.log(fx.data[0].url);
*/
async soundEffect(text, options) {
const body = {
model: options?.model || DEFAULT_SOUNDFX_MODEL,
text
};
if (options?.durationSeconds !== void 0) body.duration_seconds = options.durationSeconds;
if (options?.promptInfluence !== void 0) body.prompt_influence = options.promptInfluence;
if (options?.responseFormat) body.response_format = options.responseFormat;
return this.requestWithPayment("/v1/audio/sound-effects", body);
}
/**
* List available voices for TTS (free, rate-limited 60 req/min/IP).
*
* Pass a voice's `alias` (if present) or `voice_id` as the `voice`
* option to {@link generate}.
*/
async listVoices() {
const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/audio/voices`, {
method: "GET"
});
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
const payload = await response.json();
return payload.data || [];
}
async requestWithPayment(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, endpoint, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
return response.json();
}
async handlePaymentAndRetry(url, endpoint, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || `${this.apiUrl}${endpoint}`,
resourceDescription: details.resource?.description || "BlockRun Voice",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
}
const data = await retryResponse.json();
if (!settlementSkipped(retryResponse)) {
this.sessionCalls++;
const paidUsd = Number(details.amount) / 1e6;
if (Number.isFinite(paidUsd)) this.sessionTotalUsd += paidUsd;
}
const txHash = retryResponse.headers.get("x-payment-receipt") || retryResponse.headers.get("X-Payment-Receipt");
if (txHash) data.txHash = txHash;
return data;
}
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
// src/video.ts
var import_accounts6 = require("viem/accounts");
var DEFAULT_API_URL5 = "https://blockrun.ai/api";
var DEFAULT_MODEL4 = "xai/grok-imagine-video";
var DEFAULT_TIMEOUT5 = 12e4;
var POLL_INTERVAL_MS2 = 5e3;
var DEFAULT_GENERATE_BUDGET_MS = 9e5;
var MAX_TIMEOUT_SECONDS = 600;
var MAX_POLL_RESIGNS = 2;
var VideoClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts6.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL5;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT5;
}
/**
* Generate a short video clip from a text prompt (or text + image).
*
* Submits an async job, then polls until the video is ready. Typical total
* wall-time is 60-180s, but upstream status can lag several minutes behind
* actual completion. If upstream runs past the budget (default 15min),
* throws with the existing poll URL; check billing before submitting again.
*
* @param prompt - Text description of the video
* @param options - Optional generation parameters
*/
async generate(prompt, options) {
if (options?.imageUrl && options?.realFaceAssetId) {
throw new Error(
"imageUrl and realFaceAssetId are mutually exclusive; pass at most one."
);
}
if (options?.realFaceAssetId && !/^ta_[A-Za-z0-9]+$/.test(options.realFaceAssetId)) {
throw new Error(
"realFaceAssetId must be a Token360 asset id matching 'ta_[A-Za-z0-9]+' (e.g. 'ta_abc123xyz')"
);
}
if (options?.lastFrameUrl && !options?.imageUrl) {
throw new Error(
"lastFrameUrl requires imageUrl: imageUrl seeds the FIRST frame and lastFrameUrl the FINAL frame \u2014 send both."
);
}
if (options?.lastFrameUrl && options?.realFaceAssetId) {
throw new Error(
"lastFrameUrl and realFaceAssetId are mutually exclusive; first-and-last-frame uses imageUrl + lastFrameUrl."
);
}
if (options?.referenceImageUrls?.length) {
if (options.imageUrl || options.lastFrameUrl || options.realFaceAssetId) {
throw new Error(
"referenceImageUrls is mutually exclusive with imageUrl, lastFrameUrl, and realFaceAssetId."
);
}
const imageLimit = options.model?.replace(/^bytedance\//, "") === "seedance-2.5" ? 30 : 9;
if (options.referenceImageUrls.length > imageLimit) {
throw new Error(`referenceImageUrls accepts at most ${imageLimit} images.`);
}
}
for (const [field, clips] of [
["referenceVideos", options?.referenceVideos],
["referenceAudios", options?.referenceAudios]
]) {
if (clips === void 0) continue;
if (clips.length < 1 || clips.length > 3) {
throw new Error(`${field} accepts 1 to 3 clips.`);
}
for (const clip of clips) {
if (typeof clip?.url !== "string" || !/^https?:\/\//i.test(clip.url)) {
throw new Error(`${field} URLs must be http(s).`);
}
if (clip.role !== void 0 && clip.role !== "reference") {
throw new Error(`${field} role must be "reference" (or omitted).`);
}
}
}
if (options?.referenceVideos?.length || options?.referenceAudios?.length) {
if (options.imageUrl || options.lastFrameUrl || options.realFaceAssetId) {
throw new Error(
"referenceVideos / referenceAudios are mutually exclusive with imageUrl, lastFrameUrl, and realFaceAssetId; combine them with referenceImageUrls instead."
);
}
if (options.referenceAudios?.length && !options.referenceImageUrls?.length && !options.referenceVideos?.length) {
throw new Error("referenceAudios requires referenceImageUrls or referenceVideos.");
}
}
const body = {
model: options?.model || DEFAULT_MODEL4,
prompt
};
if (options?.imageUrl) body.image_url = options.imageUrl;
if (options?.lastFrameUrl) body.last_frame_url = options.lastFrameUrl;
if (options?.referenceImageUrls?.length) body.reference_image_urls = options.referenceImageUrls;
if (options?.realFaceAssetId) body.real_face_asset_id = options.realFaceAssetId;
if (options?.durationSeconds !== void 0) body.duration_seconds = options.durationSeconds;
if (options?.aspectRatio) body.aspect_ratio = options.aspectRatio;
if (options?.resolution) body.resolution = options.resolution;
if (options?.generateAudio !== void 0) body.generate_audio = options.generateAudio;
if (options?.seed !== void 0) body.seed = options.seed;
if (options?.watermark !== void 0) body.watermark = options.watermark;
if (options?.returnLastFrame !== void 0) body.return_last_frame = options.returnLastFrame;
if (options?.referenceVideos !== void 0) body.reference_videos = options.referenceVideos;
if (options?.referenceAudios !== void 0) body.reference_audios = options.referenceAudios;
if (options?.bitrateMode !== void 0) body.bitrate_mode = options.bitrateMode;
if (options?.outputFormat !== void 0) body.output_format = options.outputFormat;
if (options?.cameraFixed !== void 0) body.camera_fixed = options.cameraFixed;
if (options?.safetyIdentifier !== void 0) body.safety_identifier = options.safetyIdentifier;
if (options?.inputType !== void 0) body.input_type = options.inputType;
const budgetMs = options?.budgetMs ?? DEFAULT_GENERATE_BUDGET_MS;
return this.submitAndPoll(body, budgetMs);
}
/**
* Generate a video from a standard Seedance `content[]` body.
*
* Targets the gateway's `POST /v1/videos` endpoint, which accepts the
* mainstream multimodal `content` array used by other Seedance APIs. Items
* may carry a `role` — `first_frame`, `last_frame`, `reference_image`,
* `reference_video`, `reference_audio` — which the gateway maps to the same
* validated fields as {@link generate}; a role-less single image keeps its
* first-frame meaning — so callers already holding a
* `content[]`-shaped request can submit it unchanged. The gateway validates
* unsupported inputs *before* charging, then delegates to the same x402
* submit+poll pipeline as {@link generate}.
*
* Most SDK users should prefer {@link generate} (structured options like
* `imageUrl` / `lastFrameUrl`) — this exists for migrating existing
* `content[]` payloads with no reshaping.
*
* @param content - The Seedance `content` array, e.g.
* `[{ type: "text", text: "a red apple spinning" }]` or a text item plus
* `{ type: "image_url", image_url: { url: "https://..." } }`.
* @param options - `model`, `budgetMs`, plus the same camelCase render options
* as {@link generate} (`durationSeconds`, `aspectRatio`, `resolution`,
* `generateAudio`, `seed`, `watermark`, `returnLastFrame`, `bitrateMode`,
* `outputFormat`, `cameraFixed`, `safetyIdentifier`, `inputType`). These are mapped
* to the gateway's snake_case fields for you. Any other keys you pass are
* forwarded verbatim (use snake_case for those, since the gateway reads
* snake_case only).
*/
async generateFromContent(content, options) {
if (!Array.isArray(content) || content.length === 0) {
throw new Error("content must be a non-empty array of Seedance content items.");
}
const {
model,
budgetMs,
durationSeconds,
aspectRatio,
resolution,
generateAudio,
seed,
watermark,
returnLastFrame,
bitrateMode,
outputFormat,
cameraFixed,
safetyIdentifier,
inputType,
...extra
} = options ?? {};
const body = { ...extra, content };
if (model !== void 0) body.model = model;
if (durationSeconds !== void 0) body.duration_seconds = durationSeconds;
if (aspectRatio !== void 0) body.aspect_ratio = aspectRatio;
if (resolution !== void 0) body.resolution = resolution;
if (generateAudio !== void 0) body.generate_audio = generateAudio;
if (seed !== void 0) body.seed = seed;
if (watermark !== void 0) body.watermark = watermark;
if (returnLastFrame !== void 0) body.return_last_frame = returnLastFrame;
if (bitrateMode !== void 0) body.bitrate_mode = bitrateMode;
if (outputFormat !== void 0) body.output_format = outputFormat;
if (cameraFixed !== void 0) body.camera_fixed = cameraFixed;
if (safetyIdentifier !== void 0) body.safety_identifier = safetyIdentifier;
if (inputType !== void 0) body.input_type = inputType;
return this.submitAndPoll(body, budgetMs ?? DEFAULT_GENERATE_BUDGET_MS, "/v1/videos");
}
// --------------------------------------------------------------------
// Internal: async submit + poll
// --------------------------------------------------------------------
async submitAndPoll(body, budgetMs, submitPath = "/v1/videos/generations") {
const submitUrl = `${this.apiUrl}${submitPath}`;
const resp402 = await this.fetchWithTimeout(submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (this.apiAuth) return this.apiAuth.poll(resp402, budgetMs, POLL_INTERVAL_MS2);
if (resp402.status !== 402) {
await this.throwApiError(resp402, "Expected 402 on first POST");
}
let { payload: paymentPayload, details } = await this.signFromChallenge(
resp402,
submitUrl
);
const submitResp = await this.fetchWithTimeout(submitUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (submitResp.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (submitResp.status !== 200 && submitResp.status !== 202) {
await this.throwApiError(submitResp, "Submit failed");
}
const submitData = await submitResp.json();
if (!submitData.id || !submitData.poll_url) {
throw new APIError(
"Submit response missing id/poll_url",
submitResp.status,
{ response: submitData }
);
}
const pollUrl = this.absolute(submitData.poll_url);
const deadline = Date.now() + budgetMs;
let lastStatus = submitData.status || "queued";
let resignsLeft = MAX_POLL_RESIGNS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS2));
const pollResp = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: { "PAYMENT-SIGNATURE": paymentPayload }
});
let pollData = {};
try {
pollData = await pollResp.json();
} catch {
}
lastStatus = pollData.status || lastStatus;
if (pollResp.status === 202 && (lastStatus === "queued" || lastStatus === "in_progress")) {
continue;
}
if (lastStatus === "failed") {
throw new APIError(
`Upstream generation failed: ${pollData.error || "unknown"}`,
pollResp.status,
sanitizeErrorResponse(pollData)
);
}
if (lastStatus === "completed") {
const data = pollData;
if (!settlementSkipped(pollResp)) {
this.sessionCalls++;
this.sessionTotalUsd += Number(details.amount) / 1e6;
}
const txHash = pollResp.headers.get("x-payment-receipt") || pollResp.headers.get("X-Payment-Receipt");
if (txHash) data.txHash = txHash;
return data;
}
if (pollResp.status === 402) {
if (resignsLeft > 0) {
resignsLeft--;
const challenge = await this.fetchWithTimeout(pollUrl, { method: "GET" });
if (challenge.status === 402) {
({ payload: paymentPayload, details } = await this.signFromChallenge(
challenge,
pollUrl
));
continue;
}
}
throw new PaymentError(
"Payment verification failed mid-poll (not a signature-expiry). Check the wallet balance and that you poll from the wallet that submitted the job."
);
}
if (pollResp.status !== 200 && pollResp.status !== 202 && pollResp.status !== 504) {
await this.throwApiError(pollResp, "Poll failed");
}
}
throw new APIError(
`Video generation did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). A polling timeout does not confirm billing status. Resume the existing poll_url using the same account or wallet; check account Activity or wallet receipts before submitting another job.`,
504,
{ id: submitData.id, last_status: lastStatus, poll_url: pollUrl }
);
}
/**
* Parse an x402 challenge response and sign a payment payload for it.
* Used for the initial submit AND for mid-poll re-signing after the 600s
* authorization window lapses on long polls.
*/
async signFromChallenge(resp402, fallbackUrl) {
const paymentRequired = await this.extractPaymentRequired(resp402);
const details = extractPaymentDetails(paymentRequired);
const payload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || fallbackUrl,
resourceDescription: details.resource?.description || "BlockRun Video Generation",
// Ensure signed auth covers as much of the polling window as allowed.
maxTimeoutSeconds: Math.max(details.maxTimeoutSeconds || 0, MAX_TIMEOUT_SECONDS),
extra: details.extra
}
);
return { payload, details };
}
absolute(url) {
if (url.startsWith("http://") || url.startsWith("https://")) return url;
const base = this.apiUrl.endsWith("/api") ? this.apiUrl.slice(0, -"/api".length) : this.apiUrl;
return `${base}${url}`;
}
async extractPaymentRequired(resp) {
const header = resp.headers.get("payment-required");
if (header) return parsePaymentRequired(header);
try {
const body = await resp.json();
if (body && (body.x402Version !== void 0 || body.accepts !== void 0)) {
return body;
}
} catch {
}
throw new PaymentError("402 response but no payment requirements found");
}
async throwApiError(resp, prefix) {
let errorBody;
try {
errorBody = await resp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`${prefix}: HTTP ${resp.status}`,
resp.status,
sanitizeErrorResponse(errorBody)
);
}
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
// src/voice.ts
var import_accounts7 = require("viem/accounts");
var DEFAULT_API_URL6 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT6 = 6e4;
var CALL_PRICE_USD = 0.54;
var VALID_MODELS = /* @__PURE__ */ new Set(["base", "enhanced", "turbo"]);
var VoiceClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts7.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL6;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT6;
}
/**
* Initiate an AI-powered outbound phone call.
*
* Pricing: per call. Returns immediately once the call is queued —
* poll getStatus() for transcript and recording.
*
* @example
* const r = await client.call({
* to: '+14155552671',
* task: 'Confirm the user wants to reschedule to Tuesday 2pm.',
* voice: 'maya',
* max_duration: 3,
* });
*/
async call(options) {
if (!options.to || !options.to.trim()) {
throw new Error("'to' phone number is required (E.164 format)");
}
const task = options.task?.trim() ?? "";
if (task.length < 10) {
throw new Error("'task' must be at least 10 characters");
}
if (task.length > 4e3) {
throw new Error("'task' must be at most 4000 characters");
}
const maxDuration = options.max_duration ?? 5;
if (maxDuration < 1 || maxDuration > 30) {
throw new Error("max_duration must be between 1 and 30 minutes");
}
if (options.model && !VALID_MODELS.has(options.model)) {
throw new Error("model must be 'base' | 'enhanced' | 'turbo'");
}
if (options.interruption_threshold !== void 0 && (options.interruption_threshold < 50 || options.interruption_threshold > 500)) {
throw new Error("interruption_threshold must be between 50 and 500");
}
const body = {
to: options.to.trim(),
task,
max_duration: maxDuration,
language: options.language ?? "en-US"
};
if (options.from) body.from = options.from.trim();
if (options.voice) body.voice = options.voice;
if (options.first_sentence) body.first_sentence = options.first_sentence.trim();
if (options.wait_for_greeting !== void 0) body.wait_for_greeting = options.wait_for_greeting;
if (options.interruption_threshold !== void 0) body.interruption_threshold = options.interruption_threshold;
if (options.model) body.model = options.model;
return this.requestWithPayment("/v1/voice/call", body);
}
/**
* Poll the status of an in-progress or completed call. Free — no payment.
*
* Returns Bland.ai's full call record: status, transcript, recording URL, etc.
* Most fields populate only once the call ends.
*/
async getStatus(callId) {
if (!callId || !callId.trim()) {
throw new Error("callId is required");
}
const url = `${this.apiUrl}/v1/voice/call/${encodeURIComponent(callId.trim())}`;
const response = await this.fetchWithTimeout(url, {
method: "GET",
headers: { Accept: "application/json" }
});
if (response.status === 404) {
throw new APIError(`Call not found: ${callId}`, 404, { call_id: callId });
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
return response.json();
}
async requestWithPayment(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
return response.json();
}
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || `${this.apiUrl}/v1/voice/call`,
resourceDescription: details.resource?.description || "BlockRun Voice Call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
}
const data = await retryResponse.json();
if (!settlementSkipped(retryResponse)) {
this.sessionCalls++;
this.sessionTotalUsd += CALL_PRICE_USD;
}
const txHash = retryResponse.headers.get("x-payment-receipt") || retryResponse.headers.get("X-Payment-Receipt");
if (txHash) data.txHash = txHash;
return data;
}
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
// src/phone.ts
var import_accounts8 = require("viem/accounts");
var DEFAULT_API_URL7 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT7 = 6e4;
var PHONE_PRICES = Object.freeze({
lookup: 0.01,
"lookup/fraud": 0.05,
"numbers/buy": 5,
"numbers/renew": 5,
"numbers/list": 1e-3,
"numbers/release": 0
});
function requireE164(value) {
if (!value || typeof value !== "string") {
throw new Error("phoneNumber is required (E.164 format, e.g. '+14155552671')");
}
const v = value.trim();
if (!/^\+\d{7,15}$/.test(v)) {
throw new Error(
`phoneNumber must be E.164 (e.g. '+14155552671'), got ${JSON.stringify(value)}`
);
}
return v;
}
var PhoneClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts8.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL7;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT7;
}
/** EVM wallet address used for payments. */
getWalletAddress() {
return this.account.address;
}
/** Session-cumulative USD spend + call count across this client instance. */
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
// ─── Lookup ────────────────────────────────────────────────────────────
/**
* Carrier + line-type lookup.
*
* @param phoneNumber E.164 number (e.g. "+14155552671").
*/
async lookup(phoneNumber) {
const num = requireE164(phoneNumber);
return this.request("lookup", { phoneNumber: num });
}
/**
* Lookup + fraud signals (SIM swap, call forwarding).
*
* @param phoneNumber E.164 number.
*/
async lookupFraud(phoneNumber) {
const num = requireE164(phoneNumber);
return this.request("lookup/fraud", { phoneNumber: num });
}
// ─── Provisioning ──────────────────────────────────────────────────────
/**
* Provision a dedicated phone number for 30 days.
*
* Payment is settled only after Twilio confirms the purchase, so failed
* buys never charge your wallet.
*
* @param options.country ISO country code, "US" or "CA" (default "US").
* @param options.areaCode Optional 3-digit area-code hint. Availability is
* not guaranteed — the backend falls back to any number in the country if
* the area code can't be matched.
*/
async buyNumber(options = {}) {
const country = options.country ?? "US";
if (country !== "US" && country !== "CA") {
throw new Error("country must be 'US' or 'CA'");
}
const body = { country };
if (options.areaCode !== void 0) {
const ac = options.areaCode;
if (typeof ac !== "string" || !/^\d{3}$/.test(ac)) {
throw new Error("areaCode must be a 3-digit string, e.g. '415'");
}
body.areaCode = ac;
}
const data = await this.request("numbers/buy", body);
return data;
}
/**
* Extend an existing provisioned number by 30 days.
*
* @throws {APIError} 403 when the wallet doesn't own the number or it has expired.
*/
async renewNumber(phoneNumber) {
const num = requireE164(phoneNumber);
const data = await this.request("numbers/renew", {
phoneNumber: num
});
return data;
}
/** List the wallet's active phone numbers. */
async listNumbers() {
const data = await this.request("numbers/list", {});
return data;
}
/**
* Release a provisioned number back to the Twilio pool. Free, but still
* flows through x402 so the backend can verify ownership.
*/
async releaseNumber(phoneNumber) {
const num = requireE164(phoneNumber);
const data = await this.request("numbers/release", {
phoneNumber: num
});
return data;
}
// ─── Internals ─────────────────────────────────────────────────────────
async request(path7, body) {
const url = `${this.apiUrl}/v1/phone/${path7}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
return this.unwrap(response, false);
}
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const rb = await response.json();
if (rb.x402 || rb.accepts) paymentHeader = btoa(JSON.stringify(rb));
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || "BlockRun Phone",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retry = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retry.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
const data = await this.unwrap(retry, true);
if (!settlementSkipped(retry)) {
this.sessionCalls += 1;
this.sessionTotalUsd += Number.parseFloat(details.amount) / 1e6;
}
const txHash = retry.headers.get("x-payment-receipt") || retry.headers.get("X-Payment-Receipt");
if (txHash && data && typeof data === "object" && !("txHash" in data)) {
data.txHash = txHash;
}
return data;
}
async unwrap(response, afterPayment) {
if (response.ok) {
return response.json();
}
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
const prefix = afterPayment ? "API error after payment" : "API error";
throw new APIError(
`${prefix}: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
};
// src/portrait.ts
var import_accounts9 = require("viem/accounts");
var DEFAULT_API_URL8 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT8 = 6e4;
var PORTRAIT_ENROLLMENT_PRICE_USD = 0.01;
var PortraitClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts9.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL8;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT8;
}
/** EVM wallet address used for payments. */
getWalletAddress() {
return this.account.address;
}
/** Session-cumulative USD spend + call count across this client instance. */
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
/**
* Enroll a Virtual Portrait from a face image URL. (promo).
*
* Payment is settled only after Token360 confirms the enrollment, so failed
* enrollments never charge your wallet.
*
* @param options.name Display name (1–64 chars).
* @param options.imageUrl Public HTTPS URL to a JPG/PNG/WEBP image (≤10 MB).
* @returns The enrollment record, including `asset_id` (`ta_xxxxxx`).
*/
async enroll(options) {
const name = typeof options?.name === "string" ? options.name.trim() : "";
if (name.length < 1 || name.length > 64) {
throw new Error("name is required and must be 1\u201364 characters");
}
const imageUrl = typeof options?.imageUrl === "string" ? options.imageUrl.trim() : "";
if (!/^https?:\/\//i.test(imageUrl)) {
throw new Error(
"imageUrl must be a public http(s) URL to a JPG/PNG/WEBP image"
);
}
const data = await this.request({
name,
image_url: imageUrl
});
return data;
}
// ─── Internals ─────────────────────────────────────────────────────────
async request(body) {
const url = `${this.apiUrl}/v1/portrait/enroll`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
return this.unwrap(response, false);
}
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const rb = await response.json();
if (rb.x402 || rb.accepts) paymentHeader = btoa(JSON.stringify(rb));
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || "BlockRun Virtual Portrait",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retry = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retry.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
const data = await this.unwrap(retry, true);
if (!settlementSkipped(retry)) {
this.sessionCalls += 1;
this.sessionTotalUsd += Number.parseFloat(details.amount) / 1e6;
}
if (data && typeof data === "object" && !("txHash" in data)) {
const settlement = data.settlement;
if (settlement?.tx_hash) {
data.txHash = settlement.tx_hash;
}
}
return data;
}
async unwrap(response, afterPayment) {
if (response.ok) {
return response.json();
}
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
const prefix = afterPayment ? "API error after payment" : "API error";
throw new APIError(
`${prefix}: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
};
// src/search.ts
var import_accounts10 = require("viem/accounts");
var DEFAULT_API_URL9 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT9 = 6e4;
var SearchClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts10.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL9;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT9;
}
async search(query, options) {
if (!query || query.length > 1e3) {
throw new Error("query must be 1-1000 characters");
}
const maxResults = options?.maxResults ?? 10;
if (maxResults < 1 || maxResults > 50) {
throw new Error("maxResults must be between 1 and 50");
}
const body = {
query,
max_results: maxResults
};
if (options?.sources) body.sources = options.sources;
if (options?.fromDate) body.from_date = options.fromDate;
if (options?.toDate) body.to_date = options.toDate;
return this.requestWithPayment("/v1/search", body);
}
async requestWithPayment(endpoint, body) {
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
return response.json();
}
async handlePaymentAndRetry(url, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || "BlockRun Search",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retry = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retry.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retry.ok) {
let errorBody;
try {
errorBody = await retry.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retry.status}`,
retry.status,
sanitizeErrorResponse(errorBody)
);
}
return retry.json();
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
};
// src/price.ts
var import_accounts11 = require("viem/accounts");
var DEFAULT_API_URL10 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT10 = 3e4;
var PriceClient = class {
apiAuth;
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
account = null;
privateKey = null;
apiUrl;
timeout;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = this.apiAuth ? void 0 : options.privateKey || envKey;
const requireWallet2 = options.requireWallet ?? true;
if (!this.apiAuth && !privateKey && requireWallet2) {
throw new Error(
"Private key required for paid endpoints. Pass privateKey in options, set BLOCKRUN_WALLET_KEY, or pass requireWallet: false for free-only usage."
);
}
if (privateKey) {
validatePrivateKey(privateKey);
this.privateKey = privateKey;
this.account = (0, import_accounts11.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL10;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT10;
}
async price(category, symbol, options) {
if (!symbol) throw new Error("symbol is required");
const path7 = categoryPath(category, options?.market, "price", symbol);
const query = {};
if (options?.session) query.session = options.session;
const data = await this.getWithPayment(path7, query);
return {
symbol: data.symbol ?? symbol.toUpperCase(),
price: data.price,
publishTime: data.publishTime,
confidence: data.confidence,
feedId: data.feedId,
timestamp: data.timestamp,
assetType: data.assetType,
category: data.category,
source: data.source,
free: data.free
};
}
async history(category, symbol, options) {
if (!symbol) throw new Error("symbol is required");
if (!options.from || options.from <= 0) {
throw new Error("history requires options.from (unix seconds)");
}
const path7 = categoryPath(category, options.market, "history", symbol);
const query = {
resolution: options.resolution ?? "D",
from: String(options.from)
};
if (options.to) query.to = String(options.to);
if (options.session) query.session = options.session;
const data = await this.getWithPayment(path7, query);
return {
symbol: data.symbol ?? symbol.toUpperCase(),
resolution: data.resolution ?? (options.resolution ?? "D"),
from: data.from,
to: data.to,
bars: data.bars ?? [],
source: data.source,
category: data.category
};
}
async listSymbols(category, options) {
const path7 = categoryPath(category, options?.market, "list");
const query = {
limit: String(options?.limit && options.limit > 0 ? options.limit : 100)
};
if (options?.query) query.q = options.query;
const data = await this.getWithPayment(path7, query);
if (Array.isArray(data)) {
return { symbols: data, count: data.length };
}
const obj = data;
const symbols = obj.symbols ?? obj.feeds ?? [];
return {
symbols,
count: obj.count ?? symbols.length
};
}
getWalletAddress() {
return this.account?.address ?? null;
}
async getWithPayment(endpoint, query) {
const url = buildUrl(`${this.apiUrl}${endpoint}`, query);
const response = await this.fetchWithTimeout(url, { method: "GET" });
if (response.status === 402) {
if (!this.privateKey || !this.account) {
throw new PaymentError(
`${endpoint} returned 402 Payment Required but no wallet is configured.`
);
}
return this.payAndRetry(url, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error: ${response.status}`,
response.status,
sanitizeErrorResponse(errorBody)
);
}
return response.json();
}
async payAndRetry(url, response) {
if (!this.privateKey || !this.account) {
throw new PaymentError("Wallet required to sign payment.");
}
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402) {
paymentHeader = btoa(JSON.stringify(respBody.x402));
} else if (respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || "BlockRun Price Data",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retry = await this.fetchWithTimeout(url, {
method: "GET",
headers: { "PAYMENT-SIGNATURE": paymentPayload }
});
if (retry.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retry.ok) {
let errorBody;
try {
errorBody = await retry.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`API error after payment: ${retry.status}`,
retry.status,
sanitizeErrorResponse(errorBody)
);
}
return retry.json();
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
};
function categoryPath(category, market, kind, symbol) {
let base;
if (category === "stocks") {
if (!market) {
throw new Error("market is required when category === 'stocks'");
}
base = `/v1/stocks/${market}`;
} else if (["crypto", "fx", "commodity", "usstock"].includes(category)) {
base = `/v1/${category}`;
} else {
throw new Error(`unknown category: ${category}`);
}
if (!symbol) return `${base}/${kind}`;
return `${base}/${kind}/${encodeURIComponent(symbol.toUpperCase())}`;
}
function buildUrl(base, query) {
const params = Object.entries(query);
if (params.length === 0) return base;
const qs = params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
return `${base}?${qs}`;
}
// src/surf.ts
var import_accounts12 = require("viem/accounts");
var DEFAULT_API_URL11 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT11 = 6e4;
var SURF_PREFIX = "/v1/surf";
var warnedRetired = false;
var SurfClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
constructor(options = {}) {
if (!warnedRetired) {
warnedRetired = true;
console.warn(
"[blockrun] SurfClient is deprecated: the gateway retired /v1/surf/* (410 endpoint_retired). Use PriceClient, LLMClient.defi*, LLMClient.pm() or LLMClient.dex* instead."
);
}
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts12.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL11;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT11;
}
/**
* GET a Surf endpoint. `path` is everything after `/v1/surf` (a leading
* `/v1/surf` is tolerated and stripped). Query params are URL-encoded;
* arrays become repeated keys (`?a=1&a=2`).
*/
async get(path7, params) {
const url = this.buildUrl(path7, params);
return this.requestWithPayment(url, "GET");
}
/**
* POST a Surf endpoint with a JSON body. Same path normalization as `get`.
*/
async post(path7, body) {
const url = this.buildUrl(path7);
return this.requestWithPayment(url, "POST", body);
}
buildUrl(path7, params) {
let normalized = path7.startsWith("/") ? path7 : `/${path7}`;
if (!normalized.startsWith(SURF_PREFIX)) {
normalized = `${SURF_PREFIX}${normalized}`;
}
const base = `${this.apiUrl}${normalized}`;
if (!params) return base;
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === void 0 || value === null) continue;
if (Array.isArray(value)) {
for (const v of value) {
if (v === void 0 || v === null) continue;
qs.append(key, String(v));
}
} else {
qs.append(key, String(value));
}
}
const query = qs.toString();
return query ? `${base}?${query}` : base;
}
async requestWithPayment(url, method, body) {
const init = { method };
if (method === "POST") {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body ?? {});
}
const response = await this.fetchWithTimeout(url, init);
if (response.status === 402) {
return this.handlePaymentAndRetry(url, method, body, response);
}
if (!response.ok) {
await this.throwApiError(response, `Surf request failed (${method} ${url})`);
}
return response.json();
}
async handlePaymentAndRetry(url, method, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402Version !== void 0 || respBody.accepts !== void 0) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || "BlockRun Surf",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retryInit = {
method,
headers: { "PAYMENT-SIGNATURE": paymentPayload }
};
if (method === "POST") {
retryInit.headers = {
...retryInit.headers,
"Content-Type": "application/json"
};
retryInit.body = JSON.stringify(body ?? {});
}
const retry = await this.fetchWithTimeout(url, retryInit);
if (retry.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retry.ok) {
await this.throwApiError(retry, `Surf request failed after payment (${method} ${url})`);
}
return retry.json();
}
async throwApiError(resp, prefix) {
let errorBody;
try {
errorBody = await resp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`${prefix}: HTTP ${resp.status}`,
resp.status,
sanitizeErrorResponse(errorBody)
);
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
};
// src/rpc.ts
var import_accounts13 = require("viem/accounts");
var DEFAULT_API_URL12 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT12 = 6e4;
var RPC_PRICE_USD = 2e-3;
var SUPPORTED_NETWORKS = [
// EVM
"ethereum",
"base",
"arbitrum",
"arbitrum-nova",
"optimism",
"polygon",
"bsc",
"avalanche",
"fantom",
"cronos",
"celo",
"gnosis",
"zksync",
"berachain",
"unichain",
"monad",
"chiliz",
"moonbeam",
"aurora",
"flare",
"oasis",
"kaia",
"sonic",
"xdc",
"abstract",
"hyperevm",
"plume",
"ronin",
"rootstock",
// Non-EVM (JSON-RPC-compatible)
"solana",
"bitcoin",
"litecoin",
"dogecoin",
"bitcoin-cash",
"near",
"sui",
"ripple",
"polkadot",
"kusama",
"zcash"
];
var NETWORK_ALIASES = {
eth: "ethereum",
arb: "arbitrum",
"arbitrum-one": "arbitrum",
"arb-one": "arbitrum",
"arb-nova": "arbitrum-nova",
op: "optimism",
matic: "polygon",
pol: "polygon",
bnb: "bsc",
binance: "bsc",
"binance-smart-chain": "bsc",
avax: "avalanche",
ftm: "fantom",
bera: "berachain",
klaytn: "kaia",
chz: "chiliz",
hyperliquid: "hyperevm",
rsk: "rootstock",
sol: "solana",
btc: "bitcoin",
ltc: "litecoin",
doge: "dogecoin",
bch: "bitcoin-cash",
xrp: "ripple",
xrpl: "ripple",
dot: "polkadot",
zec: "zcash"
};
var RpcClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts13.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL12;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT12;
}
/**
* Make a single JSON-RPC 2.0 call. Flat per-call rate.
*
* @param network - Chain name (e.g. "ethereum", "base", "solana") or a
* common alias ("eth", "sol", "matic", ...). See
* SUPPORTED_NETWORKS / NETWORK_ALIASES.
* @param method - Chain RPC method, e.g. "eth_blockNumber", "eth_call",
* "eth_getBalance" (EVM) or "getSlot", "getAccountInfo"
* (Solana).
* @param params - Method-specific params array (optional).
* @returns RpcResponse with `result` (or JSON-RPC `error`), plus
* `network`, `cacheHit` and `txHash` metadata.
*
* @example
* const block = await client.call('ethereum', 'eth_blockNumber');
* console.log(parseInt(block.result as string, 16));
*/
async call(network, method, params) {
const body = { jsonrpc: "2.0", id: 1, method };
if (params !== void 0) body.params = params;
const { data, headers } = await this.requestWithPayment(network, body);
return this.toResponse(data, headers);
}
/**
* Make a JSON-RPC 2.0 batch call. Priced per element (rate x N).
*
* @param network - Chain name or alias (see {@link call}).
* @param requests - Requests, each with a `method` and optional
* `params` / `id`. `jsonrpc` and missing ids are
* filled in automatically.
* @returns Array of RpcResponse, in upstream order.
*
* @example
* const out = await client.batch('base', [
* { method: 'eth_blockNumber' },
* { method: 'eth_gasPrice' },
* ]);
*/
async batch(network, requests) {
if (!requests.length) {
throw new Error("batch requires at least one request");
}
const body = requests.map((req, i) => {
if (!req.method) throw new Error(`batch request ${i} is missing 'method'`);
return { jsonrpc: "2.0", id: i + 1, ...req };
});
const { data, headers } = await this.requestWithPayment(network, body);
const items = Array.isArray(data) ? data : [data];
return items.map((item) => this.toResponse(item, headers));
}
toResponse(data, headers) {
const base = data !== null && typeof data === "object" && !Array.isArray(data) ? { ...data } : { result: data };
const network = headers.get("x-network");
if (network) base.network = network;
base.cacheHit = (headers.get("x-cache") || "").toUpperCase() === "HIT";
const txHash = headers.get("x-payment-receipt") || headers.get("X-Payment-Receipt");
if (txHash) base.txHash = txHash;
return base;
}
async requestWithPayment(network, body) {
const endpoint = `/v1/rpc/${network}`;
const url = `${this.apiUrl}${endpoint}`;
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 402) {
return this.handlePaymentAndRetry(url, endpoint, body, response);
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
return { data: await response.json(), headers: response.headers };
}
async handlePaymentAndRetry(url, endpoint, body, response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || `${this.apiUrl}${endpoint}`,
resourceDescription: details.resource?.description || "BlockRun Multi-chain RPC",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra
}
);
const retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
});
if (retryResponse.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (!retryResponse.ok) {
let errorBody;
try {
errorBody = await retryResponse.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error after payment: ${retryResponse.status}`, retryResponse.status, sanitizeErrorResponse(errorBody));
}
if (!settlementSkipped(retryResponse)) {
this.sessionCalls++;
const paidUsd = Number(details.amount) / 1e6;
if (Number.isFinite(paidUsd)) this.sessionTotalUsd += paidUsd;
}
return { data: await retryResponse.json(), headers: retryResponse.headers };
}
async fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
// src/sse.ts
async function* readSseFrames(response, onMissingBody) {
if (!response.body) throw onMissingBody(response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch {
}
}
}
} finally {
reader.releaseLock();
}
}
// src/blockrun.ts
var import_accounts14 = require("viem/accounts");
var DEFAULT_API_URL13 = "https://blockrun.ai/api";
var DEFAULT_TIMEOUT13 = 6e4;
var DEFAULT_POLL_INTERVAL_MS = 5e3;
var DEFAULT_POLL_BUDGET_MS = 3e5;
var MAX_SIGNED_AUTH_SECONDS = 600;
var BlockrunClient = class {
_account;
get account() {
return requireWallet(this._account);
}
apiAuth;
/** Active billing mode. */
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set BLOCKRUN_WALLET_KEY environment variable."
);
}
validatePrivateKey(privateKey);
this._privateKey = privateKey;
this._account = (0, import_accounts14.privateKeyToAccount)(privateKey);
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || DEFAULT_API_URL13;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
this.timeout = options.timeout || DEFAULT_TIMEOUT13;
}
/**
* GET a BlockRun endpoint. `path` is everything after `/api` (a leading
* `/api` is tolerated and stripped). Query params are URL-encoded; arrays
* become repeated keys (`?a=1&a=2`); undefined/null are dropped.
*/
async get(path7, params) {
const url = this.buildUrl(path7, params);
return this.requestWithPayment(url, "GET");
}
/**
* POST a BlockRun endpoint with a JSON body.
*/
async post(path7, body) {
const url = this.buildUrl(path7);
return this.requestWithPayment(url, "POST", body);
}
/**
* Submit a long-running job and poll until it completes.
*
* Pattern: submit → 402 → sign → 202 `{ id, poll_url, status }` → loop GET
* the poll_url with the SAME `PAYMENT-SIGNATURE` until status=completed (or
* deadline exceeded). Settlement happens only when upstream returns 200 +
* completed — upstream failure or caller giving up = no charge.
*
* **EVM only, and enforced upstream.** Replaying one signature works because
* an EIP-3009 authorization stays valid for as long as it was signed for. A
* Solana payment is a transaction pinned to a recent blockhash and expires in
* ~150 blocks (~60s), so replaying one could not cover a long render. No
* guard is needed here: `signFrom402` already refuses a `solana:` network
* outright, so this loop is unreachable on Solana. Use `SolanaLLMClient`,
* whose poll loop signs afresh each time; the gateway binds a job to the
* payer address rather than to the signature, and still settles exactly once.
*
* If the gateway returns 200 directly on submit (no async surface), this
* short-circuits and returns the body. Most long-running endpoints (image,
* video, music, voice) return 202 with a poll_url.
*/
async poll(path7, body, options) {
const submitUrl = this.buildUrl(path7);
const budgetMs = options?.budgetMs ?? DEFAULT_POLL_BUDGET_MS;
const intervalMs = options?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const resp402 = await this.fetchWithTimeout(submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {})
});
if (this.apiAuth) return this.apiAuth.poll(resp402, budgetMs, intervalMs);
if (resp402.status === 200) {
return resp402.json();
}
if (resp402.status !== 402) {
await this.throwApiError(resp402, `poll submit failed (${submitUrl})`);
}
const { paymentPayload, costUsd } = await this.signFrom402(resp402, submitUrl, {
description: "BlockRun async job",
maxTimeoutSeconds: MAX_SIGNED_AUTH_SECONDS
});
const submitResp = await this.fetchWithTimeout(submitUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body ?? {})
});
if (submitResp.status === 402) {
throw new PaymentError("Payment was rejected. Check your wallet balance.");
}
if (submitResp.status !== 200 && submitResp.status !== 202) {
await this.throwApiError(submitResp, `poll submit failed (${submitUrl})`);
}
if (submitResp.status === 200) {
this.recordSpending(submitResp, costUsd);
return submitResp.json();
}
const submitData = await submitResp.json();
if (!submitData.id || !submitData.poll_url) {
throw new APIError(
"Async submit response missing id/poll_url",
submitResp.status,
{ response: submitData }
);
}
const pollUrl = this.absolute(submitData.poll_url);
const deadline = Date.now() + budgetMs;
let lastStatus = submitData.status || "queued";
while (Date.now() < deadline) {
await sleep(intervalMs);
const pollResp = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: { "PAYMENT-SIGNATURE": paymentPayload }
});
let pollData = {};
try {
pollData = await pollResp.json();
} catch {
}
lastStatus = pollData.status || lastStatus;
if (pollResp.status === 202 && (lastStatus === "queued" || lastStatus === "in_progress")) {
continue;
}
if (lastStatus === "failed") {
throw new APIError(
`Upstream job failed: ${pollData.error || "unknown"}`,
pollResp.status,
sanitizeErrorResponse(pollData)
);
}
if (pollResp.status === 200 && lastStatus === "completed") {
this.recordSpending(pollResp, costUsd);
return pollData;
}
if (pollResp.status !== 200 && pollResp.status !== 202 && pollResp.status !== 504) {
await this.throwApiError(pollResp, `poll failed (${pollUrl})`);
}
}
throw new APIError(
`Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). No payment was taken.`,
504,
{ id: submitData.id, last_status: lastStatus }
);
}
/**
* Stream a Server-Sent Events endpoint.
*
* Yields each `data: …` line parsed as JSON. Stops when the upstream emits
* `data: [DONE]` or closes the connection. Caller is responsible for typing
* the chunk shape; pass a generic for typed yields.
*
* Example — streaming chat:
* for await (const chunk of br.stream<ChatChunk>("/v1/chat/completions", {
* model: "anthropic/claude-sonnet-4-6",
* messages: [{ role: "user", content: "Hi" }],
* stream: true,
* })) {
* process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
* }
*/
async *stream(path7, body) {
const url = this.buildUrl(path7);
const requestBody = JSON.stringify(body ?? {});
const resp402 = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: requestBody
});
let streamResp;
if (resp402.status === 200) {
streamResp = resp402;
} else if (resp402.status === 402) {
const { paymentPayload, costUsd } = await this.signFrom402(resp402, url, {
description: "BlockRun stream"
});
streamResp = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentPayload
},
body: requestBody
});
if (streamResp.status === 402) {
throw new PaymentError(
"Payment was rejected. Check your wallet balance."
);
}
if (!streamResp.ok) {
await this.throwApiError(streamResp, `stream failed after payment (${url})`);
}
this.recordSpending(streamResp, costUsd);
} else {
await this.throwApiError(resp402, `stream failed (${url})`);
return;
}
yield* readSseFrames(
streamResp,
(status2) => new APIError("Stream response has no body", status2, {})
);
}
// --------------------------------------------------------------------
// Internal: shared infrastructure
// --------------------------------------------------------------------
buildUrl(path7, params) {
let normalized = path7.startsWith("/") ? path7 : `/${path7}`;
if (normalized.startsWith("/api/")) {
normalized = normalized.slice(4);
}
const base = `${this.apiUrl}${normalized}`;
if (!params) return base;
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === void 0 || value === null) continue;
if (Array.isArray(value)) {
for (const v of value) {
if (v === void 0 || v === null) continue;
qs.append(key, String(v));
}
} else {
qs.append(key, String(value));
}
}
const query = qs.toString();
return query ? `${base}?${query}` : base;
}
absolute(url) {
if (url.startsWith("http://") || url.startsWith("https://")) return url;
const base = this.apiUrl.endsWith("/api") ? this.apiUrl.slice(0, -"/api".length) : this.apiUrl;
return `${base}${url}`;
}
async requestWithPayment(url, method, body) {
const init = { method };
if (method === "POST") {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body ?? {});
}
const response = await this.fetchWithTimeout(url, init);
if (response.status === 402) {
return this.handlePaymentAndRetry(url, method, body, response);
}
if (!response.ok) {
await this.throwApiError(response, `${method} ${url} failed`);
}
return response.json();
}
async handlePaymentAndRetry(url, method, body, response) {
const { paymentPayload, costUsd } = await this.signFrom402(response, url, {
description: "BlockRun"
});
const retryInit = {
method,
headers: { "PAYMENT-SIGNATURE": paymentPayload }
};
if (method === "POST") {
retryInit.headers = {
...retryInit.headers,
"Content-Type": "application/json"
};
retryInit.body = JSON.stringify(body ?? {});
}
const retry = await this.fetchWithTimeout(url, retryInit);
if (retry.status === 402) {
throw new PaymentError(
"Payment was rejected. Check your wallet balance."
);
}
if (!retry.ok) {
await this.throwApiError(retry, `${method} ${url} failed after payment`);
}
this.recordSpending(retry, costUsd);
return retry.json();
}
/**
* Read a 402 response's payment requirements (header or body), then sign and
* return the base64 PAYMENT-SIGNATURE payload with what it pays, in USD.
* The cost stays with the call that signed it (booked by `recordSpending`):
* concurrent calls on one client never share it.
*/
async signFrom402(response, url, opts) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402Version !== void 0 || respBody.accepts !== void 0) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const costUsd = parseFloat(details.amount) / 1e6;
const paymentPayload = await createPaymentPayload(
this.privateKey,
this.account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: details.resource?.url || url,
resourceDescription: details.resource?.description || opts.description,
maxTimeoutSeconds: Math.max(
details.maxTimeoutSeconds || 0,
opts.maxTimeoutSeconds || 300
),
extra: details.extra,
asset: details.asset
}
);
return { paymentPayload, costUsd };
}
/**
* Book `costUsd`, what the call that `response` answered signed. A response
* whose settlement the gateway skipped ({@link settlementSkipped}:
* `X-Settlement-Skipped: <reason>` or `X-Payment-Waived`) moved nothing, so
* nothing is booked. `X-Payment-Settled: false` alone is still booked: the
* payment is owed.
*/
recordSpending(response, costUsd) {
if (settlementSkipped(response)) return;
if (costUsd > 0) {
this.sessionCalls += 1;
this.sessionTotalUsd += costUsd;
}
}
async throwApiError(resp, prefix) {
let errorBody;
try {
errorBody = await resp.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(
`${prefix}: HTTP ${resp.status}`,
resp.status,
sanitizeErrorResponse(errorBody)
);
}
async fetchWithTimeout(url, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
// --------------------------------------------------------------------
// Public surface: wallet + spending
// --------------------------------------------------------------------
getWalletAddress() {
return this.account.address;
}
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
};
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// src/wallet.ts
var import_accounts16 = require("viem/accounts");
// node_modules/.pnpm/@blockrun+core@0.1.1_bufferutil@4.1.0_typescript@6.0.3_utf-8-validate@6.0.6_zod@3.25.76/node_modules/@blockrun/core/dist/config.js
var fs2 = __toESM(require("fs"), 1);
var os2 = __toESM(require("os"), 1);
var path2 = __toESM(require("path"), 1);
function homeDir() {
return process.env.BLOCKRUN_HOME || os2.homedir();
}
function blockrunDir() {
return path2.join(homeDir(), ".blockrun");
}
function paths() {
const dir = blockrunDir();
return {
dir,
session: path2.join(dir, ".session"),
legacy: path2.join(dir, "wallet.key"),
solana: path2.join(dir, ".solana"),
config: path2.join(dir, "config.json"),
costLog: path2.join(dir, "cost_log.jsonl")
};
}
// node_modules/.pnpm/@blockrun+core@0.1.1_bufferutil@4.1.0_typescript@6.0.3_utf-8-validate@6.0.6_zod@3.25.76/node_modules/@blockrun/core/dist/wallet.js
var fs3 = __toESM(require("fs"), 1);
var path3 = __toESM(require("path"), 1);
var import_accounts15 = require("viem/accounts");
function normalize(raw) {
const k = raw.trim();
return k.startsWith("0x") ? k : `0x${k}`;
}
function addressFromKey(raw) {
try {
return (0, import_accounts15.privateKeyToAccount)(normalize(raw)).address;
} catch {
return null;
}
}
function scanWallets() {
const home = homeDir();
const results = [];
try {
for (const entry of fs3.readdirSync(home, { withFileTypes: true })) {
if (!entry.name.startsWith(".") || !entry.isDirectory())
continue;
const walletFile2 = path3.join(home, entry.name, "wallet.json");
if (!fs3.existsSync(walletFile2))
continue;
try {
const data = JSON.parse(fs3.readFileSync(walletFile2, "utf-8"));
const pk = data.privateKey || "";
if (!pk)
continue;
const derived = addressFromKey(pk);
if (!derived)
continue;
results.push({ mtime: fs3.statSync(walletFile2).mtimeMs, privateKey: pk, address: derived, source: walletFile2 });
} catch {
continue;
}
}
} catch {
}
results.sort((a, b) => b.mtime - a.mtime);
return results.map(({ privateKey, address, source }) => ({ privateKey, address, source }));
}
function listDiscoveredWallets() {
return scanWallets().map(({ address, source }) => ({ address, source }));
}
function adoptWallet(address) {
const wanted = address.trim().toLowerCase();
const p = paths();
for (const entry of scanWallets()) {
if (entry.address.toLowerCase() !== wanted)
continue;
const privateKey = normalize(entry.privateKey);
if (fs3.existsSync(p.session)) {
const current = fs3.readFileSync(p.session, "utf8").trim();
if (current && normalize(current) !== privateKey) {
const backup = path3.join(p.dir, `.session.backup-${Math.floor(Date.now() / 1e3)}`);
fs3.writeFileSync(backup, current, { mode: 384 });
}
}
fs3.mkdirSync(p.dir, { recursive: true });
fs3.writeFileSync(p.session, privateKey, { mode: 384 });
return { address: entry.address, privateKey, source: "session" };
}
const available = listDiscoveredWallets().map((w) => w.address);
throw new Error(`No discovered wallet controls ${address}. Available: ${available.length ? available.join(", ") : "none"}`);
}
function resolveFromFiles() {
const p = paths();
if (fs3.existsSync(p.session)) {
const raw = fs3.readFileSync(p.session, "utf8").trim();
if (raw)
return { privateKey: normalize(raw), source: "session" };
}
if (fs3.existsSync(p.legacy)) {
const raw = fs3.readFileSync(p.legacy, "utf8").trim();
if (raw)
return { privateKey: normalize(raw), source: "legacy" };
}
return null;
}
function resolvePrivateKey(env = process.env) {
const fromEnv = env.BLOCKRUN_WALLET_KEY || env.BASE_CHAIN_WALLET_KEY;
if (fromEnv && fromEnv.trim()) {
return { privateKey: normalize(fromEnv), source: "env" };
}
return resolveFromFiles();
}
// src/wallet.ts
var fs4 = __toESM(require("fs"), 1);
var USDC_BASE_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
var BASE_CHAIN_ID2 = "8453";
function walletDir() {
return paths().dir;
}
function walletFile() {
return paths().session;
}
function createWallet() {
const privateKey = (0, import_accounts16.generatePrivateKey)();
const account = (0, import_accounts16.privateKeyToAccount)(privateKey);
return {
address: account.address,
privateKey
};
}
function saveWallet(privateKey) {
const dir = walletDir();
const file = walletFile();
if (!fs4.existsSync(dir)) {
fs4.mkdirSync(dir, { recursive: true });
}
fs4.writeFileSync(file, privateKey, { mode: 384 });
return file;
}
function scanWallets2() {
return scanWallets();
}
function listDiscoveredWallets2() {
return listDiscoveredWallets();
}
function importWallet(address) {
return adoptWallet(address).address;
}
function loadWallet() {
return resolveFromFiles()?.privateKey ?? null;
}
function formatWalletMigrationNotice(newAddress) {
let addresses;
try {
addresses = listDiscoveredWallets().map((w) => w.address);
} catch {
return null;
}
if (addresses.length === 0) return null;
const found = addresses.map((addr) => ` ${addr}`).join("\n");
return `
NOTICE: BlockRun created a new wallet, but also found existing wallet(s)
belonging to other applications on this system:
${found}
BlockRun now uses only its own wallet:
${newAddress}
Discovered wallets are never adopted automatically \u2014 one may belong to a
different application, or have been planted to make you fund an address you
do not control.
If an address above is yours and holds your USDC, adopt it deliberately:
import { importWallet } from '@blockrun/llm';
importWallet("<address-from-the-list-above>");
Your current wallet is backed up first. You can also set
BLOCKRUN_WALLET_KEY=<private-key> for a single run without changing anything.
`;
}
function getOrCreateWallet() {
const resolved = resolvePrivateKey();
if (resolved) {
const account = (0, import_accounts16.privateKeyToAccount)(resolved.privateKey);
return { address: account.address, privateKey: resolved.privateKey, isNew: false };
}
const { address, privateKey } = createWallet();
saveWallet(privateKey);
return { address, privateKey, isNew: true };
}
function getWalletAddress() {
const resolved = resolvePrivateKey();
return resolved ? (0, import_accounts16.privateKeyToAccount)(resolved.privateKey).address : null;
}
function getEip681Uri(address, amountUsdc = 1) {
const amountWei = Math.floor(amountUsdc * 1e6);
return `ethereum:${USDC_BASE_CONTRACT}@${BASE_CHAIN_ID2}/transfer?address=${address}&uint256=${amountWei}`;
}
function getPaymentLinks(address) {
return {
basescan: `https://basescan.org/address/${address}`,
walletLink: `ethereum:${USDC_BASE_CONTRACT}@${BASE_CHAIN_ID2}/transfer?address=${address}`,
ethereum: `ethereum:${address}@${BASE_CHAIN_ID2}`,
blockrun: `https://blockrun.ai/fund?address=${address}`
};
}
function formatWalletCreatedMessage(address) {
const links = getPaymentLinks(address);
return `
I'm your BlockRun Agent! I can access GPT-4, Grok, image generation, and more.
Please send $1-5 USDC on Base to start:
${address}
What is Base? Base is Coinbase's blockchain network.
You can buy USDC on Coinbase and send it directly to me.
What $1 USDC gets you:
- ~1,000 GPT-4o calls
- ~100 image generations
- ~10,000 DeepSeek calls
Quick links:
- Check my balance: ${links.basescan}
- Get USDC: https://www.coinbase.com or https://bridge.base.org
Questions? care@blockrun.ai | Issues? github.com/BlockRunAI/blockrun-llm-ts/issues
Key stored securely in ~/.blockrun/
Your private key never leaves your machine - only signatures are sent.
`;
}
function formatNeedsFundingMessage(address) {
const links = getPaymentLinks(address);
return `
I've run out of funds! Please send more USDC on Base to continue helping you.
Send to my address:
${address}
Check my balance: ${links.basescan}
What $1 USDC gets you: ~1,000 GPT-4o calls or ~100 images.
Questions? care@blockrun.ai | Issues? github.com/BlockRunAI/blockrun-llm-ts/issues
Your private key never leaves your machine - only signatures are sent.
`;
}
function formatFundingMessageCompact(address) {
const links = getPaymentLinks(address);
return `I need a little top-up to keep helping you! Send USDC on Base to: ${address}
Check my balance: ${links.basescan}`;
}
var WALLET_FILE_PATH = walletFile();
var WALLET_DIR_PATH = walletDir();
// src/solana-wallet.ts
var fs5 = __toESM(require("fs"), 1);
var path4 = __toESM(require("path"), 1);
var os3 = __toESM(require("os"), 1);
var WALLET_DIR = path4.join(os3.homedir(), ".blockrun");
var SOLANA_WALLET_FILE = path4.join(WALLET_DIR, ".solana-session");
async function createSolanaWallet() {
const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
const bs583 = await import("bs58");
const keypair = Keypair.generate();
return {
address: keypair.publicKey.toBase58(),
privateKey: (bs583.default ?? bs583).encode(keypair.secretKey)
};
}
async function solanaKeyToBytes(privateKey) {
const key = privateKey.trim();
if (key.startsWith("[")) {
let parsed;
try {
parsed = JSON.parse(key);
} catch {
throw new Error(
"Invalid Solana private key: looks like a JSON byte array but is not valid JSON"
);
}
if (!Array.isArray(parsed) || !parsed.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) {
throw new Error(
"Invalid Solana private key: JSON array must contain only byte values (0-255)"
);
}
if (parsed.length !== 64) {
throw new Error(
`Invalid Solana key length: expected 64 bytes, got ${parsed.length}`
);
}
return Uint8Array.from(parsed);
}
const hex = key.startsWith("0x") || key.startsWith("0X") ? key.slice(2) : key;
if (/^[0-9a-fA-F]+$/.test(hex) && (hex.length === 64 || hex.length === 128)) {
if (hex.length === 64) {
throw new Error(
"Invalid Solana private key: this is a 32-byte hex key \u2014 the EVM (Base) wallet format, not a Solana key. Solana keys are 64 bytes, usually base58-encoded (86-88 characters)."
);
}
const bytes = new Uint8Array(64);
for (let i = 0; i < 64; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
try {
const bs583 = await import("bs58");
const bytes = (bs583.default ?? bs583).decode(key);
if (bytes.length !== 64) {
throw new Error(`Invalid Solana key length: expected 64 bytes, got ${bytes.length}`);
}
return bytes;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`Invalid Solana private key: ${msg}. Expected a base58-encoded 64-byte key (standard Solana format), a 64-byte hex string, or a Solana CLI JSON byte array.`,
{ cause: err }
);
}
}
async function solanaPublicKey(privateKey) {
const { Keypair } = await loadSolanaWeb3("Solana wallet operations");
const bytes = await solanaKeyToBytes(privateKey);
return Keypair.fromSecretKey(bytes).publicKey.toBase58();
}
function saveSolanaWallet(privateKey) {
if (!fs5.existsSync(WALLET_DIR)) fs5.mkdirSync(WALLET_DIR, { recursive: true });
fs5.writeFileSync(SOLANA_WALLET_FILE, privateKey, { mode: 384 });
return SOLANA_WALLET_FILE;
}
function scanSolanaWallets() {
const home = os3.homedir();
const results = [];
try {
const entries = fs5.readdirSync(home, { withFileTypes: true });
for (const entry of entries) {
if (!entry.name.startsWith(".") || !entry.isDirectory()) continue;
const solanaWalletFile = path4.join(home, entry.name, "solana-wallet.json");
if (fs5.existsSync(solanaWalletFile)) {
try {
const data = JSON.parse(fs5.readFileSync(solanaWalletFile, "utf-8"));
const pk = data.privateKey || "";
const addr = data.address || "";
if (pk && addr) {
const mtime = fs5.statSync(solanaWalletFile).mtimeMs;
results.push({ mtime, secretKey: pk, publicKey: addr, source: solanaWalletFile });
}
} catch {
}
}
if (entry.name === ".brcc") {
const brccWalletFile = path4.join(home, entry.name, "wallet.json");
if (fs5.existsSync(brccWalletFile)) {
try {
const data = JSON.parse(fs5.readFileSync(brccWalletFile, "utf-8"));
const pk = data.privateKey || "";
const addr = data.address || "";
if (pk && addr) {
const mtime = fs5.statSync(brccWalletFile).mtimeMs;
results.push({ mtime, secretKey: pk, publicKey: addr, source: brccWalletFile });
}
} catch {
}
}
}
}
} catch {
}
results.sort((a, b) => b.mtime - a.mtime);
return results.map(({ secretKey, publicKey, source }) => ({ secretKey, publicKey, source }));
}
function loadSolanaWallet() {
if (fs5.existsSync(SOLANA_WALLET_FILE)) {
const key = fs5.readFileSync(SOLANA_WALLET_FILE, "utf-8").trim();
if (key) return key;
}
return null;
}
async function listDiscoveredSolanaWallets() {
const listed = [];
for (const entry of scanSolanaWallets()) {
try {
listed.push({ address: await solanaPublicKey(entry.secretKey), source: entry.source });
} catch {
continue;
}
}
return listed;
}
async function importSolanaWallet(address) {
const wanted = address.trim();
for (const entry of scanSolanaWallets()) {
let derived;
try {
derived = await solanaPublicKey(entry.secretKey);
} catch {
continue;
}
if (derived !== wanted) continue;
if (fs5.existsSync(SOLANA_WALLET_FILE)) {
const current = fs5.readFileSync(SOLANA_WALLET_FILE, "utf-8").trim();
if (current && current !== entry.secretKey) {
const backup = path4.join(
WALLET_DIR,
`.solana-session.backup-${Math.floor(Date.now() / 1e3)}`
);
fs5.writeFileSync(backup, current, { mode: 384 });
}
}
saveSolanaWallet(entry.secretKey);
return derived;
}
const available = (await listDiscoveredSolanaWallets()).map((w) => w.address);
throw new Error(
`No discovered wallet controls ${address}. Available: ${available.length ? available.join(", ") : "none"}`
);
}
async function formatSolanaWalletMigrationNotice(newAddress) {
let discovered;
try {
discovered = scanSolanaWallets();
} catch {
return null;
}
const addresses = [];
for (const entry of discovered) {
try {
addresses.push(await solanaPublicKey(entry.secretKey));
} catch {
continue;
}
}
if (addresses.length === 0) return null;
const found = addresses.map((addr) => ` ${addr}`).join("\n");
return `
NOTICE: BlockRun created a new Solana wallet, but also found existing
wallet(s) belonging to other applications on this system:
${found}
BlockRun now uses only its own wallet:
${newAddress}
Discovered wallets are never adopted automatically \u2014 one may belong to a
different application, or have been planted to make you fund an address you
do not control.
If an address above is yours and holds your USDC, adopt it deliberately:
import { importSolanaWallet } from '@blockrun/llm';
await importSolanaWallet("<address-from-the-list-above>");
Your current wallet is backed up first. You can also set
SOLANA_WALLET_KEY=<private-key> for a single run without changing anything.
`;
}
async function solanaPublicKeyFrom(privateKey, source) {
try {
return await solanaPublicKey(privateKey);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`${msg} (key loaded from ${source})`, { cause: err });
}
}
async function getOrCreateSolanaWallet() {
const envKey = typeof process !== "undefined" && process.env ? process.env.SOLANA_WALLET_KEY : void 0;
if (envKey) {
const address2 = await solanaPublicKeyFrom(
envKey,
"the SOLANA_WALLET_KEY environment variable"
);
return { privateKey: envKey, address: address2, isNew: false };
}
if (fs5.existsSync(SOLANA_WALLET_FILE)) {
const fileKey = fs5.readFileSync(SOLANA_WALLET_FILE, "utf-8").trim();
if (fileKey) {
const address2 = await solanaPublicKeyFrom(fileKey, SOLANA_WALLET_FILE);
return { privateKey: fileKey, address: address2, isNew: false };
}
}
const { address, privateKey } = await createSolanaWallet();
saveSolanaWallet(privateKey);
return { address, privateKey, isNew: true };
}
// src/poll-ownership.ts
var import_crypto = require("crypto");
var import_bs58 = __toESM(require("bs58"), 1);
var POLL_AUTH_HEADERS = {
wallet: "x-poll-wallet",
timestamp: "x-poll-timestamp",
signature: "x-poll-signature"
};
function pollAuthMessage(kind, jobId, timestampS) {
return `blockrun-poll:v1:${kind}:${jobId}:${timestampS}`;
}
function pollJobIdFromUrl(pollUrl) {
const path7 = new URL(pollUrl, "https://placeholder.invalid").pathname.replace(/\/+$/, "");
const segment = path7.slice(path7.lastIndexOf("/") + 1);
if (!segment) throw new Error(`poll_url has no job id segment: ${pollUrl}`);
return decodeURIComponent(segment);
}
var ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
function signPollOwnership(secretKey, kind, jobId, nowMs = Date.now()) {
if (secretKey.length !== 64 && secretKey.length !== 32) {
throw new Error(`Solana secret key must be 64 bytes (or a 32-byte seed), got ${secretKey.length}`);
}
const seed = Buffer.from(secretKey.subarray(0, 32));
const privateKey = (0, import_crypto.createPrivateKey)({
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
format: "der",
type: "pkcs8"
});
const jwk = (0, import_crypto.createPublicKey)(privateKey).export({ format: "jwk" });
if (!jwk.x) throw new Error("Could not derive the Ed25519 public key");
const publicKey = Buffer.from(jwk.x, "base64url");
const timestampS = Math.floor(nowMs / 1e3);
const signature = (0, import_crypto.sign)(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
return {
[POLL_AUTH_HEADERS.wallet]: import_bs58.default.encode(publicKey),
[POLL_AUTH_HEADERS.timestamp]: String(timestampS),
[POLL_AUTH_HEADERS.signature]: import_bs58.default.encode(signature)
};
}
// src/solana-batch.ts
var import_async_hooks = require("async_hooks");
var import_crypto2 = require("crypto");
var fs6 = __toESM(require("fs"), 1);
var path5 = __toESM(require("path"), 1);
var import_bs582 = __toESM(require("bs58"), 1);
var BATCH_SCHEME = "batch-settlement";
var BLOCKRUN_SOL_OPERATOR = "5YKPQUFjw5WQqhSUkEGKNNfYYVqnRRNbpYyL71qQ1vm3";
var DEFAULT_BATCH_RATE_LIMIT = { maxAttempts: 3, maxWaitMs: 6e4 };
var DEFAULT_BATCH_IN_DOUBT = { maxWaitMs: 3e5 };
var DEFAULT_BATCH_LOCK_WAIT_MS = 6e4;
var LOCK_POLL_MS = 500;
var BatchPaymentUnresolvedError = class extends PaymentError {
/** The {@link SolanaBatchEvent.reason} of the `unresolved` event reported with it. */
reason;
/** The paying wallet's address. */
wallet;
/** The request id of the payment that may have been charged. */
requestId;
/** The channel it paid into. */
channelId;
/** What it carried. */
payloadKind;
/** HTTP status of the gateway's last answer to it, when there was one. */
status;
/** Whether it carried a deposit, which is now in doubt as well. */
depositInDoubt;
constructor(init) {
super(
`batch-settlement payment ${init.requestId ?? "<unknown>"} from wallet ${init.wallet} may have been charged and is not paid again${init.detail ? `: ${init.detail}` : ""}`
);
this.name = "BatchPaymentUnresolvedError";
withDisposition(this, "paid-or-in-doubt");
this.reason = init.reason;
this.wallet = init.wallet;
this.requestId = init.requestId;
this.channelId = init.channelId;
this.payloadKind = init.payloadKind;
this.status = init.status;
this.depositInDoubt = init.payloadKind === "open" || init.payloadKind === "top-up";
if (init.cause !== void 0) this.cause = init.cause;
}
};
var BatchCloseDeferredError = class extends BlockrunError {
reason;
/** The paying wallet's address. */
wallet;
/** The channels whose deposit is in doubt (`deposit_in_doubt`). */
channelIds;
constructor(init) {
super(`batch-settlement channel close deferred for wallet ${init.wallet}: ${init.detail}`);
this.name = "BatchCloseDeferredError";
this.reason = init.reason;
this.wallet = init.wallet;
this.channelIds = init.channelIds ?? [];
}
};
var BatchChannelLockedError = class extends BlockrunError {
/** The paying wallet's address. */
wallet;
/** The live owner's pid, when the SDK knows it. */
ownerPid;
/** How long this call waited for the lock, in ms (0 unless `onLocked` is `"wait"`). */
waitedMs;
constructor(init) {
super(`batch-settlement unavailable for wallet ${init.wallet}: ${init.detail}`);
this.name = "BatchChannelLockedError";
this.wallet = init.wallet;
if (init.ownerPid !== void 0) this.ownerPid = init.ownerPid;
this.waitedMs = init.waitedMs;
withDisposition(this, "unpaid");
}
};
var INSTALL_HINT2 = "npm install @x402/core@~2.28.0 @x402/svm@~2.28.0 @solana/kit";
async function load(pkg, importer) {
try {
return await importer();
} catch (err) {
throw new Error(
`@blockrun/llm: Solana batch-settlement requires the optional peer dependency "${pkg}", which is not installed.
${INSTALL_HINT2}
Original error: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
var FileChannelStorage = class {
constructor(file) {
this.file = file;
}
file;
read() {
try {
const parsed = JSON.parse(fs6.readFileSync(this.file, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (err) {
if (err.code === "ENOENT") return {};
throw err;
}
}
write(all) {
fs6.mkdirSync(path5.dirname(this.file), { recursive: true, mode: 448 });
const tmp = `${this.file}.${process.pid}.tmp`;
fs6.writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 384 });
fs6.renameSync(tmp, this.file);
}
async get(key) {
return this.read()[key];
}
async set(key, record) {
const all = this.read();
all[key] = record;
this.write(all);
}
async delete(key) {
const all = this.read();
if (!(key in all)) return;
delete all[key];
this.write(all);
}
/** The keys of every stored channel. */
async keys() {
return Object.keys(this.read());
}
/** Forget every channel in this file. */
clear() {
try {
fs6.unlinkSync(this.file);
} catch (err) {
if (err.code !== "ENOENT") throw err;
}
}
};
var MemoryChannelStorage = class {
records = /* @__PURE__ */ new Map();
async get(key) {
const raw = this.records.get(key);
return raw === void 0 ? void 0 : JSON.parse(raw);
}
async set(key, record) {
this.records.set(key, JSON.stringify(record));
}
async delete(key) {
this.records.delete(key);
}
async keys() {
return [...this.records.keys()];
}
clear() {
this.records.clear();
}
};
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function intentProblem(key, intent) {
const atomic = (value) => typeof value === "string" && /^\d+$/.test(value);
if (!isPlainObject(intent)) return "not an object";
if (intent.key !== key || !key) return "key does not match its entry";
if (typeof intent.channelId !== "string" || !intent.channelId) return "channelId missing";
if (!isPlainObject(intent.channelConfig)) return "channelConfig missing";
if (intent.kind !== "open" && intent.kind !== "top-up") return "kind is neither open nor top-up";
if (!atomic(intent.cumulative)) return "cumulative is not an atomic amount";
if (!atomic(intent.expectDeposit)) return "expectDeposit is not an atomic amount";
if (intent.knownDeposit !== void 0 && !atomic(intent.knownDeposit)) return "knownDeposit is not an atomic amount";
if (intent.requestId !== void 0 && typeof intent.requestId !== "string") return "requestId is not a string";
if (intent.anchorHeight !== void 0 && (!Number.isSafeInteger(intent.anchorHeight) || intent.anchorHeight < 0)) {
return "anchorHeight is not a block height";
}
if (typeof intent.at !== "number" || !Number.isFinite(intent.at)) return "at is not a time";
return void 0;
}
function fsyncDirectory(dir) {
let fd;
try {
fd = fs6.openSync(dir, "r");
fs6.fsyncSync(fd);
} catch (err) {
const code = err.code;
if (!["EISDIR", "EPERM", "EACCES", "EINVAL", "ENOTSUP", "EBADF"].includes(code ?? "")) throw err;
} finally {
if (fd !== void 0) fs6.closeSync(fd);
}
}
var FileIntentJournal = class _FileIntentJournal {
constructor(file) {
this.file = file;
}
file;
durable = true;
/** The journal file for a channel store file: its full path plus `.deposit-intents`, one per store. */
static beside(channelFile) {
return new _FileIntentJournal(`${channelFile}.deposit-intents`);
}
/**
* Where earlier builds kept a store's journal: the store's name without
* `.json`, plus `.deposit-intents.json`. Two stores could share it
* (`/x/channels` and `/x/channels.json`), so its intents are moved by payer
* ({@link adoptLegacy}). No current journal path ever ends in `.json`, so
* this never names one.
*/
static legacyBeside(channelFile) {
const base = path5.basename(channelFile).replace(/\.json$/i, "");
return path5.join(path5.dirname(channelFile), `${base}.deposit-intents.json`);
}
/**
* Move `payer`'s intents from a journal an earlier build wrote at
* `legacyFile` into this one, before this one is used: written here first
* (durably), then removed there (the file goes once empty). Intents of other
* payers are left where they are, for the store they belong to. An intent
* already here is kept (a migration interrupted between the two writes).
*
* @throws when the old journal cannot be read or is not valid, or holds an
* intent naming no payer: the wallet then keeps batch off, as for an
* unreadable journal, and nothing is dropped.
*/
adoptLegacy(legacyFile, payer) {
if (legacyFile === this.file) return;
const legacy = new _FileIntentJournal(legacyFile);
const found = legacy.read();
const mine = [];
const others = {};
for (const [key, intent] of Object.entries(found)) {
const owner = intent.channelConfig.payer;
if (typeof owner !== "string") {
throw new Error(`deposit-intent journal ${legacyFile} holds an intent naming no payer (${JSON.stringify(key)}); move it by hand`);
}
if (owner === payer) mine.push(intent);
else others[key] = intent;
}
if (mine.length === 0) return;
const intents = this.read();
for (const intent of mine) intents[intent.key] ??= intent;
this.write(intents);
legacy.write(others);
}
/**
* The journaled intents. Only a missing file is an empty journal: anything
* else that is not a version-1 journal of well-formed intents throws, so a
* deposit in doubt is never lost to a damaged or unknown file (the wallet
* then keeps batch off, `deposit_journal_unreadable`).
*/
read() {
let text;
try {
text = fs6.readFileSync(this.file, "utf8");
} catch (err) {
if (err.code === "ENOENT") return {};
throw err;
}
const invalid = (why) => new Error(`deposit-intent journal ${this.file} is not valid (${why})`);
let parsed;
try {
parsed = JSON.parse(text);
} catch (err) {
throw invalid(err instanceof Error ? err.message : String(err));
}
if (!isPlainObject(parsed)) throw invalid("not a JSON object");
if (parsed.version !== 1) throw invalid(`unsupported version ${JSON.stringify(parsed.version)}`);
const { intents } = parsed;
if (!isPlainObject(intents)) throw invalid("intents is not an object");
for (const [key, intent] of Object.entries(intents)) {
const why = intentProblem(key, intent);
if (why) throw invalid(`intent ${JSON.stringify(key)}: ${why}`);
}
return intents;
}
write(intents) {
const dir = path5.dirname(this.file);
fs6.mkdirSync(dir, { recursive: true, mode: 448 });
if (Object.keys(intents).length === 0) {
try {
fs6.unlinkSync(this.file);
} catch (err) {
if (err.code !== "ENOENT") throw err;
return;
}
fsyncDirectory(dir);
return;
}
const tmp = `${this.file}.${process.pid}.${(0, import_crypto2.randomUUID)()}.tmp`;
const fd = fs6.openSync(tmp, "w", 384);
try {
fs6.writeSync(fd, JSON.stringify({ version: 1, intents }, null, 2));
fs6.fsyncSync(fd);
} finally {
fs6.closeSync(fd);
}
fs6.renameSync(tmp, this.file);
fsyncDirectory(dir);
}
list() {
return Object.values(this.read());
}
put(intent) {
const intents = this.read();
intents[intent.key] = intent;
this.write(intents);
}
remove(key) {
const intents = this.read();
if (!(key in intents)) return;
delete intents[key];
this.write(intents);
}
};
var MemoryIntentJournal = class {
durable = false;
intents = /* @__PURE__ */ new Map();
list() {
return [...this.intents.values()];
}
put(intent) {
this.intents.set(intent.key, { ...intent });
}
remove(key) {
this.intents.delete(key);
}
};
function processAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err.code === "EPERM";
}
}
var BATCH_REGISTRY = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-registry/v1");
var registryHost = globalThis;
var registry2 = registryHost[BATCH_REGISTRY] ??= {
wallets: /* @__PURE__ */ new Map(),
locks: /* @__PURE__ */ new Map(),
exitHook: false
};
var lockIds = registry2.lockIds ??= /* @__PURE__ */ new Map();
function fileIdentity(file) {
try {
const stat = fs6.statSync(file);
return `${stat.dev}:${stat.ino}:${stat.mtimeMs}`;
} catch {
return void 0;
}
}
function lockPredatesProcess(lock) {
try {
return fs6.statSync(lock).mtimeMs < performance.timeOrigin;
} catch {
return false;
}
}
function holdsChannelFile(file) {
const token = registry2.locks.get(file);
if (!token) return false;
const lock = `${file}.lock`;
if (!ownsLock(lock, token)) return false;
const id = lockIds.get(file);
return id === void 0 || fileIdentity(lock) === id;
}
function lockTokenFile(lock) {
return `${lock}.owner`;
}
function readLockOwner(lock) {
let raw;
try {
raw = fs6.readFileSync(lock, "utf8");
} catch (err) {
if (err.code === "ENOENT") return void 0;
throw err;
}
let pid = Number(raw.trim());
if (!Number.isInteger(pid)) {
try {
const parsed = JSON.parse(raw);
pid = parsed && typeof parsed === "object" && Number.isInteger(parsed.pid) ? parsed.pid : 0;
} catch {
pid = 0;
}
}
return { pid, raw };
}
function readLockToken(lock) {
try {
const parsed = JSON.parse(fs6.readFileSync(lockTokenFile(lock), "utf8"));
return parsed && typeof parsed === "object" && typeof parsed.token === "string" ? parsed.token : void 0;
} catch {
return void 0;
}
}
function ownsLock(lock, token) {
return readLockOwner(lock)?.pid === process.pid && readLockToken(lock) === token;
}
function releaseHeldLocks() {
for (const [file, token] of registry2.locks) {
const lock = `${file}.lock`;
try {
if (!ownsLock(lock, token)) continue;
fs6.unlinkSync(lockTokenFile(lock));
fs6.unlinkSync(lock);
} catch {
}
}
}
function lockChannelFile(file) {
if (registry2.locks.has(file)) return void 0;
const lock = `${file}.lock`;
fs6.mkdirSync(path5.dirname(lock), { recursive: true, mode: 448 });
const token = (0, import_crypto2.randomUUID)();
for (let attempt = 0; attempt < 2; attempt += 1) {
const tmp = `${lock}.${process.pid}.${token}.tmp`;
try {
fs6.writeFileSync(tmp, String(process.pid), { mode: 384 });
try {
fs6.linkSync(tmp, lock);
} catch (err) {
const code = err.code;
if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "EOPNOTSUPP") throw err;
fs6.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
}
try {
writeLockToken(lock, token);
} catch (err) {
try {
fs6.unlinkSync(lock);
} catch {
}
throw err;
}
registry2.locks.set(file, token);
const id = fileIdentity(lock);
if (id) lockIds.set(file, id);
else lockIds.delete(file);
if (!registry2.exitHook) {
registry2.exitHook = true;
process.once("exit", releaseHeldLocks);
}
return void 0;
} catch (err) {
if (err.code !== "EEXIST") throw err;
} finally {
try {
fs6.unlinkSync(tmp);
} catch {
}
}
const owner = readLockOwner(lock);
if (!owner) continue;
if (owner.pid === process.pid) {
if (!lockPredatesProcess(lock)) return process.pid;
} else if (owner.pid > 0 && processAlive(owner.pid)) {
return owner.pid;
}
try {
if (readLockOwner(lock)?.raw === owner.raw) fs6.unlinkSync(lock);
} catch {
}
}
return -1;
}
function writeLockToken(lock, token) {
const sidecar = lockTokenFile(lock);
const tmp = `${sidecar}.${process.pid}.${token}.tmp`;
try {
fs6.writeFileSync(tmp, JSON.stringify({ pid: process.pid, token }), { mode: 384 });
fs6.renameSync(tmp, sidecar);
} finally {
try {
fs6.unlinkSync(tmp);
} catch {
}
}
}
function pendingDeposit(pending) {
const entries = Array.isArray(pending) ? pending : [pending];
return entries.some(
(entry) => entry?.payment?.payload?.type === "deposit"
);
}
var ChannelResyncRequiredError = class extends Error {
constructor(channelId) {
super(`batch-settlement channel ${channelId ?? "<unknown>"} needs a chain re-read before it is used`);
this.channelId = channelId;
this.name = "ChannelResyncRequiredError";
}
channelId;
};
function isResyncRequired(err) {
return err instanceof Error && err.name === "ChannelResyncRequiredError";
}
function parseAtomic(value) {
return typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : 0n;
}
function confirmedCumulative(record, channelId) {
if (!record || record.channelId !== channelId) return 0n;
return record.pending === void 0 || record.hasConfirmedState ? parseAtomic(record.chargedCumulativeAmount) : 0n;
}
var ChannelBook = class {
constructor(base, onUnverified = () => {
}) {
this.base = base;
this.onUnverified = onUnverified;
}
base;
onUnverified;
seen = /* @__PURE__ */ new Map();
async get(key) {
const record = await this.base.get(key);
if (record) this.seen.set(key, record);
if (!record?.pending || record.channelConfig?.voucherSigner !== "server") return record;
if (record.hasConfirmedState && !pendingDeposit(record.pending)) {
const { pending: _pending, hasConfirmedState: _confirmed, ...confirmed } = record;
await this.set(key, confirmed);
return confirmed;
}
if (typeof record.channelId === "string") {
const pendingEntries = Array.isArray(record.pending) ? record.pending : [record.pending];
const inDoubt = pendingEntries.find((entry) => entry?.payment?.payload?.type === "deposit");
this.onUnverified({
key,
channelId: record.channelId,
channelConfig: record.channelConfig,
cumulative: record.hasConfirmedState ? parseAtomic(record.chargedCumulativeAmount) : 0n,
...record.hasConfirmedState ? { knownDeposit: parseAtomic(record.deposit) } : {},
...inDoubt ? { expectDeposit: parseAtomic(inDoubt.deposit) } : {},
reason: "orphaned_deposit"
});
}
throw new ChannelResyncRequiredError(record.channelId);
}
/**
* Store a record, and only once it is stored remember it as the last one
* seen. If storing fails (say, while the scheme saves a reconciled
* receipt), the scheme reports the receipt unreconciled, and the SDK must
* still see the state from before it: `distrust()` adds that call's charge
* to it, and a remembered post-receipt cumulative would count it twice.
*/
async set(key, record) {
await this.base.set(key, record);
this.seen.set(key, record);
}
async delete(key) {
await this.base.delete(key);
}
/** The keys of every stored channel. */
async keys() {
return await this.base.keys?.() ?? [];
}
/** The last record seen for a channel, even one the scheme has deleted since. */
find(channelId) {
for (const [key, record] of this.seen) {
if (record.channelId === channelId) return { key, record };
}
return void 0;
}
clear() {
this.seen.clear();
this.base.clear?.();
}
/** Forget one channel: its stored record and the last record seen for it. An emptied store is removed. */
async forget(key) {
this.seen.delete(key);
await this.base.delete(key);
if ((await this.keys()).length === 0) this.clear();
}
};
var PAYMENT_CHANNELS_PROGRAM_ID = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX";
var CHANNEL = {
size: 256,
rentPayer: 216,
discriminator: 1,
status: 3,
deposit: 12,
settled: 20,
closureStartedAt: 36,
payer: 88,
authorizedSigner: 152,
mint: 184
};
function decodeChannelAccount(owner, data) {
if (owner !== PAYMENT_CHANNELS_PROGRAM_ID || data.length < CHANNEL.size || data[0] !== CHANNEL.discriminator) {
return void 0;
}
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const key = (offset) => import_bs582.default.encode(data.subarray(offset, offset + 32));
return {
deposit: view.getBigUint64(CHANNEL.deposit, true),
settled: view.getBigUint64(CHANNEL.settled, true),
open: data[CHANNEL.status] === 0 && view.getBigInt64(CHANNEL.closureStartedAt, true) === 0n,
payer: key(CHANNEL.payer),
authorizedSigner: key(CHANNEL.authorizedSigner),
mint: key(CHANNEL.mint)
};
}
var ChannelUnreadableError = class extends Error {
constructor(channelId, why) {
super(`account ${channelId} is on chain but is not a readable payment channel (${why})`);
this.name = "ChannelUnreadableError";
}
};
var ChannelLayoutError = class extends Error {
constructor(message, options) {
super(message, options);
this.name = "ChannelLayoutError";
}
};
async function readChannelAccount(rpcUrl, channelId, rpcHeaders, options = {}) {
await checkChannelLayout();
const config = { encoding: "base64", commitment: options.commitment ?? "finalized" };
if (options.minContextSlot !== void 0) config.minContextSlot = options.minContextSlot;
const response = await fetch(rpcUrl, {
method: "POST",
headers: { ...rpcHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getAccountInfo", params: [channelId, config] }),
signal: AbortSignal.timeout(15e3),
// rpcHeaders may carry an API key: never let a redirect take it elsewhere.
redirect: "error"
});
if (!response.ok) throw new Error(`getAccountInfo answered HTTP ${response.status}`);
const body = await response.json();
if (body.error) throw new Error(`getAccountInfo failed: ${body.error.message ?? JSON.stringify(body.error)}`);
if (!body.result || typeof body.result !== "object" || !("value" in body.result)) {
throw new Error("getAccountInfo returned no result");
}
const value = body.result.value;
if (value === null) return void 0;
if (!value || typeof value !== "object") throw new ChannelUnreadableError(channelId, "malformed account value");
if (!Array.isArray(value.data) || typeof value.data[0] !== "string" || value.data[1] !== "base64") {
throw new ChannelUnreadableError(channelId, "unsupported data encoding");
}
const channel = decodeChannelAccount(value.owner, Buffer.from(value.data[0], "base64"));
if (!channel) throw new ChannelUnreadableError(channelId, "wrong owner or layout");
return channel;
}
async function rpcRequest(rpcUrl, method, params, rpcHeaders) {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { ...rpcHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(15e3),
// rpcHeaders may carry an API key: never let a redirect take it elsewhere.
redirect: "error"
});
if (!response.ok) throw new Error(`${method} answered HTTP ${response.status}`);
const body = await response.json();
if (body.error) throw new Error(`${method} failed: ${body.error.message ?? JSON.stringify(body.error)}`);
if (body.result === void 0 || body.result === null) throw new Error(`${method} returned no result`);
return body.result;
}
function rpcInteger(value, what) {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`${what} is not a block height or slot`);
return value;
}
var layoutChecked;
function checkChannelLayout() {
layoutChecked ??= (async () => {
const svm = await load("@x402/svm", () => import("@x402/svm")).catch((err) => {
throw new ChannelLayoutError(
`cannot check the payment-channel account layout: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
});
if (svm.CHANNEL_ACCOUNT_SIZE !== BigInt(CHANNEL.size) || svm.CHANNEL_RENT_PAYER_OFFSET !== BigInt(CHANNEL.rentPayer)) {
throw new ChannelLayoutError("@x402/svm changed the payment-channel account layout; this SDK cannot read channels from it");
}
})();
layoutChecked.catch(() => {
layoutChecked = void 0;
});
return layoutChecked;
}
var LANDING_MARGIN_BLOCKS = 300;
function savedReclaimAnchor(record) {
const saved = record.reclaimAnchorHeight;
return typeof saved === "number" && Number.isSafeInteger(saved) && saved > 0 ? saved : void 0;
}
function maxOf(a, b) {
if (a === void 0) return b;
if (b === void 0) return a;
return a > b ? a : b;
}
var ResyncError = class extends Error {
constructor(reason, message) {
super(message);
this.reason = reason;
this.name = "ResyncError";
}
reason;
};
function payloadChannelId(payload) {
return payload.payload?.authorization?.channelId ?? payload.payload?.voucher?.channelId;
}
function clientSignedBatch(accept) {
return accept.scheme === BATCH_SCHEME && accept.extra?.voucherSigner !== "server";
}
function batchUnavailable(paymentRequired) {
const batch = (paymentRequired.accepts ?? []).filter((accept) => accept.scheme === BATCH_SCHEME);
if (batch.length === 0) return { reason: "not_offered" };
if (!batch.every(clientSignedBatch)) return void 0;
return {
reason: "client_signed_not_supported",
detail: "the 402 offers only client-signed batch-settlement accepts; this SDK pays batch only into server-signed (operator) channels"
};
}
function withoutClientSignedBatch(paymentRequired) {
const accepts = paymentRequired.accepts ?? [];
return accepts.some(clientSignedBatch) ? { ...paymentRequired, accepts: accepts.filter((accept) => !clientSignedBatch(accept)) } : paymentRequired;
}
var DEPOSIT_BLOCKHASH_TTL_MS = 1e4;
var MAX_DEPOSIT_BLOCKHASH_ENDPOINTS = 8;
var depositBlockhashes = /* @__PURE__ */ new Map();
async function finalizedBlockhash(rpcUrl, rpcHeaders) {
const cached = depositBlockhashes.get(rpcUrl);
if (cached && Date.now() - cached.fetchedAt < DEPOSIT_BLOCKHASH_TTL_MS) return cached;
const result = await rpcRequest(rpcUrl, "getLatestBlockhash", [{ commitment: "finalized" }], rpcHeaders);
const blockhash = result.value?.blockhash;
if (typeof blockhash !== "string" || !blockhash) throw new Error("getLatestBlockhash returned no blockhash");
const entry = {
blockhash,
lastValidBlockHeight: rpcInteger(result.value?.lastValidBlockHeight, "getLatestBlockhash lastValidBlockHeight"),
fetchedAt: Date.now()
};
depositBlockhashes.delete(rpcUrl);
depositBlockhashes.set(rpcUrl, entry);
while (depositBlockhashes.size > MAX_DEPOSIT_BLOCKHASH_ENDPOINTS) {
depositBlockhashes.delete(depositBlockhashes.keys().next().value);
}
return entry;
}
function withDepositBlockhash(paymentRequired, blockhash) {
const accepts = paymentRequired.accepts ?? [];
const lacking = (accept) => accept.scheme === BATCH_SCHEME && !accept.extra?.recentBlockhash;
if (!accepts.some(lacking)) return paymentRequired;
return {
...paymentRequired,
accepts: accepts.map(
(accept) => lacking(accept) ? { ...accept, extra: { ...accept.extra, recentBlockhash: blockhash.blockhash, lastValidBlockHeight: blockhash.lastValidBlockHeight } } : accept
)
};
}
var PAYMENT_IDENTIFIER = "payment-identifier";
function withPaymentIdentifier(paymentRequired) {
const declared = paymentRequired.extensions?.[PAYMENT_IDENTIFIER];
if (typeof declared?.info?.required !== "boolean") return paymentRequired;
const id = `pay_${(0, import_crypto2.randomUUID)().replace(/-/g, "")}`;
return {
...paymentRequired,
extensions: { ...paymentRequired.extensions, [PAYMENT_IDENTIFIER]: { ...declared, info: { ...declared.info, id } } }
};
}
function peekReceipt(http, getHeader) {
try {
return http.getPaymentSettleResponse(getHeader);
} catch {
return void 0;
}
}
function receiptSucceeded(http, answer) {
return peekReceipt(http, (name) => answer.headers.get(name))?.success === true;
}
function hasPaymentIdentifier(payload) {
const info = payload.extensions?.[PAYMENT_IDENTIFIER]?.info;
return typeof info?.id === "string";
}
var IN_DOUBT_MIN_WAIT_MS = 1e3;
var IN_DOUBT_MAX_WAIT_MS = 3e4;
function replayMayResolve(doubt) {
if (doubt.unanswered) return true;
const answer = doubt.held;
if (!answer) return false;
return answer.status >= 500 || answer.status === 429 || answer.status === 409 && Boolean(answer.headers.get("retry-after")?.trim());
}
function inDoubtWait(doubt, replay) {
const asked = doubt.held ? parseRetryAfter(doubt.held.headers.get("retry-after")) : void 0;
return Math.min(IN_DOUBT_MAX_WAIT_MS, Math.max(IN_DOUBT_MIN_WAIT_MS, asked ?? backoffDelay(replay)));
}
function replayDoubt(outcome) {
if (outcome.kind === "charged" || outcome.kind === "in_doubt") return outcome;
return { kind: "in_doubt", reason: "replay_unresolved", replayable: false, settled: true, detail: "the replay was refused" };
}
function trustedBatchAccepts(paymentRequired, operators) {
return (paymentRequired.accepts ?? []).filter((candidate) => {
const extra = candidate.extra;
return candidate.scheme === BATCH_SCHEME && extra?.voucherSigner === "server" && operators.includes(extra.operator);
});
}
function trustedBatchAccept(paymentRequired, operators) {
return trustedBatchAccepts(paymentRequired, operators)[0];
}
function chargedUsd(getReceipt) {
let receipt;
try {
receipt = getReceipt();
} catch {
return 0;
}
return receiptChargedUsd(receipt) ?? 0;
}
function rebuiltCharge(settled, payload) {
if (settled?.success !== true) return 0n;
const charged = parseAtomic(typeof settled.extra?.chargedAmount === "string" ? settled.extra.chargedAmount : settled.amount);
const ceiling = parseAtomic(payload.accepted?.amount);
return charged > ceiling ? ceiling : charged;
}
function confirmedState(record) {
if (!record || !(record.pending === void 0 || record.hasConfirmedState)) return void 0;
return { cumulative: parseAtomic(record.chargedCumulativeAmount), deposit: parseAtomic(record.deposit) };
}
function recordAdvanced(before, after, payload, receipt) {
const deposited = payload.payload?.type === "deposit" ? parseAtomic(payload.payload.deposit?.amount) : 0n;
const charged = rebuiltCharge(receipt, payload);
if (deposited === 0n && charged === 0n) return true;
if (!after) return false;
const prior = before ?? { cumulative: 0n, deposit: 0n };
return after.deposit >= prior.deposit + deposited && after.cumulative >= prior.cumulative + charged;
}
var BATCH_REFUSALS = /* @__PURE__ */ new Set([
"batch_payer_not_allowed",
"batch_payer_not_admitted",
"batch_admission_paused",
"batch_server_signed_only",
"batch_unavailable",
"batch_channel_limit",
"PAYMENT_VERIFICATION_UNAVAILABLE"
]);
var PRE_REQUEST_REFUSALS = /* @__PURE__ */ new Set([
"batch_payer_not_allowed",
"batch_payer_not_admitted",
"batch_admission_paused",
"batch_server_signed_only",
"batch_unavailable",
"batch_channel_limit"
]);
async function batchRefusal(response) {
if (![400, 403, 409, 503].includes(response.status)) return void 0;
let body;
try {
body = await response.clone().json();
} catch {
return void 0;
}
const { error, code } = body ?? {};
for (const value of [code, error]) {
if (typeof value === "string" && BATCH_REFUSALS.has(value)) return value;
}
return void 0;
}
async function duplicateAnswer(response) {
const codes = [];
try {
const body = await response.clone().json();
if (body && typeof body === "object") codes.push(body.error, body.code, body.reason, body.invalidReason);
} catch {
}
const header = response.headers.get("PAYMENT-REQUIRED");
if (header) {
try {
const challenge = parsePaymentRequired(header);
codes.push(challenge.error, challenge.invalidReason);
} catch {
}
}
for (const code of codes) {
if (typeof code === "string" && /duplicate/i.test(code)) return code;
}
return void 0;
}
function cleanRefusalReceipt(receipt) {
if (receipt === void 0) return true;
return receipt.success === false && !receipt.transaction && receipt.errorReason !== "settlement_pending";
}
async function afterPaymentError(response) {
let body;
try {
body = await response.clone().json();
} catch {
body = { error: "Request failed" };
}
return withDisposition(
new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(body)),
"paid-or-in-doubt"
);
}
function cancelledReceipt(receipt) {
return receipt?.success === false && receipt.errorReason === "batch_cancelled" && !receipt.transaction;
}
async function cancelledError(response) {
let body;
try {
body = await response.clone().json();
} catch {
body = { error: "Request failed" };
}
return withDisposition(new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(body)), "unpaid");
}
function describeReceipt(receipt) {
if (!receipt) return "no receipt";
const parts = [`success=${String(receipt.success)}`];
if (receipt.errorReason) parts.push(`errorReason=${receipt.errorReason}`);
if (receipt.transaction) parts.push(`transaction=${receipt.transaction}`);
return `receipt ${parts.join(" ")}`;
}
var MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1e3;
function parseRetryAfter(value, now = Date.now()) {
const text = value?.trim();
if (!text) return void 0;
let ms;
if (/^\d+(\.\d+)?$/.test(text)) {
ms = Math.round(Number(text) * 1e3);
} else {
const at = Date.parse(text);
if (Number.isNaN(at)) return void 0;
ms = Math.max(0, at - now);
}
return Number.isFinite(ms) && ms <= MAX_RETRY_AFTER_MS ? ms : void 0;
}
var NOT_BROADCAST_REFUSALS = /* @__PURE__ */ new Set([
// 429
"batch_account_channel_capacity_exhausted",
"batch_channel_capacity_exhausted",
"batch_deposit_rate_limited",
"batch_deposit_ip_rate_limited",
// 503
"batch_signer_funding_headroom_exhausted",
"batch_new_channels_disabled",
"batch_public_new_channels_disabled",
// 403
"batch_account_admission_paused",
"batch_channel_owner_mismatch",
"batch_fee_payer_unavailable",
"batch_fee_payer_not_dedicated",
"batch_fee_payer_lane_not_authorized",
"batch_transaction_fee_payer_mismatch",
// 400
"batch_initial_deposit_out_of_range",
"batch_deposit_asset_not_supported",
"batch_withdraw_delay_out_of_range"
]);
function provesNothingBroadcast(receipt) {
return receipt?.success === false && !receipt.transaction && typeof receipt.errorReason === "string" && NOT_BROADCAST_REFUSALS.has(receipt.errorReason);
}
var CONFIG_REFUSALS = /* @__PURE__ */ new Set([
"batch_initial_deposit_out_of_range",
"batch_deposit_asset_not_supported",
"batch_withdraw_delay_out_of_range"
]);
var REFUSAL_COOLDOWN_MS = 6e4;
var CONFIG_REFUSAL_COOLDOWN_MS = 10 * 6e4;
var REFUSAL_COOLDOWN_MAX_MS = 15 * 6e4;
function refusalCooldownMs(errorReason, retryAfterMs2) {
if (retryAfterMs2 !== void 0) return Math.min(retryAfterMs2, REFUSAL_COOLDOWN_MAX_MS);
return errorReason !== void 0 && CONFIG_REFUSALS.has(errorReason) ? CONFIG_REFUSAL_COOLDOWN_MS : REFUSAL_COOLDOWN_MS;
}
function backoffDelay(attempt) {
const base = 1e3 * 2 ** Math.max(0, attempt - 1);
return Math.round(base * (0.75 + Math.random() * 0.5));
}
async function rateLimitReason(response, settled) {
let body;
try {
body = await response.clone().json();
} catch {
body = void 0;
}
const fields = body ?? {};
for (const value of [fields.errorReason, fields.error, fields.code, fields.reason, settled?.errorReason]) {
if (typeof value === "string" && value) return value;
}
return void 0;
}
function creationFailure(err) {
const detail = err instanceof Error ? err.message : String(err);
if (err instanceof Error && err.name === "UntrustedOperatorError") return { reason: "untrusted_operator", detail };
if (detail.includes("serverSignedChannelsPolicy maxDeposit")) return { reason: "deposit_over_cap", detail };
if (detail.includes("has a pending request")) return { reason: "channel_pending", detail };
if (detail.includes("requires the optional peer dependency")) return { reason: "peer_dependency_missing", detail };
return { reason: "payment_creation_failed", detail };
}
function logValue(value) {
const text = String(value);
return /^[\w.:/@+-]+$/.test(text) ? text : JSON.stringify(text);
}
function formatEvent(event) {
const fields = [
["event", event.type],
["reason", event.reason],
["status", event.status],
["errorReason", event.errorReason],
["retryAfterMs", event.retryAfterMs],
["attempt", event.attempt],
["wallet", event.wallet],
["next", event.type === "fallback" ? "exact" : event.type === "backoff" ? "retry" : event.type === "unresolved" ? "raise" : void 0],
["detail", event.detail]
];
const parts = fields.filter(([, v]) => v !== void 0 && v !== "").map(([k, v]) => `${k}=${logValue(v)}`);
return `[@blockrun/llm] batch-settlement ${parts.join(" ")}`;
}
var realSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
var sleep2 = realSleep;
var warnedNoSizer = false;
function channelKeyOf(requirements) {
const accept = requirements;
const extra = accept?.extra ?? {};
if (typeof extra.feePayer !== "string" || typeof extra.withdrawDelay !== "number") return void 0;
return [
accept.network,
accept.asset,
accept.payTo,
extra.feePayer,
extra.withdrawDelay,
extra.receiverAuthorizer ?? "",
extra.voucherSigner ?? "client",
extra.operator ?? ""
].join(":");
}
function capOutstandingEscrow(scheme, wallet) {
const target = scheme;
const original = target.resolveDepositAmount;
if (typeof original !== "function") {
if (!warnedNoSizer) {
warnedNoSizer = true;
console.error(
"[@blockrun/llm] batch-settlement: this @x402/svm does not expose resolveDepositAmount; maxDeposit caps lifetime deposits"
);
}
return;
}
target.resolveDepositAmount = function(requirements, requestAmount, needed, context, trust, existingDeposit) {
const topUp = wallet.topUp;
const widened = topUp && existingDeposit > 0n && existingDeposit === topUp.deposit && channelKeyOf(requirements) === topUp.key && typeof trust?.maxDeposit === "bigint" ? { ...trust, maxDeposit: trust.maxDeposit + topUp.settled } : trust;
return original.call(this, requirements, requestAmount, needed, context, widened, existingDeposit);
};
}
var payScope = new import_async_hooks.AsyncLocalStorage();
function registerHookDispatchers(client) {
client.onBeforePaymentCreation(async (context) => {
for (const hook of payScope.getStore()?.hooks.before ?? []) {
const result = await hook(context);
if (result && typeof result === "object" && result.abort) return result;
}
return void 0;
});
client.onAfterPaymentCreation(async (context) => {
const scope = payScope.getStore();
for (const hook of scope?.hooks.after ?? []) {
try {
await hook(context);
} catch (error) {
if (scope && !scope.afterError) scope.afterError = { error };
}
}
});
client.onPaymentCreationFailure(async (context) => {
await runFailureHooks(payScope.getStore(), context);
return void 0;
});
}
async function runFailureHooks(scope, context) {
for (const hook of scope?.hooks.failure ?? []) {
try {
await hook(context);
} catch {
}
}
}
var RPC_SCOPE_STORE = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-rpc-scope/v1");
var RPC_HEADER_HOOK = /* @__PURE__ */ Symbol.for("@blockrun/llm/batch-rpc-headers/v1");
var sharedGlobal = globalThis;
var rpcScope = sharedGlobal[RPC_SCOPE_STORE] ??= new import_async_hooks.AsyncLocalStorage();
function requestUrl(input) {
return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
}
function installRpcHeaderHook() {
const current = globalThis.fetch;
if (current[RPC_HEADER_HOOK]) return;
const hooked = (input, init) => {
const scope = rpcScope.getStore();
if (!scope || requestUrl(input) !== scope.url) return current(input, init);
const headers = new Headers(input instanceof Request ? input.headers : void 0);
new Headers(scope.headers).forEach((value, name) => headers.set(name, value));
new Headers(init?.headers).forEach((value, name) => headers.set(name, value));
return current(input, { ...init, headers, redirect: "error" });
};
hooked[RPC_HEADER_HOOK] = true;
globalThis.fetch = hooked;
}
function addResyncTarget(wallet, target) {
const existing = wallet.resyncs.get(target.key);
if (!existing) {
wallet.resyncs.set(target.key, target);
return;
}
wallet.resyncs.set(target.key, {
...existing,
channelConfig: target.channelConfig,
cumulative: maxOf(existing.cumulative, target.cumulative) ?? 0n,
expectDeposit: maxOf(existing.expectDeposit, target.expectDeposit),
knownDeposit: maxOf(existing.knownDeposit, target.knownDeposit),
anchorHeight: existing.anchorHeight === void 0 || target.anchorHeight === void 0 ? existing.anchorHeight ?? target.anchorHeight : Math.max(existing.anchorHeight, target.anchorHeight)
});
}
function intentTarget(intent) {
return {
key: intent.key,
channelId: intent.channelId,
channelConfig: intent.channelConfig,
cumulative: parseAtomic(intent.cumulative),
expectDeposit: parseAtomic(intent.expectDeposit),
...intent.knownDeposit !== void 0 ? { knownDeposit: parseAtomic(intent.knownDeposit) } : {},
...intent.anchorHeight !== void 0 ? { anchorHeight: intent.anchorHeight } : {},
reason: "orphaned_deposit"
};
}
var SolanaBatchPayer = class {
constructor(init) {
this.init = init;
init.configureClient?.({
onBeforePaymentCreation: (hook) => void this.hooks.before.push(hook),
onAfterPaymentCreation: (hook) => void this.hooks.after.push(hook),
onPaymentCreationFailure: (hook) => void this.hooks.failure.push(hook)
});
const { operators, rateLimit, inDoubt } = init.options;
if (!Array.isArray(operators) || operators.length === 0 || operators.some((o) => typeof o !== "string" || !o)) {
throw new Error("batch.operators must list at least one base58 operator public key");
}
this.maxAttempts = rateLimit?.maxAttempts ?? DEFAULT_BATCH_RATE_LIMIT.maxAttempts;
this.maxWaitMs = rateLimit?.maxWaitMs ?? DEFAULT_BATCH_RATE_LIMIT.maxWaitMs;
if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
throw new Error("batch.rateLimit.maxAttempts must be an integer >= 1");
}
if (!Number.isFinite(this.maxWaitMs) || this.maxWaitMs < 0) {
throw new Error("batch.rateLimit.maxWaitMs must be a finite number of ms >= 0");
}
this.inDoubtMaxWaitMs = inDoubt?.maxWaitMs ?? DEFAULT_BATCH_IN_DOUBT.maxWaitMs;
if (!Number.isFinite(this.inDoubtMaxWaitMs) || this.inDoubtMaxWaitMs < 0) {
throw new Error("batch.inDoubt.maxWaitMs must be a finite number of ms >= 0");
}
this.onLocked = init.options.onLocked ?? "exact";
if (this.onLocked !== "exact" && this.onLocked !== "wait" && this.onLocked !== "throw") {
throw new Error('batch.onLocked must be "exact", "wait" or "throw"');
}
this.lockWaitMs = init.options.lockWaitMs ?? DEFAULT_BATCH_LOCK_WAIT_MS;
if (!Number.isFinite(this.lockWaitMs) || this.lockWaitMs < 0) {
throw new Error("batch.lockWaitMs must be a finite number of ms >= 0");
}
}
init;
state;
maxAttempts;
maxWaitMs;
inDoubtMaxWaitMs;
onLocked;
lockWaitMs;
counters = {
fallbacks: 0,
fallbacksByReason: {},
backoffs: 0,
retries: 0,
recoveries: 0,
resyncs: 0,
unresolved: 0,
unresolvedByReason: {}
};
hooks = { before: [], after: [], failure: [] };
/** A copy of this client's batch counters. */
stats() {
return {
...this.counters,
fallbacksByReason: { ...this.counters.fallbacksByReason },
unresolvedByReason: { ...this.counters.unresolvedByReason }
};
}
async storeFile() {
const { channelStore } = this.init.options;
if (channelStore === false) return void 0;
return path5.resolve(channelStore ?? path5.join(paths().dir, "solana-batch", `${await this.init.address()}.json`));
}
/**
* This wallet's shared batch state, or why this client cannot use batch:
* another client in this process holds the wallet with a different trust
* configuration, or another live process owns its channel file.
*/
wallet() {
this.state ??= (async () => {
const address = await this.init.address();
const file = await this.storeFile();
const { operators, maxDeposit } = this.init.options;
const headers = Object.entries(this.init.rpcHeaders ?? {}).sort(([a], [b]) => a.localeCompare(b));
const config = JSON.stringify([[...operators].sort(), String(maxDeposit ?? ""), file ?? null, this.init.rpcUrl, headers]);
const existing = registry2.wallets.get(address);
if (existing) {
return existing.config === config ? existing : {
reason: "wallet_config_conflict",
detail: "another client in this process uses this wallet with different batch options"
};
}
if (file) {
const owner = lockChannelFile(file);
if (owner !== void 0) {
return {
reason: "channel_store_locked",
ownerPid: owner,
detail: owner === process.pid ? `channel store ${file} is in use by another copy of @blockrun/llm in this process (or by a process that had this pid and died: remove ${file}.lock if nothing uses it)` : `channel store ${file} is in use by process ${owner}`
};
}
}
const resyncs = /* @__PURE__ */ new Map();
const base = file ? new FileChannelStorage(file) : new MemoryChannelStorage();
const book = new ChannelBook(base, (target) => addResyncTarget(created, target));
const intents = file ? FileIntentJournal.beside(file) : new MemoryIntentJournal();
let journaled;
try {
if (file) intents.adoptLegacy(FileIntentJournal.legacyBeside(file), address);
journaled = intents.list();
} catch (err) {
return {
reason: "deposit_journal_unreadable",
detail: `cannot read ${intents.file}: ${err instanceof Error ? err.message : String(err)}`
};
}
const created = {
config,
busy: false,
cooldownUntil: 0,
active: 0,
...file ? { file } : {},
book,
resyncs,
intents,
closes: 0
};
for (const intent of journaled) addResyncTarget(created, intentTarget(intent));
registry2.wallets.set(address, created);
return created;
})();
const state = this.state;
state.then(
(result) => {
if ("reason" in result && result.reason !== "wallet_config_conflict" && this.state === state) this.state = void 0;
},
() => {
if (this.state === state) this.state = void 0;
}
);
return state;
}
build(wallet) {
wallet.client ??= (async () => {
const [core, svm, kit] = await Promise.all([
load("@x402/core", () => import("@x402/core/client")),
load("@x402/svm", () => import("@x402/svm/batch-settlement/client")),
load("@solana/kit", () => import("@solana/kit"))
]);
const signer = await kit.createKeyPairSignerFromBytes(await this.init.secretKey());
const scheme = new svm.BatchSvmScheme(signer, {
rpcUrl: this.init.rpcUrl,
serverSignedChannelsPolicy: {
allowedOperators: this.init.options.operators,
maxDeposit: this.init.options.maxDeposit ?? svm.DEFAULT_SERVER_SIGNED_MAX_DEPOSIT
},
// Always a store, memory included: a record the SDK repairs from the
// chain survives the scheme being rebuilt to pick it up.
channelStorage: wallet.book
});
capOutstandingEscrow(scheme, wallet);
const client = new core.x402Client().register("solana:*", scheme).registerPolicy(scheme.paymentPolicy);
client.setSpendControls({ maxAmountPerPayment: false });
registerHookDispatchers(client);
const raw = new core.x402HTTPClient(client);
const http = {
getPaymentSettleResponse: (getHeader) => raw.getPaymentSettleResponse(getHeader),
createPaymentPayload: (paymentRequired) => this.withRpcHeaders(() => raw.createPaymentPayload(paymentRequired)),
encodePaymentSignatureHeader: (payload) => raw.encodePaymentSignatureHeader(payload),
processPaymentResult: (payload, getHeader, status2) => this.withRpcHeaders(() => raw.processPaymentResult(payload, getHeader, status2))
};
const loader = scheme.loadChannel;
return {
http,
refund: (url, requirements) => this.withRpcHeaders(() => scheme.refund(url, { requirements })),
load: async (key) => {
if (typeof loader === "function") await loader.call(scheme, key);
}
};
})();
wallet.client.catch(() => {
wallet.client = void 0;
});
return wallet.client;
}
/** Run a scheme call so its RPC requests carry this client's `rpcHeaders`. */
withRpcHeaders(call) {
const headers = this.init.rpcHeaders;
if (!headers || Object.keys(headers).length === 0) return call();
installRpcHeaderHook();
return rpcScope.run({ url: this.init.rpcUrl, headers }, call);
}
/**
* Drop everything this wallet knows about one channel, on disk and in
* memory, leaving its other channels' records alone. The scheme is rebuilt
* on the next call, without the closed channel in its memory.
*/
async forget(wallet, key) {
wallet.client = void 0;
wallet.resyncs.delete(key);
await wallet.book.forget(key);
this.forgetIntent(wallet, key);
}
/**
* Stop trusting the record of the channel a payload paid into: the next
* payment re-reads that channel from the chain first. The scheme has
* already restored (or, for an open, dropped) its record; the book still
* knows the channel and the cumulative it last confirmed.
*/
distrust(wallet, sent, reason, charged = 0n) {
const { payload } = sent;
const channelId = payloadChannelId(payload);
const known = channelId ? wallet.book.find(channelId) : void 0;
if (!channelId || !known) {
wallet.client = void 0;
let intent;
try {
intent = sent.key ? wallet.intents.list().find((candidate) => candidate.key === sent.key) : void 0;
} catch {
intent = void 0;
}
if (intent) addResyncTarget(wallet, { ...intentTarget(intent), reason });
return;
}
const { key, record } = known;
const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
const confirmed = hasConfirmed ? parseAtomic(record.chargedCumulativeAmount) : 0n;
const expectDeposit = sent.expectDeposit ?? this.depositExpectation(wallet, payload).expectDeposit;
addResyncTarget(wallet, {
key,
channelId,
channelConfig: record.channelConfig,
cumulative: confirmed + charged,
...hasConfirmed ? { knownDeposit: parseAtomic(record.deposit) } : {},
...expectDeposit !== void 0 ? { expectDeposit } : {},
reason
});
}
/**
* The channel's total deposit if a deposit payload landed: the confirmed
* deposit plus this one (for an open, nothing plus this one). Undefined for
* a payment without a deposit, or a channel the SDK has no record of.
*/
depositExpectation(wallet, payload) {
if (payload.payload?.type !== "deposit") return {};
const channelId = payloadChannelId(payload);
const record = channelId ? wallet.book.find(channelId)?.record : void 0;
if (!record) return {};
const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
return { expectDeposit: (hasConfirmed ? parseAtomic(record.deposit) : 0n) + parseAtomic(payload.payload.deposit?.amount) };
}
/**
* Re-read every distrusted channel from the chain, at `finalized`
* commitment, and rewrite its record: the on-chain deposit, and the highest
* of the cumulative the SDK can vouch for, the cumulative the stored record
* already confirmed, and the on-chain settled amount. A closing or closed
* channel is dropped. The scheme is rebuilt to load the repaired record.
*
* The stored cumulative is never moved backwards. A deposit's intent can
* outlive the receipt that reconciled it (the process died, or the journal
* could not be cleared, after the scheme saved the new state), so its
* cumulative may predate vouchers the scheme has since confirmed; and
* vouchers are redeemed on-chain asynchronously, so `settled` can lag too.
* Rewriting the record with either would leave every later voucher
* unreconcilable.
*
* A deposit in doubt is settled by the chain alone:
* - landed: the finalized channel holds at least the deposit it would
* bring (and at least what the record already claimed);
* - never landed: the finalized chain is {@link LANDING_MARGIN_BLOCKS}
* past the target's anchor height, and a read at least that recent still
* does not show it. An open's record is then dropped; a top-up's channel
* is rewritten with the deposit it really has.
* Until one of those holds, it is pending. This settles only the deposit:
* whether the gateway charged the payment that carried it is never read
* from the chain.
*
* @throws ResyncError when the chain cannot be read, or a deposit may still
* land: the caller pays exact and the re-read is tried again next call.
* Nothing opens or tops up a channel while any re-read is pending.
*/
async resync(wallet) {
for (const target of [...wallet.resyncs.values()]) {
const failed = (err) => err instanceof ResyncError ? err : err instanceof ChannelUnreadableError ? new ResyncError("channel_unreadable", err.message) : err instanceof ChannelLayoutError ? new ResyncError("channel_unreadable", `channel ${target.channelId}: ${err.message}`) : new ResyncError(
"channel_resync_failed",
`could not read channel ${target.channelId} from the chain: ${err instanceof Error ? err.message : String(err)}`
);
let channel;
let expired = false;
let stored;
try {
channel = await this.readOwnChannel(target);
const required = maxOf(target.expectDeposit, target.knownDeposit) ?? 0n;
if (channel === void 0 || channel.open && channel.deposit < required) {
if (target.anchorHeight === void 0) {
target.anchorHeight = rpcInteger(
await rpcRequest(this.init.rpcUrl, "getBlockHeight", [{ commitment: "confirmed" }], this.init.rpcHeaders),
"getBlockHeight"
);
try {
const intent = wallet.intents.list().find((candidate) => candidate.key === target.key);
if (intent) wallet.intents.put({ ...intent, anchorHeight: target.anchorHeight });
} catch {
}
}
const epoch = await rpcRequest(this.init.rpcUrl, "getEpochInfo", [{ commitment: "finalized" }], this.init.rpcHeaders);
const finalizedHeight = rpcInteger(epoch.blockHeight, "getEpochInfo blockHeight");
const finalizedSlot = rpcInteger(epoch.absoluteSlot, "getEpochInfo absoluteSlot");
if (finalizedHeight <= target.anchorHeight + LANDING_MARGIN_BLOCKS) {
throw new ResyncError(
"channel_resync_pending",
channel === void 0 ? `channel ${target.channelId} is not finalized on chain, and its deposit may still land` : `channel ${target.channelId} holds ${channel.deposit} finalized, short of the ${required} it should hold, and a deposit may still land`
);
}
channel = await this.readOwnChannel(target, finalizedSlot);
expired = true;
}
stored = await wallet.book.base.get(target.key);
} catch (err) {
throw failed(err);
}
let outcome;
if (typeof stored?.channelId === "string" && stored.channelId !== target.channelId) {
outcome = `the record under its key now names channel ${stored.channelId}; left untouched`;
} else if (channel === void 0) {
await wallet.book.delete(target.key);
outcome = "no such channel at finalized commitment, past its deposit's last valid block; record dropped";
} else if (!channel.open) {
await wallet.book.delete(target.key);
outcome = "channel is closing or closed; record dropped";
} else {
const cumulative = maxOf(maxOf(target.cumulative, channel.settled), confirmedCumulative(stored, target.channelId)) ?? 0n;
await wallet.book.set(target.key, {
channelConfig: target.channelConfig,
channelId: target.channelId,
chargedCumulativeAmount: cumulative.toString(),
deposit: channel.deposit.toString()
});
outcome = `record rewritten from finalized chain: deposit=${channel.deposit} settled=${channel.settled} cumulative=${cumulative}` + (expired ? " (the deposit in doubt never landed)" : "");
}
wallet.resyncs.delete(target.key);
this.forgetIntent(wallet, target.key);
wallet.client = void 0;
await this.report({ type: "resync", reason: target.reason, detail: `channel ${target.channelId}: ${outcome}` });
}
}
/**
* A distrusted channel's account at `finalized` commitment, checked to be
* this wallet's channel for the record's operator and mint.
*
* @throws ResyncError (`channel_unreadable`) for any other payment channel.
*/
async readOwnChannel(target, minContextSlot) {
const channel = await readChannelAccount(this.init.rpcUrl, target.channelId, this.init.rpcHeaders, { minContextSlot });
if (channel !== void 0 && (channel.payer !== await this.init.address() || channel.authorizedSigner !== target.channelConfig.payerAuthorizer || channel.mint !== target.channelConfig.token)) {
throw new ResyncError("channel_unreadable", `account ${target.channelId} is a payment channel for another payer, operator or mint`);
}
return channel;
}
/**
* Make one event observable three ways: a stderr line (every time, never
* deduplicated), this client's counters, and the caller's `onEvent`.
*/
async report(event) {
let wallet;
try {
wallet = await this.init.address();
} catch {
wallet = "unknown";
}
const full = { ...event, wallet, at: Date.now() };
if (full.type === "fallback") {
this.counters.fallbacks += 1;
this.counters.fallbacksByReason[full.reason] = (this.counters.fallbacksByReason[full.reason] ?? 0) + 1;
} else if (full.type === "backoff") {
this.counters.backoffs += 1;
} else if (full.type === "recovered") {
this.counters.recoveries += 1;
} else if (full.type === "unresolved") {
this.counters.unresolved += 1;
this.counters.unresolvedByReason[full.reason] = (this.counters.unresolvedByReason[full.reason] ?? 0) + 1;
} else {
this.counters.resyncs += 1;
}
console.error(formatEvent(full));
const failed = (err) => console.error(
`[@blockrun/llm] batch-settlement onEvent callback threw: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
);
try {
const result = this.init.options.onEvent?.(full);
if (result && typeof result.then === "function") {
result.then(void 0, failed);
}
} catch (err) {
failed(err);
}
}
/** Report a fallback and tell the caller to pay exact. */
async fallback(event) {
await this.report({ type: "fallback", ...event });
return { kind: "fallback", reason: event.reason };
}
/**
* Pay one 402 with batch-settlement, or say why the caller should use exact.
*
* `send` replays the original request with the payment headers. Every send
* goes through {@link sendOnce}, the one place an answer is classified as
* charged, not charged, or in doubt:
*
* - charged: the 2xx is returned (`paid`);
* - not charged (the gateway's explicit answer to a first send): a 429
* whose receipt proves nothing was broadcast is backed off
* (`Retry-After`, else 1s, 2s, 4s with jitter) and paid again with a new
* payment built from a fresh 402 (`rechallenge`: the original's
* `recentBlockhash` / `recentSlot` can be stale by then), within
* `rateLimit.maxAttempts` / `maxWaitMs`, then with exact; any other is
* paid with exact (`fallback`). A fresh challenge served with a 2xx is
* the call's result (`served`, nothing paid); one that fails otherwise
* is the call's error (`failed`, `"unpaid"`), never an exact payment
* against the stale challenge;
* - in doubt: ends only with a definitive success receipt from a replay
* of the identical payment ({@link resolveInDoubt}: the one replay a
* receipt-less 429 is allowed, or the replays of a payment carrying a
* payment identifier while the gateway says one can succeed, within
* `inDoubt.maxWaitMs`), or a thrown {@link BatchPaymentUnresolvedError}.
* No new authorization, no new deposit, no exact payment and no fallback
* model is ever paid for a call in doubt.
*
* The wallet stays in cooldown after a 429, so its other calls wait
* instead of sending opens of their own. After a deposit the facilitator
* refused before broadcasting it (a 402 with a failed `batch_*` admission
* receipt), its calls do not wait: they pay exact at once
* (`admission_refused`) until the refusal cooldown ends.
*
* @param closesAtStart - {@link closeFence}, taken before the call's first
* (unpaid) request. If a close completed since, the call pays exact
* (`closed_during_call`): it started before the close, so it must not
* open a channel the caller believes is closed.
*/
async pay(paymentRequired, send, rechallenge, closesAtStart) {
const unavailable = batchUnavailable(paymentRequired);
if (unavailable) return this.fallback(unavailable);
let wallet;
let waitedMs = 0;
try {
wallet = await this.wallet();
if ("reason" in wallet && wallet.reason === "channel_store_locked" && this.onLocked === "wait") {
const started = Date.now();
wallet = await this.waitForLock(wallet);
waitedMs = Date.now() - started;
}
} catch (err) {
return this.fallback(creationFailure(err));
}
if ("reason" in wallet) {
if (wallet.reason === "channel_store_locked" && this.onLocked !== "exact") {
return this.lockedOut(wallet.detail, wallet.ownerPid, waitedMs);
}
return this.fallback({ reason: wallet.reason, detail: wallet.detail });
}
if (closesAtStart !== void 0 && wallet.closes !== closesAtStart) {
return this.fallback({
reason: "closed_during_call",
detail: "closeBatchChannel() closed this wallet's channel after this call started; it opens no channel"
});
}
wallet.active += 1;
try {
return await payScope.run(
{ hooks: this.hooks },
() => this.payWith(wallet, paymentRequired, send, rechallenge, closesAtStart ?? wallet.closes)
);
} finally {
wallet.active -= 1;
}
}
/**
* `onLocked: "wait"`: try the wallet's channel store again every
* {@link LOCK_POLL_MS} until its owner lets go of it or `lockWaitMs` runs
* out. Reported once as a `backoff` event (`channel_store_locked`).
*/
async waitForLock(refusal) {
const deadline = Date.now() + this.lockWaitMs;
let current = refusal;
if (this.lockWaitMs > 0) {
await this.report({
type: "backoff",
reason: "channel_store_locked",
retryAfterMs: this.lockWaitMs,
detail: refusal.detail
});
}
while ("reason" in current && current.reason === "channel_store_locked") {
const left = deadline - Date.now();
if (left <= 0) break;
await sleep2(Math.min(LOCK_POLL_MS, left));
current = await this.wallet();
}
return current;
}
/** `onLocked` other than `"exact"`: the call's `"unpaid"` error, nothing signed. */
async lockedOut(detail, ownerPid, waitedMs) {
const error = new BatchChannelLockedError({
wallet: await this.init.address(),
detail,
waitedMs,
...ownerPid !== void 0 ? { ownerPid } : {}
});
return { kind: "failed", error };
}
/**
* The close fence for a call about to send its first request: how many
* closes this wallet has completed so far, to hand to {@link pay}.
* `close()` refuses while a call is inside `pay()`, but a call still
* waiting for its 402 is not there yet; the fence covers that window.
* Undefined when the wallet cannot be named (no fence then; `pay()`
* reports why batch is unavailable).
*/
async closeFence() {
try {
return registry2.wallets.get(await this.init.address())?.closes ?? 0;
} catch {
return void 0;
}
}
/** {@link pay}, for a wallet this client may use. */
async payWith(wallet, paymentRequired, send, rechallenge, fence) {
let current = paymentRequired;
const fallback = async (event) => {
const result = await this.fallback(event);
return current === paymentRequired ? result : { ...result, paymentRequired: current };
};
const budget = { attempt: 1, waited: 0 };
let waitedUntil = 0;
let backedOff;
let holding = false;
let sent;
const release = () => {
if (holding) wallet.busy = false;
holding = false;
};
try {
for (; ; ) {
const now = Date.now();
if ((wallet.refusedUntil ?? 0) > now) {
return fallback({
reason: "admission_refused",
errorReason: wallet.refusedFor,
retryAfterMs: (wallet.refusedUntil ?? 0) - now,
attempt: budget.attempt,
detail: `the facilitator refused this wallet's last batch deposit (${wallet.refusedFor ?? "unknown"}); batch is skipped until the refusal cooldown ends`
});
}
if (wallet.cooldownUntil > now && wallet.cooldownUntil !== waitedUntil) {
const wait = wallet.cooldownUntil - now;
if (budget.waited + wait > this.maxWaitMs) {
return fallback({
reason: "rate_limited",
retryAfterMs: wait,
attempt: budget.attempt,
detail: "wallet is in a 429 cooldown longer than batch.rateLimit.maxWaitMs allows"
});
}
await this.report({ type: "backoff", reason: "cooldown", retryAfterMs: wait, attempt: budget.attempt });
backedOff ??= "cooldown";
waitedUntil = wallet.cooldownUntil;
budget.waited += wait;
await sleep2(wait);
continue;
}
if (budget.attempt > 1 || budget.waited > 0) {
let fresh;
try {
fresh = await rechallenge();
} catch (err) {
return { kind: "failed", error: withDisposition(err instanceof Error ? err : new Error(String(err)), "unpaid") };
}
if (fresh.kind === "served") {
await this.report({
type: "recovered",
reason: "served_unpaid_on_rechallenge",
status: fresh.response.status,
attempt: budget.attempt,
detail: "the fresh challenge after the wait was served without a payment; nothing was paid"
});
return { kind: "served", response: fresh.response };
}
current = fresh.paymentRequired;
const unavailable = batchUnavailable(current);
if (unavailable) return fallback({ ...unavailable, attempt: budget.attempt });
}
if (wallet.closes !== fence) {
return fallback({
reason: "closed_during_call",
attempt: budget.attempt,
detail: "closeBatchChannel() closed this wallet's channel after this call started; it opens no channel"
});
}
if (wallet.busy) return fallback({ reason: "channel_busy", attempt: budget.attempt });
if (wallet.file && !holdsChannelFile(wallet.file)) {
const detail = `this process no longer holds the lock on ${wallet.file}; another owner may be paying into its channels`;
if (this.onLocked !== "exact") return this.lockedOut(detail, void 0, 0);
return fallback({ reason: "channel_store_locked", attempt: budget.attempt, detail });
}
wallet.busy = true;
holding = true;
let built;
try {
built = await this.buildPayment(wallet, current);
} catch (err) {
release();
if (err instanceof ResyncError) return fallback({ reason: err.reason, detail: err.message, attempt: budget.attempt });
return fallback({ ...creationFailure(err), attempt: budget.attempt });
}
if (built.kind !== "authorization") {
try {
this.journalDeposit(wallet, built);
} catch (err) {
await this.settle(built.http, built.payload, () => null, 0);
release();
return fallback({
reason: "deposit_journal_failed",
attempt: budget.attempt,
detail: err instanceof Error ? err.message : String(err)
});
}
}
sent = built;
const outcome = await this.sendOnce(wallet, built, send, backedOff === "rate_limited", false);
if (outcome.kind === "charged") {
if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
sent = void 0;
release();
if (backedOff) await this.report({ type: "recovered", reason: backedOff, attempt: budget.attempt });
return { kind: "paid", response: outcome.response, chargedUsd: outcome.chargedUsd };
}
if (outcome.kind === "not_charged") {
if (built.kind !== "authorization") {
if (outcome.depositSafe) this.forgetIntent(wallet, built.key);
else this.distrust(wallet, built, "deposit_refused");
if (outcome.refusedForMs !== void 0) {
const until = Date.now() + outcome.refusedForMs;
if (until > (wallet.refusedUntil ?? 0)) {
wallet.refusedUntil = until;
wallet.refusedFor = outcome.errorReason;
}
}
} else if (built.key && outcome.reason === "payment_required" && outcome.refusedForMs === void 0) {
await this.recheckRefusedChannel(wallet, built.key, built.reclaimAnchor);
}
sent = void 0;
release();
if (!outcome.rateLimited) {
const { kind: _kind, rateLimited: _limited, depositSafe: _safe, refusedForMs, ...event } = outcome;
return fallback({
...event,
...refusedForMs !== void 0 && built.kind !== "authorization" ? { retryAfterMs: refusedForMs } : {},
attempt: budget.attempt
});
}
const wait = this.coolDown(wallet, outcome.rateLimited.retryAfterMs ?? backoffDelay(budget.attempt));
if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
return fallback({
reason: "rate_limited",
status: 429,
errorReason: outcome.errorReason,
retryAfterMs: wait,
attempt: budget.attempt,
detail: budget.attempt >= this.maxAttempts ? "batch.rateLimit.maxAttempts reached" : "Retry-After exceeds what is left of batch.rateLimit.maxWaitMs"
});
}
await this.report({
type: "backoff",
reason: "rate_limited",
status: 429,
errorReason: outcome.errorReason,
retryAfterMs: wait,
attempt: budget.attempt
});
backedOff = "rate_limited";
waitedUntil = wallet.cooldownUntil;
budget.waited += wait;
await sleep2(wait);
budget.attempt += 1;
continue;
}
if (outcome.kind === "cancelled") {
sent = void 0;
release();
await this.report({
type: "recovered",
reason: "batch_cancelled",
status: outcome.status,
errorReason: "batch_cancelled",
attempt: budget.attempt,
detail: "the gateway cancelled the payment: nothing was charged, and its error is raised unpaid"
});
return { kind: "failed", error: outcome.error };
}
const resolved = await this.resolveInDoubt(wallet, built, outcome, send, budget);
if (resolved.kind === "charged") {
if (built.kind !== "authorization" && !(built.key && wallet.resyncs.has(built.key))) this.forgetIntent(wallet, built.key);
sent = void 0;
release();
await this.report({ type: "recovered", reason: outcome.status === 429 ? "rate_limited" : "in_doubt", attempt: budget.attempt });
return { kind: "paid", response: resolved.response, chargedUsd: resolved.chargedUsd };
}
return await this.raiseUnresolved(wallet, built, resolved, budget.attempt);
}
} catch (err) {
if (err instanceof Error && err.name === "BatchPaymentUnresolvedError") throw err;
if (sent) {
try {
return await this.raiseUnresolved(
wallet,
sent,
{
kind: "in_doubt",
reason: "outcome_unknown",
replayable: false,
settled: false,
detail: `an error after the payment was sent: ${err instanceof Error ? err.message : String(err)}`,
cause: err
},
budget.attempt
);
} catch (raised) {
throw withDisposition(raised, "paid-or-in-doubt");
}
}
return fallback({ ...creationFailure(err), attempt: budget.attempt });
} finally {
release();
}
}
/**
* Put the wallet in a 429 cooldown for `delay` ms (`Retry-After`, or the
* default backoff), and return how long this call would have to wait.
*
* The shared cooldown is capped at `rateLimit.maxWaitMs`, so one header,
* however long, never keeps the wallet's calls off batch for longer than
* the retry budget. This call still weighs the full delay: one it cannot
* wait out within its budget is not waited at all, and the caller pays
* exact (or raises a payment in doubt) at once.
*/
coolDown(wallet, delay) {
const limitedAt = Date.now();
wallet.cooldownUntil = Math.max(wallet.cooldownUntil, limitedAt + Math.min(delay, this.maxWaitMs));
return Math.max(delay, wallet.cooldownUntil - limitedAt);
}
/**
* Try to end a payment in doubt with the gateway's own word.
*
* The one source of evidence this SDK accepts is a definitive success
* receipt on a byte-identical replay of the payment. Any other answer to a
* replay (a 402 or `duplicate_settlement`, which mean the original reached
* the gateway; a 409; another 429; a 5xx; a timeout) leaves it in doubt.
*
* - A payment carrying a payment identifier is replayed for as long as
* the gateway says a later replay can succeed ({@link replayInDoubt}).
* - Any other payment gets a replay only after a 429 without a receipt:
* one, after its backoff (the owner's bounded safety attempt), within
* `rateLimit`. Every other in-doubt outcome is never replayed.
*
* Chain state is not evidence here: it can settle a deposit (see
* `resync`), never whether the gateway charged.
*
* @returns the charged replay, or the doubt to raise.
*/
async resolveInDoubt(wallet, sent, doubt, send, budget) {
if (hasPaymentIdentifier(sent.payload)) return this.replayInDoubt(wallet, sent, doubt, send, budget.attempt);
if (!doubt.replayable) return doubt;
const wait = this.coolDown(wallet, doubt.retryAfterMs ?? backoffDelay(budget.attempt));
if (budget.attempt >= this.maxAttempts || budget.waited + wait > this.maxWaitMs) {
return {
...doubt,
detail: `${doubt.detail}; batch.rateLimit leaves no room for its one replay`
};
}
await this.report({
type: "backoff",
reason: "rate_limited",
status: 429,
errorReason: doubt.errorReason,
retryAfterMs: wait,
attempt: budget.attempt
});
budget.waited += wait;
await sleep2(wait);
budget.attempt += 1;
return replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
}
/**
* Replay a payment in doubt that carries a payment identifier until the
* gateway's answer settles it.
*
* A gateway that declares `payment-identifier` stores each paid response
* under its identifier and answers a byte-identical replay of a completed
* request with it, the original receipt included; while the original is
* still running, it answers 409 with a `Retry-After`. So the identical
* payment (the same `PAYMENT-SIGNATURE` header and the same body) is
* replayed for as long as the last answer says a later one can succeed
* ({@link replayMayResolve}), after {@link inDoubtWait} each time, as long
* as the wait fits in `inDoubt.maxWaitMs`, counted from the first doubt:
* every wait (as long as it really took) and every replay's round trip. A
* replay already sent is not cut short. These replays do not count
* against `rateLimit`; a 429 among them still puts the wallet in cooldown.
*
* Every answer that does not settle it is held back from the scheme
* ({@link InDoubt.held}) and handed over only once no replay follows, so
* the scheme rolls the payment back once, or takes the receipt that answer
* carries. That is the last answer, unless an earlier one carried a
* success receipt and the later ones do not: a success receipt is the
* gateway's word that it charged, and handing the scheme a receipt-less
* refusal of a replay instead would roll a real charge back and desync the
* channel's cumulative.
*
* @returns the charged replay, or the doubt to raise.
*/
async replayInDoubt(wallet, sent, first, send, attempt) {
let doubt = first;
let held = first.held;
let heldSucceeded = held !== void 0 && receiptSucceeded(sent.http, held);
let replays = 0;
let spent = 0;
const stop = async (note) => {
if (!doubt.settled && held) {
const answer = held;
await this.settle(sent.http, sent.payload, (name) => answer.headers.get(name), answer.status);
}
const notes = [replays > 0 ? `${replays} replay${replays === 1 ? "" : "s"} did not resolve it` : "", note ?? ""];
return {
...doubt,
settled: doubt.settled || held !== void 0,
held: void 0,
firstReason: first.reason,
detail: [doubt.detail, ...notes].filter(Boolean).join("; ")
};
};
for (; ; ) {
if (!replayMayResolve(doubt)) {
return stop(doubt.held?.status === 409 ? "the gateway sent no Retry-After, so no replay can resolve it" : void 0);
}
const wait = inDoubtWait(doubt, replays + 1);
const status2 = doubt.held?.status;
if (status2 === 429) this.coolDown(wallet, (doubt.held && parseRetryAfter(doubt.held.headers.get("retry-after"))) ?? wait);
if (spent + wait > this.inDoubtMaxWaitMs) {
return stop(`batch.inDoubt.maxWaitMs (${this.inDoubtMaxWaitMs} ms) leaves no room for ${replays > 0 ? "another replay" : "a replay"}`);
}
await this.report({
type: "backoff",
reason: status2 === 429 ? "rate_limited" : "in_doubt",
status: status2,
errorReason: doubt.errorReason,
retryAfterMs: wait,
attempt,
detail: `replay ${replays + 1} of the payment in doubt`
});
const sleptFrom = Date.now();
await sleep2(wait);
spent += Math.max(wait, Date.now() - sleptFrom);
replays += 1;
const startedAt = Date.now();
const replayed = replayDoubt(await this.sendOnce(wallet, sent, send, true, true));
spent += Math.max(0, Date.now() - startedAt);
if (replayed.kind === "charged") return replayed;
doubt = replayed;
if (doubt.held) {
const succeeded = receiptSucceeded(sent.http, doubt.held);
if (succeeded || !heldSucceeded) {
held = doubt.held;
heldSucceeded = succeeded;
}
} else if (doubt.settled) {
held = void 0;
heldSucceeded = false;
}
}
}
/**
* Raise a payment in doubt: release the scheme's hold on it (the channel
* goes back to its confirmed state), put a deposit's channel in doubt so
* it is re-read from the chain before anything pays into it again, report
* the `unresolved` event, and throw {@link BatchPaymentUnresolvedError}.
*/
async raiseUnresolved(wallet, sent, doubt, attempt) {
if (!doubt.settled) await this.settle(sent.http, sent.payload, () => null, 0);
if (sent.kind !== "authorization") {
const reason = doubt.firstReason ?? doubt.reason;
this.distrust(
wallet,
sent,
reason === "no_response" ? "deposit_unanswered" : reason === "replay_unresolved" ? "deposit_rate_limited" : "deposit_failed"
);
}
await this.report({
type: "unresolved",
reason: doubt.reason,
status: doubt.status,
errorReason: doubt.errorReason,
attempt,
detail: `${doubt.detail}; it may have been charged, so it is not paid again`
});
throw new BatchPaymentUnresolvedError({
reason: doubt.reason,
wallet: await this.init.address(),
requestId: sent.payload.payload?.authorization?.requestId,
channelId: payloadChannelId(sent.payload),
payloadKind: sent.kind,
status: doubt.status,
detail: doubt.detail,
cause: doubt.cause
});
}
/** Build a new batch payment for a 402: the payload, its headers and its kind. */
async buildPayment(wallet, paymentRequired) {
const acceptKey = channelKeyOf(trustedBatchAccept(paymentRequired, this.init.options.operators));
let reclaimAnchor;
try {
const stored = acceptKey ? await wallet.book.base.get(acceptKey) : void 0;
reclaimAnchor = stored ? savedReclaimAnchor(stored) : void 0;
} catch {
}
const { http, payload } = await this.createPayload(wallet, paymentRequired);
const scope = payScope.getStore();
if (scope?.afterError) {
const { error } = scope.afterError;
scope.afterError = void 0;
await this.settle(http, payload, () => null, 0);
await runFailureHooks(scope, {
paymentRequired,
selectedRequirements: payload.accepted,
error
});
throw error;
}
const headers = http.encodePaymentSignatureHeader(payload);
const channelId = payloadChannelId(payload);
const known = channelId ? wallet.book.find(channelId) : void 0;
let kind = "authorization";
if (payload.payload?.type === "deposit") {
const record = known?.record;
kind = record && (record.pending === void 0 || record.hasConfirmedState) ? "top-up" : "open";
}
return {
http,
payload,
headers,
kind,
key: known?.key,
...reclaimAnchor !== void 0 && known?.key === acceptKey ? { reclaimAnchor } : {},
sentAt: Date.now(),
sends: 0
};
}
/**
* Write a deposit's intent to the wallet's journal before it is sent
* (with the file store: the file and its directory fsynced).
*
* @throws when it cannot be recorded; the deposit must then not be sent.
*/
journalDeposit(wallet, sent) {
const channelId = payloadChannelId(sent.payload);
const record = channelId ? wallet.book.find(channelId)?.record : void 0;
const { expectDeposit } = this.depositExpectation(wallet, sent.payload);
if (!sent.key || !channelId || !record || expectDeposit === void 0 || sent.kind === "authorization") {
throw new Error("the deposit's channel record is missing");
}
const hasConfirmed = record.pending === void 0 || record.hasConfirmedState;
sent.expectDeposit = expectDeposit;
wallet.intents.put({
key: sent.key,
channelId,
channelConfig: record.channelConfig,
requestId: sent.payload.payload?.authorization?.requestId,
kind: sent.kind,
cumulative: hasConfirmed ? String(parseAtomic(record.chargedCumulativeAmount)) : "0",
expectDeposit: expectDeposit.toString(),
...hasConfirmed ? { knownDeposit: String(parseAtomic(record.deposit)) } : {},
at: Date.now()
});
}
/**
* Remove a deposit's intent once it is reconciled. A failure is logged,
* not thrown: the intent then outlives its deposit, and after a restart
* the chain re-read settles it again.
*/
forgetIntent(wallet, key) {
if (!key) return;
try {
wallet.intents.remove(key);
} catch (err) {
console.error(
`[@blockrun/llm] batch-settlement could not clear a deposit intent: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
);
}
}
/**
* THE choke point: send a batch payment once and classify the answer.
*
* The only caller of `send` for a batch payment, and the only place its
* outcome is decided (see {@link SendOutcome}). Every exception once the
* send has started is `in_doubt`, whatever its type or `cause.code`: the
* request may have reached the gateway. "Not charged" is only ever minted
* here, and only for a first send.
*
* @param retry - count this send in `retries` (it follows this call's own 429).
* @param replay - this is a replay of a payment in doubt: only a 2xx with
* a success receipt counts, everything else stays in doubt.
*/
async sendOnce(wallet, sent, send, retry, replay) {
if (retry) this.counters.retries += 1;
sent.sends += 1;
let response;
try {
response = await send(sent.headers);
} catch (err) {
return {
kind: "in_doubt",
reason: replay ? "replay_unresolved" : "no_response",
replayable: false,
settled: false,
unanswered: true,
detail: `${replay ? "the replay" : "the payment"} got no answer (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`,
cause: err
};
}
try {
return await this.classify(wallet, sent, response, replay);
} catch (err) {
return {
kind: "in_doubt",
reason: replay ? "replay_unresolved" : "outcome_unknown",
replayable: false,
settled: false,
status: response.status,
detail: `the answer could not be classified (${err instanceof Error ? err.message : String(err)})`,
cause: err
};
}
}
/** {@link sendOnce}'s classifier, for an HTTP answer. */
async classify(wallet, sent, response, replay) {
const { http, payload } = sent;
const getHeader = (name) => response.headers.get(name);
const receipted = Boolean(getHeader("PAYMENT-RESPONSE") || getHeader("X-PAYMENT-RESPONSE"));
const status2 = response.status;
const hold = hasPaymentIdentifier(payload) && (!response.ok || replay && peekReceipt(http, getHeader)?.success !== true);
if (status2 === 429 && !receipted) {
return {
kind: "in_doubt",
reason: "replay_unresolved",
replayable: !replay,
settled: false,
status: status2,
retryAfterMs: parseRetryAfter(getHeader("retry-after")),
errorReason: await rateLimitReason(response, void 0),
detail: replay ? "the replay was answered 429 without a receipt" : "a 429 without a receipt",
...hold ? { held: response } : {}
};
}
const outcome = await this.classifyAnswer(wallet, sent, response, replay, hold);
if (!hold) return outcome;
if (outcome.kind === "in_doubt") return { ...outcome, settled: false, held: response };
await this.settle(http, payload, getHeader, status2);
return outcome;
}
/**
* {@link classify}, past a receipt-less 429. With `hold`, the answer is not
* handed to the scheme: its receipt is only read.
*/
async classifyAnswer(wallet, sent, response, replay, hold) {
const { http, payload } = sent;
const getHeader = (name) => response.headers.get(name);
const status2 = response.status;
const channelId = payloadChannelId(payload);
const before = channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0;
const reconciled = hold ? { settleResponse: peekReceipt(http, getHeader), ok: false } : await this.settle(http, payload, getHeader, status2);
const receipt = reconciled.settleResponse;
const definitive = receipt?.success === true && reconciled.ok && recordAdvanced(before, channelId ? confirmedState(wallet.book.find(channelId)?.record) : void 0, payload, receipt);
if (!response.ok && !replay && sent.kind === "authorization" && cancelledReceipt(receipt)) {
return { kind: "cancelled", status: status2, error: await cancelledError(response) };
}
if (response.ok && (!replay || definitive)) {
if (!definitive) {
this.distrust(wallet, sent, receipt ? "receipt_unreconciled" : "receipt_missing", rebuiltCharge(receipt, payload));
}
return { kind: "charged", response, chargedUsd: chargedUsd(() => http.getPaymentSettleResponse(getHeader)) };
}
if (replay) {
return {
kind: "in_doubt",
reason: "replay_unresolved",
replayable: false,
settled: true,
status: status2,
errorReason: status2 === 429 ? await rateLimitReason(response, receipt) : receipt?.errorReason,
detail: `the replay was answered ${status2} with ${describeReceipt(receipt)}`,
cause: await afterPaymentError(response)
};
}
if (status2 === 429) {
if (provesNothingBroadcast(receipt)) {
return {
kind: "not_charged",
reason: "rate_limited",
status: status2,
errorReason: await rateLimitReason(response, receipt),
rateLimited: { retryAfterMs: parseRetryAfter(getHeader("retry-after")) },
// The facilitator's explicit failed receipt names no transaction: the
// deposit was never broadcast (see provesNothingBroadcast).
depositSafe: true
};
}
return {
kind: "in_doubt",
reason: "ambiguous_rate_limit",
replayable: false,
settled: true,
status: status2,
errorReason: await rateLimitReason(response, receipt),
detail: `a 429 whose ${describeReceipt(receipt)} does not prove nothing was broadcast`,
cause: await afterPaymentError(response)
};
}
if (cleanRefusalReceipt(receipt)) {
if (status2 === 402) {
const duplicate = await duplicateAnswer(response);
if (duplicate) {
return {
kind: "in_doubt",
reason: "duplicate_settlement",
replayable: false,
settled: true,
status: status2,
errorReason: duplicate,
detail: `the payment was answered 402 ${duplicate}: another copy of this request reached the gateway`,
cause: await afterPaymentError(response)
};
}
const refused = provesNothingBroadcast(receipt);
return {
kind: "not_charged",
reason: "payment_required",
status: status2,
errorReason: receipt?.errorReason,
depositSafe: refused,
...refused ? { refusedForMs: refusalCooldownMs(receipt?.errorReason, parseRetryAfter(getHeader("retry-after"))) } : {}
};
}
const refusal = await batchRefusal(response);
if (refusal) {
return { kind: "not_charged", reason: refusal, status: status2, errorReason: refusal, depositSafe: PRE_REQUEST_REFUSALS.has(refusal) };
}
}
return {
kind: "in_doubt",
reason: "outcome_unknown",
replayable: false,
settled: true,
status: status2,
errorReason: receipt?.errorReason,
detail: `the payment was answered ${status2} with ${describeReceipt(receipt)}`,
cause: await afterPaymentError(response)
};
}
/**
* Build the payment payload, re-reading any distrusted channel from the
* chain first. A stored record the book refuses to hand over (a deposit a
* dead process never heard back about), or one a top-up's read found
* unusable ({@link prepareTopUp}), is re-read and the payload rebuilt
* once.
*/
async createPayload(wallet, paymentRequired) {
for (let pass = 0; ; pass += 1) {
await this.resync(wallet);
const http = (await this.build(wallet)).http;
try {
await this.prepareTopUp(wallet, paymentRequired);
const required = await this.withDepositBlockhash(wallet, withoutClientSignedBatch(paymentRequired));
return { http, payload: await http.createPaymentPayload(withPaymentIdentifier(required)) };
} catch (err) {
if (pass > 0 || !isResyncRequired(err) || wallet.resyncs.size === 0) throw err;
} finally {
wallet.topUp = void 0;
}
}
}
/**
* The 402 to build this payment from: with a finalized blockhash
* ({@link withDepositBlockhash}) when the payment will likely carry a
* deposit (no usable record for the trusted accept's channel, so an open,
* or a top-up {@link prepareTopUp} just sized). An authorization signs no
* transaction and costs no RPC read. Best effort: a failed read leaves the
* 402 as it is, and `@x402/svm` fetches its own blockhash.
*/
async withDepositBlockhash(wallet, paymentRequired) {
const accept = trustedBatchAccept(paymentRequired, this.init.options.operators);
const key = accept ? channelKeyOf(accept) : void 0;
if (!accept || accept.extra?.recentBlockhash) return paymentRequired;
let deposit = wallet.topUp !== void 0;
if (!deposit) {
try {
const record = key ? await wallet.book.base.get(key) : void 0;
deposit = !record || typeof record.channelId !== "string" || record.pending !== void 0;
} catch {
deposit = true;
}
}
if (!deposit) return paymentRequired;
try {
return withDepositBlockhash(paymentRequired, await finalizedBlockhash(this.init.rpcUrl, this.init.rpcHeaders));
} catch {
return paymentRequired;
}
}
/**
* Queue a re-read of a stored channel whose account is gone at finalized
* and at confirmed commitment (closed, its account reclaimed), anchored at
* the SDK's own `getBlockHeight` at confirmed. The anchor is saved in the
* record (`reclaimAnchorHeight`), so a short-lived process finishes the
* wait instead of starting it over; a failed read leaves resync to take it,
* and a failed save only costs that. resync pays exact until finality is
* LANDING_MARGIN_BLOCKS past the anchor, then drops the record if the
* finalized chain still has no account (a lagging read finds the channel
* and rewrites the record, without the anchor).
*/
async queueReclaimed(wallet, key, record, channelId, savedAnchor) {
let anchorHeight = savedReclaimAnchor(record) ?? savedAnchor;
if (anchorHeight === void 0) {
try {
anchorHeight = rpcInteger(
await rpcRequest(this.init.rpcUrl, "getBlockHeight", [{ commitment: "confirmed" }], this.init.rpcHeaders),
"getBlockHeight"
);
} catch {
}
}
if (anchorHeight !== void 0 && savedReclaimAnchor(record) !== anchorHeight) {
try {
await wallet.book.set(key, { ...record, reclaimAnchorHeight: anchorHeight });
} catch {
}
}
addResyncTarget(wallet, {
key,
channelId,
channelConfig: record.channelConfig,
cumulative: parseAtomic(record.chargedCumulativeAmount),
knownDeposit: parseAtomic(record.deposit),
...anchorHeight !== void 0 ? { anchorHeight } : {},
reason: "channel_unusable"
});
}
/**
* After the gateway refused an authorization with a plain 402 (no receipt,
* not an admission refusal), check the channel it paid into. That is how
* the gateway answers an authorization into a channel the facilitator
* closed (its verify rejects it), and a channel with deposit to spare is
* otherwise never read again: every call would sign another authorization
* into it and pay exact. A channel the finalized chain shows closing,
* closed or not this wallet's is re-read through resync (record dropped,
* or kept and paid exact); one gone at every commitment is queued as
* {@link queueReclaimed}; an open one of this wallet's, or one only at
* confirmed (not finalized yet), is left as it is. A queued re-read is run
* at once. Best effort: a failed read changes nothing, and the next
* refusal checks again.
*/
async recheckRefusedChannel(wallet, key, savedAnchor) {
try {
const record = await wallet.book.get(key);
if (!record || record.pending || typeof record.channelId !== "string" || wallet.resyncs.has(key)) return;
const channelId = record.channelId;
const channel = await readChannelAccount(this.init.rpcUrl, channelId, this.init.rpcHeaders);
if (channel !== void 0) {
if (channel.open && channel.payer === await this.init.address() && channel.authorizedSigner === record.channelConfig.payerAuthorizer && channel.mint === record.channelConfig.token) {
return;
}
addResyncTarget(wallet, {
key,
channelId,
channelConfig: record.channelConfig,
cumulative: parseAtomic(record.chargedCumulativeAmount),
knownDeposit: parseAtomic(record.deposit),
reason: "channel_unusable"
});
} else {
if (await readChannelAccount(this.init.rpcUrl, channelId, this.init.rpcHeaders, { commitment: "confirmed" }) !== void 0) return;
await this.queueReclaimed(wallet, key, record, channelId, savedAnchor);
}
await this.resync(wallet);
} catch {
}
}
/**
* When this payment will top up the wallet's channel, read how much of its
* deposit is already settled on-chain (at `finalized` commitment, so never
* more than will stay settled), so `maxDeposit` caps the escrow still at
* stake rather than every deposit the channel ever took.
*
* Only a top-up costs this read (a channel's balance decides it, from the
* stored record). If the read fails, nothing settled is assumed, which is
* the stricter, lifetime cap. If the chain shows a larger deposit than the
* record, the record is behind (a deposit it never heard about landed):
* sizing a top-up from it could exceed `maxDeposit`, so this call pays
* exact and the channel is re-read before the next payment. If
* `@x402/svm`'s channel layout is not the one this SDK decodes, or the
* account at the channel's address cannot be read as a channel, the call
* pays exact (`channel_unreadable`), record kept. If
* the finalized channel is closing or closed, or is not this wallet's for
* the record's operator and mint, no top-up is sized from the record: it
* is re-read at once (`channel_unusable`), see {@link createPayload}. So is
* a channel with no account at finalized or at confirmed commitment (closed
* and reclaimed); resync drops its record only once finality is past its
* own anchor. One that shows only at confirmed is not finalized yet: the
* lifetime cap applies.
*/
async prepareTopUp(wallet, paymentRequired) {
wallet.topUp = void 0;
const accept = trustedBatchAccept(paymentRequired, this.init.options.operators);
if (!accept) return;
const key = channelKeyOf(accept);
const record = key ? await wallet.book.get(key) : void 0;
if (!key || !record || record.pending || typeof record.channelId !== "string") return;
const deposit = parseAtomic(record.deposit);
if (parseAtomic(record.chargedCumulativeAmount) + parseAtomic(accept.amount) <= deposit) return;
let channel;
let unfinalized = false;
try {
channel = await readChannelAccount(this.init.rpcUrl, record.channelId, this.init.rpcHeaders);
if (channel === void 0) {
unfinalized = await readChannelAccount(this.init.rpcUrl, record.channelId, this.init.rpcHeaders, { commitment: "confirmed" }) !== void 0;
}
} catch (err) {
if (err instanceof ChannelLayoutError) {
throw new ResyncError("channel_unreadable", `channel ${record.channelId}: ${err.message}; no top-up is sized from it`);
}
if (err instanceof ChannelUnreadableError) {
throw new ResyncError("channel_unreadable", `${err.message}; no top-up is signed into it`);
}
throw new ResyncError(
"channel_resync_failed",
`could not read channel ${record.channelId} before topping it up: ${err instanceof Error ? err.message : String(err)}`
);
}
if (channel === void 0 && unfinalized) return;
if (channel === void 0) {
await this.queueReclaimed(wallet, key, record, record.channelId);
throw new ChannelResyncRequiredError(record.channelId);
}
if (!channel.open || channel.payer !== await this.init.address() || channel.authorizedSigner !== record.channelConfig.payerAuthorizer || channel.mint !== record.channelConfig.token) {
addResyncTarget(wallet, {
key,
channelId: record.channelId,
channelConfig: record.channelConfig,
cumulative: parseAtomic(record.chargedCumulativeAmount),
knownDeposit: deposit,
reason: "channel_unusable"
});
throw new ChannelResyncRequiredError(record.channelId);
}
if (channel.deposit > deposit) {
addResyncTarget(wallet, {
key,
channelId: record.channelId,
channelConfig: record.channelConfig,
cumulative: parseAtomic(record.chargedCumulativeAmount),
knownDeposit: channel.deposit,
reason: "deposit_unrecorded"
});
throw new ResyncError(
"channel_resync_pending",
`channel ${record.channelId} holds ${channel.deposit} on chain, more than the ${deposit} its record knows; it is re-read before any top-up`
);
}
wallet.topUp = { key, deposit, settled: channel.settled };
}
/**
* Hand the gateway's answer to the scheme, which verifies the operator's
* voucher and advances (or rolls back) the channel's local state.
*
* A malformed receipt must not lose a response the caller has been served,
* so a scheme error is logged rather than thrown.
*/
async settle(http, payload, getHeader, status2) {
try {
return { settleResponse: (await http.processPaymentResult(payload, getHeader, status2)).settleResponse, ok: true };
} catch (err) {
console.error(
`[@blockrun/llm] batch-settlement receipt not reconciled: ${JSON.stringify(err instanceof Error ? err.message : String(err))}`
);
return { settleResponse: peekReceipt(http, getHeader), ok: false };
}
}
/**
* Close the channel the gateway's current challenge names and return its
* unused escrow to the wallet.
*
* The scheme keeps a closed channel in its records, and would go on paying
* into it, so a successful close forgets that channel. The next batch call
* finds no open channel on-chain and opens a new one. Any other channel
* (say, one opened with an operator key that is being rotated out) keeps
* its record, so it can still be closed without an on-chain scan. A
* client-signed channel an earlier SDK version opened is closed too, once
* the trusted server-signed one has no record (see {@link refundTarget}).
*
* @param url - any batch-enabled route on the gateway the channel was opened with.
* @throws BatchCloseDeferredError while a batch call for the wallet is in
* flight, or while any of its deposits is in doubt (until a finalized
* chain read settles it). Nothing is refunded or forgotten then.
*/
async close(url) {
const wallet = await this.wallet();
if ("reason" in wallet) throw new Error(`batch-settlement unavailable: ${wallet.detail}`);
if (wallet.busy || wallet.active > 0) {
throw new BatchCloseDeferredError({
reason: "call_in_flight",
wallet: await this.init.address(),
detail: "a batch call is in flight (it may be waiting out a 429); close the channel when it returns"
});
}
wallet.busy = true;
try {
await this.settleDoubtsForClose(wallet);
const { accept, key } = await this.refundTarget(wallet, url);
const result = await (await this.prepareClose(wallet, key)).refund(url, accept);
wallet.closes += 1;
await this.forget(wallet, key);
return result;
} finally {
wallet.busy = false;
}
}
/**
* Re-read every distrusted channel before a close. A deposit still in
* doubt defers the close: refunding (or forgetting) a channel a deposit
* may yet land on could strand that deposit, and after a restart nothing
* would remember it. A record re-read that only failed for another reason
* (an RPC error on a channel with no deposit in doubt) does not.
*/
async settleDoubtsForClose(wallet) {
try {
await this.resync(wallet);
} catch (err) {
if (!(err instanceof ResyncError)) throw err;
const inDoubt = [...wallet.resyncs.values()].filter((target) => target.expectDeposit !== void 0);
if (inDoubt.length > 0) {
throw new BatchCloseDeferredError({
reason: "deposit_in_doubt",
wallet: await this.init.address(),
channelIds: inDoubt.map((target) => target.channelId),
detail: `a deposit on channel ${inDoubt.map((target) => target.channelId).join(", ")} is in doubt (${err.message}); close once a finalized chain read settles it`
});
}
}
}
/**
* The channel a close targets: the one the gateway's refund challenge
* names, by the scheme's storage key for its trusted server-signed accept
* (network, asset, receiver, fee payer, withdraw delay, receiver
* authorizer, operator). Probed with an unpaid GET, as `@x402/svm`'s own
* refund probes; that accept is then handed to the refund, so upstream
* does not probe again and pick another accept.
*
* A legacy client-signed channel (opened by an earlier SDK version, which
* could pay a client-signed accept; this one never opens one) must stay
* refundable. When the trusted server-signed accept has no stored record,
* or there is none, a client-signed accept in the challenge whose storage
* key names a stored client-signed record is the target instead, and the
* refund runs through `@x402/svm`'s client-signed path (a refund voucher
* signed by the payer at its confirmed cumulative). The signer modes match:
* a server-signed record is never refunded under a client-signed accept.
*/
async refundTarget(wallet, url) {
const probe = await fetch(url, { method: "GET" });
const header = probe.status === 402 ? probe.headers.get("PAYMENT-REQUIRED") : null;
if (!header) throw new Error(`refund probe expected a 402 with PAYMENT-REQUIRED from ${url}, got HTTP ${probe.status}`);
const paymentRequired = parsePaymentRequired(header);
const trustedAccepts = trustedBatchAccepts(paymentRequired, this.init.options.operators).map((accept) => ({ accept, key: channelKeyOf(accept) })).filter((candidate) => !!candidate.key);
for (const candidate of trustedAccepts) {
if (await wallet.book.base.get(candidate.key)) return candidate;
}
for (const accept of (paymentRequired.accepts ?? []).filter(clientSignedBatch)) {
const key = channelKeyOf(accept);
const record = key ? await wallet.book.base.get(key) : void 0;
if (key && record && (record.channelConfig?.voucherSigner ?? "client") === "client") return { accept, key };
}
const first = trustedAccepts[0];
if (!first) throw new Error(`${url} offers no batch-settlement accept with a trusted operator to close against`);
return first;
}
/**
* Get the stored channel `key` ready to refund.
*
* `@x402/svm` refunds a server-signed channel only from the scheme's memory,
* or after an on-chain scan that many RPCs refuse; it never reads the
* stored record. So the target channel is loaded into a freshly built
* scheme, and only that one: upstream's refund falls back to the first
* channel in memory for the same receiver and asset, whatever its operator.
* A record in doubt (a deposit this process never confirmed, or a pending
* deposit left by a process that died) is re-read from the chain before it
* is loaded. If a re-read cannot settle a record, the refund is still
* attempted and may find the channel by its scan.
*/
async prepareClose(wallet, key) {
for (let pass = 0; ; pass += 1) {
await this.settleDoubtsForClose(wallet);
wallet.client = void 0;
const built = await this.build(wallet);
try {
await built.load(key);
return built;
} catch (err) {
if (pass > 0 || !isResyncRequired(err)) return built;
}
}
}
};
// src/solana-client.ts
var SOLANA_API_URL = "https://sol.blockrun.ai/api";
var DEFAULT_MAX_TOKENS2 = 1024;
var STALE_BLOCKHASH_RETRY_BACKOFFS_MS = [500, 2e3];
var MAX_PAYMENT_FAILURE_BYTES = 64 * 1024;
var DEFAULT_VIDEO_MODEL = "xai/grok-imagine-video";
var DEFAULT_MUSIC_MODEL = "minimax/music-2.5+";
var DEFAULT_VIDEO_BUDGET_MS = 9e5;
var DEFAULT_MUSIC_BUDGET_MS = 3e5;
var VIDEO_POLL_INTERVAL_MS = 5e3;
var MAX_PAID_POLL_REFUSALS = 4;
var MAX_RETRY_AFTER_MS2 = 12e4;
var MEDIA_SUBMIT_TIMEOUT_MS = 12e4;
function sleep3(ms) {
return new Promise((resolve2) => setTimeout(resolve2, ms));
}
async function readJsonOrEmpty(response) {
try {
const parsed = await response.json();
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function isPendingPollStatus(status2) {
return status2 === 202 || status2 === 503 || status2 === 504;
}
function retryAfterMs(response) {
const raw = response.headers.get("retry-after");
if (!raw || !/^\d+$/.test(raw.trim())) return void 0;
return Math.min(Number(raw.trim()) * 1e3, MAX_RETRY_AFTER_MS2);
}
function pollKindFor(url) {
const path7 = new URL(url).pathname;
if (path7.includes("/v1/videos")) return "video";
if (path7.includes("/v1/audio/")) return "audio";
return "image";
}
var SafeStaleBlockhashError = class extends PaymentError {
constructor() {
super("Payment verification used an expired Solana blockhash; retrying with a fresh quote.");
this.name = "SafeStaleBlockhashError";
}
};
function normalizePaymentSignal(value) {
return typeof value === "string" ? value.toLowerCase().replace(/[_\-\s:]/g, "") : "";
}
async function readPaymentFailureBody(response) {
const reader = response.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let total = 0;
let text = "";
try {
for (; ; ) {
const { done, value } = await reader.read();
if (done) return text + decoder.decode();
total += value.byteLength;
if (total > MAX_PAYMENT_FAILURE_BYTES) {
void reader.cancel();
return null;
}
text += decoder.decode(value, { stream: true });
}
} catch {
return null;
}
}
async function isSafeStaleBlockhashResponse(response) {
const length = Number(response.headers.get("content-length") || "0");
if (Number.isFinite(length) && length > MAX_PAYMENT_FAILURE_BYTES) return false;
const text = await readPaymentFailureBody(response);
if (text === null) return false;
let body;
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
body = parsed;
} catch {
return false;
}
const nested = body.error && typeof body.error === "object" && !Array.isArray(body.error) ? body.error : void 0;
const errorLabel = typeof body.error === "string" ? body.error : "";
const code = normalizePaymentSignal(body.code ?? nested?.code);
const reason = normalizePaymentSignal(body.reason);
const detail = normalizePaymentSignal(body.invalidMessage);
const message = normalizePaymentSignal(nested?.message ?? body.message);
const label = normalizePaymentSignal(errorLabel);
if (code.includes("settlementfailed") || label.includes("settlementfailed") || message.includes("settlementfailed")) return false;
const verifyPhase = code === "paymentinvalid" || label.includes("verificationfailed") || message.includes("verificationfailed");
if (!verifyPhase) return false;
return code === "paymentblockhashstale" || detail.includes("blockhashnotfound") || detail.includes("blockheightexceeded") || reason === "expiredsignature" || message.includes("expiredsignature");
}
async function waitForStaleRetry(attempt) {
await new Promise(
(resolve2) => setTimeout(resolve2, STALE_BLOCKHASH_RETRY_BACKOFFS_MS[attempt])
);
}
async function unpaidApiError(response) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
return withDisposition(
new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
"unpaid"
);
}
var DEFAULT_SOLANA_RPC_URL = "https://sol.blockrun.ai/api/v1/solana/rpc";
function resolveRpcConfig(rpcUrl, rpcHeaders) {
const env = typeof process !== "undefined" && process.env ? process.env : {};
const url = rpcUrl || env.SOLANA_RPC_URL || DEFAULT_SOLANA_RPC_URL;
let headers;
if (rpcHeaders) {
headers = { ...rpcHeaders };
} else if (env.SOLANA_RPC_HEADERS) {
try {
const parsed = JSON.parse(env.SOLANA_RPC_HEADERS);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
headers = Object.fromEntries(
Object.entries(parsed).map(([k, v]) => [String(k), String(v)])
);
}
} catch {
}
} else if (env.SOLANA_RPC_API_KEY) {
headers = { "x-api-key": env.SOLANA_RPC_API_KEY };
}
return headers ? { url, headers } : { url };
}
var SolanaLLMClient = class {
static SOLANA_API_URL = SOLANA_API_URL;
apiAuth;
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
_privateKey;
get privateKey() {
return requireWallet(this._privateKey);
}
apiUrl;
rpcUrl;
rpcHeaders;
timeout;
sessionTotalUsd = 0;
sessionCalls = 0;
addressCache = null;
modelPricingCache = null;
modelPricingPromise = null;
batchPayer;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const envKey = typeof process !== "undefined" && process.env ? process.env.SOLANA_WALLET_KEY : void 0;
const privateKey = options.privateKey || envKey;
if (!privateKey) {
throw new Error(
"Private key required. Pass privateKey in options or set SOLANA_WALLET_KEY environment variable."
);
}
this._privateKey = privateKey;
}
const apiUrl = this.apiAuth?.apiUrl || options.apiUrl || SOLANA_API_URL;
validateApiUrl(apiUrl);
this.apiUrl = apiUrl.replace(/\/$/, "");
const rpc = resolveRpcConfig(options.rpcUrl, options.rpcHeaders);
this.rpcUrl = rpc.url;
this.rpcHeaders = rpc.headers;
this.timeout = options.timeout || DEFAULT_TIMEOUT;
if (options.batch && !this.apiAuth) {
this.batchPayer = new SolanaBatchPayer({
options: options.batch,
secretKey: () => solanaKeyToBytes(this.privateKey),
address: () => this.getWalletAddress(),
rpcUrl: this.rpcUrl,
rpcHeaders: this.rpcHeaders
});
}
}
/**
* Close this wallet's batch-settlement channel and return its unused escrow.
*
* Closes the channel for the operator the gateway's current 402 names; a
* channel opened with another trusted operator key (during a key rotation)
* keeps its record. The gateway closes it cooperatively when it can;
* otherwise this starts a payer-forced close and the escrow comes back
* after the channel's grace period. Only meaningful with the `batch`
* option.
*
* @throws BatchCloseDeferredError, closing nothing, while a chat call for
* this wallet is still in flight (including one waiting out a 429, so a
* retry can never reopen a channel you just closed), or while one of its
* deposits is in doubt (until a `finalized` chain read shows it landed or
* that it no longer can, usually a few minutes after it was sent). Call
* it again later.
*/
async closeBatchChannel() {
if (!this.batchPayer) throw new Error("closeBatchChannel() requires the `batch` option in wallet mode");
return this.batchPayer.close(`${this.apiUrl}/v1/chat/completions`);
}
/**
* This client's batch-settlement counters: calls that fell back to `exact`
* (in total and by reason), 429 backoffs, batch retries, and recoveries.
* Every one of those is also logged to stderr and passed to `batch.onEvent`.
* Only meaningful with the `batch` option.
*/
getBatchStats() {
if (!this.batchPayer) throw new Error("getBatchStats() requires the `batch` option in wallet mode");
return this.batchPayer.stats();
}
/** Get Solana wallet address (public key in base58). */
async getWalletAddress() {
if (!this.addressCache) {
this.addressCache = await solanaPublicKey(this.privateKey);
}
return this.addressCache;
}
/** Simple 1-line chat. */
async chat(model, prompt, options) {
const messages = [];
if (options?.system) messages.push({ role: "system", content: options.system });
messages.push({ role: "user", content: prompt });
const result = await this.chatCompletion(model, messages, {
maxTokens: options?.maxTokens,
temperature: options?.temperature,
topP: options?.topP,
search: options?.search,
searchParameters: options?.searchParameters,
responseFormat: options?.responseFormat,
stop: options?.stop,
fallbackModels: options?.fallbackModels
});
return result.choices[0].message.content || "";
}
/** Full chat completion (OpenAI-compatible). */
async chatCompletion(model, messages, options) {
const routingProfile = routingProfileForModel(model);
if (routingProfile) {
return (await this.smartChatCompletion(messages, {
...options,
routingProfile
})).response;
}
validateMaxTokens(options?.maxTokens);
const buildBody = (candidate) => {
const body = {
model: candidate,
messages,
max_tokens: options?.maxTokens || DEFAULT_MAX_TOKENS2
};
if (options?.temperature !== void 0) body.temperature = options.temperature;
if (options?.topP !== void 0) body.top_p = options.topP;
if (options?.searchParameters !== void 0) body.search_parameters = toWireSearchParameters(options.searchParameters);
else if (options?.search === true) body.search_parameters = { mode: "on" };
if (options?.tools !== void 0) body.tools = toWireTools(options.tools);
if (options?.toolChoice !== void 0) body.tool_choice = options.toolChoice;
if (options?.responseFormat !== void 0) body.response_format = options.responseFormat;
if (options?.stop !== void 0) body.stop = options.stop;
return body;
};
const chain = [model, ...options?.fallbackModels ?? []];
let lastError;
for (let index = 0; index < chain.length; index += 1) {
const candidate = chain[index];
try {
return await this.requestWithPayment("/v1/chat/completions", buildBody(candidate), options?.timeout);
} catch (error) {
lastError = error;
if (!isTransientError(error) || index === chain.length - 1) throw error;
console.error(`[@blockrun/llm] ${candidate} -> ${chain[index + 1]} (${errSummary(error)})`);
}
}
throw lastError;
}
async getModelPricing() {
if (this.modelPricingCache) return this.modelPricingCache;
if (this.modelPricingPromise) return this.modelPricingPromise;
this.modelPricingPromise = (async () => {
const models = await this.listModels();
const pricing = /* @__PURE__ */ new Map();
for (const model of models) {
const raw = model;
if (model.available === false) continue;
const num = (value) => {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
};
const inputPrice = num(model.inputPrice ?? raw.input_price ?? raw.pricing?.input);
const outputPrice = num(model.outputPrice ?? raw.output_price ?? raw.pricing?.output);
const flatPrice = num(model.flatPrice ?? raw.flat_price ?? raw.pricing?.flat);
pricing.set(model.id, {
inputPrice,
outputPrice,
...(model.billingMode ?? raw.billing_mode) === "flat" && flatPrice > 0 ? { flatPrice } : {}
});
}
registerSearchModels(pricing, models);
return pricing;
})();
try {
this.modelPricingCache = await this.modelPricingPromise;
return this.modelPricingCache;
} finally {
this.modelPricingPromise = null;
}
}
/** Inspect a Solana route without making or paying for a model call. */
async route(prompt, options) {
return routeWithCatalog(
prompt,
options?.system,
options?.maxOutputTokens ?? options?.maxTokens ?? DEFAULT_MAX_TOKENS2,
await this.getModelPricing(),
{
routingProfile: options?.routingProfile,
requiresStructuredOutput: options?.responseFormat !== void 0,
search: options?.search,
searchParameters: options?.searchParameters,
minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
}
);
}
/** Smart one-line chat paid on Solana. */
async smartChat(prompt, options) {
const decision = await this.route(prompt, options);
const response = await this.chat(decision.model, prompt, {
...options,
// An explicit caller-supplied chain wins over the routed one.
fallbackModels: options?.fallbackModels ?? decision.fallbacks
});
return { response, model: decision.model, routing: decision };
}
/** Smart full message/tool completion paid on Solana. */
async smartChatCompletion(messages, options = {}) {
const { prompt, systemPrompt, conversationChars, hasVision } = routingText(messages);
const decision = routeWithCatalog(
prompt,
systemPrompt,
options.maxOutputTokens ?? options.maxTokens ?? DEFAULT_MAX_TOKENS2,
await this.getModelPricing(),
{
routingProfile: options.routingProfile,
requiresStructuredOutput: options.responseFormat !== void 0,
tools: options.tools,
toolChoice: options.toolChoice,
search: options.search,
searchParameters: options.searchParameters,
conversationChars,
hasVision,
minimumPaymentUsd: this.apiAuth ? 0 : SOLANA_MINIMUM_PAYMENT_USD
}
);
const response = await this.chatCompletion(decision.model, messages, {
maxTokens: options.maxTokens,
temperature: options.temperature,
topP: options.topP,
search: options.search,
searchParameters: options.searchParameters,
tools: options.tools,
toolChoice: options.toolChoice,
responseFormat: options.responseFormat,
stop: options.stop,
timeout: options.timeout,
// An explicit caller-supplied chain wins over the routed one.
fallbackModels: options.fallbackModels ?? decision.fallbacks
});
response.routing = decision;
return { response, model: decision.model, routing: decision };
}
/**
* Stream a Server-Sent Events endpoint, paid on Solana.
*
* The Solana counterpart to `BlockrunClient.stream`, and the reason it had to
* exist: a streaming harness cannot use this client at all without it, so
* "BlockRun supports Solana" stopped being true the moment a caller streamed.
* `chatCompletion` buffers the whole answer, which is the wrong shape for an
* agent loop and for anything that shows tokens as they arrive.
*
* The handshake is the one the non-streaming paths use — `402`, sign an SPL
* TransferChecked authorization locally, replay with `PAYMENT-SIGNATURE` —
* with the same verification-phase re-sign on a stale blockhash. What differs
* is that the paid response is not read as JSON: it is handed to the SSE
* reader with its body untouched.
*
* A `200` on the first request is returned as-is and settles nothing. That is
* the free tier (the gateway answers a `billing_mode: "free"` model without a
* 402 at all) and it is also API-key mode, where billing is on the account
* rather than on a wallet.
*
* Yields each `data:` frame parsed as JSON, and stops at `data: [DONE]`.
* Malformed frames are skipped rather than thrown — see {@link readSseFrames}.
*
* @example
* for await (const chunk of client.stream<ChatChunk>("/v1/chat/completions", {
* model: "deepseek/deepseek-chat",
* messages: [{ role: "user", content: "Hi" }],
* stream: true,
* })) {
* process.stdout.write(chunk.choices?.[0]?.delta?.content ?? "");
* }
*
* @param path - endpoint after the API root; a leading `/api` is tolerated.
* @param body - JSON request body. Set `stream: true` yourself — this method
* does not inject it, because the gateway prices a streaming and a
* non-streaming request the same and silently rewriting a caller's body is
* how you end up debugging a request you did not send.
* @returns each decoded SSE frame, in order.
*/
async *stream(path7, body) {
const url = this.buildUrl(path7);
const response = await this.openPaidStream(url, JSON.stringify(body ?? {}));
yield* readSseFrames(
response,
(status2) => new APIError("Stream response has no body", status2, {})
);
}
/**
* Get to a streaming response, paying for it if the gateway asks.
*
* Separate from {@link SolanaLLMClient.stream} because a generator cannot
* retry cleanly around a `yield`: the stale-blockhash re-sign has to finish
* before the first frame is handed out, and putting the loop here keeps the
* payment decision entirely ahead of any output the caller has seen.
*
* @param url - resolved endpoint URL.
* @param requestBody - the serialized body, reused verbatim on the paid retry
* so the gateway prices and answers the same request it quoted for.
* @returns a response whose body has not been read.
*/
async openPaidStream(url, requestBody) {
for (let staleRetries = 0; ; ) {
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: requestBody
});
if (response.ok) return response;
if (response.status !== 402) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
try {
const { paymentPayload, costUsd } = await this.signPaymentFrom402(
url,
response,
staleRetries > 0
);
const paid = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: requestBody
});
await this.assertPaid(paid);
this.recordSettlement(costUsd, paid);
return paid;
} catch (error) {
if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
await waitForStaleRetry(staleRetries++);
continue;
}
}
}
/**
* Resolve an endpoint path against this client's API root.
*
* A leading `/api` is stripped for the same reason `BlockrunClient` strips
* it: the documented paths are written `/api/v1/…` on the website and
* `/v1/…` in this SDK, and a caller who copies one into the other should get
* their request rather than a 404.
* @param path - endpoint path, with or without a leading slash.
* @returns the absolute URL to call.
*/
buildUrl(path7) {
let normalized = path7.startsWith("/") ? path7 : `/${path7}`;
if (normalized.startsWith("/api/")) normalized = normalized.slice(4);
return `${this.apiUrl}${normalized}`;
}
/** List available models. */
async listModels() {
const response = await this.fetchWithTimeout(`${this.apiUrl}/v1/models`, { method: "GET" });
if (!response.ok) {
throw new APIError(`Failed to list models: ${response.status}`, response.status);
}
const data = await response.json();
return data.data || [];
}
/**
* Get Solana USDC balance.
*
* @returns USDC balance as a float
*/
async getBalance(options) {
const usdc_mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const address = await this.getWalletAddress();
try {
const response = await fetch(this.rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.rpcHeaders ?? {} },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getTokenAccountsByOwner",
params: [address, { mint: usdc_mint }, { encoding: "jsonParsed" }]
})
});
const data = await response.json();
if (options?.strict && (!response.ok || data.error)) {
throw new Error(`Solana RPC balance lookup failed: ${data.error?.message ?? `HTTP ${response.status}`}`);
}
const accounts = data.result?.value || [];
if (!accounts.length) return 0;
let total = 0;
for (const acct of accounts) {
total += acct.account?.data?.parsed?.info?.tokenAmount?.uiAmount || 0;
}
return total;
} catch (error) {
if (options?.strict) throw error;
return 0;
}
}
/** Edit an image using img2img (Solana payment). */
/**
* Generate an image on Solana.
*
* Slow models answer 202 `{ id, poll_url }`; `requestWithPaymentRaw` follows
* that to completion, re-signing per poll, and settles once at the end. Fast
* models answer 200 inline and settle there.
*/
async image(prompt, options = {}) {
const body = { prompt, ...options };
const data = await this.requestWithPaymentRaw("/v1/images/generations", body);
return data;
}
async imageEdit(prompt, image, options) {
const body = {
model: options?.model || "openai/gpt-image-2",
prompt,
image,
size: options?.size || "1024x1024",
n: options?.n || 1
};
if (options?.mask !== void 0) body.mask = options.mask;
const data = await this.requestWithPaymentRaw("/v1/images/image2image", body);
return data;
}
/**
* Generate a short video on Solana.
*
* The POST is verified, not charged. The job is then polled with a wallet
* signature (no payment) until it finishes, and ONE payment is signed for
* the finished video; a failed job is never charged. If the gateway does not
* accept poll signatures for the job, it is polled with a payment per poll
* instead, which is likewise only charged on completion.
*
* @param prompt - text description of the video.
* @param options - `model` (default `xai/grok-imagine-video`), `budgetMs`
* (total wait, default 15 min); any other keys are sent verbatim, so use
* the gateway's snake_case names (`duration_seconds`, `image_url`,
* `aspect_ratio`, `resolution`, …).
*/
async video(prompt, options = {}) {
const { model, budgetMs, ...rest } = options;
const body = { ...rest, model: model || DEFAULT_VIDEO_MODEL, prompt };
const data = await this.requestWithPaymentRaw(
"/v1/videos/generations",
body,
{ pollBudgetMs: budgetMs ?? DEFAULT_VIDEO_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
);
return data;
}
/**
* Generate a music track on Solana. Paid at the POST; a slow track is then
* polled with a wallet signature, never another payment.
*
* @param prompt - music style, mood, or description.
* @param options - `model` (default `minimax/music-2.5+`), `instrumental`
* (default true), `lyrics` (requires `instrumental: false`), `budgetMs`
* (total wait, default 5 min); other keys are sent verbatim.
*/
async music(prompt, options = {}) {
const { model, instrumental: instrumentalOption, lyrics: rawLyrics, budgetMs, ...rest } = options;
const instrumental = instrumentalOption ?? true;
const lyrics = rawLyrics?.trim();
if (instrumental && lyrics) throw new Error("Cannot specify lyrics when instrumental is true");
const body = { ...rest, model: model || DEFAULT_MUSIC_MODEL, prompt, instrumental };
if (lyrics) body.lyrics = lyrics;
const data = await this.requestWithPaymentRaw(
"/v1/audio/generations",
body,
{ pollBudgetMs: budgetMs ?? DEFAULT_MUSIC_BUDGET_MS, submitTimeoutMs: Math.max(this.timeout, MEDIA_SUBMIT_TIMEOUT_MS) }
);
return data;
}
/** Standalone search (Solana payment). */
async search(query, options) {
const body = { query, max_results: options?.maxResults || 10 };
if (options?.sources !== void 0) body.sources = options.sources;
if (options?.fromDate !== void 0) body.from_date = options.fromDate;
if (options?.toDate !== void 0) body.to_date = options.toDate;
const data = await this.requestWithPaymentRaw("/v1/search", body);
return data;
}
// ── Prediction Markets (Powered by Predexon) ────────────────────────────
async pm(path7, params) {
return this.getWithPaymentRaw(`/v1/pm/${path7}`, params);
}
async pmQuery(path7, query) {
return this.requestWithPaymentRaw(`/v1/pm/${path7}`, query);
}
// ── Exa Web Search (Powered by Exa) ──────────────────────────────────────
/**
* Generic Exa endpoint proxy (POST, Solana payment). Powered by Exa.
*
* @param path - Exa endpoint: "search" | "find-similar" | "contents" | "answer"
* @param body - Request body (see Exa API docs)
*
* @example
* const results = await client.exa("search", { query: "latest AI research", numResults: 5 });
*/
async exa(path7, body) {
return this.requestWithPaymentRaw(`/v1/exa/${path7}`, body);
}
/**
* Neural and keyword web search via Exa (Solana payment).
*
* @example
* const results = await client.exaSearch("latest AI papers", { numResults: 5 });
*/
async exaSearch(query, options) {
return this.requestWithPaymentRaw("/v1/exa/search", { query, ...options });
}
/**
* Find pages semantically similar to a given URL via Exa (Solana payment).
*
* @example
* const results = await client.exaFindSimilar("https://openai.com/research/gpt-4", { numResults: 5 });
*/
async exaFindSimilar(url, options) {
return this.requestWithPaymentRaw("/v1/exa/find-similar", { url, ...options });
}
/**
* Extract full text content from URLs via Exa (Solana payment).
*
* @example
* const data = await client.exaContents(["https://arxiv.org/abs/2303.08774"]);
*/
async exaContents(urls, options) {
return this.requestWithPaymentRaw("/v1/exa/contents", { urls, ...options });
}
/**
* AI-generated answer grounded in live web search via Exa (Solana payment).
*
* @example
* const answer = await client.exaAnswer("What is the current state of AI safety research?");
*/
async exaAnswer(query, options) {
return this.requestWithPaymentRaw("/v1/exa/answer", { query, ...options });
}
// ============================================================
// DefiLlama (DeFi protocols / TVL / yields / prices)
// ============================================================
/**
* Query DefiLlama DeFi data (GET passthrough). Powered by DefiLlama.
* for protocols / protocol/{slug} / chains / yields;
* for prices/{coins}.
*
* @param path - e.g. "protocols", "protocol/aave", "chains", "yields",
* "prices/coingecko:bitcoin,base:0x..."
* @param params - Query parameters passed through to DefiLlama
*/
async defi(path7, params) {
return this.getWithPaymentRaw(`/v1/defillama/${path7}`, params);
}
/** All DeFi protocols with TVL. */
async defiProtocols() {
return this.defi("protocols");
}
/** Single protocol details + historical TVL. */
async defiProtocol(slug) {
return this.defi(`protocol/${slug}`);
}
/** Current TVL of every chain. */
async defiChains() {
return this.defi("chains");
}
/** Yield pools with APY/TVL. */
async defiYields(params) {
return this.defi("yields", params);
}
/** Token price lookup. Coins like "coingecko:bitcoin" or "{chain}:{address}". */
async defiPrices(coins) {
const joined = Array.isArray(coins) ? coins.join(",") : coins;
return this.defi(`prices/${joined}`);
}
// ============================================================
// 0x DEX (swap quotes + gasless) — free passthrough
// ============================================================
/**
* Query the 0x Swap / Gasless APIs (free — no x402 payment; BlockRun
* takes an on-chain affiliate fee on executed swaps instead).
*
* @param path - "price", "quote", "gasless/price", "gasless/quote",
* "gasless/submit" (POST), "gasless/status/{hash}",
* "gasless/approval-tokens", "gasless/chains", "swap/chains"
* @param params - Query params (chainId, sellToken, buyToken, sellAmount, taker, ...)
* @param body - JSON body — pass to switch to POST (gasless/submit only)
*/
async dex(path7, params, body) {
if (body) return this.requestWithPaymentRaw(`/v1/zerox/${path7}`, body);
return this.getWithPaymentRaw(`/v1/zerox/${path7}`, params);
}
/** Indicative Permit2 swap price — no commitment (free). */
async dexPrice(params) {
return this.dex("price", params);
}
/** Firm Permit2 swap quote with permit2.eip712 + tx data (free). */
async dexQuote(params) {
return this.dex("quote", params);
}
/** Gasless indicative price quote (free). */
async dexGaslessPrice(params) {
return this.dex("gasless/price", params);
}
/** Gasless firm quote — returns trade.eip712 to sign (free). */
async dexGaslessQuote(params) {
return this.dex("gasless/quote", params);
}
/** Submit a signed gasless trade; the 0x relayer pays gas (free). */
async dexGaslessSubmit(body) {
return this.dex("gasless/submit", void 0, body);
}
/** Poll a gasless trade's status by tradeHash (free). */
async dexGaslessStatus(tradeHash) {
return this.dex(`gasless/status/${tradeHash}`);
}
/** Chains where the Swap API is supported (free). */
async dexChains() {
return this.dex("swap/chains");
}
/** Chains where the Gasless API is supported (free). */
async dexGaslessChains() {
return this.dex("gasless/chains");
}
// ============================================================
// Modal Sandbox (pay-per-call cloud compute)
// ============================================================
/**
* Call the Modal sandbox compute API (POST passthrough).
*
* @param path - "sandbox/create" (CPU / GPU tiers), "sandbox/exec",
* "sandbox/status", "sandbox/terminate"
* @param body - JSON body for the endpoint
*/
async modal(path7, body) {
return this.requestWithPaymentRaw(`/v1/modal/${path7}`, body ?? {});
}
/** Create a sandboxed compute environment (CPU / GPU tiers). */
async modalSandboxCreate(body) {
return this.modal("sandbox/create", body);
}
/** Execute a command in a sandbox; returns stdout/stderr. */
async modalSandboxExec(sandboxId, command, extra) {
return this.modal("sandbox/exec", { sandbox_id: sandboxId, command, ...extra });
}
/** Check a sandbox's status. */
async modalSandboxStatus(sandboxId) {
return this.modal("sandbox/status", { sandbox_id: sandboxId });
}
/** Terminate a sandbox. */
async modalSandboxTerminate(sandboxId) {
return this.modal("sandbox/terminate", { sandbox_id: sandboxId });
}
/** Get session spending. */
getSpending() {
if (this.apiAuth) throw new Error("Account usage is available at https://user.blockrun.ai/dashboard; getSpending() tracks x402 settlements only.");
return { totalUsd: this.sessionTotalUsd, calls: this.sessionCalls };
}
/** True if using sol.blockrun.ai. */
isSolana() {
return this.apiUrl.includes("sol.blockrun.ai");
}
/**
* Chat request with automatic x402 payment.
*
* Every error it throws carries a {@link RetryDisposition}, which is what
* {@link SolanaLLMClient.chatCompletion}'s fallback walk honours: errors of
* the unpaid first request, its 402 challenge and the signing step are
* `"unpaid"`; anything after a payment was sent (exact or batch) is
* `"paid-or-in-doubt"`, and so is any error with an API key once the billed
* request was sent, except the account API's explicit 4xx refusal.
*/
async requestWithPayment(endpoint, body, timeoutMs) {
const url = `${this.apiUrl}${endpoint}`;
const json = JSON.stringify(body);
const closesAtStart = await this.batchPayer?.closeFence();
for (let staleRetries = 0; ; ) {
const response = await this.sendUnpaid(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: json
}, timeoutMs);
if (response.status === 402) {
try {
let paymentRequired;
try {
paymentRequired = await this.readPaymentRequired(response);
} catch (error) {
throw withDisposition(error, "unpaid");
}
let exactRequired = paymentRequired;
if (this.batchPayer && staleRetries === 0) {
const batch = await this.batchPayer.pay(
paymentRequired,
(paymentHeaders) => this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT, ...paymentHeaders },
body: json
}, timeoutMs),
// A fresh, unpaid challenge after a 429 wait: the request itself,
// sent again without a payment. Every error it throws is unpaid.
async () => {
const challenge = await this.sendUnpaid(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: json
}, timeoutMs);
if (challenge.status === 402) {
try {
return { kind: "challenge", paymentRequired: await this.readPaymentRequired(challenge) };
} catch (error) {
throw withDisposition(error, "unpaid");
}
}
if (challenge.ok) return { kind: "served", response: challenge };
throw await unpaidApiError(challenge);
},
closesAtStart
);
if (batch.kind === "paid") {
this.recordSettlement(batch.chargedUsd, batch.response);
const paid = await this.readPaidJson(batch.response);
return withCost(paid, batch.response, batch.chargedUsd, "batch");
}
if (batch.kind === "served") return this.withUnpaidCost(await this.readUnpaidJson(batch.response));
if (batch.kind === "failed") throw batch.error;
exactRequired = batch.paymentRequired ?? paymentRequired;
}
return await this.handlePaymentAndRetry(url, json, exactRequired, staleRetries > 0, timeoutMs);
} catch (error) {
if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
await waitForStaleRetry(staleRetries++);
continue;
}
}
if (!response.ok) throw await unpaidApiError(response);
return this.withUnpaidCost(await this.readUnpaidJson(response));
}
}
/**
* Cost of a chat response served without a payment: 0 with a wallet (a free
* model). With an API key the call is billed to the account, so costUsd stays
* undefined (see the dashboard).
*/
withUnpaidCost(served) {
if (!this.apiAuth) {
served.costUsd = 0;
served.settlement = { scheme: "free", quotedUsd: 0 };
}
return served;
}
/**
* Read the JSON body of a 2xx served without a payment: the free tier, or
* with an API key, where this very request was the billed one.
*/
async readUnpaidJson(response) {
try {
return await response.json();
} catch (error) {
throw withDisposition(error, this.apiAuth ? "paid-or-in-doubt" : "unpaid");
}
}
/**
* Send a request that carries no payment, marking what it throws with its
* {@link RetryDisposition}: `"unpaid"` with a wallet; with an API key, see
* {@link accountErrorDisposition} (the request itself is billed).
*/
async sendUnpaid(url, init, timeoutMs) {
try {
return await this.fetchWithTimeout(url, init, timeoutMs);
} catch (error) {
throw withDisposition(error, this.apiAuth ? accountErrorDisposition(error) : "unpaid");
}
}
/** Read a paid 2xx response's JSON body. The call was charged, so a failure here is never retried. */
async readPaidJson(response) {
try {
return await response.json();
} catch (error) {
throw withDisposition(error, "paid-or-in-doubt");
}
}
/**
* Follow a 202 `{ id, poll_url }` to completion on a Solana media route
* (image, music, video).
*
* **Polls prove ownership, they do not pay.** A Solana payment is a
* transaction pinned to a recent blockhash (~60s), so paying every poll of a
* multi-minute job meant signing a fresh transaction every few seconds. Each
* poll here instead carries the `x-poll-*` headers: the submitting wallet's
* ed25519 signature over `blockrun-poll:v1:<kind>:<job id>:<unix seconds>`
* (see `poll-ownership.ts`). That proof never charges.
*
* **Who pays when:**
* - image / music: the POST already settled (sol settles at submit because the
* signed transaction would be dead by completion). The caller recorded the
* cost at POST; this loop only fetches the result and never signs a payment.
* A batch-billed image job still answers its proof poll with 402: that job
* falls back to the per-poll payment below.
* - video: settles on completion, and a failed job is never charged. A
* finished, unpaid job answers the proof poll with 402 `{status:"completed"}`
* and a normal x402 challenge; ONE payment is signed from that challenge and
* the same poll is re-sent with it. The cost is recorded here, on delivery.
* A paid poll the gateway answers 402 (settlement in progress on another
* poll, outcome unknown, stale blockhash) is re-polled after its
* Retry-After — the gateway's per-job settlement claim makes a later fresh
* payment safe — up to `MAX_PAID_POLL_REFUSALS` times.
*
* **Fallback, once per job:** if the gateway refuses the proof with its "no
* such job" answer (video 400, image/music 404) or answers it with a plain
* payment challenge (a gateway or job that predates poll proofs), the rest of
* this job is polled the old way — an x-payment signed per poll, which the
* gateway verifies against the job's payer and does not charge until a video
* completes. Logged to stderr; never switches back.
*/
async followSolanaJob(submitBody, budgetMs, kind, intervalMs = kind === "video" ? VIDEO_POLL_INTERVAL_MS : 2e3) {
const id = submitBody.id;
const pollPath = submitBody.poll_url;
if (!id || !pollPath) return submitBody;
const pollUrl = this.resolvePollUrl(pollPath);
const jobId = pollJobIdFromUrl(pollUrl);
const deadline = Date.now() + budgetMs;
let lastStatus = submitBody.status || "queued";
let ownershipPolls = true;
let waitMs = intervalMs;
let paidRefusalsLeft = MAX_PAID_POLL_REFUSALS;
let videoCostUsd;
let settleAttempted = false;
const fallBack = (why) => {
ownershipPolls = false;
waitMs = 0;
console.warn(
`[@blockrun/llm] ${kind} job ${id}: ${why}; falling back to x-payment polling for this job`
);
};
const deliver = (data, response) => {
if (kind === "video") {
if (videoCostUsd !== void 0) this.recordSettlement(videoCostUsd, response);
const txHash = response.headers.get("x-payment-receipt");
if (txHash) data.txHash = txHash;
}
return data;
};
const failIfFailed = (data, status2) => {
if (lastStatus === "failed") {
throw new APIError(
`Upstream job failed: ${data.error || "unknown"}`,
status2,
sanitizeErrorResponse(data)
);
}
};
while (Date.now() < deadline) {
if (waitMs > 0) await sleep3(Math.min(waitMs, Math.max(0, deadline - Date.now())));
waitMs = intervalMs;
let challenge;
if (ownershipPolls) {
const proof = signPollOwnership(await solanaKeyToBytes(this.privateKey), kind, jobId);
const polled = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: { "User-Agent": USER_AGENT, ...proof }
});
const data2 = await readJsonOrEmpty(polled);
if (polled.status === 402) {
if (kind === "video" && data2.status === "completed") {
lastStatus = "completed";
settleAttempted = true;
challenge = new Response(JSON.stringify(data2), { status: 402, headers: polled.headers });
} else {
fallBack(
kind === "image" ? "the gateway asks for a payment on this poll (a batch-billed job, or a gateway without poll proofs)" : "the gateway answered the poll proof with a payment challenge (a gateway without poll proofs)"
);
continue;
}
} else if (polled.status === (kind === "video" ? 400 : 404)) {
fallBack(`the gateway refused the poll proof (HTTP ${polled.status})`);
continue;
} else {
lastStatus = data2.status || lastStatus;
failIfFailed(data2, polled.status);
if (polled.status === 200 && lastStatus === "completed") return deliver(data2, polled);
if (!isPendingPollStatus(polled.status)) {
throw new APIError(`Poll failed: ${polled.status}`, polled.status, sanitizeErrorResponse(data2));
}
waitMs = retryAfterMs(polled) ?? intervalMs;
continue;
}
}
if (!challenge) {
const unpaid = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: { "User-Agent": USER_AGENT }
});
if (unpaid.status === 200) {
return deliver(await unpaid.json(), unpaid);
}
if (unpaid.status !== 402) {
let errorBody;
try {
errorBody = await unpaid.json();
} catch {
errorBody = { error: "Poll failed" };
}
throw new APIError(`Poll failed: ${unpaid.status}`, unpaid.status, sanitizeErrorResponse(errorBody));
}
challenge = unpaid;
}
const { paymentPayload, costUsd } = await this.signPaymentFrom402(
pollUrl,
challenge,
true,
// always a fresh blockhash; the last one is stale by now
pollUrl
);
if (kind === "video") videoCostUsd = costUsd;
let paid;
try {
paid = await this.fetchWithTimeout(pollUrl, {
method: "GET",
headers: { "User-Agent": USER_AGENT, "PAYMENT-SIGNATURE": paymentPayload }
});
} catch (error) {
if (kind === "video" && lastStatus === "completed") settleAttempted = true;
if (--paidRefusalsLeft <= 0) {
throw withDisposition(
new PaymentError(
`Paid poll failed in transit (${error instanceof Error ? error.message : String(error)}). The payment may have settled: resume the job by polling ${pollUrl} with the same wallet before submitting again.`
),
"paid-or-in-doubt"
);
}
continue;
}
const data = await readJsonOrEmpty(paid);
lastStatus = data.status || lastStatus;
failIfFailed(data, paid.status);
if (paid.status === 200 && lastStatus === "completed") return deliver(data, paid);
if (paid.status === 402 && kind === "video") {
if (data.error === "Payment verification failed" && !await isSafeStaleBlockhashResponse(new Response(JSON.stringify(data), { status: 402 }))) {
throw new PaymentError(
`Payment for the finished video was rejected. Check your Solana USDC balance, then resume the job by polling ${pollUrl} with the same wallet.`
);
}
settleAttempted = true;
if (--paidRefusalsLeft <= 0) {
throw withDisposition(
new PaymentError(
`Payment for the finished video did not settle after ${MAX_PAID_POLL_REFUSALS} attempts (last: ${data.error || "HTTP 402"}). It may still settle: resume the job by polling ${pollUrl} with the same wallet before submitting again.`
),
"paid-or-in-doubt"
);
}
waitMs = retryAfterMs(paid) ?? intervalMs;
continue;
}
if (!isPendingPollStatus(paid.status) && paid.status !== 200) {
throw new APIError(`Poll failed: ${paid.status}`, paid.status, sanitizeErrorResponse(data));
}
waitMs = retryAfterMs(paid) ?? intervalMs;
}
const billing = kind === "video" ? settleAttempted ? "A payment for the finished video was sent and may still settle." : "Nothing was charged: a video is charged only when a completed poll is paid." : "The submit was already paid.";
throw new APIError(
`Job did not complete within ${Math.round(budgetMs / 1e3)}s (last status: ${lastStatus}). ${billing} Resume by polling the same poll_url with the same wallet before submitting again.`,
504,
{ id, last_status: lastStatus, poll_url: pollUrl }
);
}
/**
* Absolute URL for a job's `poll_url`. The gateway returns `/api/v1/...`;
* {@link buildUrl} drops the `/api` that `apiUrl` already ends with. An
* absolute poll_url must be on the gateway's own origin: the poll carries a
* wallet signature (and, for a finished video, a payment).
*/
resolvePollUrl(pollPath) {
if (!/^https?:\/\//i.test(pollPath)) return this.buildUrl(pollPath);
if (new URL(pollPath).origin !== new URL(this.apiUrl).origin) {
throw new APIError(`poll_url is not on the gateway origin: ${pollPath}`, 502, { poll_url: pollPath });
}
return pollPath;
}
/**
* Turn a `402` into a signed Solana payment payload.
*
* Extracted because four call sites need it — chat, the raw POST helpers, the
* raw GET helper, and {@link SolanaLLMClient.stream} — and it had been
* written out three times before this. That mattered more than ordinary
* duplication: this is the code that signs a transfer of the caller's USDC,
* so three copies meant every fix to it had to be applied three times or
* quietly apply to two thirds of the paths.
*
* @param url - the request being paid for.
* @param response - the gateway's `402`, not yet consumed.
* @param forceFreshBlockhash - set on a re-sign after a stale-blockhash
* rejection, so the retry cannot produce byte-identical transaction bytes.
* @param resourceFallback - resource URL to claim when the 402 states none.
* @returns the header value to replay with, and what it will settle for.
* @throws PaymentError when the 402 carries no usable Solana requirements.
*/
async signPaymentFrom402(url, response, forceFreshBlockhash, resourceFallback = url) {
return this.signExactPayment(
url,
await this.readPaymentRequired(response),
forceFreshBlockhash,
resourceFallback
);
}
/** Read a `402`'s payment requirements from its header, or its JSON body. */
async readPaymentRequired(response) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.accepts || respBody.x402Version) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new PaymentError("402 response but no payment requirements found");
}
return parsePaymentRequired(paymentHeader);
}
/** Sign the `exact` accept of a 402 as an SPL TransferChecked payment. */
async signExactPayment(url, paymentRequired, forceFreshBlockhash, resourceFallback = url) {
const details = extractPaymentDetails(
{ ...paymentRequired, accepts: paymentRequired.accepts.filter((accept) => accept.scheme !== BATCH_SCHEME) },
SOLANA_NETWORK
);
if (!details.network?.startsWith("solana:")) {
throw new PaymentError(
`Expected Solana payment network, got: ${details.network}. Use LLMClient for Base payments.`
);
}
const feePayer = details.extra?.feePayer;
if (!feePayer) throw new PaymentError("Missing feePayer in 402 extra field");
const fromAddress = await this.getWalletAddress();
const secretKey = await solanaKeyToBytes(this.privateKey);
const extensions = withPaymentIdentifier(paymentRequired).extensions;
const paymentPayload = await createSolanaPaymentPayload(
secretKey,
fromAddress,
details.recipient,
details.amount,
feePayer,
{
resourceUrl: validateResourceUrl(
details.resource?.url || resourceFallback,
this.apiUrl
),
resourceDescription: details.resource?.description || "BlockRun Solana AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
extensions,
rpcUrl: this.rpcUrl,
rpcHeaders: this.rpcHeaders,
forceFreshBlockhash
}
);
return { paymentPayload, costUsd: parseFloat(details.amount) / 1e6 };
}
async handlePaymentAndRetry(url, json, paymentRequired, forceFreshBlockhash = false, timeoutMs) {
let signed;
try {
signed = await this.signExactPayment(url, paymentRequired, forceFreshBlockhash, `${this.apiUrl}/v1/chat/completions`);
} catch (error) {
throw withDisposition(error, "unpaid");
}
const { paymentPayload, costUsd } = signed;
let retryResponse;
try {
retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: json
}, timeoutMs);
} catch (error) {
throw withDisposition(error, "paid-or-in-doubt");
}
await this.assertPaid(retryResponse);
this.recordSettlement(costUsd, retryResponse);
const paid = await this.readPaidJson(retryResponse);
return withCost(paid, retryResponse, costUsd, "exact");
}
async requestWithPaymentRaw(endpoint, body, media = {}) {
const url = `${this.apiUrl}${endpoint}`;
const pollBudgetMs = media.pollBudgetMs ?? this.timeout;
for (let staleRetries = 0; ; ) {
const response = await this.fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
body: JSON.stringify(body)
}, media.submitTimeoutMs);
if (response.status === 402) {
try {
return await this.handlePaymentAndRetryRaw(url, body, response, staleRetries > 0, media);
} catch (error) {
if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
await waitForStaleRetry(staleRetries++);
continue;
}
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
if (this.apiAuth && /^\/v1\/(images|videos|audio)\//.test(endpoint)) {
return this.apiAuth.poll(
response,
pollBudgetMs,
endpoint.startsWith("/v1/videos/") ? VIDEO_POLL_INTERVAL_MS : 2e3
);
}
return response.json();
}
}
async handlePaymentAndRetryRaw(url, body, response, forceFreshBlockhash = false, media = {}) {
const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
let retryResponse;
try {
retryResponse = await this.fetchWithTimeout(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
},
body: JSON.stringify(body)
}, media.submitTimeoutMs);
} catch (error) {
throw withDisposition(error, "paid-or-in-doubt");
}
await this.assertPaid(retryResponse);
if (retryResponse.status === 202) {
const kind = pollKindFor(url);
if (kind !== "video") this.recordSettlement(costUsd, retryResponse);
const submitted = await retryResponse.json();
return this.followSolanaJob(submitted, media.pollBudgetMs ?? this.timeout, kind);
}
this.recordSettlement(costUsd, retryResponse);
return retryResponse.json();
}
async getWithPaymentRaw(endpoint, params) {
const query = params ? "?" + new URLSearchParams(params).toString() : "";
const url = `${this.apiUrl}${endpoint}${query}`;
for (let staleRetries = 0; ; ) {
const response = await this.fetchWithTimeout(url, {
method: "GET",
headers: { "User-Agent": USER_AGENT }
});
if (response.status === 402) {
try {
return await this.handleGetPaymentAndRetryRaw(url, endpoint, params, response, staleRetries > 0);
} catch (error) {
if (!(error instanceof SafeStaleBlockhashError) || staleRetries >= STALE_BLOCKHASH_RETRY_BACKOFFS_MS.length) throw error;
await waitForStaleRetry(staleRetries++);
continue;
}
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
throw new APIError(`API error: ${response.status}`, response.status, sanitizeErrorResponse(errorBody));
}
return response.json();
}
}
async handleGetPaymentAndRetryRaw(url, endpoint, params, response, forceFreshBlockhash = false) {
const { paymentPayload, costUsd } = await this.signPaymentFrom402(url, response, forceFreshBlockhash);
const query = params ? "?" + new URLSearchParams(params).toString() : "";
const retryUrl = `${this.apiUrl}${endpoint}${query}`;
const retryResponse = await this.fetchWithTimeout(retryUrl, {
method: "GET",
headers: {
"User-Agent": USER_AGENT,
"PAYMENT-SIGNATURE": paymentPayload
}
});
await this.assertPaid(retryResponse);
this.recordSettlement(costUsd, retryResponse);
return retryResponse.json();
}
/**
* Fail a post-payment response, telling a re-signable rejection from a real one.
*
* A `402` here is not "pay again": it is the gateway refusing the payment we
* just signed. Only a rejection the gateway attributes to the VERIFICATION
* phase is safe to re-sign — anything settled, or ambiguous about which
* phase it failed in, could already have moved USDC, and re-signing it would
* pay twice. {@link isSafeStaleBlockhashResponse} is where that judgement
* lives.
* @param response - the reply to the paid request.
* @throws SafeStaleBlockhashError when the caller should re-sign, PaymentError
* when it should not, APIError for any other failure. Every one but the
* first is marked `"paid-or-in-doubt"`: the payment may have settled.
*/
async assertPaid(response) {
if (response.status === 402) {
if (await isSafeStaleBlockhashResponse(response)) {
throw withDisposition(new SafeStaleBlockhashError(), "unpaid");
}
throw withDisposition(new PaymentError("Payment was rejected. Check your Solana USDC balance."), "paid-or-in-doubt");
}
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch {
errorBody = { error: "Request failed" };
}
const unverified = response.status === 503 && errorBody?.code === "PAYMENT_VERIFICATION_UNAVAILABLE";
throw withDisposition(
new APIError(`API error after payment: ${response.status}`, response.status, sanitizeErrorResponse(errorBody)),
unverified ? "unpaid" : "paid-or-in-doubt"
);
}
}
/**
* Count one settled x402 payment against the session total.
*
* Takes the paid response so no booking site can forget the check: a
* response whose settlement the gateway skipped ({@link settlementSkipped}:
* `X-Settlement-Skipped: free-fallback | empty-result | unexecuted | …`, or
* `X-Payment-Waived`) moved nothing on chain and books nothing.
* `X-Payment-Settled: false` alone is still booked: the payment is owed.
*/
recordSettlement(costUsd, response) {
if (settlementSkipped(response)) return;
this.sessionCalls += 1;
this.sessionTotalUsd += costUsd;
}
async fetchWithTimeout(url, options, timeoutMs = this.timeout) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await (this.apiAuth ? this.apiAuth.fetch.bind(this.apiAuth) : fetch)(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
};
function solanaClient(options = {}) {
return new SolanaLLMClient({ ...options, apiUrl: SOLANA_API_URL });
}
// src/cache.ts
var fs7 = __toESM(require("fs"), 1);
var path6 = __toESM(require("path"), 1);
var os4 = __toESM(require("os"), 1);
var crypto2 = __toESM(require("crypto"), 1);
var CACHE_DIR = path6.join(os4.homedir(), ".blockrun", "cache");
var DATA_DIR = path6.join(os4.homedir(), ".blockrun", "data");
var COST_LOG_FILE2 = path6.join(os4.homedir(), ".blockrun", "cost_log.jsonl");
var DEFAULT_TTL = {
"/v1/partner/": 3600 * 1e3,
"/v1/pm/": 1800 * 1e3,
"/v1/chat/": 0,
"/v1/search": 900 * 1e3,
"/v1/image": 0,
"/v1/models": 86400 * 1e3
};
function getTtl(endpoint) {
for (const [pattern, ttl] of Object.entries(DEFAULT_TTL)) {
if (endpoint.includes(pattern)) return ttl;
}
return 3600 * 1e3;
}
function cacheKey(endpoint, body) {
const keyData = JSON.stringify({ endpoint, body }, Object.keys({ endpoint, body }).sort());
return crypto2.createHash("sha256").update(keyData).digest("hex").slice(0, 16);
}
function cachePath(key) {
return path6.join(CACHE_DIR, `${key}.json`);
}
function getCached(key) {
const filePath = cachePath(key);
if (!fs7.existsSync(filePath)) return null;
try {
const raw = fs7.readFileSync(filePath, "utf-8");
const entry = JSON.parse(raw);
const ttl = entry.ttlMs ?? getTtl(entry.endpoint ?? "");
if (ttl <= 0) return null;
if (Date.now() - entry.cachedAt > ttl) {
try {
fs7.unlinkSync(filePath);
} catch {
}
return null;
}
return entry.response;
} catch {
return null;
}
}
function getCachedByRequest(endpoint, body) {
const ttl = getTtl(endpoint);
if (ttl <= 0) return null;
const key = cacheKey(endpoint, body);
return getCached(key);
}
function setCache(key, data, ttlMs) {
if (ttlMs <= 0) return;
try {
fs7.mkdirSync(CACHE_DIR, { recursive: true });
} catch {
}
const entry = {
cachedAt: Date.now(),
response: data,
ttlMs
};
try {
fs7.writeFileSync(cachePath(key), JSON.stringify(entry));
} catch {
}
}
function readableFilename(endpoint, body) {
const now = /* @__PURE__ */ new Date();
const ts = now.toISOString().slice(0, 10) + "_" + String(now.getHours()).padStart(2, "0") + String(now.getMinutes()).padStart(2, "0") + String(now.getSeconds()).padStart(2, "0");
let ep = endpoint.replace(/\/+$/, "").split("/").pop() || "";
if (endpoint.includes("/v1/chat/")) {
ep = "chat";
} else if (endpoint.includes("/v1/search")) {
ep = "search";
} else if (endpoint.includes("/v1/image")) {
ep = "image";
}
let label = body.query || body.username || body.handle || body.model || (typeof body.prompt === "string" ? body.prompt.slice(0, 40) : "") || "";
label = String(label).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40).replace(/^_+|_+$/g, "");
return label ? `${ep}_${ts}_${label}.json` : `${ep}_${ts}.json`;
}
function saveReadable(endpoint, body, response, costUsd) {
try {
fs7.mkdirSync(DATA_DIR, { recursive: true });
} catch {
}
const filename = readableFilename(endpoint, body);
const entry = {
saved_at: (/* @__PURE__ */ new Date()).toISOString(),
endpoint,
cost_usd: costUsd,
request: body,
response
};
try {
fs7.writeFileSync(path6.join(DATA_DIR, filename), JSON.stringify(entry, null, 2));
} catch {
}
}
function appendCostLog(endpoint, costUsd) {
if (costUsd <= 0) return;
try {
fs7.mkdirSync(path6.dirname(COST_LOG_FILE2), { recursive: true });
} catch {
}
const entry = {
ts: Date.now() / 1e3,
endpoint,
cost_usd: costUsd
};
try {
fs7.appendFileSync(COST_LOG_FILE2, JSON.stringify(entry) + "\n");
} catch {
}
}
function saveToCache(endpoint, body, response, costUsd = 0) {
const ttl = getTtl(endpoint);
if (ttl > 0) {
try {
fs7.mkdirSync(CACHE_DIR, { recursive: true });
} catch {
}
const key = cacheKey(endpoint, body);
const entry = {
cachedAt: Date.now(),
endpoint,
body,
response,
costUsd
};
try {
fs7.writeFileSync(cachePath(key), JSON.stringify(entry));
} catch {
}
}
saveReadable(endpoint, body, response, costUsd);
appendCostLog(endpoint, costUsd);
}
function clearCache() {
if (!fs7.existsSync(CACHE_DIR)) return 0;
let count = 0;
try {
const files = fs7.readdirSync(CACHE_DIR);
for (const file of files) {
if (file.endsWith(".json")) {
try {
fs7.unlinkSync(path6.join(CACHE_DIR, file));
count++;
} catch {
}
}
}
} catch {
}
return count;
}
function getCostLogSummary() {
if (!fs7.existsSync(COST_LOG_FILE2)) {
return { totalUsd: 0, calls: 0, byEndpoint: {} };
}
let totalUsd = 0;
let calls = 0;
const byEndpoint = {};
try {
const content = fs7.readFileSync(COST_LOG_FILE2, "utf-8").trim();
if (!content) return { totalUsd: 0, calls: 0, byEndpoint: {} };
for (const line of content.split("\n")) {
if (!line) continue;
try {
const entry = JSON.parse(line);
const cost = entry.cost_usd ?? 0;
const ep = entry.endpoint ?? "unknown";
totalUsd += cost;
calls += 1;
byEndpoint[ep] = (byEndpoint[ep] || 0) + cost;
} catch {
}
}
} catch {
}
return { totalUsd, calls, byEndpoint };
}
// src/setup.ts
var import_node_fs = require("fs");
var import_node_os = require("os");
var import_node_path = require("path");
function setupAgentWallet(options) {
const { address, privateKey, isNew } = getOrCreateWallet();
if (isNew) {
const notice = formatWalletMigrationNotice(address);
if (notice) console.error(notice);
if (!options?.silent) {
console.error(
`
BlockRun Agent Wallet Created!
Address: ${address}
Send USDC on Base to get started.
`
);
}
}
return new LLMClient({ privateKey });
}
async function setupAgentSolanaWallet(options) {
const result = await getOrCreateSolanaWallet();
if (result.isNew) {
const notice = await formatSolanaWalletMigrationNotice(result.address);
if (notice) console.error(notice);
if (!options?.silent) {
console.error(
`
BlockRun Solana Agent Wallet Created!
Address: ${result.address}
Send USDC on Solana to get started.
`
);
}
}
return new SolanaLLMClient({ privateKey: result.privateKey });
}
async function status() {
const client = setupAgentWallet({ silent: true });
const address = client.getWalletAddress();
const balance = await client.getBalance();
console.log(`Wallet: ${address}`);
console.log(`Balance: $${balance.toFixed(2)} USDC`);
return { address, balance };
}
async function setupAgentClient(options = {}) {
if (resolveApiKeyAuth(options)) return new LLMClient({ apiKey: options.apiKey });
let chain = options.chain;
if (!chain) {
for (const file of ["payment-chain", ".chain"]) {
try {
const saved = (0, import_node_fs.readFileSync)((0, import_node_path.join)((0, import_node_os.homedir)(), ".blockrun", file), "utf8").trim();
if (saved === "solana" || saved === "base") {
chain = saved;
break;
}
} catch {
}
}
}
chain ??= !loadSolanaWallet() && loadWallet() ? "base" : "solana";
return chain === "solana" ? setupAgentSolanaWallet(options) : setupAgentWallet(options);
}
// src/openai-compat.ts
var StreamingResponse = class {
reader;
decoder;
buffer = "";
model;
id;
constructor(response, model) {
if (!response.body) {
throw new Error("Response body is null");
}
this.reader = response.body.getReader();
this.decoder = new TextDecoder();
this.model = model;
this.id = `chatcmpl-${Date.now()}`;
}
async *[Symbol.asyncIterator]() {
try {
while (true) {
const { done, value } = await this.reader.read();
if (done) break;
this.buffer += this.decoder.decode(value, { stream: true });
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") return;
try {
const parsed = JSON.parse(data);
yield this.transformChunk(parsed);
} catch {
}
}
}
} finally {
this.reader.releaseLock();
}
}
transformChunk(data) {
const choices = data.choices || [];
return {
id: data.id || this.id,
object: "chat.completion.chunk",
created: data.created || Math.floor(Date.now() / 1e3),
model: data.model || this.model,
choices: choices.map((choice, index) => ({
index: choice.index ?? index,
delta: {
role: choice.delta?.role,
content: choice.delta?.content
},
finish_reason: choice.finish_reason || null
}))
};
}
};
var ChatCompletions = class {
constructor(client, apiUrl, timeout) {
this.client = client;
this.apiUrl = apiUrl;
this.timeout = timeout;
}
client;
apiUrl;
timeout;
async create(params) {
if (params.stream) {
return this.createStream(params);
}
const response = await this.client.chatCompletion(
params.model,
params.messages,
{
maxTokens: params.max_tokens,
temperature: params.temperature,
topP: params.top_p,
tools: params.tools,
toolChoice: params.tool_choice,
responseFormat: params.response_format,
stop: params.stop
}
);
return this.transformResponse(response);
}
async createStream(params) {
const response = await this.client.chatCompletionStream(
params.model,
params.messages,
{
maxTokens: params.max_tokens,
temperature: params.temperature,
topP: params.top_p,
tools: params.tools,
toolChoice: params.tool_choice,
responseFormat: params.response_format,
stop: params.stop
}
);
return new StreamingResponse(response, params.model);
}
transformResponse(response) {
return {
id: response.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: response.created || Math.floor(Date.now() / 1e3),
model: response.model,
choices: response.choices.map((choice, index) => ({
index: choice.index ?? index,
message: {
role: "assistant",
content: choice.message.content,
tool_calls: choice.message.tool_calls
},
finish_reason: choice.finish_reason || "stop"
})),
usage: response.usage,
routing: response.routing
};
}
};
var Chat = class {
completions;
constructor(client, apiUrl, timeout) {
this.completions = new ChatCompletions(client, apiUrl, timeout);
}
};
var OpenAI = class {
chat;
get authMode() {
return this.client.authMode;
}
client;
constructor(options = {}) {
const aliases = [options.walletKey, options.privateKey].filter((k) => k !== void 0);
const privateKey = aliases.find((k) => k !== "") ?? aliases[0];
const apiUrl = resolveApiKeyAuth({ apiKey: options.apiKey, privateKey, apiUrl: options.baseURL })?.apiUrl ?? options.baseURL ?? "https://blockrun.ai/api";
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
this.client = new LLMClient({
privateKey,
apiKey: options.apiKey,
apiUrl,
timeout
});
this.chat = new Chat(this.client, apiUrl, timeout);
}
/**
* Get the wallet address being used for payments.
*/
getWalletAddress() {
return this.client.getWalletAddress();
}
};
// src/anthropic-compat.ts
var import_accounts17 = require("viem/accounts");
var AnthropicClient = class {
_client = null;
_clientPromise = null;
apiAuth;
get authMode() {
return this.apiAuth ? "api-key" : "wallet";
}
walletKey;
get _privateKey() {
return requireWallet(this.walletKey);
}
walletAccount;
get _account() {
return requireWallet(this.walletAccount);
}
_apiUrl;
_timeout;
constructor(options = {}) {
this.apiAuth = resolveApiKeyAuth(options);
if (!this.apiAuth) {
const key = options.privateKey ?? getOrCreateWallet().privateKey;
validatePrivateKey(key);
this.walletKey = key;
this.walletAccount = (0, import_accounts17.privateKeyToAccount)(this.walletKey);
}
const apiUrl = this.apiAuth?.apiUrl ?? options.apiUrl ?? "https://blockrun.ai/api";
validateApiUrl(apiUrl);
this._apiUrl = apiUrl.replace(/\/$/, "");
this._timeout = options.timeout ?? DEFAULT_TIMEOUT;
}
async _getClient() {
if (this._client) return this._client;
if (this._clientPromise) return this._clientPromise;
this._clientPromise = (async () => {
const { default: Anthropic } = await import("@anthropic-ai/sdk");
this._client = new Anthropic({
baseURL: this._apiUrl,
apiKey: "blockrun",
fetch: this._x402Fetch.bind(this),
// Never let the Anthropic SDK retry. It retries 408/409/429/5xx and
// connection errors by calling this fetch again, which in wallet mode
// runs the whole x402 flow and signs a NEW payment each time (three
// payments for one call at the default maxRetries of 2); in account
// mode the POST itself may already be billed.
maxRetries: 0
});
return this._client;
})();
return this._clientPromise;
}
async _x402Fetch(input, init) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this._timeout);
const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
const onAbort = () => controller.abort(callerSignal?.reason);
callerSignal?.addEventListener("abort", onAbort, { once: true });
if (callerSignal?.aborted) onAbort();
try {
const mergedInit = { ...init, signal: controller.signal };
if (this.apiAuth) return await this.apiAuth.fetch(input, mergedInit, false);
let response = await globalThis.fetch(input, mergedInit);
if (response.status === 402) {
let paymentHeader = response.headers.get("payment-required");
if (!paymentHeader) {
try {
const respBody = await response.json();
if (respBody.x402 || respBody.accepts) {
paymentHeader = btoa(JSON.stringify(respBody));
}
} catch {
}
}
if (!paymentHeader) {
throw new Error("402 response but no payment requirements found");
}
const paymentRequired = parsePaymentRequired(paymentHeader);
const details = extractPaymentDetails(paymentRequired);
const extensions = paymentRequired.extensions;
const paymentPayload = await createPaymentPayload(
this._privateKey,
this._account.address,
details.recipient,
details.amount,
details.network || "eip155:8453",
{
resourceUrl: validateResourceUrl(
details.resource?.url || `${this._apiUrl}/v1/messages`,
this._apiUrl
),
resourceDescription: details.resource?.description || "BlockRun AI API call",
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
extra: details.extra,
asset: details.asset,
extensions
}
);
const newHeaders = new Headers(init?.headers);
newHeaders.set("PAYMENT-SIGNATURE", paymentPayload);
response = await globalThis.fetch(input, {
...init,
headers: newHeaders,
signal: controller.signal
});
if (response.status === 402) {
throw new Error(
"Payment was rejected. Check your wallet balance."
);
}
}
return response;
} finally {
callerSignal?.removeEventListener("abort", onAbort);
clearTimeout(timeoutId);
}
}
get messages() {
const handler = {
get: (_target, prop) => {
return async (...args) => {
const client = await this._getClient();
const messages = client.messages;
return messages[prop](...args);
};
}
};
return new Proxy({}, handler);
}
getWalletAddress() {
return this._account.address;
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
APIError,
API_KEY_URL,
ARC_CHAIN_ID,
AnthropicClient,
BASE_CHAIN_ID,
BLOCKRUN_SOL_OPERATOR,
BatchChannelLockedError,
BatchCloseDeferredError,
BatchPaymentUnresolvedError,
BlockrunClient,
BlockrunError,
DEFAULT_BATCH_IN_DOUBT,
DEFAULT_BATCH_LOCK_WAIT_MS,
DEFAULT_BATCH_RATE_LIMIT,
EVM_NETWORKS,
ImageClient,
KNOWN_PROVIDERS,
LLMClient,
MusicClient,
NETWORK_ALIASES,
OpenAI,
PHONE_PRICES,
PORTAL_URL,
PORTRAIT_ENROLLMENT_PRICE_USD,
PaymentError,
PhoneClient,
PortraitClient,
PriceClient,
RPC_PRICE_USD,
RetiredEndpointError,
RpcClient,
SOLANA_NETWORK,
SOLANA_WALLET_FILE_PATH,
SUPPORTED_NETWORKS,
SearchClient,
SolanaBatchPayer,
SolanaLLMClient,
SpeechClient,
SurfClient,
USDC_ARC,
USDC_BASE,
USDC_BASE_CONTRACT,
USDC_SOLANA,
VideoClient,
VoiceClient,
WALLET_DIR_PATH,
WALLET_FILE_PATH,
clearCache,
createPaymentPayload,
createSolanaPaymentPayload,
createSolanaWallet,
createWallet,
evmNetwork,
extractPaymentDetails,
formatFundingMessageCompact,
formatNeedsFundingMessage,
formatSolanaWalletMigrationNotice,
formatWalletCreatedMessage,
formatWalletMigrationNotice,
getCached,
getCachedByRequest,
getCostLogSummary,
getCostSummary,
getEip681Uri,
getOrCreateSolanaWallet,
getOrCreateWallet,
getPaymentLinks,
getWalletAddress,
importSolanaWallet,
importWallet,
listDiscoveredSolanaWallets,
listDiscoveredWallets,
loadSolanaWallet,
loadWallet,
logCost,
parsePaymentRequired,
readSettlement,
retryDisposition,
saveSolanaWallet,
saveToCache,
saveWallet,
scanSolanaWallets,
scanWallets,
setCache,
setupAgentClient,
setupAgentSolanaWallet,
setupAgentWallet,
solanaClient,
solanaKeyToBytes,
solanaPublicKey,
status,
toWireSearchParameters,
toWireTools,
validateMaxTokens,
validateModel,
validateTemperature,
validateTopP
});