From 0e5a13f5fba2ae7a50c8c7295bdae2078b815ffa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 17:48:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(agentswarm):=20M5=20=E2=80=94=20x402=20bil?= =?UTF-8?q?ling=20wrapper=20for=20the=20swarm=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withX402() wraps the Web handler so unpaid calls get a standard HTTP 402 challenge (x402Version + accepts[]) and paid calls fall through to the agent. Payment verification is injectable (host wires CoinPay/x402); CORS preflight passes through untouched; custom challenge responses supported. README documents the optional c0mpute.com GPU backend. 4 new tests; 38/38 pass. Co-Authored-By: Claude Opus 4.8 --- packages/agentswarm/README.md | 17 ++++++++ packages/agentswarm/src/index.ts | 2 + packages/agentswarm/src/x402.test.ts | 61 ++++++++++++++++++++++++++++ packages/agentswarm/src/x402.ts | 57 ++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 packages/agentswarm/src/x402.test.ts create mode 100644 packages/agentswarm/src/x402.ts diff --git a/packages/agentswarm/README.md b/packages/agentswarm/README.md index dd49ed3..1f89fcd 100644 --- a/packages/agentswarm/README.md +++ b/packages/agentswarm/README.md @@ -89,6 +89,23 @@ const runner = createRubricRunner({ Pass the rubric per request: `{ "messages": [...], "rubric": "- one sentence\n- mentions chlorophyll" }`. +## Optional: run on community GPUs (c0mpute.com) + +Inference is swappable. If a user connects their [c0mpute.com](https://c0mpute.com) +account (peer-shared GPUs), run agents/judges/routers on it instead of a hosted +provider. agentswarm does no OAuth — the host's c0mpute connector supplies the +credentials; this just consumes them: + +```ts +import { createC0mputeModel, createDeepAgentRunner } from "@logicsrc/agentswarm"; + +// `connector` comes from the user connecting their c0mpute.com account +const model = await createC0mputeModel({ apiKey: connector.apiKey, model: "llama-3.1-70b" }); +const runner = await createDeepAgentRunner({ model }); +``` + +Opt-in: omit it and the default hosted provider is used. Requires `@langchain/openai`. + ## Status - **M1** ✓ core handler + injectable runner + deepagents adapter + x402 gate hook. diff --git a/packages/agentswarm/src/index.ts b/packages/agentswarm/src/index.ts index 2c311cc..a8a61b8 100644 --- a/packages/agentswarm/src/index.ts +++ b/packages/agentswarm/src/index.ts @@ -6,6 +6,8 @@ */ export { createSwarmHandler } from "./handler.js"; export type { SwarmHandlerOptions } from "./handler.js"; +export { withX402 } from "./x402.js"; +export type { X402Options, X402Accept } from "./x402.js"; export { createDeepAgentRunner } from "./runner.js"; export type { DeepAgentRunnerOptions } from "./runner.js"; export { createSwarm, createLLMRouter } from "./swarm.js"; diff --git a/packages/agentswarm/src/x402.test.ts b/packages/agentswarm/src/x402.test.ts new file mode 100644 index 0000000..b8eca23 --- /dev/null +++ b/packages/agentswarm/src/x402.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSwarmHandler, withX402 } from "./index.js"; +import type { SwarmRunner } from "./index.js"; + +function okRunner(): SwarmRunner { + return { + run: async (input) => ({ + threadId: "t", + messages: [...input.messages, { role: "assistant", content: "ok" }], + output: "ok" + }) + }; +} + +function post(): Request { + return new Request("http://localhost/swarm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }) + }); +} + +const handler = createSwarmHandler({ runner: okRunner() }); +const accepts = [{ scheme: "exact", network: "base", asset: "USDC", amount: 1000, payTo: "0xabc" }]; + +describe("withX402", () => { + it("passes paid requests through to the handler", async () => { + const paid = withX402(handler, { verify: () => true, accepts }); + const res = await paid(post()); + expect(res.status).toBe(200); + expect((await res.json()).output).toBe("ok"); + }); + + it("returns a 402 x402 challenge when unpaid, without running the agent", async () => { + const verify = vi.fn(() => false); + const paid = withX402(handler, { verify, accepts }); + const res = await paid(post()); + expect(res.status).toBe(402); + const body = await res.json(); + expect(body.x402Version).toBe(1); + expect(body.accepts).toEqual(accepts); + }); + + it("lets CORS preflight through without payment", async () => { + const verify = vi.fn(() => false); + const paid = withX402(handler, { verify, accepts }); + const res = await paid(new Request("http://localhost/swarm", { method: "OPTIONS" })); + expect(res.status).toBe(204); + expect(verify).not.toHaveBeenCalled(); + }); + + it("uses a custom challenge when provided", async () => { + const paid = withX402(handler, { + verify: () => false, + challenge: () => new Response("pay up", { status: 402, headers: { "x-pay": "url" } }) + }); + const res = await paid(post()); + expect(res.status).toBe(402); + expect(res.headers.get("x-pay")).toBe("url"); + }); +}); diff --git a/packages/agentswarm/src/x402.ts b/packages/agentswarm/src/x402.ts new file mode 100644 index 0000000..5a12af4 --- /dev/null +++ b/packages/agentswarm/src/x402.ts @@ -0,0 +1,57 @@ +/** + * x402 billing wrapper — gate a swarm route behind payment. Wraps the Web + * handler so unpaid requests get a standard HTTP 402 challenge (the x402 + * "accepts" shape) and paid requests fall through to the agent. The actual + * payment verification is injectable so a host wires it to CoinPay/x402. + */ + +export interface X402Accept { + scheme: string; + network: string; + asset: string; + /** Price in the asset's minor units. */ + amount: number; + /** Address / CoinPay account that receives payment. */ + payTo?: string; +} + +export interface X402Options { + /** Returns true when the request carries valid payment/authorization. */ + verify: (request: Request) => boolean | Promise; + /** Payment options advertised in the 402 challenge. */ + accepts?: X402Accept[]; + /** Fully custom 402 response; overrides `accepts`. */ + challenge?: (request: Request) => Response | Promise; +} + +const CORS_ORIGIN = { "access-control-allow-origin": "*" }; + +/** + * Wrap a `(Request) => Response` swarm handler so each call must be paid for. + * CORS preflights pass through untouched; everything else must satisfy `verify` + * or receives a 402 with the advertised payment options. + */ +export function withX402( + handler: (request: Request) => Promise, + options: X402Options +): (request: Request) => Promise { + return async function paid(request: Request): Promise { + if (request.method === "OPTIONS") { + return handler(request); + } + if (await options.verify(request)) { + return handler(request); + } + if (options.challenge) { + return options.challenge(request); + } + return new Response( + JSON.stringify({ + x402Version: 1, + error: "payment_required", + accepts: options.accepts ?? [] + }), + { status: 402, headers: { "content-type": "application/json", ...CORS_ORIGIN } } + ); + }; +}