@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,737 lines • 538 kB
JavaScript
// 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
import { privateKeyToAccount } from "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
import { signTypedData } from "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 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
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
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 = 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
import { privateKeyToAccount as privateKeyToAccount2 } from "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 = privateKeyToAccount2(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
import { privateKeyToAccount as privateKeyToAccount3 } from "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 = privateKeyToAccount3(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
import { privateKeyToAccount as privateKeyToAccount4 } from "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 = privateKeyToAccount4(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
import { privateKeyToAccount as privateKeyToAccount5 } from "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 = privateKeyToAccount5(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
import { privateKeyToAccount as privateKeyToAccount6 } from "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 = privateKeyToAccount6(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
import { privateKeyToAccount as privateKeyToAccount7 } from "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 = privateKeyToAccount7(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
import { privateKeyToAccount as privateKeyToAccount8 } from "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 = privateKeyToAccount8(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
import { privateKeyToAccount as privateKeyToAccount9 } from "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 = privateKeyToAccount9(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
import { privateKeyToAccount as privateKeyToAccount10 } from "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 = privateKeyToAccount10(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
import { privateKeyToAccount as privateKeyToAccount11 } from "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 = privateKeyToAccount11(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
import { privateKeyToAccount as privateKeyToAccount12 } from "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 = privateKeyToAccount12(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
import { privateKeyToAccount as privateKeyToAccount13 } from "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 = privateKeyToAccount13(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
import { privateKeyToAccount as privateKeyToAccount15, generatePrivateKey as generatePrivateKey2 } from "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
import * as fs2 from "fs";
import * as os2 from "os";
import * as path2 from "path";
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
import * as fs3 from "fs";
import * as path3 from "path";
import { privateKeyToAccount as privateKeyToAccount14, generatePrivateKey } from "viem/accounts";
function normalize(raw) {
const k = raw.trim();
return k.startsWith("0x") ? k : `0x${k}`;
}
function addressFromKey(raw) {
try {
return privateKeyToAccount14(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
import * as fs4 from "fs";
var USDC_BASE_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
var BASE_CHAIN_ID2 = "8453";
function walletDir() {
return paths().dir;
}
function walletFile() {
return paths().session;
}
function createWallet() {
const privateKey = generatePrivateKey2();
const account = privateKeyToAccount15(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 = privateKeyToAccount15(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 ? privateKeyToAccount15(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
import * as fs5 from "fs";
import * as path4 from "path";
import * as os3 from "os";
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
import { createPrivateKey, createPublicKey, sign as ed25519Sign } from "crypto";
import bs58 from "bs58";
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 = createPrivateKey({
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
format: "der",
type: "pkcs8"
});
const jwk = 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 = ed25519Sign(null, Buffer.from(pollAuthMessage(kind, jobId, timestampS), "utf8"), privateKey);
return {
[POLL_AUTH_HEADERS.wallet]: bs58.encode(publicKey),
[POLL_AUTH_HEADERS.timestamp]: String(timestampS),
[POLL_AUTH_HEADERS.signature]: bs58.encode(signature)
};
}
// src/solana-batch.ts
import { AsyncLocalStorage } from "async_hooks";
import { randomUUID } from "crypto";
import * as fs6 from "fs";
import * as path5 from "path";
import bs582 from "bs58";
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}.${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 = 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) => bs582.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_${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 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 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
import * as fs7 from "fs";
import * as path6 from "path";
import * as os4 from "os";
import * as crypto2 from "crypto";
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
import { readFileSync as readFileSync7 } from "fs";
import { homedir as homedir5 } from "os";
import { join as join7 } from "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 = readFileSync7(join7(homedir5(), ".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
import { privateKeyToAccount as privateKeyToAccount16 } from "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 = privateKeyToAccount16(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;
}
};
export {
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 as 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,
client_default as default,
evmNetwork,
extractPaymentDetails,
formatFundingMessageCompact,
formatNeedsFundingMessage,
formatSolanaWalletMigrationNotice,
formatWalletCreatedMessage,
formatWalletMigrationNotice,
getCached,
getCachedByRequest,
getCostLogSummary,
getCostSummary,
getEip681Uri,
getOrCreateSolanaWallet,
getOrCreateWallet,
getPaymentLinks,
getWalletAddress,
importSolanaWallet,
importWallet,
listDiscoveredSolanaWallets,
listDiscoveredWallets2 as listDiscoveredWallets,
loadSolanaWallet,
loadWallet,
logCost,
parsePaymentRequired,
readSettlement,
retryDisposition,
saveSolanaWallet,
saveToCache,
saveWallet,
scanSolanaWallets,
scanWallets2 as scanWallets,
setCache,
setupAgentClient,
setupAgentSolanaWallet,
setupAgentWallet,
solanaClient,
solanaKeyToBytes,
solanaPublicKey,
status,
toWireSearchParameters,
toWireTools,
validateMaxTokens,
validateModel,
validateTemperature,
validateTopP
};