Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-bunny-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name: Deploy Bunny JS twins
#
# This workflow ships CODE only. Each script's environment (BRAVE_API_KEY,
# MISTRAL_API_KEY, the receiver authorizer key, the S3 channel-store credentials, RPC
# URLs, SEARCH_SERVICE_URL, the Cockpit observability pair SCW_COCKPIT_PUSH_TOKEN +
# URLs, the Cockpit observability pair SCW_COCKPIT_PUSH_TOKEN +
# COCKPIT_LOKI_URL) is configured on the script itself — bunny dashboard or
# `bunny scripts env set` — and is deliberately not part of CI: a repo leak must not be
# a secrets leak.
Expand Down
357 changes: 106 additions & 251 deletions bunny_js/README.md

Large diffs are not rendered by default.

This file was deleted.

73 changes: 52 additions & 21 deletions bunny_js/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,64 @@
* <link rel="icon"> from the HTML, and only then probing COMMON_FAVICON_PATHS
* (/favicon.ico, /favicon.png, /favicon.svg). We satisfy the root-HTML path here and
* point it at /favicon.png (which, unlike /favicon.ico, does reach the function).
*
* The root page doubles as the agent-facing "README": an agent that lands on the origin
* (curl, MCP client, ERC-8004 shepherd) should learn what the service is, where the
* machine-readable contracts live, what a call costs, how to pay, and what a 402 means —
* in one fetch. Every fact on the page is GENERATED by the caller from the same committed
* constants/files that serve it (spec title/description, `x-payment-info`, endpoint
* constants), never hand-restated: a restated fact is the next silent drift bug. Surface
* area is deliberately minimal — human-oriented prose lives on the website, this page is
* the last mile from discovery to a first (unpaid) honest 402.
*/

/** Minimal HTML served at the origin root so scrapers can discover the favicon. */
export const FAVICON_DISCOVERY_HTML = `<!doctype html>
/** The fields `buildRootPage` renders; `endpoints` carries absolute hrefs. */
export interface RootPageInput {
title: string;
summary: string;
endpoints: { href: string; label: string }[];
/** One line per route/price pair, generated from the spec's `x-payment-info`. */
price: string[];
payment: string;
/** The no-wallet first look — usually the curl that returns the honest 402. */
tryIt: string;
errors: string;
}

const escapeHtml = (text: string): string =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

/** The agent-facing root page. The favicon link stays first — x402scan parses it. */
export function buildRootPage(input: RootPageInput): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="/favicon.png" type="image/jpeg">
<title>Fretchen x402 service</title>
<title>${escapeHtml(input.title)}</title>
</head>
<body></body>
<body>
<h1>${escapeHtml(input.title)}</h1>
<p>${escapeHtml(input.summary)}</p>
<h2>Endpoints</h2>
<ul>
${input.endpoints
.map(
(e) =>
` <li><a href="${escapeHtml(e.href)}">${escapeHtml(e.href)}</a> — ${escapeHtml(e.label)}</li>`,
)
.join("\n")}
</ul>
<h2>Price</h2>
<ul>
${input.price.map((p) => ` <li>${escapeHtml(p)}</li>`).join("\n")}
</ul>
<h2>Payment</h2>
<p>${escapeHtml(input.payment)}</p>
<h2>Try it</h2>
<pre>${escapeHtml(input.tryIt)}</pre>
<h2>Errors</h2>
<p>${escapeHtml(input.errors)}</p>
</body>
</html>`;

/**
* True when the client is asking for HTML (a browser or discovery scraper), rather than
* an x402 payment client — those POST JSON and never send `Accept: text/html`. Used to
* decide whether a GET on the origin root should return the discovery HTML instead of the
* normal "only POST" rejection. Header lookup is case-insensitive.
*/
export function wantsHtml(headers: Record<string, string> | undefined): boolean {
if (!headers) {
return false;
}
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === "accept") {
return typeof value === "string" && value.toLowerCase().includes("text/html");
}
}
return false;
}
48 changes: 44 additions & 4 deletions bunny_js/src/llm_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ import {
type SdkPaymentPayload,
type SdkPaymentRequirements,
} from "./x402_server.js";
import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js";
import { buildRootPage } from "./discovery.js";
import {
AGENT_REGISTRATION_PATH,
MCP_SERVICE,
OASF_MANIFEST_PATH,
buildAgentRegistration,
buildOasfRecord,
Expand All @@ -73,6 +74,44 @@ export const LLM_COMPLETION_BOUND_SECONDS = 40;
/** The favicon as raw bytes — web Response bodies are bytes, not Scaleway base64 strings. */
const FAVICON_BYTES = Uint8Array.from(atob(faviconBase64), (c) => c.charCodeAt(0));

/**
* The agent-facing root page, generated from the same constants/files that serve the twin:
* title/summary from the committed spec, the price ceiling from the live MAX_PRICE_ATOMIC
* constant (the static spec's x-payment-info max is a documentation baseline that is
* overridden at serve time — the page must show the real one), links to the well-known
* contracts. Nothing is hand-restated — see discovery.ts.
*/
const ROOT_PAGE = buildRootPage({
title: openapiSpec.info.title,
summary: openapiSpec.info.description,
endpoints: [
{
href: `${openapiSpec.servers[0].url}/openapi.json`,
label: "OpenAPI 3.1 spec — request/response schemas and payment terms",
},
{
href: `${openapiSpec.servers[0].url}/${AGENT_REGISTRATION_PATH}`,
label: "ERC-8004 registration (identity, services, wallet)",
},
{
href: `${openapiSpec.servers[0].url}/${OASF_MANIFEST_PATH}`,
label: "OASF capability record (skills, domains)",
},
{
href: MCP_SERVICE.endpoint,
label: "stateless MCP endpoint — same tools as JSON-RPC 2.0 over POST (chat_completion)",
},
],
price: [
`POST / — metered by real token usage, at most $${formatUsdcAtomicAsDecimalUsd(MAX_PRICE_ATOMIC.USDC)} per request`,
],
payment:
"x402 (HTTP 402): no account, no API key. An unpaid request answers 402 with the machine-readable Payment-Required challenge — exact terms (scheme batch-settlement, network, token, ceiling) are in the challenge, then retry the same request with the payment attached.",
tryIt: `curl -X POST '${openapiSpec.servers[0].url}/' -H 'Content-Type: application/json' -d '{"model":"${advertisedModelIds()[0]}","messages":[{"role":"user","content":"ping"}]}'\n# → HTTP 402 + Payment-Required header: the terms. No wallet needed for this first look.\n# Testnet note: on Base Sepolia the payment settles and the completion is a mocked answer — a free end-to-end test.`,
errors:
"402 = unpaid or invalid payment (challenge in the Payment-Required header); 400 = invalid request (OpenAI-shaped error body); 404 = unknown model. Response schemas: openapi.json.",
});

function jsonError(statusCode: number, error: string): Response {
return new Response(JSON.stringify({ error }), { status: statusCode, headers: CORS_HEADERS });
}
Expand Down Expand Up @@ -158,8 +197,9 @@ export async function handleLLMRequest(request: Request): Promise<Response> {
});
}

if (isGetOrHead && path === "" && wantsHtml(headers)) {
return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, {
if (isGetOrHead && path === "") {
// Unconditional on purpose: the reader is an agent, and agents send Accept: */* too.
return new Response(isHead ? null : ROOT_PAGE, {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" },
});
Expand Down Expand Up @@ -205,7 +245,7 @@ export async function handleLLMRequest(request: Request): Promise<Response> {
networks: getBatchSettlementNetworks(),
maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS,
extensions: buildLlmBazaarExtension(),
serviceName: "Fretchen AI Assistant",
serviceName: "Fretchen LLM Inference",
tags: ["llm", "chat", "x402"],
iconUrl: `${openapiSpec.servers[0].url}/favicon.png`,
});
Expand Down
48 changes: 44 additions & 4 deletions bunny_js/src/mcp_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,49 @@
* does not do, per the MCP spec's session-less allowance.
*/
import { CORS_HEADERS } from "./utils.js";
import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js";
import { buildRootPage } from "./discovery.js";
import { faviconBase64, faviconContentType } from "./favicon.js";
import { MCP_TOOLS } from "./mcp_tools.js";
import { AGENT_REGISTRATION_PATH, buildMcpOriginRegistration } from "./agent_registration.js";
import {
AGENT_REGISTRATION_PATH,
MCP_SERVICE,
buildMcpOriginRegistration,
} from "./agent_registration.js";

/**
* The agent-facing gateway page. This origin relays to the twin sellers and holds no spec
* of its own, so the page is hand-composed (no spec to generate from): it points at the
* two service origins — the single sources of truth for their schemas, prices and 402
* terms — plus this origin's own registration document and the MCP protocol curls. See
* discovery.ts for the generated-not-restated rule.
*/
const ROOT_PAGE = buildRootPage({
title: "Fretchen MCP Gateway — pay-per-call tools for agents",
summary: `A stateless MCP endpoint (Streamable HTTP, JSON-RPC 2.0 over POST) relaying ${MCP_TOOLS.length} paid tools. No sessions, no server-initiated streams: initialize and tools/list are free; tools/call relays to the seller, whose x402 terms apply — an unpaid call comes back as a tool result with isError, the payment demands in structuredContent.`,
endpoints: [
{
href: "https://llm-agent.fretchen.eu/",
label: "chat_completion — the LLM seller's page, spec and terms",
},
{
href: "https://web-agent.fretchen.eu/",
label: "web_search — the web seller's page, spec and terms",
},
{
href: `https://mcp-agent.fretchen.eu/${AGENT_REGISTRATION_PATH}`,
label: "this origin's registration document (domain control proof for both agents)",
},
],
price: MCP_TOOLS.map(
(tool) =>
`${tool.name} — priced at the seller it relays to; the unpaid call's tool result carries the exact 402 terms`,
),
payment:
"per tool, via x402 at the relaying seller: no account, no API key. initialize and tools/list are free; an unpaid tools/call returns the 402 demands as structuredContent in an isError tool result — retry with the payment attached.",
tryIt: `curl -X POST '${MCP_SERVICE.endpoint}' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'\n# → the tools and their input schemas, free (as is initialize). tools/call carries the x402 payment.`,
errors:
"JSON-RPC errors: -32601 unknown method (a stateless server: initialize, tools/list, tools/call only), -32602 unknown tool. GET /mcp is 405 Allow: POST (the optional GET stream is not offered). Unpaid tools/call is NOT an error — it is a tools result with isError and the 402 demands in structuredContent.",
});

/** The latest protocol revision this server implements. */
const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
Expand Down Expand Up @@ -201,8 +240,9 @@ export async function handleMCPRequest(request: Request): Promise<Response> {
// of the scw original's blanket 405.
if (method === "GET" || method === "HEAD") {
const isHead = method === "HEAD";
if (path === "" && wantsHtml(Object.fromEntries(request.headers))) {
return new Response(isHead ? null : FAVICON_DISCOVERY_HTML, {
if (path === "") {
// Unconditional on purpose: the reader is an agent, and agents send Accept: */* too.
return new Response(isHead ? null : ROOT_PAGE, {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "text/html; charset=utf-8" },
});
Expand Down
4 changes: 2 additions & 2 deletions bunny_js/src/openapi.llm.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"openapi": "3.1.0",
"info": {
"title": "Fretchen AI Assistant (LLM) Service",
"description": "AI chat assistant, paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).",
"title": "Fretchen LLM Inference — pay-per-call chat completions for agents",
"description": "OpenAI-shaped chat completions from Mistral models, sold per request to autonomous agents via x402 batch-settlement payment channels — no account, no API key. Metered by real token usage under a fixed per-request ceiling; settled in USDC (Base or Optimism), EURC or EURe (Base), while Base Sepolia settles for free and answers with a mocked completion.",
"version": "1.0.0",
"x-guidance": "OpenAI chat-completions body. POST / with { model, messages: [{ role, content }, ...] } and no payment header to receive a 402 with x402 batch-settlement payment requirements (accepts[]). Open/top up a payment channel per the requirements, retry with the payment header, and the service returns a standard OpenAI chat.completion object. Streaming (stream: true) is not supported. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower. Other standard OpenAI chat params (temperature, top_p, stop, seed, the penalties, response_format) are forwarded to the upstream model and work as normal; they are not enumerated here because the upstream owns that contract. The exceptions are stream, n and max_tokens, which are rejected with a 400 rather than ignored, because each would move cost past the fixed per-message ceiling this endpoint meters against. Tool calling is supported (see x-capabilities): send OpenAI-shaped tools, and a tool call comes back as choices[].message.tool_calls with finish_reason: \"tool_calls\" and content: null. Execute it yourself and send the result back as a role:\"tool\" message with the matching tool_call_id — this endpoint never calls a tool on your behalf, and accepts no callback or MCP server URL. Note: payment uses x402 batch-settlement (a stateful channel), so a stock OpenAI SDK cannot pay this endpoint without batch-settlement client wiring — the OpenAI shape is for body legibility, not drop-in SDK use.",
"contact": {
Expand Down
2 changes: 1 addition & 1 deletion bunny_js/src/openapi.search.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"openapi": "3.1.0",
"info": {
"title": "Fretchen Web Access Service",
"title": "Fretchen Web Retrieval — pay-per-call web access for agents",
"description": "Web search and single-page fetch for agents, paid per call via x402 batch-settlement payment channels in USDC (or EURC on Base).",
"version": "1.0.0",
"x-guidance": "GET /search?q=... or GET /fetch?url=... with no payment header to receive a 402 carrying x402 batch-settlement payment requirements (accepts[]). Open or top up a payment channel per those requirements, retry with the payment header, and the service returns JSON. Payment travels in the header only — these are GET routes, so there is no body fallback. Both routes bill onto the SAME channel as llm-agent.fretchen.eu when the payer, voucher signer and receiver match, so an agent already chatting there pays for web access without a second deposit. Prices are fixed per route and settled only when the call succeeds: an upstream failure returns 500 and takes no money. Mainnet only — testnet USDC is free and these routes spend real money on the caller's behalf.",
Expand Down
Loading
Loading