From 216b4a3fc13dc503a7e6601265dfeedc89a4e282 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 14:30:14 +0200 Subject: [PATCH 1/3] try the switch --- scw_js/README.md | 2 +- scw_js/llm_service.ts | 99 +++++++++++++++++++----- scw_js/openapi.llm.json | 79 ++++++++++++------- scw_js/sc_llm_x402.ts | 89 ++++++++++++++++++--- scw_js/test/llm_service.test.ts | 17 +++- scw_js/test/sc_llm_x402.test.ts | 111 +++++++++++++++++++++------ website/components/AssistantChat.tsx | 2 +- website/hooks/useX402Chat.ts | 12 ++- website/test/AssistantChat.test.tsx | 4 +- website/test/useX402Chat.test.ts | 17 ++-- website/types/x402.ts | 18 ++++- 11 files changed, 351 insertions(+), 99 deletions(-) diff --git a/scw_js/README.md b/scw_js/README.md index d8f1b7579..40e75626d 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -68,7 +68,7 @@ Generates AI images using Black Forest Labs API with USDC payment via x402 proto LLM chat paid via x402 batch-settlement USDC payment channels — no bearer token, the payment voucher itself proves wallet control. `llmx402` handles chat requests (deposit/voucher/402 flow); `llmx402cron` claims and settles accumulated channels on a 12h schedule. See [`assistent_plan.md`](../assistent_plan.md) at the repo root for the full design record (deposit/voucher/claim/settle lifecycle, pricing model, gotchas). -**`llm/v1` contract.** [`openapi.llm.json`](./openapi.llm.json) declares `x-service-type: "llm/v1"` — the interchangeable-agent contract. It is defined entirely by that document: the request/response schema (`LLMChatRequest`/`LLMChatResponse`) plus the `x-interop-floor` (a compatible agent must advertise ≥1 `accepts[]` entry with USDC on Base `eip155:8453`, scheme `batch-settlement`). A `v2` aligning to the OpenAI chat-completions shape is the intended evolution, not yet built. +**`llm/v1` contract.** [`openapi.llm.json`](./openapi.llm.json) declares `x-service-type: "llm/v1"` — the interchangeable-agent contract. It is defined entirely by that document: the request/response body is the **OpenAI chat-completions shape** (`{ model, messages }` in → an OpenAI `chat.completion` object out — `LLMChatRequest`/`LLMChatResponse`), plus the `x-interop-floor` (a compatible agent must advertise ≥1 `accepts[]` entry with USDC on Base `eip155:8453`, scheme `batch-settlement`). Payment stays x402 batch-settlement, so the OpenAI shape is for **body legibility, not drop-in OpenAI-SDK use** — a stock SDK sends `Authorization: Bearer` and can't satisfy the 402. Streaming is not supported. `model` is validated against the advertised id(s) (`mistral-large-latest`; more can be added later). ### `growth_api.ts` - Growth Agent Draft Approval diff --git a/scw_js/llm_service.ts b/scw_js/llm_service.ts index 93803fb93..df69166ee 100644 --- a/scw_js/llm_service.ts +++ b/scw_js/llm_service.ts @@ -49,14 +49,52 @@ export interface LLMMessage { content: string; } -interface LLMResponse { - content: string; - usage: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; +export interface LLMUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +/** + * An OpenAI chat-completion response object. Mistral (and the other OpenAI-compatible + * upstreams in LLM_PROVIDERS) already return this shape, so we forward it largely as-is + * rather than unwrapping it — the endpoint's public contract is the OpenAI chat-completions + * shape. `usage` is load-bearing: the batch-settlement handler prices the settled amount + * from it (see getSettleAmount in sc_llm_x402.ts). + */ +export interface LLMResponse { + id: string; + object: "chat.completion"; + created: number; model: string; + choices: Array<{ + index: number; + message: { role: string; content: string }; + finish_reason: string | null; + }>; + usage: LLMUsage; +} + +/** + * Resolve an OpenAI-style `model` id (as sent by a caller in the request body) to the + * internal provider whose config serves it. Returns `null` for an unknown/unsupported model + * so the handler can answer with an OpenAI-shaped `model_not_found` error. Today only + * Mistral's `mistral-large-latest` is advertised; exposing IONOS later is a one-line change + * (add its `defaultModel` here) — the registry (LLM_PROVIDERS) already carries the ids. + */ +export function resolveModel(modelId: string): { provider: string } | null { + for (const [provider, config] of Object.entries(LLM_PROVIDERS)) { + if (config.defaultModel === modelId) { + return { provider }; + } + } + return null; +} + +/** The model ids this endpoint currently advertises + serves (for OpenAPI enum + validation). */ +export function advertisedModelIds(): string[] { + // Only Mistral is live today (see sc_llm_x402.ts's LLM_PROVIDER); IONOS stays internal. + return [LLM_PROVIDERS.mistral.defaultModel]; } export async function callLLMAPI( @@ -66,9 +104,18 @@ export async function callLLMAPI( ): Promise { if (dummy) { return { - content: "I am a placeholder for the LLM response", + id: "chatcmpl-mock", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "placeholder-model", + choices: [ + { + index: 0, + message: { role: "assistant", content: "I am a placeholder for the LLM response" }, + finish_reason: "stop", + }, + ], usage: { prompt_tokens: 5, completion_tokens: 15, total_tokens: 15 }, - model: "placeholder model", }; } const config = getLLMProviderConfig(provider); @@ -104,19 +151,35 @@ export async function callLLMAPI( ); } - const data = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - usage: LLMResponse["usage"]; - model: string; + // Upstream is OpenAI-compatible; forward its chat-completion envelope, synthesizing only + // the fields a given provider might omit (id/created/object) so our response is a + // well-formed OpenAI chat.completion regardless of upstream quirks. + const data = (await response.json()) as Partial & { + choices?: Array<{ + index?: number; + message: { role?: string; content: string }; + finish_reason?: string | null; + }>; + usage?: LLMUsage; + model?: string; }; - const firstChoice = data.choices[0]; - if (!firstChoice) { - throw new Error(`LLM API returned empty choices (model: ${data.model})`); + const firstChoice = data.choices?.[0]; + if (!firstChoice || !data.usage) { + throw new Error( + `LLM API returned an incomplete completion (model: ${data.model ?? config.defaultModel})`, + ); } return { - content: firstChoice.message.content, + id: data.id ?? `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: data.created ?? Math.floor(Date.now() / 1000), + model: data.model ?? config.defaultModel, + choices: data.choices!.map((c, i) => ({ + index: c.index ?? i, + message: { role: c.message.role ?? "assistant", content: c.message.content }, + finish_reason: c.finish_reason ?? "stop", + })), usage: data.usage, - model: data.model, }; } diff --git a/scw_js/openapi.llm.json b/scw_js/openapi.llm.json index bb3a75ef3..c6f802481 100644 --- a/scw_js/openapi.llm.json +++ b/scw_js/openapi.llm.json @@ -4,7 +4,7 @@ "title": "Fretchen AI Assistant (LLM) Service", "description": "AI chat assistant, paid via x402 batch-settlement USDC payment channels.", "version": "1.0.0", - "x-guidance": "POST / with { data: { prompt: [{ 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 the LLM's response. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower.", + "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. 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": { "name": "fretchen", "url": "https://www.fretchen.eu", @@ -64,44 +64,69 @@ "schemas": { "LLMChatRequest": { "type": "object", - "required": ["data"], + "required": ["model", "messages"], + "description": "OpenAI chat-completions request body.", "properties": { - "data": { - "type": "object", - "required": ["prompt"], - "properties": { - "prompt": { - "type": "array", - "description": "Chat message history to send to the LLM", - "items": { - "type": "object", - "properties": { - "role": { - "type": "string", - "enum": ["user", "assistant", "system"], - "description": "The speaker's role in the conversation" - }, - "content": { "type": "string", "description": "The message text" } - } - } + "model": { + "type": "string", + "enum": ["mistral-large-latest"], + "description": "The model to use. Only the advertised model id(s) are served; others return model_not_found." + }, + "messages": { + "type": "array", + "minItems": 1, + "description": "The conversation so far.", + "items": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { + "type": "string", + "enum": ["system", "user", "assistant"], + "description": "The speaker's role." + }, + "content": { "type": "string", "description": "The message text." } } } + }, + "useDummyData": { + "type": "boolean", + "description": "Vendor extension (not OpenAI): forces a mock completion. Testnet networks always mock regardless." } } }, "LLMChatResponse": { "type": "object", + "description": "OpenAI chat.completion object.", "properties": { - "message": { "type": "string", "description": "The LLM's response content" }, + "id": { "type": "string" }, + "object": { "type": "string", "enum": ["chat.completion"] }, + "created": { "type": "integer", "description": "Unix timestamp (seconds)." }, + "model": { "type": "string" }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { "type": "integer" }, + "message": { + "type": "object", + "properties": { + "role": { "type": "string" }, + "content": { "type": "string", "description": "The generated reply." } + } + }, + "finish_reason": { "type": "string", "nullable": true } + } + } + }, "usage": { "type": "object", - "description": "Token usage for this request, used to derive the settled charge", + "description": "Token usage; the settled charge is derived from it.", "properties": { - "prompt_tokens": { "type": "integer", "description": "Input tokens consumed" }, - "completion_tokens": { - "type": "integer", - "description": "Output tokens generated" - } + "prompt_tokens": { "type": "integer" }, + "completion_tokens": { "type": "integer" }, + "total_tokens": { "type": "integer" } } } } diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index 15053dd9f..8c32fc1f4 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -1,4 +1,10 @@ -import { callLLMAPI, convertTokensToUsdcCost, type LLMMessage } from "./llm_service.js"; +import { + callLLMAPI, + convertTokensToUsdcCost, + resolveModel, + advertisedModelIds, + type LLMMessage, +} from "./llm_service.js"; import { parseJsonBody } from "./utils.js"; import { getUSDCConfig, isTestnet } from "@fretchen/chain-utils"; import pino from "pino"; @@ -87,6 +93,25 @@ function errorResponse(statusCode: number, error: string): ScwResponse { return { body: JSON.stringify({ error }), headers: CORS_HEADERS, statusCode }; } +/** + * OpenAI-shaped error body ({ error: { message, type, code } }) for request/model validation + * failures, so callers reusing OpenAI response types parse our errors too. Payment (402) and + * internal (500) errors keep the plain x402-style `errorResponse` above — those are not part + * of the OpenAI request contract. + */ +function openAiError( + statusCode: number, + message: string, + type: string, + code: string | null = null, +): ScwResponse { + return { + body: JSON.stringify({ error: { message, type, code } }), + headers: CORS_HEADERS, + statusCode, + }; +} + export async function handle(event: ScwEvent, _context: unknown): Promise { if (event.httpMethod === "OPTIONS") { return { statusCode: 200, headers: CORS_HEADERS, body: "" }; @@ -143,23 +168,61 @@ export async function handle(event: ScwEvent, _context: unknown): Promise | undefined; - if (!Array.isArray(data?.["prompt"])) { - return errorResponse(400, "No prompt provided"); + const messages = body["messages"]; + if (!Array.isArray(messages) || messages.length === 0) { + return openAiError(400, "'messages' must be a non-empty array.", "invalid_request_error"); + } + const validMessages = messages.every( + (m) => + m && typeof m === "object" && typeof m.role === "string" && typeof m.content === "string", + ); + if (!validMessages) { + return openAiError( + 400, + "Each message must have a string 'role' and string 'content'.", + "invalid_request_error", + ); + } + const prompt = messages as LLMMessage[]; + + const requestedModel = body["model"]; + if (typeof requestedModel !== "string" || !requestedModel) { + return openAiError(400, "'model' is required.", "invalid_request_error"); + } + const resolved = resolveModel(requestedModel); + if (!resolved) { + return openAiError( + 404, + `The model '${requestedModel}' does not exist or is not served by this endpoint. Available: ${advertisedModelIds().join(", ")}.`, + "invalid_request_error", + "model_not_found", + ); } - const prompt = data["prompt"] as LLMMessage[]; + // `useDummyData` is our vendor extension (testnet mock control), not part of OpenAI. // Deliberately left undefined (not defaulted to false) when absent — the testnet // reject check below needs to distinguish "not sent" from "explicitly false". let useDummyData: boolean | undefined; - if (data["useDummyData"] !== undefined) { - if (typeof data["useDummyData"] !== "boolean") { - return errorResponse(400, "Invalid useDummyData flag"); + if (body["useDummyData"] !== undefined) { + if (typeof body["useDummyData"] !== "boolean") { + return openAiError(400, "Invalid useDummyData flag.", "invalid_request_error"); } - useDummyData = data["useDummyData"]; + useDummyData = body["useDummyData"] as boolean; } const receiverAddress = process.env.NFT_WALLET_PUBLIC_KEY; @@ -306,7 +369,11 @@ export async function handle(event: ScwEvent, _context: unknown): Promise { }), }), ); + // callLLMAPI forwards the upstream OpenAI chat.completion envelope, synthesizing any + // fields the upstream omits (id/created/object, and per-choice index/role/finish_reason). expect(result).toEqual({ - content: "Antwort vom LLM", - usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + id: expect.any(String), + object: "chat.completion", + created: expect.any(Number), model: "meta-llama/Llama-3.3-70B-Instruct", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Antwort vom LLM" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, }); }); @@ -84,7 +95,7 @@ describe("llm_service.js", () => { { role: "assistant", content: "Quantenphysik ist..." }, ]; const result = await callLLMAPI(prompt); - expect(result.content).toBe("Antwort vom LLM"); + expect(result.choices[0].message.content).toBe("Antwort vom LLM"); expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ diff --git a/scw_js/test/sc_llm_x402.test.ts b/scw_js/test/sc_llm_x402.test.ts index 2710ad1ea..38cc330a7 100644 --- a/scw_js/test/sc_llm_x402.test.ts +++ b/scw_js/test/sc_llm_x402.test.ts @@ -74,6 +74,11 @@ const { vi.mock("../llm_service.js", () => ({ callLLMAPI: mockCallLLMAPI, convertTokensToUsdcCost: mockConvertTokensToUsdcCost, + // Real, tiny implementations (keep in sync with llm_service.ts): the handler validates the + // request `model` against these, so a mock that omitted them would fail every request. + resolveModel: (modelId: string) => + modelId === "mistral-large-latest" ? { provider: "mistral" } : null, + advertisedModelIds: () => ["mistral-large-latest"], })); vi.mock("../x402_server.js", () => ({ @@ -111,11 +116,29 @@ import { handle } from "../sc_llm_x402.js"; const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +// The endpoint's request body is the OpenAI chat-completions shape. +const TEST_MODEL = "mistral-large-latest"; + +// callLLMAPI now resolves to an OpenAI chat.completion object; build one for the mock. +function openAiCompletion( + content: string, + usage = { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, +) { + return { + id: "chatcmpl-test", + object: "chat.completion" as const, + created: 0, + model: "test-model", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage, + }; +} + function makeEvent(overrides: Record = {}) { return { httpMethod: "POST", headers: {}, - body: JSON.stringify({ data: { prompt: [{ role: "user", content: "hi" }] } }), + body: JSON.stringify({ model: TEST_MODEL, messages: [{ role: "user", content: "hi" }] }), path: "/llmx402", ...overrides, }; @@ -165,11 +188,7 @@ describe("sc_llm_x402", () => { accepts: [], }); mockCreateSettlementHeaders.mockReturnValue({ "Payment-Response": "encoded" }); - mockCallLLMAPI.mockResolvedValue({ - content: "answer", - usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, - model: "test-model", - }); + mockCallLLMAPI.mockResolvedValue(openAiCompletion("answer")); mockVerifyPayment.mockResolvedValue({ isValid: true, payer: "0xpayer" }); mockSettlePayment.mockResolvedValue({ success: true, @@ -311,17 +330,48 @@ describe("sc_llm_x402", () => { expect(res.statusCode).toBe(400); }); - it("returns 400 when prompt is missing", async () => { - const res = await handle(makeEvent({ body: JSON.stringify({ data: {} }) }) as never, {}); + it("returns 400 (OpenAI-shaped) when messages is missing or empty", async () => { + const res = await handle( + makeEvent({ body: JSON.stringify({ model: TEST_MODEL, messages: [] }) }) as never, + {}, + ); expect(res.statusCode).toBe(400); - expect(JSON.parse(res.body).error).toBe("No prompt provided"); + expect(JSON.parse(res.body).error.type).toBe("invalid_request_error"); + }); + + it("returns 404 model_not_found for an unadvertised model", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error.code).toBe("model_not_found"); + }); + + it("rejects stream: true with an OpenAI-shaped error", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + stream: true, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.code).toBe("stream_unsupported"); }); it("returns 400 for an invalid useDummyData flag", async () => { const res = await handle( makeEvent({ body: JSON.stringify({ - data: { prompt: [{ role: "user", content: "hi" }], useDummyData: "yes" }, + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: "yes", }), }) as never, {}, @@ -484,7 +534,9 @@ describe("sc_llm_x402", () => { const res = await handle( makeEvent({ body: JSON.stringify({ - data: { prompt: [{ role: "user", content: "hi" }], useDummyData: false }, + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: false, }), }) as never, {}, @@ -499,7 +551,9 @@ describe("sc_llm_x402", () => { const res = await handle( makeEvent({ body: JSON.stringify({ - data: { prompt: [{ role: "user", content: "hi" }], useDummyData: true }, + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: true, }), }) as never, {}, @@ -516,7 +570,9 @@ describe("sc_llm_x402", () => { const res = await handle( makeEvent({ body: JSON.stringify({ - data: { prompt: [{ role: "user", content: "hi" }], useDummyData: false }, + model: TEST_MODEL, + messages: [{ role: "user", content: "hi" }], + useDummyData: false, }), }) as never, {}, @@ -534,7 +590,10 @@ describe("sc_llm_x402", () => { it("returns 200 with the LLM response and settlement headers on success", async () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body).content).toBe("answer"); + const body = JSON.parse(res.body); + expect(body.choices[0].message.content).toBe("answer"); + expect(body.object).toBe("chat.completion"); + expect(body.usage).toBeDefined(); expect(res.headers["Payment-Response"]).toBe("encoded"); expect(mockSettlePayment).toHaveBeenCalledTimes(1); @@ -573,11 +632,13 @@ describe("sc_llm_x402", () => { it("settles for the LLM's actual token usage, not the ceiling", async () => { // 200 prompt + 800 completion -> 0.5*200 + 1.5*800 = 100 + 1200 = 1300 (Mistral // rates), well under the 3000 ceiling (2000 tokens, all priced as completion). - mockCallLLMAPI.mockResolvedValue({ - content: "answer", - usage: { prompt_tokens: 200, completion_tokens: 800, total_tokens: 1000 }, - model: "test-model", - }); + mockCallLLMAPI.mockResolvedValue( + openAiCompletion("answer", { + prompt_tokens: 200, + completion_tokens: 800, + total_tokens: 1000, + }), + ); const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); @@ -601,11 +662,13 @@ describe("sc_llm_x402", () => { // 500 prompt + 2500 completion -> 0.5*500 + 1.5*2500 = 250 + 3750 = 4000, which // exceeds the 3000 ceiling — must be capped there rather than settling for more // than the client authorized (or aborting). - mockCallLLMAPI.mockResolvedValue({ - content: "answer", - usage: { prompt_tokens: 500, completion_tokens: 2500, total_tokens: 3000 }, - model: "test-model", - }); + mockCallLLMAPI.mockResolvedValue( + openAiCompletion("answer", { + prompt_tokens: 500, + completion_tokens: 2500, + total_tokens: 3000, + }), + ); const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index ce24ad763..1dacb36ed 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -151,7 +151,7 @@ export function AssistantChat() { const assistantMsg: ChatMessage = { role: "assistant", - content: data.content ?? noResponseMessage, + content: data.choices?.[0]?.message?.content ?? noResponseMessage, timestamp: Date.now(), }; setMessages((prev) => [...prev, assistantMsg]); diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index d247b0869..8aa85871f 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -33,6 +33,10 @@ import type { export const DEFAULT_LLM_AGENT_URL = (import.meta.env.PUBLIC_ENV__LLM_X402_ENDPOINT as string | undefined) ?? "https://llm-agent.fretchen.eu"; +// The model id sent in the OpenAI-shaped request. Must match an id the target agent +// advertises in its openapi.json (the default fretchen agent serves mistral-large-latest). +const LLM_MODEL = (import.meta.env.PUBLIC_ENV__LLM_MODEL as string | undefined) ?? "mistral-large-latest"; + /** * Client-side `ClientChannelStorage` backed by the Web Storage API. Persists channel * state to `localStorage` so an open channel survives a page reload (the browser @@ -203,7 +207,9 @@ export function useX402Chat(network: string, agentUrl: string = DEFAULT_LLM_AGEN const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner, depositStrategy }); const client = new x402Client(); - client.register(network, scheme); + // `network` is a CAIP-2 id (e.g. "eip155:8453"); register's type wants the literal + // `${string}:${string}` shape, which every CAIP-2 value satisfies. + client.register(network as `${string}:${string}`, scheme); // === Validating fetch: reject before signing if the server doesn't offer our network === const validatingFetch: typeof fetch = async (input, init) => { @@ -231,7 +237,9 @@ export function useX402Chat(network: string, agentUrl: string = DEFAULT_LLM_AGEN const response = await fetchWithPayment(agentUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: { prompt } }), + // OpenAI chat-completions body. `model` must be one the agent advertises in its + // openapi.json (mistral-large-latest for fretchen's default agent). + body: JSON.stringify({ model: LLM_MODEL, messages: prompt }), }); setStatus("processing"); diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 7fa303c68..5b13a598c 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -71,7 +71,9 @@ describe("AssistantChat", () => { beforeEach(() => { vi.clearAllMocks(); mockSwitchIfNeeded.mockResolvedValue(true); - mockSendMessage.mockResolvedValue({ content: "Paris is the capital of France." }); + mockSendMessage.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "Paris is the capital of France." } }], + }); vi.mocked(useX402Chat).mockReturnValue({ sendMessage: mockSendMessage, status: "idle", diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts index c08d2e82e..d1b88701f 100644 --- a/website/test/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -17,7 +17,7 @@ import type { X402ChatMessage } from "../types/x402"; const mockRegister = vi.fn(); const mockGetPaymentSettleResponse = vi.fn(); const mockBatchSettlementEvmScheme = vi.fn(); -const mockToClientEvmSigner = vi.fn((signer: unknown) => signer); +const mockToClientEvmSigner = vi.fn((...args: unknown[]) => args[0]); vi.mock("../hooks/useConfiguredPublicClient", () => ({ useConfiguredPublicClient: vi.fn(() => ({ readContract: vi.fn() })), @@ -179,21 +179,24 @@ describe("useX402Chat", () => { it("returns the parsed response and sets status through success", async () => { vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ content: "Paris is the capital of France." }), { status: 200 }), + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Paris is the capital of France." } }], + }), + { status: 200 }, ), + ), ); const { result } = renderHook(() => useX402Chat(NETWORK)); - let response: { content: string } | undefined; + let response: Awaited> | undefined; await act(async () => { response = await result.current.sendMessage([{ role: "user", content: "Capital of France?" }]); }); - expect(response?.content).toBe("Paris is the capital of France."); + expect(response?.choices[0].message.content).toBe("Paris is the capital of France."); expect(result.current.status).toBe("success"); expect(result.current.error).toBeNull(); }); diff --git a/website/types/x402.ts b/website/types/x402.ts index ba478f550..e28508635 100644 --- a/website/types/x402.ts +++ b/website/types/x402.ts @@ -41,19 +41,29 @@ export type X402GenerationStatus = "idle" | "awaiting-signature" | "processing" * off-chain voucher signatures. See hooks/useX402Chat.ts. */ -/** A single chat turn, matching the `data.prompt[]` contract of sc_llm_x402.ts. */ +/** A single chat turn, matching the OpenAI `messages[]` contract of sc_llm_x402.ts. */ export interface X402ChatMessage { role: string; content: string; } -/** Response body from sc_llm_x402.ts on a settled request. */ +/** + * OpenAI chat.completion response from sc_llm_x402.ts on a settled request. The reply text is + * `choices[0].message.content`; `usage` is what the endpoint settled the charge from. + */ export interface X402ChatResponse { - content: string; + id?: string; + object?: string; + created?: number; + model?: string; + choices: Array<{ + index?: number; + message: { role: string; content: string }; + finish_reason?: string | null; + }>; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; - model?: string; } From 28b30487b3a7d49bac9b7d466abef0ae262985af Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 16:25:21 +0200 Subject: [PATCH 2/3] Update sc_llm_x402_buyer.ipynb --- scw_js/notebooks/sc_llm_x402_buyer.ipynb | 162 +++++++++++++---------- 1 file changed, 93 insertions(+), 69 deletions(-) diff --git a/scw_js/notebooks/sc_llm_x402_buyer.ipynb b/scw_js/notebooks/sc_llm_x402_buyer.ipynb index a4a02a6f0..0fe3e4e57 100644 --- a/scw_js/notebooks/sc_llm_x402_buyer.ipynb +++ b/scw_js/notebooks/sc_llm_x402_buyer.ipynb @@ -106,15 +106,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "🚨 REAL MONEY on Base Mainnet\n", - " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + "🧪 Base Sepolia (Testnet)\n", + " eip155:84532 • USDC USDC @ 0x036CbD53842c5426634e7929541eC2318f3dCF7e\n", " Service: http://localhost:8085\n" ] } @@ -174,9 +174,17 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ buyer client ready, registered for eip155:84532\n" + ] + } + ], "source": [ "import { x402Client, wrapFetchWithPayment, x402HTTPClient } from \"npm:@x402/fetch@^2.17.0\";\n", "import { toClientEvmSigner } from \"npm:@x402/evm@^2.17.0\";\n", @@ -263,10 +271,19 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "aae724d6", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💵 Buyer USDC: 18.6445 (estimated deposit ≈ 0.5)\n", + " ✅ enough USDC to open the channel\n" + ] + } + ], "source": [ "import { formatUnits } from \"npm:viem@2\";\n", "\n", @@ -330,36 +347,27 @@ "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=12901ms\n", - "📨 body: {\n", - " \"content\": \"The capital of France is **Paris**.\",\n", - " \"usage\": {\n", - " \"prompt_tokens\": 10,\n", - " \"total_tokens\": 19,\n", - " \"completion_tokens\": 9,\n", - " \"prompt_tokens_details\": {\n", - " \"cached_tokens\": 0\n", - " }\n", - " },\n", - " \"model\": \"mistral-large-latest\"\n", - "}\n", + "📡 status=200 elapsed=3418ms\n", + "📨 reply: I am a placeholder for the LLM response\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"0xcf5f89bae9626c199793dd75fef91afc7b097050d674347bc1b7b851c19c10ff\",\n", - " \"network\": \"eip155:8453\",\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", - " \"balance\": \"15000\",\n", + " \"channelId\": \"0x6697fd8be2d5899f6799d31c5e70411db50f1b5b0c0a0f7cbe76c4bb4498a517\",\n", + " \"balance\": \"0\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"18\"\n", + " \"chargedCumulativeAmount\": \"130\"\n", " },\n", - " \"chargedAmount\": \"18\"\n", + " \"chargedAmount\": \"25\"\n", " }\n", - "}\n" + "}\n", + "✅ msg1: OpenAI reply received (39 chars), usage present\n" ] } ], @@ -378,15 +386,17 @@ " // failure either way). Keeping this try/catch as defense-in-depth, not because the crash is\n", " // expected anymore.\n", " //\n", - " // No `useDummyData` field — matches the real client (useX402Chat.ts) exactly. Mock-vs-real\n", - " // is decided entirely server-side by NETWORK (see the network-selection cell): testnet always\n", - " // mocks, mainnet always calls the real Mistral API. There is nothing else to set here.\n", + " // OpenAI chat-completions request body ({ model, messages }) — matches the real client\n", + " // (useX402Chat.ts) exactly. `model` must be one the server advertises (mistral-large-latest).\n", + " // No `useDummyData` field: mock-vs-real is decided entirely server-side by NETWORK (see the\n", + " // network-selection cell): testnet always mocks, mainnet always calls the real Mistral API.\n", + " // The response is a standard OpenAI chat.completion — the reply is body.choices[0].message.content.\n", " let response: Response;\n", " try {\n", " response = await fetchWithPayment(SERVICE_URL, {\n", " method: \"POST\",\n", " headers: { \"Content-Type\": \"application/json\" },\n", - " body: JSON.stringify({ data: { prompt: [{ role: \"user\", content }] } }),\n", + " body: JSON.stringify({ model: \"mistral-large-latest\", messages: [{ role: \"user\", content }] }),\n", " });\n", " } catch (err) {\n", " const elapsedMs = Math.round(performance.now() - started);\n", @@ -401,7 +411,7 @@ " try { body = JSON.parse(bodyText); } catch { body = bodyText; }\n", "\n", " console.log(`📡 status=${response.status} elapsed=${elapsedMs}ms`);\n", - " console.log(\"📨 body:\", JSON.stringify(body, null, 2).slice(0, 600));\n", + " console.log(\"📨 reply:\", body?.choices?.[0]?.message?.content ?? JSON.stringify(body, null, 2).slice(0, 600));\n", "\n", " let receipt: unknown = null;\n", " try {\n", @@ -414,7 +424,28 @@ " return { response, body, receipt, elapsedMs, threw: null };\n", "}\n", "\n", - "const msg1 = await sendChatMessage(\"What is the capital of France?\");" + "// Minimal viable check (not a full test suite — a notebook `throw` fails the cell loudly).\n", + "// Asserts the three things this notebook otherwise only eyeballs: (1) the x402 flow succeeded\n", + "// (HTTP 200, not a 402/500), (2) the message round-tripped (a non-empty reply came back), and\n", + "// (3) the response is the correct OpenAI chat.completion shape with `usage` present (the field\n", + "// settlement is priced from). On testnet the reply is the server's placeholder mock, so this\n", + "// proves shape + transmission + payment, NOT real inference quality (that costs mainnet money).\n", + "function assertOk(label: string, r: { response: Response | null; body: any }) {\n", + " if (!r.response || r.response.status !== 200) {\n", + " throw new Error(`${label}: expected HTTP 200, got ${r.response?.status ?? \"no response\"}`);\n", + " }\n", + " const reply = r.body?.choices?.[0]?.message?.content;\n", + " if (typeof reply !== \"string\" || reply.length === 0) {\n", + " throw new Error(`${label}: no OpenAI-shaped reply (choices[0].message.content) in body`);\n", + " }\n", + " if (!r.body?.usage) {\n", + " throw new Error(`${label}: response missing 'usage' (batch-settlement pricing depends on it)`);\n", + " }\n", + " console.log(`✅ ${label}: OpenAI reply received (${reply.length} chars), usage present`);\n", + "}\n", + "\n", + "const msg1 = await sendChatMessage(\"What is the capital of France?\");\n", + "assertOk(\"msg1\", msg1);" ] }, { @@ -442,63 +473,56 @@ "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=5345ms\n", - "📨 body: {\n", - " \"content\": \"The capital of Germany is **Berlin**. It has been the capital since the reunification of Germany in 1990, following the fall of the Berlin Wall in 1989. Before that, Bonn served as the capital of West Germany (Federal Republic of Germany) during the Cold War.\",\n", - " \"usage\": {\n", - " \"prompt_tokens\": 11,\n", - " \"total_tokens\": 74,\n", - " \"completion_tokens\": 63,\n", - " \"prompt_tokens_details\": {\n", - " \"cached_tokens\": 0\n", - " }\n", - " },\n", - " \"model\": \"mistral-large-latest\"\n", - "}\n", + "📡 status=200 elapsed=5216ms\n", + "📨 reply: I am a placeholder for the LLM response\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:8453\",\n", - " \"amount\": \"\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xbb6abd3e35362ed66de255294e830a2db1139836ae1357c3f59e06b218c99da5\",\n", + " \"network\": \"eip155:84532\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", - " \"balance\": \"0\",\n", - " \"totalClaimed\": \"0\",\n", + " \"channelId\": \"0x6697fd8be2d5899f6799d31c5e70411db50f1b5b0c0a0f7cbe76c4bb4498a517\",\n", + " \"balance\": \"1507100\",\n", + " \"totalClaimed\": \"30\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"118\"\n", + " \"chargedCumulativeAmount\": \"155\"\n", " },\n", - " \"chargedAmount\": \"100\"\n", + " \"chargedAmount\": \"25\"\n", " }\n", "}\n", - "📡 status=200 elapsed=23975ms\n", - "📨 body: {\n", - " \"content\": \"Italy is a fascinating country with a rich history, vibrant culture, and significant global influence. Here’s a quick overview of key aspects:\\n\\n### **1. Geography & Regions**\\n- **Location**: Southern Europe, shaped like a boot, surrounded by the Mediterranean Sea (Adriatic, Ionian, Tyrrhenian, and Ligurian Seas).\\n- **Regions**: 20 regions, including iconic ones like **Tuscany** (Florence, Siena), **Lombardy** (Milan), **Veneto** (Venice), **Lazio** (Rome), **Campania** (Naples, Pompeii), and **Sicily** (Palermo).\\n- **Landmarks**: The Alps (north), Apennine Mountains (spine\n", + "✅ msg2: OpenAI reply received (39 chars), usage present\n", + "📡 status=200 elapsed=1741ms\n", + "📨 reply: I am a placeholder for the LLM response\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"0xebadfc6c97ee7988d69cb1c696649d96053f9a63f8da4ae7058ddf8ca798b673\",\n", - " \"network\": \"eip155:8453\",\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:84532\",\n", + " \"amount\": \"\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", - " \"balance\": \"30000\",\n", - " \"totalClaimed\": \"0\",\n", + " \"channelId\": \"0x6697fd8be2d5899f6799d31c5e70411db50f1b5b0c0a0f7cbe76c4bb4498a517\",\n", + " \"balance\": \"1507100\",\n", + " \"totalClaimed\": \"30\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"1871\"\n", + " \"chargedCumulativeAmount\": \"180\"\n", " },\n", - " \"chargedAmount\": \"1753\"\n", + " \"chargedAmount\": \"25\"\n", " }\n", - "}\n" + "}\n", + "✅ msg3: OpenAI reply received (39 chars), usage present\n" ] } ], "source": [ "const msg2 = await sendChatMessage(\"And what is the capital of Germany?\");\n", - "const msg3 = await sendChatMessage(\"And Italy?\");" + "assertOk(\"msg2\", msg2);\n", + "\n", + "const msg3 = await sendChatMessage(\"And Italy?\");\n", + "assertOk(\"msg3\", msg3);" ] }, { From 4c368ce425bc906481f151e3df423a85fd549c2c Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 28 Jul 2026 17:08:38 +0200 Subject: [PATCH 3/3] Clean up --- website/components/AssistantChat.tsx | 2 +- website/hooks/x402Discovery.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 1dacb36ed..97c1e7a6b 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -140,7 +140,7 @@ export function AssistantChat() { throw new Error(switchError ?? `Please switch your wallet to ${getViemChain(network).name}`); } - // Full conversation history, matching sc_llm_x402's { data: { prompt: [...] } } contract. + // Full conversation history, as the OpenAI `messages[]` array sc_llm_x402 expects. const promptArray = [ { role: "system", content: systemPromptMessage }, ...messages.map((msg) => ({ role: msg.role, content: msg.content })), diff --git a/website/hooks/x402Discovery.ts b/website/hooks/x402Discovery.ts index 3a63c1e09..d391d8ce6 100644 --- a/website/hooks/x402Discovery.ts +++ b/website/hooks/x402Discovery.ts @@ -99,7 +99,11 @@ export async function fetchAgentCard(agentUrl: string): Promise