diff --git a/README.md b/README.md index 72dbd7c..45a454f 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # Franklin Trading -**The AI trading agent with a wallet.** +**The AI trading agent with account API and wallet support.** -Researches, debates, paper-trades against real prices, and settles every paid call in USDC. +Researches, debates, and paper-trades against real prices with account API or x402 billing. Risk limits live in code, not in the prompt. Every fill has a receipt. -Fund the wallet. Set a budget. Walk away — and come back to a book. +Set a budget. Connect a transaction wallet for live trades. Walk away — and come back to a book. [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/) @@ -17,7 +17,7 @@ Fund the wallet. Set a budget. Walk away — and come back to a book. > Franklin Trading is a fork of [Franklin](https://github.com/BlockRunAI/Franklin) — the > general-purpose Autonomous Economic Agent — specialized as a wallet-native trading -> agent. It inherits Franklin's economic substrate (x402 micropayments, USDC settlement, +> agent. It inherits Franklin's economic substrate (account API access, x402 micropayments, > the shared [Router Core](https://github.com/BlockRunAI/router-core) engine across > 76 models, removable-by-design harness components) > and adds a deterministic fee-aware risk engine, a wallet-bound trade journal, a @@ -26,11 +26,11 @@ Fund the wallet. Set a budget. Walk away — and come back to a book. ## What works today This section is the honest one. Everything in it ships in `@blockrun/franklin-trading` 0.3.0 -and is covered by the local test suite (386 tests, no network). +and is covered by the local test suite (392 tests, no network). | Capability | Status | Where | |---|---|---| -| USDC wallet on Base or Solana, x402 pay-per-call to every model and paid API | ✅ shipped | `src/wallet/`, `@blockrun/llm` | +| Account API key or USDC wallet on Solana / Base for every model and paid API | ✅ shipped | `src/payments/`, `src/wallet/`, `@blockrun/llm` | | Auto model routing on the shared Router Core engine, 76 models, dead-model kill-switch | ✅ shipped | `src/router/` | | Paper trading against **live** CoinGecko marks (real P&L, simulated fills) | ✅ shipped | `src/trading/live-exchange.ts` | | Deterministic risk engine: cash **including exchange fee**, per-position cap, total exposure cap, sell integrity | ✅ shipped | `src/trading/risk.ts` | @@ -59,6 +59,30 @@ persistent memory, TradingAgents-style hierarchical persona debate, Hummingbot-s execution rigor — wrapped in Franklin's wallet-native economic substrate, with the one thing none of them do: **the money is real from day one**, so the guardrails had to be too. +## Account API key + +Register at [user.blockrun.ai](https://user.blockrun.ai), create an +[API key](https://user.blockrun.ai/dashboard/keys), and add +[credits](https://user.blockrun.ai/dashboard/credits). + +```bash +export BLOCKRUN_API_KEY="brk_live_..." +franklin-trading +``` + +The account endpoint defaults to `https://api.blockrun.ai`; set +`BLOCKRUN_API_BASE_URL` only when using another trusted BlockRun deployment. +API mode covers the agent, subagents, local proxy, model catalog, Exa, +prediction markets, DeFiLlama, RPC and BlockRun market data. A 401 points to +the key dashboard; a 402 points to account credit top-up. Franklin Trading +does not fall back to a wallet payment after either response. + +The API key pays for BlockRun services. It cannot sign an exchange order or +an on-chain transaction. Paper trading needs no transaction wallet; live +trading still requires a separate Solana or Base wallet. `setup`, `balance` +and the Wallet tool continue to manage and report that transaction wallet. +Never put either credential in source control. + ## Risk lives outside the model [Conviction #5](docs/CONVICTIONS.md): the LLM is never the last line of defense. Every @@ -93,20 +117,30 @@ validated on load (an `Infinity` balance would otherwise disarm every cap). ```bash npm install -g @blockrun/franklin-trading -# 1. Run — free out of the box (nvidia/nemotron-nano-9b-v2, no wallet needed) +# Option A: use account credits for models, research and market data +export BLOCKRUN_API_KEY="brk_live_..." franklin-trading -# 2. Create a USDC wallet on Base (or solana) to unlock every paid model + API -franklin-trading setup base +# Add a separate transaction wallet only when you are ready for live trades. +# Solana is the default; Base remains available explicitly. +franklin-trading setup solana +# franklin-trading setup base + +# Option B: unset the key and use x402 wallet billing for BlockRun calls +unset BLOCKRUN_API_KEY +franklin-trading setup solana -# 3. Fund it with $5+ USDC — print the address with: +# Print the active transaction wallet and its USDC balance franklin-trading balance -# 4. Start with a budget — Franklin Trading stops when the cap is hit +# Franklin Trading stops when the local session estimate reaches the cap franklin-trading --max-spend 5 ``` -Zero signup, zero API keys, zero card. The wallet is the identity. +Account API usage is recorded in the +[account dashboard](https://user.blockrun.ai/dashboard). In API mode, local +cost totals and `--max-spend` are estimates; the dashboard ledger is +authoritative. Unset `BLOCKRUN_API_KEY` to return to x402 wallet billing. ## A 60-second tour @@ -206,7 +240,7 @@ call in USDC. No free alias ever falls back to a paid model. │ Execution: LiveExchange (paper, live marks) today · Hyperliquid · Jupiter · 0x · Polymarket (M4–M5) │ - Economic substrate (inherited): USDC wallet on Base + Solana, x402 micropayments + Economic substrate: account API · x402 USDC on Solana + Base · separate trade signing ``` See [`PHILOSOPHY.md`](PHILOSOPHY.md) for the design principles, @@ -324,8 +358,8 @@ franklin-trading run btc-funding-basis --mode live # real on-chain orders ```bash npm install npm run build # tsc + copy bundled skills -npm test # 386 local tests, no network, no wallet -npm run test:e2e # hits real models — needs a funded wallet +npm test # local tests, no network or funded wallet +npm run test:e2e # real models — needs BLOCKRUN_API_KEY or a funded x402 wallet ``` Upstream sync: model catalog, router and pricing changes land in diff --git a/package.json b/package.json index 10499e3..bb58226 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "build": "tsc && node scripts/copy-plugin-assets.mjs", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs", + "test": "npm run build && node --test --test-reporter=spec test/local.mjs test/skills.local.mjs test/api-key.local.mjs", "test:e2e": "npm run build && node --test --test-reporter=spec test/e2e.mjs", + "test:api-key:e2e": "npm run build && node --test --test-reporter=spec test/api-key.e2e.mjs", "test:strategies": "npm run build && node --test --test-reporter=spec test/strategies.mjs", "test:free-models": "npm run build && node --test --test-reporter=spec test/free-model-matrix.mjs", "test:all": "npm run test && npm run test:strategies && npm run test:e2e", diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 36e8337..4b9df04 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -1,3 +1,4 @@ +import { accountMode, ACCOUNT_PORTAL } from '../payments/account.js'; /** * Slash command registry for Franklin. * Extracted from loop.ts for maintainability. @@ -933,6 +934,7 @@ export async function handleSlashCommand( } catch { balance = '(unavailable)'; } } ctx.onEvent({ kind: 'text_delta', text: + (accountMode() ? `Account API billing: ${ACCOUNT_PORTAL}/dashboard\nTransaction wallet:\n` : '') + `**Wallet**\n` + ` Chain: ${chain}\n` + ` Address: ${address}\n` + diff --git a/src/agent/context.ts b/src/agent/context.ts index 7420982..3bd0a13 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -1,3 +1,4 @@ +import { accountMode, ACCOUNT_PORTAL } from '../payments/account.js'; /** * Context Manager for Franklin * Assembles system instructions, reads project config, injects environment info. @@ -181,17 +182,19 @@ Do NOT check access before acting. Do NOT explain what you tried. Just deliver, } function getWalletKnowledgeSection(): string { + if (accountMode()) return `# BlockRun account billing +Model, media, search and data requests use the configured API key. No payment wallet is needed for those calls. Account balance and usage: ${ACCOUNT_PORTAL}/dashboard; top up: ${ACCOUNT_PORTAL}/dashboard/credits. Never inspect or print BLOCKRUN_API_KEY. Actual on-chain trades and transactions still require a separately configured transaction wallet. Local model cost totals are estimates, not the account ledger.`; return `# Wallet Storage (answer "where is my wallet" directly — no searching) Franklin Trading stores wallet keys in ~/.blockrun/. When the user asks about wallet location, answer from this map — do not grep or scan. -- Base / EVM wallet (the primary wallet shown in Franklin's startup banner): - Private key file: ~/.blockrun/.session - Format: 66-char hex string starting with 0x (file name intentionally looks like a session token for obscurity) - Address: derivable from the key; also available via getWalletAddress() from @blockrun/llm - Solana wallet: Private key file: ~/.blockrun/.solana-session Format: bare base58 secret key (file name mirrors the Base wallet's obscurity convention; mode 600) Address: derivable; available via getOrCreateSolanaWallet() from @blockrun/llm +- Base / EVM wallet: + Private key file: ~/.blockrun/.session + Format: 66-char hex string starting with 0x (file name intentionally looks like a session token for obscurity) + Address: derivable from the key; also available via getWalletAddress() from @blockrun/llm - Chain selection: ~/.blockrun/payment-chain ("base" or "solana"). Legacy file ~/.blockrun/.chain may also exist on installs that haven't migrated; canonical is payment-chain. - Spending data: - ~/.blockrun/franklin-stats.json — rolling totals + per-model breakdown (what \`franklin stats\` reads). @@ -200,7 +203,7 @@ Franklin Trading stores wallet keys in ~/.blockrun/. When the user asks about wa - Use \`franklin stats\` / \`franklin content list\` instead of parsing files when the user asks "how much did I spend". - Programmatic access: import { getWalletAddress, getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm' -When the user asks about "my wallet" without qualifier, default to Base (it's the primary chain shown at launch). Only mention Solana if the chain file says solana or the user explicitly asks. +When the user asks about "my wallet", use the saved active chain. New users default to Solana; preserve existing Base selections. ## Funding the wallet ("how do I deposit / recharge / fund / top up", in any language) @@ -221,8 +224,8 @@ function getBlockRunApiSection(): string { You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun API", "check all endpoints", or call the gateway directly, use ONLY the paths below. **Never invent, pluralize, or singularize an endpoint** — \`/v1/image/generate\` (singular) is wrong, \`/v1/images/generations\` (plural) is correct. If a path you have in mind isn't in this list, fetch the canonical discovery endpoints before calling it. **Base URLs** -- Base chain: \`https://blockrun.ai/api\` (alias: \`https://api.blockrun.ai\`) - Solana chain: \`https://sol.blockrun.ai/api\` +- Base chain: \`https://blockrun.ai/api\` (alias: \`https://api.blockrun.ai\`) **Discovery (always free, GET) — fetch these BEFORE guessing a path** - \`GET /openapi.json\` (or \`/.well-known/openapi.json\`) — full OpenAPI 3.1 contract, every route + request schema diff --git a/src/agent/error-classifier.ts b/src/agent/error-classifier.ts index 7bac673..081b7a4 100644 --- a/src/agent/error-classifier.ts +++ b/src/agent/error-classifier.ts @@ -1,3 +1,4 @@ +import { ACCOUNT_PORTAL } from '../payments/account.js'; /** * Classify model/runtime errors so recovery and UX can be more consistent. * @@ -52,6 +53,7 @@ function includesAny(text: string, patterns: string[]): boolean { export function classifyAgentError(message: string): AgentErrorInfo { const err = message.toLowerCase(); + if (err.includes("account credits exhausted")) return { category: "payment", label: "Payment", isTransient: false, maxRetries: 0, suggestion: `Top up at ${ACCOUNT_PORTAL}/dashboard/credits.` }; // Extract Retry-After hint that streaming-client appended (see llm.ts // 429 path). Surfaces on the AgentErrorInfo so the loop can honor the diff --git a/src/agent/intent-prefetch.ts b/src/agent/intent-prefetch.ts index 881d19f..27743e6 100644 --- a/src/agent/intent-prefetch.ts +++ b/src/agent/intent-prefetch.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../payments/account.js'; /** * Proactive prefetch for live-world questions. * @@ -175,7 +176,7 @@ async function exaAnswerTry(query: string, client: ModelClient): Promise { const out: Check[] = []; + if (accountMode()) { + try { + validateAccountConfig(); + out.push({ name: 'API authentication', status: 'ok', detail: `configured · ${ACCOUNT_PORTAL}/dashboard` }); + } catch (err) { + out.push({ + name: 'API authentication', + status: 'fail', + detail: (err as Error).message, + remedy: `Create a key at ${ACCOUNT_PORTAL}/dashboard/keys`, + }); + } + } + // Kick off the authoritative version fetch FIRST, in parallel with the // other checks. Doctor is a diagnostic — the user just asked "am I // healthy?" — so a 24h-stale cache is the wrong answer. The fetch is @@ -100,7 +121,7 @@ async function runChecks(): Promise { name: 'Chain', status: 'fail', detail: `failed to load — ${(err as Error).message}`, - remedy: 'Run: franklin setup base (or: franklin setup solana)', + remedy: 'Run: franklin-trading setup solana (or: franklin-trading setup base)', }); } @@ -119,7 +140,7 @@ async function runChecks(): Promise { walletBalance = await client.getBalance(); } out.push({ - name: 'Wallet', + name: accountMode() ? 'Transaction wallet' : 'Wallet', status: 'ok', detail: `${walletAddress.slice(0, 10)}…${walletAddress.slice(-6)}`, }); @@ -143,7 +164,7 @@ async function runChecks(): Promise { ? `Send USDC on ${chain} to ${walletAddress} (or open http://localhost:3100/#wallet)` : undefined; out.push({ - name: 'USDC balance', + name: accountMode() ? 'Transaction wallet USDC' : 'USDC balance', status: balanceStatus, detail: balanceDetail, remedy: balanceRemedy, @@ -151,12 +172,12 @@ async function runChecks(): Promise { } catch (err) { const msg = (err as Error).message || ''; out.push({ - name: 'Wallet', - status: 'fail', + name: accountMode() ? 'Transaction wallet' : 'Wallet', + status: accountMode() ? 'warn' : 'fail', detail: `error — ${msg.slice(0, 120)}`, remedy: msg.includes('ENOENT') || msg.includes('wallet') || msg.includes('key') - ? 'Run: franklin setup' + ? 'Run: franklin-trading setup before live trading' : 'Check network / wallet file permissions', }); } @@ -164,15 +185,17 @@ async function runChecks(): Promise { // ── 6. Gateway reachability ─────────────────────────────────────── if (chain) { - const apiUrl = API_URLS[chain]; + const apiUrl = accountMode() ? accountBaseURL() : API_URLS[chain]; try { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 5000); - const res = await fetch(`${apiUrl}/health`, { signal: ctl.signal }).catch(() => null); + const res = accountMode() + ? await gatewayFetch(`${apiUrl}/v1/models`, { signal: ctl.signal }).catch(() => null) + : await fetch(`${apiUrl}/health`, { signal: ctl.signal }).catch(() => null); clearTimeout(t); if (res && res.ok) { out.push({ - name: 'Gateway', + name: accountMode() ? 'Account API' : 'Gateway', status: 'ok', detail: apiUrl, }); @@ -181,16 +204,16 @@ async function runChecks(): Promise { // don't expose /health but the API is up. const ctl2 = new AbortController(); const t2 = setTimeout(() => ctl2.abort(), 5000); - const res2 = await fetch(`${apiUrl}/v1/messages`, { + const res2 = accountMode() ? null : await fetch(`${apiUrl}/v1/messages`, { method: 'HEAD', signal: ctl2.signal, }).catch(() => null); clearTimeout(t2); out.push({ - name: 'Gateway', + name: accountMode() ? 'Account API' : 'Gateway', status: res2 ? 'ok' : 'fail', - detail: res2 ? apiUrl : `unreachable: ${apiUrl}`, - remedy: res2 ? undefined : 'Check network or try the other chain', + detail: res2 ? apiUrl : `unreachable or unauthorized: ${apiUrl}`, + remedy: res2 ? undefined : (accountMode() ? `Check your key at ${ACCOUNT_PORTAL}/dashboard/keys` : 'Check network or try the other chain'), }); } } catch (err) { diff --git a/src/commands/proxy.ts b/src/commands/proxy.ts index a899fa6..e70bcc6 100644 --- a/src/commands/proxy.ts +++ b/src/commands/proxy.ts @@ -1,3 +1,4 @@ +import { accountMode, ACCOUNT_PORTAL, validateAccountConfig, accountBaseURL } from '../payments/account.js'; /** * Proxy-only mode — runs the BlockRun payment proxy for Anthropic-compatible CLI agents. * The proxy translates requests and handles x402 payments so any compatible client can use any model. @@ -33,6 +34,13 @@ export async function proxyCommand(options: ProxyOptions) { const model = options.model || config['default-model']; + if (accountMode()) { + validateAccountConfig(); + console.log(`Account API proxy: http://localhost:${port} — ${ACCOUNT_PORTAL}/dashboard`); + launchProxy(createProxy({ port, apiUrl: accountBaseURL(), chain, modelOverride: model, debug: options.debug, fallbackEnabled: false }), port, options.debug); + return; + } + if (chain === 'solana') { const wallet = await getOrCreateSolanaWallet(); if (wallet.isNew) { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 886a121..f84ea7e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,3 +1,4 @@ +import { accountMode, ACCOUNT_PORTAL, validateAccountConfig } from '../payments/account.js'; import chalk from 'chalk'; import { getOrCreateWallet, @@ -8,6 +9,11 @@ import { import { type Chain, saveChain } from '../config.js'; export async function setupCommand(chainArg?: string) { + if (accountMode()) { + validateAccountConfig(); + console.log(chalk.cyan(`Account API billing configured: ${ACCOUNT_PORTAL}/dashboard`)); + console.log(chalk.dim('Setting up the separate transaction wallet used for on-chain trades.\n')); + } // Solana is the default chain; `franklin setup base` opts into Base. const chain: Chain = chainArg === 'base' ? 'base' : 'solana'; diff --git a/src/commands/start.ts b/src/commands/start.ts index 2f5df48..c5b7e0e 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -1,3 +1,4 @@ +import { accountMode, ACCOUNT_PORTAL, validateAccountConfig } from '../payments/account.js'; import chalk from 'chalk'; import fs from 'node:fs'; import path from 'node:path'; @@ -228,9 +229,13 @@ export async function startCommand(options: StartOptions) { console.log(chalk.dim(` Switch to free with: /model free\n`)); } - // Auto-create wallet if needed (no interruption — free models work without funding) + // Account billing does not create a payment wallet. Trading tools still ask + // for a transaction wallet when the user executes an on-chain action. + validateAccountConfig(); let walletAddress = ''; - if (chain === 'solana') { + if (accountMode()) { + console.log(chalk.cyan(` Account API key • ${ACCOUNT_PORTAL}/dashboard`)); + } else if (chain === 'solana') { const wallet = await getOrCreateSolanaWallet(); walletAddress = wallet.address; if (wallet.isNew) { @@ -260,7 +265,7 @@ export async function startCommand(options: StartOptions) { // Session info — aligned, minimal. Model + balance live in the input bar below. // Full wallet address is shown so the user can copy-paste it to fund the wallet. - console.log(chalk.dim(' Wallet: ') + (walletAddress || chalk.yellow('not set'))); + console.log(chalk.dim(accountMode() ? ' Account: ' : ' Wallet: ') + (accountMode() ? ACCOUNT_PORTAL : walletAddress || chalk.yellow('not set'))); console.log(chalk.dim(' Dir: ') + workDir); console.log(chalk.dim(' Help: ') + chalk.cyan('/help')); console.log(''); @@ -273,6 +278,7 @@ export async function startCommand(options: StartOptions) { // is provably non-empty. retryFetchBalance does one extra round-trip on a // zero result; genuinely empty wallets still resolve to $0.00 quickly. const fetchBalance = async (): Promise => { + if (accountMode()) return "Account credits · user.blockrun.ai"; try { const bal = await retryFetchBalance(async () => { if (chain === 'solana') { @@ -293,8 +299,8 @@ export async function startCommand(options: StartOptions) { // Fetch balance in background (don't block startup) const walletInfo: { address: string; balance: string; chain: string } = { address: walletAddress, - balance: 'checking...', - chain, + balance: accountMode() ? 'Account credits · user.blockrun.ai' : 'checking...', + chain: accountMode() ? 'account' : chain, }; // Balance fetch callback — will update Ink UI once resolved let onBalanceFetched: ((bal: string) => void) | undefined; diff --git a/src/config.ts b/src/config.ts index 5548e08..1f52b62 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,8 +20,8 @@ export const BLOCKRUN_DIR = path.join(os.homedir(), '.blockrun'); export const CHAIN_FILE = path.join(BLOCKRUN_DIR, 'payment-chain'); export const API_URLS: Record = { - base: 'https://blockrun.ai/api', solana: 'https://sol.blockrun.ai/api', + base: 'https://blockrun.ai/api', }; export const DEFAULT_PROXY_PORT = 8402; diff --git a/src/gateway-models.ts b/src/gateway-models.ts index ed298a4..4979b8c 100644 --- a/src/gateway-models.ts +++ b/src/gateway-models.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch } from './payments/account.js'; /** * Dynamic model catalog from BlockRun Gateway. * diff --git a/src/index.ts b/src/index.ts index 5f95c83..80ff738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,16 +36,16 @@ const program = new Command(); program .name('franklin-trading') .description( - 'Franklin Trading — The AI trading agent with a wallet.\n\n' + + 'Franklin Trading — The AI trading agent with account API and wallet support.\n\n' + 'Researches, debates, backtests, paper-trades and live-trades autonomously.\n' + - 'Every decision is a multi-persona debate; every fill has an on-chain x402 USDC receipt.\n\n' + - 'Fund your wallet. Set a budget. Walk away — and come back to a book.' + 'API keys pay for AI and data; transaction wallets sign live trades.\n\n' + + 'Set a budget. Walk away — and come back to a book.' ) .version(version); program .command('setup [chain]') - .description('Create a new wallet for payments (base or solana)') + .description('Create a transaction wallet for live trades (solana or base)') .action((chain) => setupCommand(chain)); program @@ -80,7 +80,7 @@ program program .command('proxy') - .description('Run payment proxy for Anthropic-compatible CLI agents') + .description('Run an API-key or x402 proxy for Anthropic-compatible CLI agents') .option('-p, --port ', 'Proxy port', '8402') .option( '-m, --model ', diff --git a/src/payments/account.ts b/src/payments/account.ts new file mode 100644 index 0000000..6893e80 --- /dev/null +++ b/src/payments/account.ts @@ -0,0 +1,80 @@ +/** Shared account authentication for Franklin's direct gateway requests. */ +export const ACCOUNT_PORTAL = 'https://user.blockrun.ai'; +export function accountMode(): boolean { return process.env.BLOCKRUN_API_KEY !== undefined; } +export function accountBaseURL(): string { + const raw = (process.env.BLOCKRUN_API_BASE_URL || 'https://api.blockrun.ai').replace(/\/+$/, '').replace(/\/v1$/, ''); + const url = new URL(raw); + if (url.username || url.password || url.search || url.hash || + (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) { + throw new Error('BLOCKRUN_API_BASE_URL requires HTTPS (except localhost) and no credentials, query or fragment.'); + } + return raw; +} +export function accountStatus() { + return { authMode: 'api-key', address: '', chain: 'account', balance: null, balanceUsd: undefined, portalUrl: ACCOUNT_PORTAL, creditsUrl: `${ACCOUNT_PORTAL}/dashboard/credits` }; +} + +function key(): string { + const value = process.env.BLOCKRUN_API_KEY?.trim() || ''; + if (!/^brk_[A-Za-z0-9_-]+$/.test(value)) throw new Error(`Invalid BLOCKRUN_API_KEY. Create a key at ${ACCOUNT_PORTAL}/dashboard/keys.`); + return value; +} +export function validateAccountConfig(): void { if (accountMode()) { key(); accountBaseURL(); } } + +function accountRequestURL(input: string | URL | Request): URL { + const base = new URL(accountBaseURL()); + const source = new URL(input instanceof Request ? input.url : String(input), `${base}/`); + const gateways = new Set(['https://blockrun.ai', 'https://sol.blockrun.ai', 'https://api.blockrun.ai', base.origin]); + if (!gateways.has(source.origin) || source.username || source.password) throw new Error('Refusing to forward an account key to an unknown gateway or polling origin.'); + source.protocol = base.protocol; source.host = base.host; + source.pathname = source.pathname.replace(/^\/api\/v1\//, '/v1/'); + return source; +} + +// Caller receives the actual HTTP status, so existing stream parsers and proxy +// clients keep their error semantics. No x402 branch may run in account mode. +export async function gatewayFetch(input: string | URL | Request, init?: RequestInit): Promise { + if (!accountMode()) return globalThis.fetch(input, init); + const credential = key(); + const url = accountRequestURL(input); + const request = input instanceof Request ? input : undefined; + const headers = new Headers(init?.headers ?? request?.headers); + for (const name of [...headers.keys()]) if (/payment/i.test(name) || /^(x-api-key|authorization)$/i.test(name)) headers.delete(name); + headers.set('authorization', `Bearer ${credential}`); + const response = await globalThis.fetch(request ? new Request(url, request) : url, { ...init, headers, redirect: 'error' }); + if (response.ok) return response; + let body: unknown; + try { body = await response.json(); } catch { body = {}; } + const outer = body && typeof body === 'object' ? body as Record : {}; + const detail = outer.error && typeof outer.error === 'object' ? outer.error as Record : outer; + const safe = Object.fromEntries(['message', 'code', 'type', 'param'].flatMap(name => typeof detail[name] === 'string' ? [[name, (detail[name] as string).split(credential).join('[REDACTED]')]] : [])); + if (response.status === 402) safe.message = `BlockRun account credits exhausted (402). Top up at ${ACCOUNT_PORTAL}/dashboard/credits.`; + if (response.status === 401) safe.message = `BlockRun account authentication failed (401). Check your key at ${ACCOUNT_PORTAL}/dashboard/keys.`; + const safeHeaders = new Headers({ 'content-type': 'application/json' }); + const retryAfter = response.headers.get('retry-after'); if (retryAfter) safeHeaders.set('retry-after', retryAfter); + return new Response(JSON.stringify({ error: safe }), { status: response.status, headers: safeHeaders }); +} + +/** Poll direct API jobs with an abortable deadline; never resubmit a job. */ +export async function pollAccountJob(response: Response, signal?: AbortSignal, intervalMs = 2000): Promise { + if (!accountMode() || response.status !== 202) return response; + const initial = await response.clone().json() as { poll_url?: string }; + if (!initial.poll_url) throw new Error('Account async response missing poll_url'); + const endpoint = accountRequestURL(initial.poll_url); + const deadline = AbortSignal.timeout(15 * 60_000); + const abort = signal ? AbortSignal.any([signal, deadline]) : deadline; + while (!abort.aborted) { + await new Promise((resolve, reject) => { + const onAbort = () => { clearTimeout(timer); reject(abort.reason); }; + const timer = setTimeout(() => { abort.removeEventListener('abort', onAbort); resolve(); }, intervalMs); + abort.addEventListener('abort', onAbort, { once: true }); + if (abort.aborted) onAbort(); + }); + const polled = await gatewayFetch(endpoint, { signal: abort }); + if (!polled.ok) return polled; + const data = await polled.clone().json() as { status?: string }; + if (data.status === 'completed') return polled; + if (['failed', 'cancelled', 'canceled'].includes(data.status || '')) throw new Error('Account job failed or was cancelled'); + } + throw new Error('Account job polling stopped; check job before resubmitting'); +} diff --git a/src/proxy/fallback.ts b/src/proxy/fallback.ts index a0f3d78..1692117 100644 --- a/src/proxy/fallback.ts +++ b/src/proxy/fallback.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch } from '../payments/account.js'; /** * Fallback chain for Franklin * Automatically switches to backup models when primary fails (429, 5xx, etc.) diff --git a/src/proxy/server.ts b/src/proxy/server.ts index f3abbd3..3f3bb19 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../payments/account.js'; import http from 'node:http'; import { getOrCreateWallet, @@ -240,9 +241,9 @@ export function createProxy(options: ProxyOptions): http.Server { // happen regardless; only the live stderr mirror is gated. setDebugMode(!!options.debug); - const chain = options.chain || 'base'; + const chain = options.chain || 'solana'; let currentModel: string | null = options.modelOverride || DEFAULT_MODEL; - const fallbackEnabled = options.fallbackEnabled !== false; // Default true + const fallbackEnabled = !accountMode() && options.fallbackEnabled !== false; // Default true // Resolve timeouts once at construction. The option wins over the env var // so callers (esp. tests) can configure a single proxy without polluting // process.env for the rest of the process — and for any sibling proxy. @@ -252,14 +253,14 @@ export function createProxy(options: ProxyOptions): http.Server { let baseWallet: { privateKey: string; address: string } | null = null; let solanaWallet: { privateKey: string; address: string } | null = null; - if (chain === 'base') { + if (!accountMode() && chain === 'base') { const w = getOrCreateWallet(); baseWallet = { privateKey: w.privateKey, address: w.address }; } let solanaInitPromise: Promise | null = null; const initSolana = () => { - if (chain !== 'solana' || solanaWallet) return Promise.resolve(); + if (accountMode() || chain !== 'solana' || solanaWallet) return Promise.resolve(); if (!solanaInitPromise) { solanaInitPromise = getOrCreateSolanaWallet().then((w) => { solanaWallet = { privateKey: w.privateKey, address: w.address }; @@ -837,7 +838,7 @@ async function fetchModelAttempt( ); // Non-402 path: free model or cached response — no payment, paidUsd = 0. - if (response.status !== 402) return { response, paidUsd: 0 }; + if (accountMode() || response.status !== 402) return { response, paidUsd: 0 }; if (payment.chain === 'solana' && payment.solanaWallet) { return handleSolanaPayment( @@ -1003,7 +1004,7 @@ async function handleBasePayment( body: body || undefined, }, timeoutMs, `Paid proxy request for ${model}`); - if (paid.status === 402) return { response: paid, paidUsd: 0 }; + if (paid.status === 402 && !accountMode()) return { response: paid, paidUsd: 0 }; appendSettlementRow(endpoint, paidUsd, settlementMeta); return { response: paid, paidUsd }; } @@ -1067,7 +1068,7 @@ async function handleSolanaPayment( body: body || undefined, }, timeoutMs, `Paid proxy request for ${model}`); - if (paid.status === 402) return { response: paid, paidUsd: 0 }; + if (paid.status === 402 && !accountMode()) return { response: paid, paidUsd: 0 }; appendSettlementRow(endpoint, paidUsd, settlementMeta); return { response: paid, paidUsd }; } diff --git a/src/tools/blockrun.ts b/src/tools/blockrun.ts index 752d6bc..35e1ed7 100644 --- a/src/tools/blockrun.ts +++ b/src/tools/blockrun.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode, pollAccountJob } from '../payments/account.js'; /** * BlockRun primitive — the generic x402-paid gateway capability. * @@ -158,7 +159,7 @@ async function callGateway( let response = await fetch(url, { method, signal: ctrl.signal, headers, body: payload }); let paidUsd = 0; - if (response.status === 402) { + if (response.status === 402 && !accountMode()) { const signed = await signPayment(response, chain, url, resourceDescription); if (!signed) { return { @@ -181,6 +182,7 @@ async function callGateway( // claim a paid amount the wallet didn't actually spend. if (!response.ok) paidUsd = 0; + response = await pollAccountJob(response, ctrl.signal); const raw = await response.text().catch(() => ''); let parsed: Record | unknown[] = {}; try { parsed = raw ? JSON.parse(raw) : {}; } catch { /* leave as {} */ } @@ -332,7 +334,7 @@ export const blockrunCapability: CapabilityHandler = { }; } - const head = `BlockRun ${method} ${path} → ${fmtUsd(result.paidUsd)}${result.txHash ? ` · tx ${result.txHash.slice(0, 10)}…` : ''} · ${result.latencyMs}ms`; + const head = `BlockRun ${method} ${path} → ${accountMode() ? "account billing (see user.blockrun.ai)" : fmtUsd(result.paidUsd)}${result.txHash ? ` · tx ${result.txHash.slice(0, 10)}…` : ''} · ${result.latencyMs}ms`; const payload = typeof result.body === 'object' ? JSON.stringify(result.body, null, 2) : String(result.body); return { output: `${head}\n${payload}`, diff --git a/src/tools/defillama.ts b/src/tools/defillama.ts index a54b3d4..34e79a1 100644 --- a/src/tools/defillama.ts +++ b/src/tools/defillama.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../payments/account.js'; /** * DefiLlama capabilities — TVL, yield pools, protocol metadata, and token * prices via the BlockRun `/v1/defillama/*` endpoints. Each tool handles @@ -55,7 +56,7 @@ async function getWithPayment(path: string, ctx: ExecutionScope): Promise headers, }); - if (response.status === 402) { + if (response.status === 402 && !accountMode()) { const paymentHeaders = await signPayment(response, chain, endpoint); if (!paymentHeaders) { throw new Error('Payment signing failed — check wallet balance'); diff --git a/src/tools/exa.ts b/src/tools/exa.ts index 8baa0be..1782014 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../payments/account.js'; /** * Exa research capabilities — neural web search, cited Q&A, and batch * URL content fetch via the BlockRun `/v1/exa/*` endpoints. @@ -63,7 +64,7 @@ async function postWithPayment( body: bodyStr, }); - if (response.status === 402) { + if (response.status === 402 && !accountMode()) { const paymentHeaders = await signPayment(response, chain, endpoint); if (!paymentHeaders) { throw new Error('Payment signing failed — check wallet balance'); @@ -88,6 +89,16 @@ async function postWithPayment( } } +/** The gateway historically wrapped Exa payloads in `data`; the account API + * returns the same fields at the top level. Accept both wire shapes. */ +function responseData(response: unknown): T { + if (response && typeof response === 'object' && 'data' in response) { + const wrapped = (response as { data?: unknown }).data; + if (wrapped && typeof wrapped === 'object') return wrapped as T; + } + return response as T; +} + async function signPayment( response: Response, chain: 'base' | 'solana', @@ -212,7 +223,8 @@ export const exaSearchCapability: CapabilityHandler = { try { const res = await postWithPayment('/v1/exa/search', params, ctx); - const hits = res.data?.results ?? []; + const data = responseData(res); + const hits = data.results ?? []; if (hits.length === 0) { return { output: `No Exa results for "${params.query}".` }; } @@ -222,7 +234,7 @@ export const exaSearchCapability: CapabilityHandler = { const score = h.score ? ` · score ${h.score.toFixed(2)}` : ''; lines.push(`\n**${h.title}**${date}${score}\n${h.url}`); } - const cost = res.data?.costDollars?.total; + const cost = data.costDollars?.total; if (cost) lines.push(`\n_Cost: $${cost.toFixed(4)}_`); return { output: lines.join('\n') }; } catch (err) { @@ -270,14 +282,15 @@ export const exaAnswerCapability: CapabilityHandler = { try { const res = await postWithPayment('/v1/exa/answer', params, ctx); - const ans = res.data?.answer ?? ''; - const cites = res.data?.citations ?? []; + const data = responseData(res); + const ans = data.answer ?? ''; + const cites = data.citations ?? []; const lines: string[] = [ans]; if (cites.length > 0) { lines.push('\n**Sources**'); for (const c of cites) lines.push(`- [${c.title}](${c.url})`); } - const cost = res.data?.costDollars?.total; + const cost = data.costDollars?.total; if (cost) lines.push(`\n_Cost: $${cost.toFixed(4)}_`); return { output: lines.join('\n') }; } catch (err) { @@ -338,7 +351,8 @@ export const exaReadUrlsCapability: CapabilityHandler = { try { const res = await postWithPayment('/v1/exa/contents', params, ctx); - const results = res.data?.results ?? []; + const data = responseData(res); + const results = data.results ?? []; if (results.length === 0) { return { output: `No readable content returned for the ${params.urls.length} URL(s).` }; } @@ -346,7 +360,7 @@ export const exaReadUrlsCapability: CapabilityHandler = { for (const r of results) { lines.push(`\n### ${r.title ?? r.url}\n_Source: ${r.url}_\n\n${r.text}`); } - const cost = res.data?.costDollars?.total; + const cost = data.costDollars?.total; if (cost) lines.push(`\n_Cost: $${cost.toFixed(4)}_`); return { output: lines.join('\n') }; } catch (err) { diff --git a/src/tools/prediction.ts b/src/tools/prediction.ts index c32739d..3e295c9 100644 --- a/src/tools/prediction.ts +++ b/src/tools/prediction.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../payments/account.js'; /** * PredictionMarket — unified access to Polymarket / Kalshi / Limitless / * Opinion / Predict.Fun / cross-platform / smart-money / wallet endpoints @@ -101,7 +102,7 @@ async function getWithPayment(path: string, query: Record { const chain = loadChain(); + const accountLine = accountMode() + ? `Account API billing: ${ACCOUNT_PORTAL}/dashboard\nTransaction wallet (required for on-chain trades):\n` + : ''; try { if (chain === 'solana') { const { setupAgentSolanaWallet } = await import('@blockrun/llm'); const c = await setupAgentSolanaWallet({ silent: true }); const address = await c.getWalletAddress(); const balance = await c.getBalance(); - return { output: formatWalletReport({ chain, address, balanceUsd: balance }) }; + return { output: accountLine + formatWalletReport({ chain, address, balanceUsd: balance }) }; } const { setupAgentWallet } = await import('@blockrun/llm'); const c = setupAgentWallet({ silent: true }); const address = c.getWalletAddress(); const balance = await c.getBalance(); - return { output: formatWalletReport({ chain, address, balanceUsd: balance }) }; + return { output: accountLine + formatWalletReport({ chain, address, balanceUsd: balance }) }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { output: - `Wallet read failed (${msg}). The user may not have run \`franklin setup\` yet, ` + + `${accountLine}Wallet read failed (${msg}). The user may not have run \`franklin-trading setup\` yet, ` + `or the chain RPC is temporarily unreachable. Surface this to the user as-is.`, isError: true, }; @@ -61,7 +65,7 @@ export const walletCapability: CapabilityHandler = { spec: { name: 'Wallet', description: - 'Read Franklin\'s wallet status — chain, address, and USDC balance. ' + + 'Read Franklin Trading\'s transaction wallet status — chain, address, and USDC balance. ' + 'Use this for any "what\'s my balance / how much money / wallet status" question. ' + 'Cheaper and more direct than running `franklin balance` via Bash, and never costs USDC.', input_schema: { diff --git a/src/trading/providers/blockrun/client.ts b/src/trading/providers/blockrun/client.ts index 6392118..0eb82d9 100644 --- a/src/trading/providers/blockrun/client.ts +++ b/src/trading/providers/blockrun/client.ts @@ -1,3 +1,4 @@ +import { gatewayFetch as fetch, accountMode } from '../../../payments/account.js'; /** * Shared BlockRun Gateway HTTP client + short-TTL cache. * @@ -45,9 +46,9 @@ const cache = new Map>(); export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; - if (hit && hit.expiry > Date.now()) return hit.data; + if (!accountMode() && hit && hit.expiry > Date.now()) return hit.data; const data = await fn(); - cache.set(key, { data, expiry: Date.now() + ttlMs }); + if (!accountMode()) cache.set(key, { data, expiry: Date.now() + ttlMs }); return data; } @@ -87,7 +88,7 @@ export async function blockrunGet( recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs }); return { kind: 'not-found', message: `BlockRun Gateway 404 for ${path}` }; } - if (res.status === 402) { + if (res.status === 402 && !accountMode()) { // Free-path client should never see a 402. If the Gateway starts // charging for an endpoint that was free, surface an actionable // error and let the caller migrate to `blockrunGetPaid`. @@ -222,7 +223,7 @@ export async function blockrunGetPaid( }; try { let res = await fetch(url, { headers, signal: ctrl.signal }); - if (res.status === 402) { + if (res.status === 402 && !accountMode()) { try { const paid = await signGatewayPayment(res, chain, url); if (!paid) { diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 3c1ca76..44252e1 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -746,7 +746,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail return balance; })()} {chain ? · {chain}{walletTail ? :{walletTail} : ''} : ''} - {sessionCost > 0.00001 ? -${sessionCost.toFixed(4)} : ''} + {sessionCost > 0.00001 ? {chain === "account" ? "~$" : "-$"}{sessionCost.toFixed(4)} : ''} {contextPct !== undefined && contextPct > 0 ? (() => { // Visual context bar: ▓▓▓▓▓▓░░░░ 75% const filled = Math.round(contextPct / 10); diff --git a/test/api-key.e2e.mjs b/test/api-key.e2e.mjs new file mode 100644 index 0000000..d824f64 --- /dev/null +++ b/test/api-key.e2e.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.FRANKLIN_NO_AUDIT = '1'; +process.env.FRANKLIN_NO_PERSIST = '1'; +process.env.FRANKLIN_NO_PREFETCH = '1'; +process.env.FRANKLIN_NO_EVAL = '1'; +process.env.FRANKLIN_NO_ANALYZER = '1'; + +const enabled = Boolean(process.env.BLOCKRUN_API_KEY); + +test('live account API: catalog, model stream and Exa answer', { skip: !enabled, timeout: 180_000 }, async () => { + const { getGatewayModels } = await import('../dist/gateway-models.js'); + const { ModelClient } = await import('../dist/agent/llm.js'); + const { exaAnswerCapability } = await import('../dist/tools/exa.js'); + + const models = await getGatewayModels(); + assert.ok(models.length > 0, 'account model catalog must not be empty'); + + const client = new ModelClient({ apiUrl: 'https://sol.blockrun.ai/api', chain: 'solana' }); + let text = ''; + for await (const chunk of client.streamCompletion({ + model: 'openai/gpt-4.1-nano', + messages: [{ role: 'user', content: 'Reply with exactly: FRANKLIN_TRADING_API_OK' }], + max_tokens: 32, + })) { + if (chunk.kind === 'error') throw new Error(String(chunk.payload.message || 'model stream failed')); + if (chunk.kind === 'content_block_delta') { + const delta = chunk.payload.delta; + if (delta && typeof delta === 'object' && 'text' in delta) text += String(delta.text); + } + } + assert.equal(text.trim(), 'FRANKLIN_TRADING_API_OK'); + + const exa = await exaAnswerCapability.execute( + { query: 'What is the x402 protocol?' }, + { workingDir: process.cwd(), abortSignal: new AbortController().signal }, + ); + assert.notEqual(exa.isError, true, exa.output); + assert.ok(exa.output.length > 100, 'Exa answer must contain substantive text'); + assert.match(exa.output, /Sources/); +}); diff --git a/test/api-key.local.mjs b/test/api-key.local.mjs new file mode 100644 index 0000000..3994681 --- /dev/null +++ b/test/api-key.local.mjs @@ -0,0 +1,156 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; + +process.env.FRANKLIN_NO_AUDIT = '1'; +process.env.FRANKLIN_NO_PERSIST = '1'; +process.env.FRANKLIN_NO_PREFETCH = '1'; +process.env.FRANKLIN_NO_EVAL = '1'; +process.env.FRANKLIN_NO_ANALYZER = '1'; + +const key = 'brk_live_unit_test'; +const originalFetch = globalThis.fetch; +const savedKey = process.env.BLOCKRUN_API_KEY; +const savedBase = process.env.BLOCKRUN_API_BASE_URL; +const json = (data, status = 200, headers = {}) => new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json', ...headers }, +}); + +beforeEach(() => { + process.env.BLOCKRUN_API_KEY = key; + delete process.env.BLOCKRUN_API_BASE_URL; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (savedKey === undefined) delete process.env.BLOCKRUN_API_KEY; + else process.env.BLOCKRUN_API_KEY = savedKey; + if (savedBase === undefined) delete process.env.BLOCKRUN_API_BASE_URL; + else process.env.BLOCKRUN_API_BASE_URL = savedBase; +}); + +test('account auth rewrites BlockRun gateways and never forwards payment headers', async () => { + const { gatewayFetch } = await import('../dist/payments/account.js'); + const seen = []; + globalThis.fetch = async (url, options) => { + seen.push([String(url), options]); + return json({ ok: true }); + }; + + await gatewayFetch('https://sol.blockrun.ai/api/v1/search?q=x', { + headers: { 'PAYMENT-SIGNATURE': 'remove', 'x-api-key': 'placeholder' }, + }); + assert.equal(seen[0][0], 'https://api.blockrun.ai/v1/search?q=x'); + assert.equal(seen[0][1].headers.get('authorization'), `Bearer ${key}`); + assert.equal(seen[0][1].headers.get('payment-signature'), null); + assert.equal(seen[0][1].redirect, 'error'); + await assert.rejects(() => gatewayFetch('https://evil.example/job'), /unknown gateway/); + assert.equal(seen.length, 1); +}); + +test('account auth redacts credentials and does not x402-sign quota errors', async () => { + const { ModelClient } = await import('../dist/agent/llm.js'); + const { classifyAgentError } = await import('../dist/agent/error-classifier.js'); + let count = 0; + globalThis.fetch = async () => { + count++; + return json({ error: { message: key } }, 402, { 'payment-required': 'must-not-sign' }); + }; + const client = new ModelClient({ apiUrl: 'https://sol.blockrun.ai/api', chain: 'solana' }); + const chunks = []; + for await (const chunk of client.streamCompletion({ + model: 'anthropic/claude-sonnet-4.6', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 5, + stream: false, + })) chunks.push(chunk); + + assert.equal(count, 1); + const error = chunks.find(chunk => chunk.kind === 'error'); + assert.equal(error.payload.status, 402); + assert.match(error.payload.message, /account credits exhausted/i); + assert.equal(classifyAgentError(error.payload.message).isTransient, false); + assert.equal(client.getLastPaidUsd(), 0); + assert.ok(!JSON.stringify(chunks).includes(key)); +}); + +test('ModelClient preserves Anthropic SSE events in account mode', async () => { + const { ModelClient } = await import('../dist/agent/llm.js'); + let seen; + globalThis.fetch = async (url, options) => { + seen = [String(url), options]; + return new Response( + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n', + { headers: { 'content-type': 'text/event-stream' } }, + ); + }; + const client = new ModelClient({ apiUrl: 'https://blockrun.ai/api', chain: 'base' }); + const chunks = []; + for await (const chunk of client.streamCompletion({ + model: 'anthropic/claude-sonnet-4.6', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 5, + })) chunks.push(chunk); + + assert.equal(seen[0], 'https://api.blockrun.ai/v1/messages'); + assert.equal(seen[1].headers.get('authorization'), `Bearer ${key}`); + assert.ok(chunks.some(chunk => chunk.kind === 'content_block_delta')); +}); + +test('Exa account requests authenticate once without creating a payment wallet', async () => { + const { exaAnswerCapability } = await import('../dist/tools/exa.js'); + let count = 0; + globalThis.fetch = async (_url, options) => { + count++; + assert.equal(options.headers.get('authorization'), `Bearer ${key}`); + return json({ answer: 'account answer', citations: [] }); + }; + const result = await exaAnswerCapability.execute( + { query: 'hi' }, + { workingDir: process.cwd(), abortSignal: new AbortController().signal }, + ); + assert.notEqual(result.isError, true); + assert.match(result.output, /account answer/); + assert.equal(count, 1); +}); + +test('trading market-data client uses account auth without wallet signing', async () => { + const { blockrunGetPaid, clearCache } = await import('../dist/trading/providers/blockrun/client.js'); + clearCache(); + let count = 0; + globalThis.fetch = async (url, options) => { + count++; + assert.equal(String(url), 'https://api.blockrun.ai/v1/stocks/us/price/AAPL'); + assert.equal(options.headers.get('authorization'), `Bearer ${key}`); + return json({ data: { symbol: 'AAPL', price: 200 } }); + }; + const result = await blockrunGetPaid('/api/v1/stocks/us/price/AAPL', { endpoint: 'stock-price', costUsd: 0.001 }); + assert.equal(result.data.price, 200); + assert.equal(count, 1); +}); + +test('local proxy returns account quota errors without wallet fallback', async () => { + const { createProxy } = await import('../dist/proxy/server.js'); + let count = 0; + globalThis.fetch = async () => { + count++; + return json({ error: { message: 'quota' } }, 402); + }; + const proxy = createProxy({ port: 0, apiUrl: 'https://blockrun.ai/api', chain: 'base', fallbackEnabled: true }); + proxy.listen(0, '127.0.0.1'); + await once(proxy, 'listening'); + try { + const response = await originalFetch(`http://127.0.0.1:${proxy.address().port}/v1/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'anthropic/claude-sonnet-4.6', max_tokens: 5, messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.equal(response.status, 402); + assert.match(await response.text(), /account credits exhausted/i); + assert.equal(count, 1); + } finally { + proxy.closeAllConnections(); + await new Promise(resolve => proxy.close(resolve)); + } +});