diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e792d6..a9b7335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,17 @@ wallet" while account credits sat there. They read the credit balance now, and an ungated account or an unreachable gateway means no local ceiling rather than an invented one. +**The agent was told the account host was an alias of the Base gateway.** It is +not. `api.blockrun.ai` authenticates a bearer key and 401s without one; the +wallet hosts answer a 402 challenge and ignore a key entirely. Calling them +aliases invited the model to send a wallet-signed request to a host that cannot +settle it, or a key to a host that cannot read it. The gateway section now names +the ONE host the session is actually on — `api.blockrun.ai` for a key, +`sol.blockrun.ai/api` for Solana, `blockrun.ai/api` for Base — and says plainly +which hosts are not yours and why. The instruction cache is keyed on the chain +as well as the pay mode, so a base-to-solana switch cannot serve the previous +chain's host. + **A Solana zero is the SDK's error value, not a balance.** `getBalance()` catches every transport error and returns 0, and an RPC that answers 200 with a JSON-RPC error body reaches the same 0. The reservation layer could not tell an diff --git a/src/agent/context.ts b/src/agent/context.ts index cb31ed6..1077bfe 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -11,7 +11,7 @@ import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm'; import { Keypair } from '@solana/web3.js'; import bs58 from 'bs58'; import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '../learnings/store.js'; -import { isKeyMode } from '../payments/auth-mode.js'; +import { isKeyMode, resolvePayMode } from '../payments/auth-mode.js'; // ─── System Instructions Assembly ────────────────────────────────────────── // Composable prompt sections — each independently maintainable and conditionally includable. @@ -263,22 +263,40 @@ Hard rules: } function getBlockRunApiSection(): string { + // Which host answers depends on how this session pays, and the three are not + // interchangeable: api.blockrun.ai authenticates a bearer key and 401s + // without one, while blockrun.ai / sol.blockrun.ai answer a 402 challenge and + // ignore a bearer key entirely. This section used to call api.blockrun.ai an + // "alias" of the Base gateway, which invited the model to send a wallet-signed + // request to a host that cannot settle it, or a key to a host that cannot read + // it. Name the one host this session actually uses. + // + // assembleInstructions() keys its cache on the pay mode, so this is safe to + // vary and cannot be served stale after a switch. + const mode = resolvePayMode(); + const chargedBy = mode.kind === 'key' ? 'account credits' : 'x402 wallet payment'; + const hosts = mode.kind === 'key' + ? `- **Your host: \`https://api.blockrun.ai\`** — the account API. Requests carry your configured key; the built-in tools attach it. It is a SEPARATE service, not an alias of the Base gateway. +- Not yours: \`https://blockrun.ai/api\` (Base x402 wallet) and \`https://sol.blockrun.ai/api\` (Solana x402 wallet). They ignore an account key and answer 402. Never send one there, and never attach a wallet payment proof to an account request.` + : `- **Your host: \`${mode.apiBase}\`** — the ${mode.chain === 'solana' ? 'Solana' : 'Base'} x402 wallet gateway. Paid calls answer 402 and the tools sign from your ${mode.chain === 'solana' ? 'Solana' : 'Base'} wallet. +- Not yours: \`${mode.chain === 'solana' ? 'https://blockrun.ai/api' : 'https://sol.blockrun.ai/api'}\` (the other chain's wallet gateway) and \`https://api.blockrun.ai\` (the account API, which needs a key and 401s without one — it is not an alias of either wallet host).`; + return `# BlockRun Gateway API (the network you live on) 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\` +${hosts} +Use the built-in tools so the right host and credential are applied. Never switch billing modes to work around an API error. **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 - \`GET /.well-known/x402\` — x402 resource list with prices -**LLM (POST, x402-paid)** +**LLM (POST, billed to ${chargedBy})** - \`POST /v1/chat/completions\` — OpenAI-compatible. Body: \`{ model, messages, stream?, tools?, max_tokens?, temperature? }\`. \`model\` MUST come from \`GET /v1/models\` (real frontier examples on the gateway, verified live 2026-08-30: \`anthropic/claude-sonnet-5\`, \`anthropic/claude-opus-5\`, \`openai/gpt-5.6-sol\`, \`deepseek/deepseek-v4-pro\`, \`zai/glm-5.3\`, \`zai/glm-5.3-flash\`, \`xai/grok-4.5\`, \`qwen/qwen3.7-flash\`, \`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\` (free, the only free id on BOTH the Base and Solana gateways — the other free models are Base-only)). Do NOT invent versions like \`openai/gpt-5.1\` or \`xai/grok-5\` — those don't exist; the gateway 400s with the valid list in the error body, so when in doubt fetch \`GET /v1/models\` first. - \`POST /v1/messages\` — Anthropic-compatible. Body: \`{ model, messages, max_tokens, system?, tools? }\`. -**Media (POST, x402-paid; GET to poll async jobs)** +**Media (POST, billed to ${chargedBy}; GET to poll async jobs)** - \`POST /v1/images/generations\` — text-to-image. Body: \`{ model, prompt, size?, n?, response_format? }\`. - \`POST /v1/images/image2image\` — image-to-image. Body: \`{ model, prompt, image, ... }\`. - \`GET /v1/images/generations/{id}\` — fetch a generated image by id. @@ -286,7 +304,7 @@ You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun - \`GET /v1/videos/generations/{id}\` — poll video job (settles payment when complete). - \`POST /v1/audio/generations\` — music/audio. Body: \`{ model, prompt, ... }\`. Default \`model\`: \`minimax/music-2.5+\`. -**Search (POST, x402-paid)** +**Search (POST, billed to ${chargedBy})** - \`POST /v1/search\` — Exa-backed web search. Body: \`{ query }\` (1–1000 chars). - \`/v1/exa/{...path}\` — Exa passthrough (answer / search / contents). @@ -496,8 +514,13 @@ export function assembleInstructions(workingDir: string, model?: string): string // a session from key mode to wallet mode mid-process after a 401, and // without this the model would keep being told it bills account credits // long after Franklin went back to signing from the wallet. - const mode = isKeyMode() ? 'key' : 'wallet'; - const cacheKey = model ? `${workingDir}::${model}::${mode}` : `${workingDir}::${mode}`; + // Keyed on the full billing identity, not just key-vs-wallet: the gateway + // host and the wallet guidance both name the active CHAIN, so a + // base -> solana switch inside one process must not be served the previous + // chain's instructions. resolvePayMode() is memoised, so this is cheap. + const payMode = resolvePayMode(); + const billing = payMode.kind === 'key' ? 'key' : `wallet:${payMode.chain}`; + const cacheKey = model ? `${workingDir}::${model}::${billing}` : `${workingDir}::${billing}`; const cached = _instructionCache.get(cacheKey); if (cached) return cached; diff --git a/test/api-key.local.mjs b/test/api-key.local.mjs index e004225..6600f2a 100644 --- a/test/api-key.local.mjs +++ b/test/api-key.local.mjs @@ -749,6 +749,63 @@ test('no paid tool states a payment rail unconditionally', async () => { `use the billing-copy helpers (or mode-neutral wording in static spec text):\n${offenders.join('\n')}`); }); +// ── Gateway host routing ────────────────────────────────────────────────── +// api.blockrun.ai authenticates a bearer key and 401s without one; the wallet +// hosts answer a 402 challenge and ignore a key. Telling the model they are +// aliases invites a request the receiving host cannot settle. + +test('the system prompt names the one gateway host this session uses', async () => { + const { assembleInstructions } = await import('../dist/agent/context.js'); + const section = () => { + auth.resetPayModeCache(); + const all = assembleInstructions(TEST_HOME).join('\n'); + const i = all.indexOf('**Base URLs**'); + assert.ok(i > 0, 'Base URLs section must exist'); + return all.slice(i, all.indexOf('**Discovery', i)); + }; + + clean(); + process.env.RUNCODE_CHAIN = 'solana'; + let s = section(); + assert.match(s, /Your host: `https:\/\/sol\.blockrun\.ai\/api`/); + assert.doesNotMatch(s, /Your host: `https:\/\/blockrun\.ai/); + + // Same process, different chain: the instruction cache must not serve the + // previous chain's host. + process.env.RUNCODE_CHAIN = 'base'; + s = section(); + assert.match(s, /Your host: `https:\/\/blockrun\.ai\/api`/, + 'a chain switch must re-render the host, not hit a stale cache entry'); + + process.env.BLOCKRUN_API_KEY = VALID_KEY; + s = section(); + assert.match(s, /Your host: `https:\/\/api\.blockrun\.ai`/); + assert.match(s, /not an alias of the Base gateway/, + 'the account host must never be described as a wallet-gateway alias'); + + delete process.env.RUNCODE_CHAIN; + clean(); + auth.resetPayModeCache(); +}); + +test('no mode is told the account host is an alias of a wallet host', async () => { + const { assembleInstructions } = await import('../dist/agent/context.js'); + for (const setup of [ + () => { clean(); process.env.RUNCODE_CHAIN = 'solana'; }, + () => { clean(); process.env.RUNCODE_CHAIN = 'base'; }, + () => { clean(); process.env.BLOCKRUN_API_KEY = VALID_KEY; }, + ]) { + setup(); + auth.resetPayModeCache(); + const all = assembleInstructions(TEST_HOME).join('\n'); + assert.doesNotMatch(all, /alias: `https:\/\/api\.blockrun\.ai`/, + 'api.blockrun.ai is a separate account service, not an alias'); + } + delete process.env.RUNCODE_CHAIN; + clean(); + auth.resetPayModeCache(); +}); + test('cleanup', () => { clean(); rmSync(TEST_HOME, { recursive: true, force: true });