From 68f1f1259acdb610540f92212cc9e035cf39cc78 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 17:23:59 -0500 Subject: [PATCH 1/7] feat(x402): add x402-protected chat endpoint at $0.01 This commit adds a new x402-protected chat endpoint at /api/x402/chat with a price of $0.01 per request. All traffic to /api/chat now flows through the x402 endpoint using credit deductions and USDC payments. Changes: - Add CHAT_PRICE constant ($0.01) to lib/const.ts - Configure x402 middleware to protect POST /api/x402/chat - Create /api/x402/chat/route.ts endpoint handler - Add handleChatStreamX402 for x402-specific streaming - Add validateChatRequestX402 for x402 body validation (accountId required) - Add validateChatAuth for auth-only validation - Add fetchWithPaymentStream for POST requests with streaming - Add x402Chat wrapper function for calling x402 endpoint - Update /api/chat to route through x402 with credit deduction - Add comprehensive tests for all new functions Co-Authored-By: Claude Opus 4.5 --- app/api/chat/route.ts | 58 ++- app/api/x402/chat/route.ts | 39 ++ lib/chat/__tests__/validateChatAuth.test.ts | 333 +++++++++++++++++ .../__tests__/validateChatRequestX402.test.ts | 345 ++++++++++++++++++ lib/chat/handleChatStreamX402.ts | 74 ++++ lib/chat/validateChatAuth.ts | 160 ++++++++ lib/chat/validateChatRequestX402.ts | 127 +++++++ lib/const.ts | 9 +- .../__tests__/fetchWithPaymentStream.test.ts | 168 +++++++++ lib/x402/fetchWithPaymentStream.ts | 48 +++ lib/x402/recoup/__tests__/x402Chat.test.ts | 245 +++++++++++++ lib/x402/recoup/x402Chat.ts | 61 ++++ middleware.ts | 44 ++- 13 files changed, 1696 insertions(+), 15 deletions(-) create mode 100644 app/api/x402/chat/route.ts create mode 100644 lib/chat/__tests__/validateChatAuth.test.ts create mode 100644 lib/chat/__tests__/validateChatRequestX402.test.ts create mode 100644 lib/chat/handleChatStreamX402.ts create mode 100644 lib/chat/validateChatAuth.ts create mode 100644 lib/chat/validateChatRequestX402.ts create mode 100644 lib/x402/__tests__/fetchWithPaymentStream.test.ts create mode 100644 lib/x402/fetchWithPaymentStream.ts create mode 100644 lib/x402/recoup/__tests__/x402Chat.test.ts create mode 100644 lib/x402/recoup/x402Chat.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 644d7a5d..39464711 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,7 +1,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatStream } from "@/lib/chat/handleChatStream"; +import { validateChatAuth } from "@/lib/chat/validateChatAuth"; +import { x402Chat } from "@/lib/x402/recoup/x402Chat"; /** * OPTIONS handler for CORS preflight requests. @@ -19,9 +20,13 @@ export async function OPTIONS() { * POST /api/chat * * Streaming chat endpoint that processes messages and returns a streaming response. + * All requests are routed through the x402 payment system, which: + * 1. Deducts credits from the account + * 2. Makes an on-chain USDC payment + * 3. Forwards to the x402-protected chat endpoint * - * Authentication: x-api-key header required. - * The account ID is inferred from the API key. + * Authentication: x-api-key or Authorization header required. + * The account ID is inferred from the authentication. * * Request body: * - messages: Array of chat messages (mutually exclusive with prompt) @@ -36,5 +41,50 @@ export async function OPTIONS() { * @returns A streaming response or error */ export async function POST(request: NextRequest): Promise { - return handleChatStream(request); + // Validate authentication and get accountId + const authResult = await validateChatAuth(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { body, accountId, orgId } = authResult; + + try { + // Build the chat body with resolved accountId + const chatBody = { + ...body, + accountId, + orgId, + messages: body.messages || [], + }; + + // Get the base URL for the x402 endpoint + const baseUrl = request.nextUrl.origin; + + // Route through x402 endpoint (handles credit deduction and payment) + const response = await x402Chat(chatBody, baseUrl); + + // Return the streaming response with CORS headers + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: { + ...Object.fromEntries(response.headers.entries()), + ...getCorsHeaders(), + }, + }); + } catch (error) { + console.error("Error in /api/chat:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return NextResponse.json( + { + status: "error", + message: errorMessage, + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } } diff --git a/app/api/x402/chat/route.ts b/app/api/x402/chat/route.ts new file mode 100644 index 00000000..fd089c53 --- /dev/null +++ b/app/api/x402/chat/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatStreamX402 } from "@/lib/chat/handleChatStreamX402"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/x402/chat + * + * x402-protected streaming chat endpoint. Payment is verified by the x402 middleware + * before this handler is called. The accountId is passed in the request body and + * trusted because the caller paid via x402. + * + * Request body: + * - messages: Array of chat messages (mutually exclusive with prompt) + * - prompt: String prompt (mutually exclusive with messages) + * - roomId: Optional UUID of the chat room + * - artistId: Optional UUID of the artist account + * - accountId: The account ID of the user making the request + * - model: Optional model ID override + * - excludeTools: Optional array of tool names to exclude + * + * @param request - The request object + * @returns A streaming response or error + */ +export async function POST(request: NextRequest): Promise { + return handleChatStreamX402(request); +} diff --git a/lib/chat/__tests__/validateChatAuth.test.ts b/lib/chat/__tests__/validateChatAuth.test.ts new file mode 100644 index 00000000..aaaed842 --- /dev/null +++ b/lib/chat/__tests__/validateChatAuth.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateChatAuth } from "../validateChatAuth"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +// Mock dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ + validateOrganizationAccess: vi.fn(), +})); + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); +const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); +const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails); +const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); + +/** + * Helper to create mock NextRequest. + * + * @param body - The request body to mock. + * @param headers - The headers to include in the mock request. + * @returns A mock Request object. + */ +function createMockRequest(body: unknown, headers: Record = {}): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("validateChatAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("schema validation", () => { + it("accepts valid request with prompt", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "test-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.prompt).toBe("Hello"); + }); + + it("accepts valid request with messages", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const messages = [{ role: "user", content: "Hello" }]; + const request = createMockRequest({ messages }, { "x-api-key": "test-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.messages).toEqual(messages); + }); + + it("accepts valid request with optional fields", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + prompt: "Hello", + roomId: "room-123", + artistId: "artist-456", + model: "gpt-4", + excludeTools: ["tool1"], + }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.roomId).toBe("room-123"); + expect((result as any).body.artistId).toBe("artist-456"); + expect((result as any).body.model).toBe("gpt-4"); + expect((result as any).body.excludeTools).toEqual(["tool1"]); + }); + }); + + describe("authentication", () => { + it("rejects request without any auth header", async () => { + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("rejects request with both x-api-key and Authorization headers", async () => { + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "test-key", authorization: "Bearer test-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("uses accountId from valid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-abc-123"); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-abc-123"); + }); + + it("rejects request with invalid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json({ status: "error", message: "Invalid API key" }, { status: 401 }), + ); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "invalid-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts valid Authorization Bearer token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("account-from-jwt-456"); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-from-jwt-456"); + }); + + it("rejects request with invalid Authorization token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Failed to verify authentication token" }, + { status: 401 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer invalid-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("org context", () => { + it("returns orgId for org API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-account-123", + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-account-123"); + }); + + it("returns null orgId for personal API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("personal-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "personal-account-123", + orgId: null, + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "personal-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + + it("returns null orgId for bearer token auth", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("jwt-account-456"); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + }); + + describe("accountId override", () => { + it("allows org API key to override accountId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "target-account-456", + }); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "org-api-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("target-account-456"); + expect(mockValidateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "org-api-key", + targetAccountId: "target-account-456", + }); + }); + + it("rejects unauthorized accountId override", async () => { + mockGetApiKeyAccountId.mockResolvedValue("personal-account-123"); + mockValidateOverrideAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied to specified accountId" }, + { status: 403 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "personal-api-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified accountId"); + }); + }); + + describe("organizationId override", () => { + it("uses provided organizationId when user is member of org", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const request = createMockRequest( + { prompt: "Hello", organizationId: "org-456" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-456"); + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "user-account-123", + organizationId: "org-456", + }); + }); + + it("rejects organizationId when user is NOT a member of org", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); + mockValidateOrganizationAccess.mockResolvedValue(false); + + const request = createMockRequest( + { prompt: "Hello", organizationId: "org-not-member" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified organizationId"); + }); + + it("uses API key orgId when no organizationId is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "api-key-org-123", + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("api-key-org-123"); + expect(mockValidateOrganizationAccess).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/chat/__tests__/validateChatRequestX402.test.ts b/lib/chat/__tests__/validateChatRequestX402.test.ts new file mode 100644 index 00000000..1b810213 --- /dev/null +++ b/lib/chat/__tests__/validateChatRequestX402.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateChatRequestX402, chatRequestX402Schema } from "../validateChatRequestX402"; +import { setupConversation } from "@/lib/chat/setupConversation"; + +// Mock dependencies +vi.mock("@/lib/chat/setupConversation", () => ({ + setupConversation: vi.fn(), +})); + +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "mock-uuid-default"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +const mockSetupConversation = vi.mocked(setupConversation); + +/** + * Helper to create mock NextRequest. + * + * @param body - The request body to mock. + * @returns A mock Request object. + */ +function createMockRequest(body: unknown): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: () => null, + has: () => false, + }, + } as unknown as Request; +} + +describe("validateChatRequestX402", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSetupConversation.mockResolvedValue({ + roomId: "mock-uuid-default", + memoryId: "mock-uuid-default", + }); + }); + + describe("schema validation", () => { + it("rejects when accountId is not provided", async () => { + const request = createMockRequest({ prompt: "Hello" }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("rejects when neither messages nor prompt is provided", async () => { + const request = createMockRequest({ accountId: "account-123" }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("rejects when both messages and prompt are provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + messages: [{ role: "user", content: "Hello" }], + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("accepts valid request with messages and accountId", async () => { + const request = createMockRequest({ + accountId: "account-123", + messages: [{ role: "user", content: "Hello" }], + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + + it("accepts valid request with prompt and accountId", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello, world!", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + }); + + describe("no authentication required", () => { + it("does not require x-api-key header", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + // Should succeed without auth headers because x402 payment is the auth + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + + it("does not require Authorization header", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + }); + }); + + describe("accountId handling", () => { + it("uses accountId from request body", async () => { + const request = createMockRequest({ + accountId: "trusted-account-456", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("trusted-account-456"); + }); + + it("passes accountId to setupConversation", async () => { + const request = createMockRequest({ + accountId: "account-for-setup", + prompt: "Hello", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-for-setup", + }), + ); + }); + }); + + describe("organizationId handling", () => { + it("uses organizationId from request body without validation", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + organizationId: "org-456", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-456"); + }); + + it("sets orgId to null when organizationId is not provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + }); + + describe("message normalization", () => { + it("converts prompt to messages array", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello, world!", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toHaveLength(1); + expect((result as any).messages[0].role).toBe("user"); + expect((result as any).messages[0].parts[0].text).toBe("Hello, world!"); + }); + + it("preserves original messages when provided", async () => { + const originalMessages = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + ]; + const request = createMockRequest({ + accountId: "account-123", + messages: originalMessages, + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toEqual(originalMessages); + }); + }); + + describe("conversation setup", () => { + it("calls setupConversation and returns roomId", async () => { + mockSetupConversation.mockResolvedValue({ + roomId: "generated-room-id", + memoryId: "memory-id", + }); + + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("generated-room-id"); + }); + + it("passes roomId to setupConversation when provided", async () => { + mockSetupConversation.mockResolvedValue({ + roomId: "existing-room-id", + memoryId: "memory-id", + }); + + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + roomId: "existing-room-id", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "existing-room-id", + }), + ); + }); + + it("passes artistId to setupConversation when provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + artistId: "artist-xyz", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + artistId: "artist-xyz", + }), + ); + }); + }); + + describe("optional fields", () => { + it("passes through model selection", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + model: "gpt-4", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).model).toBe("gpt-4"); + }); + + it("passes through excludeTools array", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + excludeTools: ["tool1", "tool2"], + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).excludeTools).toEqual(["tool1", "tool2"]); + }); + }); + + describe("authToken handling", () => { + it("sets authToken to undefined since x402 payment is the auth", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).authToken).toBeUndefined(); + }); + }); + + describe("chatRequestX402Schema", () => { + it("exports the schema for external validation", () => { + expect(chatRequestX402Schema).toBeDefined(); + const result = chatRequestX402Schema.safeParse({ + accountId: "account-123", + prompt: "test", + }); + expect(result.success).toBe(true); + }); + + it("schema requires accountId", () => { + const result = chatRequestX402Schema.safeParse({ prompt: "test" }); + expect(result.success).toBe(false); + }); + + it("schema enforces mutual exclusivity of messages and prompt", () => { + const result = chatRequestX402Schema.safeParse({ + accountId: "account-123", + messages: [{ role: "user", content: "test" }], + prompt: "test", + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/lib/chat/handleChatStreamX402.ts b/lib/chat/handleChatStreamX402.ts new file mode 100644 index 00000000..27867d64 --- /dev/null +++ b/lib/chat/handleChatStreamX402.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { handleChatCompletion } from "./handleChatCompletion"; +import { validateChatRequestX402 } from "./validateChatRequestX402"; +import { setupChatRequest } from "./setupChatRequest"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import generateUUID from "@/lib/uuid/generateUUID"; + +/** + * Handles a streaming chat request for the x402-protected endpoint. + * + * This function: + * 1. Validates the request (body schema only - auth is handled by x402 payment) + * 2. Sets up the chat configuration (agent, model, tools) + * 3. Creates a streaming response using the AI SDK + * + * The accountId is passed in the request body and trusted because the caller + * has already paid via x402 payment verification. + * + * @param request - The incoming NextRequest + * @returns A streaming response or error NextResponse + */ +export async function handleChatStreamX402(request: NextRequest): Promise { + const validatedBodyOrError = await validateChatRequestX402(request); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; + } + const body = validatedBodyOrError; + + try { + const chatConfig = await setupChatRequest(body); + const { agent } = chatConfig; + + const stream = createUIMessageStream({ + originalMessages: body.messages, + generateId: generateUUID, + execute: async options => { + const { writer } = options; + const result = await agent.stream(chatConfig); + writer.merge(result.toUIMessageStream()); + }, + onFinish: async event => { + if (event.isAborted) { + return; + } + const assistantMessages = event.messages.filter(message => message.role === "assistant"); + const responseMessages = + assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; + await handleChatCompletion(body, responseMessages); + }, + onError: e => { + console.error("/api/x402/chat onError:", e); + return JSON.stringify({ + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }); + }, + }); + + return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); + } catch (e) { + console.error("/api/x402/chat Global error:", e); + return NextResponse.json( + { + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/chat/validateChatAuth.ts b/lib/chat/validateChatAuth.ts new file mode 100644 index 00000000..acde97b9 --- /dev/null +++ b/lib/chat/validateChatAuth.ts @@ -0,0 +1,160 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +/** + * Basic schema for chat request body validation (auth phase only). + */ +const chatAuthSchema = z.object({ + prompt: z.string().optional(), + messages: z.array(z.any()).default([]), + roomId: z.string().optional(), + accountId: z.string().optional(), + artistId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + excludeTools: z.array(z.string()).optional(), +}); + +export type ChatAuthBody = z.infer; + +export interface ChatAuthResult { + body: ChatAuthBody; + accountId: string; + orgId: string | null; +} + +/** + * Validates chat request authentication and returns the accountId. + * + * This function only handles: + * - Basic schema validation + * - Authentication (API key or Bearer token) + * - Account ID resolution + * - Organization access validation + * + * It does NOT handle conversation setup, message conversion, etc. + * Those are handled by the x402 endpoint. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error or validated auth result + */ +export async function validateChatAuth( + request: NextRequest, +): Promise { + const json = await request.json(); + const validationResult = chatAuthSchema.safeParse(json); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid input", + errors: validationResult.error.issues.map(err => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody = validationResult.data; + + // Check which auth mechanism is provided + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce that exactly one auth mechanism is provided + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { + status: "error", + message: "Exactly one of x-api-key or Authorization must be provided", + }, + { + status: 401, + headers: getCorsHeaders(), + }, + ); + } + + // Authenticate and get accountId and orgId + let accountId: string; + let orgId: string | null = null; + + if (hasApiKey) { + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + + // Get org context from API key details + const keyDetails = await getApiKeyDetails(apiKey!); + if (keyDetails) { + orgId = keyDetails.orgId; + } + + // Handle accountId override for org API keys + if (validatedBody.accountId) { + const overrideResult = await validateOverrideAccountId({ + apiKey, + targetAccountId: validatedBody.accountId, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + accountId = overrideResult.accountId; + } + } else { + // Validate bearer token authentication (no org context for JWT auth) + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + } + + // Handle organizationId override from request body + if (validatedBody.organizationId) { + const hasOrgAccess = await validateOrganizationAccess({ + accountId, + organizationId: validatedBody.organizationId, + }); + + if (!hasOrgAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organizationId", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + // Use the provided organizationId as orgId + orgId = validatedBody.organizationId; + } + + return { + body: validatedBody, + accountId, + orgId, + }; +} diff --git a/lib/chat/validateChatRequestX402.ts b/lib/chat/validateChatRequestX402.ts new file mode 100644 index 00000000..d309a65e --- /dev/null +++ b/lib/chat/validateChatRequestX402.ts @@ -0,0 +1,127 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getMessages } from "@/lib/messages/getMessages"; +import convertToUiMessages from "@/lib/messages/convertToUiMessages"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { validateMessages } from "@/lib/chat/validateMessages"; +import type { ChatRequestBody } from "./validateChatRequest"; + +/** + * Schema for x402-protected chat requests. + * Unlike the regular chat endpoint, accountId is required in the body + * since auth is handled by x402 payment verification. + */ +export const chatRequestX402Schema = z + .object({ + // Chat content + prompt: z.string().optional(), + messages: z.array(z.any()).default([]), + // Core routing / context fields + roomId: z.string().optional(), + accountId: z.string({ message: "accountId is required" }), + artistId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + excludeTools: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + const hasMessages = Array.isArray(data.messages) && data.messages.length > 0; + const hasPrompt = typeof data.prompt === "string" && data.prompt.trim().length > 0; + + if ((hasMessages && hasPrompt) || (!hasMessages && !hasPrompt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["messages"], + }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["prompt"], + }); + } + }); + +type BaseChatRequestX402Body = z.infer; + +/** + * Validates chat request body for x402-protected endpoint. + * + * Unlike the regular validateChatRequest, this function: + * - Does NOT validate authentication headers (x402 payment is the auth) + * - Requires accountId in the request body + * - Trusts the accountId because the caller has paid via x402 + * + * Returns: + * - NextResponse (400) when body is invalid + * - Parsed & augmented body when valid + * + * @param request - The NextRequest object + * @returns A NextResponse with an error or validated ChatRequestBody + */ +export async function validateChatRequestX402( + request: NextRequest, +): Promise { + const json = await request.json(); + const validationResult = chatRequestX402Schema.safeParse(json); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid input", + errors: validationResult.error.issues.map(err => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody: BaseChatRequestX402Body = validationResult.data; + + // accountId is trusted because x402 payment was verified + const accountId = validatedBody.accountId; + + // organizationId can be passed but we don't validate access since x402 payment is the auth + const orgId = validatedBody.organizationId ?? null; + + // Normalize chat content: + // - If only prompt is provided, convert it into a single user UIMessage + // - Convert all messages to UIMessage format (handles mixed formats) + const hasMessages = Array.isArray(validatedBody.messages) && validatedBody.messages.length > 0; + const hasPrompt = + typeof validatedBody.prompt === "string" && validatedBody.prompt.trim().length > 0; + + if (!hasMessages && hasPrompt) { + validatedBody.messages = getMessages(validatedBody.prompt); + } + + // Convert messages to UIMessage format and get the last (newest) message + const uiMessages = convertToUiMessages(validatedBody.messages); + const { lastMessage } = validateMessages(uiMessages); + + // Setup conversation: auto-create room if needed and persist user message + const { roomId: finalRoomId } = await setupConversation({ + accountId, + roomId: validatedBody.roomId, + promptMessage: lastMessage, + artistId: validatedBody.artistId, + memoryId: lastMessage.id, + }); + + return { + ...validatedBody, + accountId, + orgId, + roomId: finalRoomId, + // No authToken for x402 - payment is the auth + authToken: undefined, + } as ChatRequestBody; +} diff --git a/lib/const.ts b/lib/const.ts index c5e01ef7..e635621f 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -9,6 +9,7 @@ export const SMART_ACCOUNT_ADDRESS = "0xbAf31935ED514e8F7da81D0A730AB5362DEEEEb7 export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Address; export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.PAYMASTER_KEY}`; export const IMAGE_GENERATE_PRICE = "0.15"; +export const CHAT_PRICE = "0.01"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; @@ -32,10 +33,4 @@ export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b"; // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; -export const EVAL_ARTISTS = [ - "Gliiico", - "Mac Miller", - "Wiz Khalifa", - "Mod Sun", - "Julius Black", -]; +export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"]; diff --git a/lib/x402/__tests__/fetchWithPaymentStream.test.ts b/lib/x402/__tests__/fetchWithPaymentStream.test.ts new file mode 100644 index 00000000..b1e3d481 --- /dev/null +++ b/lib/x402/__tests__/fetchWithPaymentStream.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchWithPaymentStream } from "../fetchWithPaymentStream"; +import { getAccount } from "@/lib/coinbase/getAccount"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { loadAccount } from "../loadAccount"; +import { getCreditsForPrice } from "../getCreditsForPrice"; +import { CHAT_PRICE } from "@/lib/const"; + +// Import x402-fetch to mock wrapFetchWithPayment +import { wrapFetchWithPayment } from "x402-fetch"; + +// Mock dependencies +vi.mock("@/lib/coinbase/getAccount", () => ({ + getAccount: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +vi.mock("../loadAccount", () => ({ + loadAccount: vi.fn(), +})); + +vi.mock("../getCreditsForPrice", () => ({ + getCreditsForPrice: vi.fn(), +})); + +vi.mock("x402-fetch", () => ({ + wrapFetchWithPayment: vi.fn(() => vi.fn()), +})); + +vi.mock("viem/accounts", () => ({ + toAccount: vi.fn(account => account), +})); + +vi.mock("viem", () => ({ + parseUnits: vi.fn((value, decimals) => BigInt(Math.round(parseFloat(value) * 10 ** decimals))), +})); + +const mockGetAccount = vi.mocked(getAccount); +const mockDeductCredits = vi.mocked(deductCredits); +const mockLoadAccount = vi.mocked(loadAccount); +const mockGetCreditsForPrice = vi.mocked(getCreditsForPrice); +const mockWrapFetchWithPayment = vi.mocked(wrapFetchWithPayment); + +describe("fetchWithPaymentStream", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccount.mockResolvedValue({ + address: "0x1234567890abcdef", + } as any); + mockDeductCredits.mockResolvedValue(undefined); + mockLoadAccount.mockResolvedValue(undefined); + mockGetCreditsForPrice.mockReturnValue(1); + }); + + it("gets account for the given accountId", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockGetAccount).toHaveBeenCalledWith("account-123"); + }); + + it("calculates credits to deduct based on price", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockGetCreditsForPrice).toHaveBeenCalledWith(CHAT_PRICE); + }); + + it("deducts credits from the account", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetCreditsForPrice.mockReturnValue(1); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 1, + }); + }); + + it("loads the account wallet", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetAccount.mockResolvedValue({ + address: "0xWalletAddress123", + } as any); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockLoadAccount).toHaveBeenCalledWith("0xWalletAddress123"); + }); + + it("makes POST request with JSON body", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + const body = { messages: [{ role: "user", content: "Hello" }] }; + await fetchWithPaymentStream("https://example.com/api", "account-123", body); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + }); + + it("returns the response from the wrapped fetch", async () => { + const expectedResponse = new Response("streaming data", { + headers: { "Content-Type": "text/event-stream" }, + }); + const mockFetch = vi.fn().mockResolvedValue(expectedResponse); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + const result = await fetchWithPaymentStream("https://example.com/api", "account-123", { + data: "test", + }); + + expect(result).toBe(expectedResponse); + }); + + it("uses custom price when provided", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + await fetchWithPaymentStream( + "https://example.com/api", + "account-123", + { data: "test" }, + "0.05", + ); + + expect(mockGetCreditsForPrice).toHaveBeenCalledWith("0.05"); + }); + + it("throws error if account retrieval fails", async () => { + mockGetAccount.mockRejectedValue(new Error("Account not found")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Account not found"); + }); + + it("throws error if credit deduction fails", async () => { + mockDeductCredits.mockRejectedValue(new Error("Insufficient credits")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Insufficient credits"); + }); + + it("throws error if account loading fails", async () => { + mockLoadAccount.mockRejectedValue(new Error("Failed to load account")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Failed to load account"); + }); +}); diff --git a/lib/x402/fetchWithPaymentStream.ts b/lib/x402/fetchWithPaymentStream.ts new file mode 100644 index 00000000..f2835503 --- /dev/null +++ b/lib/x402/fetchWithPaymentStream.ts @@ -0,0 +1,48 @@ +import { wrapFetchWithPayment } from "x402-fetch"; +import { toAccount } from "viem/accounts"; +import { getAccount } from "@/lib/coinbase/getAccount"; +import { deductCredits } from "../credits/deductCredits"; +import { loadAccount } from "./loadAccount"; +import { getCreditsForPrice } from "./getCreditsForPrice"; +import { CHAT_PRICE } from "@/lib/const"; +import { parseUnits } from "viem"; + +/** + * Fetches a URL with x402 payment handling for POST requests with streaming response. + * + * This function: + * 1. Gets the account for the given accountId + * 2. Deducts credits from the account + * 3. Loads the account wallet and sends USDC for the payment + * 4. Makes the x402-authenticated request + * 5. Returns the streaming response + * + * @param url - The URL to fetch. + * @param accountId - The account ID. + * @param body - The request body to send. + * @param price - The price for the request (defaults to CHAT_PRICE). + * @returns Promise resolving to the Response (streaming). + */ +export async function fetchWithPaymentStream( + url: string, + accountId: string, + body: unknown, + price: string = CHAT_PRICE, +): Promise { + const account = await getAccount(accountId); + const creditsToDeduct = getCreditsForPrice(price); + await deductCredits({ accountId, creditsToDeduct }); + await loadAccount(account.address); + const fetchWithPaymentWrapper = wrapFetchWithPayment( + fetch, + toAccount(account), + parseUnits(price, 6), + ); + return fetchWithPaymentWrapper(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} diff --git a/lib/x402/recoup/__tests__/x402Chat.test.ts b/lib/x402/recoup/__tests__/x402Chat.test.ts new file mode 100644 index 00000000..bd36d2d9 --- /dev/null +++ b/lib/x402/recoup/__tests__/x402Chat.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { x402Chat } from "../x402Chat"; +import { fetchWithPaymentStream } from "../../fetchWithPaymentStream"; + +// Mock fetchWithPaymentStream +vi.mock("../../fetchWithPaymentStream", () => ({ + fetchWithPaymentStream: vi.fn(), +})); + +const mockFetchWithPaymentStream = vi.mocked(fetchWithPaymentStream); + +describe("x402Chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchWithPaymentStream.mockResolvedValue( + new Response("streaming response", { + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + it("calls fetchWithPaymentStream with correct URL", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + "https://api.example.com/api/x402/chat", + "account-123", + expect.any(Object), + ); + }); + + it("includes messages and accountId in request body", async () => { + const body = { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi!" }, + ], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + messages: body.messages, + accountId: "account-123", + }), + ); + }); + + it("includes optional prompt in request body", async () => { + const body = { + messages: [], + prompt: "Hello, world!", + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + prompt: "Hello, world!", + }), + ); + }); + + it("includes optional roomId in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + roomId: "room-456", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + roomId: "room-456", + }), + ); + }); + + it("includes optional artistId in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + artistId: "artist-789", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + artistId: "artist-789", + }), + ); + }); + + it("includes organizationId when orgId is provided", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: "org-456", + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + organizationId: "org-456", + }), + ); + }); + + it("does not include organizationId when orgId is null", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + const [, , requestBody] = mockFetchWithPaymentStream.mock.calls[0]; + expect(requestBody).not.toHaveProperty("organizationId"); + }); + + it("includes optional model in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + model: "gpt-4", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + model: "gpt-4", + }), + ); + }); + + it("includes optional excludeTools in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + excludeTools: ["tool1", "tool2"], + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + excludeTools: ["tool1", "tool2"], + }), + ); + }); + + it("returns the streaming response", async () => { + const expectedResponse = new Response("streaming data", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + mockFetchWithPaymentStream.mockResolvedValue(expectedResponse); + + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + const result = await x402Chat(body, "https://api.example.com"); + + expect(result).toBe(expectedResponse); + }); + + it("propagates errors from fetchWithPaymentStream", async () => { + mockFetchWithPaymentStream.mockRejectedValue(new Error("Payment failed")); + + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await expect(x402Chat(body, "https://api.example.com")).rejects.toThrow("Payment failed"); + }); + + it("handles all optional fields together", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + prompt: "Additional prompt", + accountId: "account-123", + roomId: "room-456", + artistId: "artist-789", + orgId: "org-abc", + model: "gpt-4", + excludeTools: ["tool1"], + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + "https://api.example.com/api/x402/chat", + "account-123", + { + messages: body.messages, + prompt: "Additional prompt", + accountId: "account-123", + roomId: "room-456", + artistId: "artist-789", + organizationId: "org-abc", + model: "gpt-4", + excludeTools: ["tool1"], + }, + ); + }); +}); diff --git a/lib/x402/recoup/x402Chat.ts b/lib/x402/recoup/x402Chat.ts new file mode 100644 index 00000000..4ff65eaa --- /dev/null +++ b/lib/x402/recoup/x402Chat.ts @@ -0,0 +1,61 @@ +import { fetchWithPaymentStream } from "../fetchWithPaymentStream"; +import type { ChatRequestBody } from "@/lib/chat/validateChatRequest"; + +/** + * Request body for the x402 chat endpoint. + * Similar to ChatRequestBody but with accountId required. + */ +export interface X402ChatRequestBody { + messages: unknown[]; + prompt?: string; + roomId?: string; + accountId: string; + artistId?: string; + organizationId?: string; + model?: string; + excludeTools?: string[]; +} + +/** + * Calls the x402-protected chat endpoint with payment. + * + * This function: + * 1. Deducts credits from the account + * 2. Makes the x402 payment (USDC transfer) + * 3. Forwards the request to the x402 chat endpoint + * 4. Returns the streaming response + * + * @param body - The validated chat request body. + * @param baseUrl - The base URL for the API. + * @returns Promise resolving to the streaming Response. + */ +export async function x402Chat(body: ChatRequestBody, baseUrl: string): Promise { + const x402Url = new URL("/api/x402/chat", baseUrl); + + // Build the request body for the x402 endpoint + const x402Body: X402ChatRequestBody = { + messages: body.messages, + accountId: body.accountId, + }; + + if (body.prompt) { + x402Body.prompt = body.prompt; + } + if (body.roomId) { + x402Body.roomId = body.roomId; + } + if (body.artistId) { + x402Body.artistId = body.artistId; + } + if (body.orgId) { + x402Body.organizationId = body.orgId; + } + if (body.model) { + x402Body.model = body.model; + } + if (body.excludeTools) { + x402Body.excludeTools = body.excludeTools; + } + + return fetchWithPaymentStream(x402Url.toString(), body.accountId, x402Body); +} diff --git a/middleware.ts b/middleware.ts index 0f844aab..86519a1c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,8 @@ import { facilitator } from "@coinbase/x402"; import { paymentMiddleware } from "x402-next"; -import { IMAGE_GENERATE_PRICE, SMART_ACCOUNT_ADDRESS } from "./lib/const"; +import { CHAT_PRICE, IMAGE_GENERATE_PRICE, SMART_ACCOUNT_ADDRESS } from "./lib/const"; -const inputSchema = { +const imageInputSchema = { queryParams: { prompt: "Text prompt describing the image to generate", files: @@ -10,6 +10,20 @@ const inputSchema = { }, }; +const chatInputSchema = { + bodyType: "json" as const, + bodyFields: { + messages: + "Array of chat messages in the format { role: 'user' | 'assistant', content: string }", + prompt: "Alternative to messages - a simple string prompt (mutually exclusive with messages)", + roomId: "Optional UUID of the chat room for conversation continuity", + artistId: "Optional UUID of the artist account for context", + accountId: "The account ID of the user making the request", + model: "Optional model ID override", + excludeTools: "Optional array of tool names to exclude", + }, +}; + // Match the image generation endpoint schema const imageGenerateOutputSchema = { type: "object" as const, @@ -52,6 +66,18 @@ const imageGenerateOutputSchema = { }, }; +// Chat endpoint output schema (streaming response) +const chatOutputSchema = { + type: "object" as const, + description: "Streaming chat response with AI-generated messages", + properties: { + stream: { + type: "string" as const, + description: "Server-sent events stream containing chat messages and tool results", + }, + }, +}; + export const middleware = paymentMiddleware( SMART_ACCOUNT_ADDRESS, { @@ -59,10 +85,20 @@ export const middleware = paymentMiddleware( price: `$${IMAGE_GENERATE_PRICE}`, network: "base", config: { - discoverable: true, // make endpoint discoverable + discoverable: true, description: "Generate an image from a text prompt using AI", outputSchema: imageGenerateOutputSchema, - inputSchema, + inputSchema: imageInputSchema, + }, + }, + "POST /api/x402/chat": { + price: `$${CHAT_PRICE}`, + network: "base", + config: { + discoverable: true, + description: "Chat with an AI agent that can use tools to help with tasks", + outputSchema: chatOutputSchema, + inputSchema: chatInputSchema, }, }, }, From 873d9e2d00ca5097f7a7ce40a3298b17ad3d7a4c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 17:51:16 -0500 Subject: [PATCH 2/7] refactor: extract streamChatResponse to follow KISS principle Extract the shared streaming logic into streamChatResponse function that both handleChatStream and handleChatStreamX402 can use. This eliminates code duplication and follows the KISS principle. Co-Authored-By: Claude Opus 4.5 --- lib/chat/handleChatStream.ts | 59 ++------------------------- lib/chat/handleChatStreamX402.ts | 55 ++------------------------ lib/chat/streamChatResponse.ts | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 108 deletions(-) create mode 100644 lib/chat/streamChatResponse.ts diff --git a/lib/chat/handleChatStream.ts b/lib/chat/handleChatStream.ts index fe971374..6a562d22 100644 --- a/lib/chat/handleChatStream.ts +++ b/lib/chat/handleChatStream.ts @@ -1,18 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { handleChatCompletion } from "./handleChatCompletion"; import { validateChatRequest } from "./validateChatRequest"; -import { setupChatRequest } from "./setupChatRequest"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import generateUUID from "@/lib/uuid/generateUUID"; +import { streamChatResponse } from "./streamChatResponse"; /** * Handles a streaming chat request. * * This function: * 1. Validates the request (auth, body schema) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Creates a streaming response using the AI SDK + * 2. Delegates to streamChatResponse for the actual streaming * * @param request - The incoming NextRequest * @returns A streaming response or error NextResponse @@ -22,54 +17,6 @@ export async function handleChatStream(request: NextRequest): Promise if (validatedBodyOrError instanceof NextResponse) { return validatedBodyOrError; } - const body = validatedBodyOrError; - try { - const chatConfig = await setupChatRequest(body); - const { agent } = chatConfig; - - const stream = createUIMessageStream({ - originalMessages: body.messages, - generateId: generateUUID, - execute: async (options) => { - const { writer } = options; - const result = await agent.stream(chatConfig); - writer.merge(result.toUIMessageStream()); - // Note: Credit handling and chat completion handling will be added - // as part of the handleChatCredits and handleChatCompletion migrations - }, - onFinish: async (event) => { - if (event.isAborted) { - return; - } - const assistantMessages = event.messages.filter( - (message) => message.role === "assistant", - ); - const responseMessages = - assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; - await handleChatCompletion(body, responseMessages); - }, - onError: (e) => { - console.error("/api/chat onError:", e); - return JSON.stringify({ - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }); - }, - }); - - return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); - } catch (e) { - console.error("/api/chat Global error:", e); - return NextResponse.json( - { - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); - } + return streamChatResponse(validatedBodyOrError, "/api/chat"); } diff --git a/lib/chat/handleChatStreamX402.ts b/lib/chat/handleChatStreamX402.ts index 27867d64..25c5afc7 100644 --- a/lib/chat/handleChatStreamX402.ts +++ b/lib/chat/handleChatStreamX402.ts @@ -1,18 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { handleChatCompletion } from "./handleChatCompletion"; import { validateChatRequestX402 } from "./validateChatRequestX402"; -import { setupChatRequest } from "./setupChatRequest"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import generateUUID from "@/lib/uuid/generateUUID"; +import { streamChatResponse } from "./streamChatResponse"; /** * Handles a streaming chat request for the x402-protected endpoint. * * This function: * 1. Validates the request (body schema only - auth is handled by x402 payment) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Creates a streaming response using the AI SDK + * 2. Delegates to streamChatResponse for the actual streaming * * The accountId is passed in the request body and trusted because the caller * has already paid via x402 payment verification. @@ -25,50 +20,6 @@ export async function handleChatStreamX402(request: NextRequest): Promise { - const { writer } = options; - const result = await agent.stream(chatConfig); - writer.merge(result.toUIMessageStream()); - }, - onFinish: async event => { - if (event.isAborted) { - return; - } - const assistantMessages = event.messages.filter(message => message.role === "assistant"); - const responseMessages = - assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; - await handleChatCompletion(body, responseMessages); - }, - onError: e => { - console.error("/api/x402/chat onError:", e); - return JSON.stringify({ - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }); - }, - }); - - return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); - } catch (e) { - console.error("/api/x402/chat Global error:", e); - return NextResponse.json( - { - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); - } + return streamChatResponse(validatedBodyOrError, "/api/x402/chat"); } diff --git a/lib/chat/streamChatResponse.ts b/lib/chat/streamChatResponse.ts new file mode 100644 index 00000000..5a8a3eea --- /dev/null +++ b/lib/chat/streamChatResponse.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { handleChatCompletion } from "./handleChatCompletion"; +import { setupChatRequest } from "./setupChatRequest"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import generateUUID from "@/lib/uuid/generateUUID"; +import type { ChatRequestBody } from "./validateChatRequest"; + +/** + * Creates a streaming chat response from a validated request body. + * + * This is the core streaming logic shared by both: + * - handleChatStream (for /api/chat with auth validation) + * - handleChatStreamX402 (for /api/x402/chat with x402 payment validation) + * + * @param body - The validated chat request body with accountId resolved. + * @param logPrefix - Optional prefix for error logs (default: "/api/chat"). + * @returns A streaming response or error NextResponse. + */ +export async function streamChatResponse( + body: ChatRequestBody, + logPrefix: string = "/api/chat", +): Promise { + try { + const chatConfig = await setupChatRequest(body); + const { agent } = chatConfig; + + const stream = createUIMessageStream({ + originalMessages: body.messages, + generateId: generateUUID, + execute: async options => { + const { writer } = options; + const result = await agent.stream(chatConfig); + writer.merge(result.toUIMessageStream()); + }, + onFinish: async event => { + if (event.isAborted) { + return; + } + const assistantMessages = event.messages.filter(message => message.role === "assistant"); + const responseMessages = + assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; + await handleChatCompletion(body, responseMessages); + }, + onError: e => { + console.error(`${logPrefix} onError:`, e); + return JSON.stringify({ + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }); + }, + }); + + return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); + } catch (e) { + console.error(`${logPrefix} Global error:`, e); + return NextResponse.json( + { + status: "error", + message: e instanceof Error ? e.message : "Unknown error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} From c67b6e1a9b627530e2f98557c75e60b0fdd8d678 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 17:55:38 -0500 Subject: [PATCH 3/7] chore: remove unused handleChatStream (all traffic goes through x402) Since /api/chat now routes through x402, handleChatStream is no longer used. Remove it and its tests to follow YAGNI principle. Co-Authored-By: Claude Opus 4.5 --- lib/chat/__tests__/handleChatStream.test.ts | 329 -------------------- lib/chat/handleChatStream.ts | 22 -- 2 files changed, 351 deletions(-) delete mode 100644 lib/chat/__tests__/handleChatStream.test.ts delete mode 100644 lib/chat/handleChatStream.ts diff --git a/lib/chat/__tests__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts deleted file mode 100644 index b29127dc..00000000 --- a/lib/chat/__tests__/handleChatStream.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextResponse } from "next/server"; - -// Mock all dependencies before importing the module under test -vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ - getApiKeyAccountId: vi.fn(), -})); - -vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ - getAuthenticatedAccountId: vi.fn(), -})); - -vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ - validateOverrideAccountId: vi.fn(), -})); - -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: vi.fn(), -})); - -vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ - validateOrganizationAccess: vi.fn(), -})); - -vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn().mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }), -})); - -vi.mock("@/lib/chat/validateMessages", () => ({ - validateMessages: vi.fn((messages) => ({ - lastMessage: messages[messages.length - 1] || { id: "mock-id", role: "user", parts: [] }, - validMessages: messages, - })), -})); - -vi.mock("@/lib/messages/convertToUiMessages", () => ({ - default: vi.fn((messages) => messages), -})); - -vi.mock("@/lib/chat/setupChatRequest", () => ({ - setupChatRequest: vi.fn(), -})); - -vi.mock("@/lib/chat/handleChatCompletion", () => ({ - handleChatCompletion: vi.fn(), -})); - -vi.mock("ai", () => ({ - createUIMessageStream: vi.fn(), - createUIMessageStreamResponse: vi.fn(), -})); - -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { setupChatRequest } from "@/lib/chat/setupChatRequest"; -import { setupConversation } from "@/lib/chat/setupConversation"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { handleChatStream } from "../handleChatStream"; - -const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); -const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); -const mockSetupConversation = vi.mocked(setupConversation); -const mockSetupChatRequest = vi.mocked(setupChatRequest); -const mockCreateUIMessageStream = vi.mocked(createUIMessageStream); -const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse); - -// Helper to create mock NextRequest -function createMockRequest( - body: unknown, - headers: Record = {}, -): Request { - return { - json: () => Promise.resolve(body), - headers: { - get: (key: string) => headers[key.toLowerCase()] || null, - has: (key: string) => key.toLowerCase() in headers, - }, - } as unknown as Request; -} - -describe("handleChatStream", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Re-setup mock return value after clearAllMocks - // Return the provided roomId if given, otherwise return mock-room-id - mockSetupConversation.mockImplementation(async ({ roomId }) => ({ - roomId: roomId || "mock-room-id", - memoryId: "mock-memory-id", - })); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("validation", () => { - it("returns 400 error when neither messages nor prompt is provided", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "test-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(400); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 401 error when no auth header is provided", async () => { - const request = createMockRequest({ prompt: "Hello" }, {}); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(401); - const json = await result.json(); - expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); - }); - }); - - describe("streaming", () => { - it("creates a streaming response for valid requests", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - - const mockResponse = new Response(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(mockResponse); - - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalled(); - expect(mockCreateUIMessageStream).toHaveBeenCalled(); - expect(mockCreateUIMessageStreamResponse).toHaveBeenCalledWith({ - stream: mockStream, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With, x-api-key", - }, - }); - expect(result).toBe(mockResponse); - }); - - it("uses messages array when provided", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest( - { messages }, - { "x-api-key": "valid-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - messages, - accountId: "account-123", - }), - ); - }); - - it("passes through optional parameters", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "claude-3-opus", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const request = createMockRequest( - { - prompt: "Hello", - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }, - { "x-api-key": "valid-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }), - ); - }); - }); - - describe("error handling", () => { - it("returns 500 error when setupChatRequest fails", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - }); - - describe("accountId override", () => { - it("allows org API key to override accountId", async () => { - mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); - mockValidateOverrideAccountId.mockResolvedValue({ - accountId: "target-account-456", - }); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "test", - system: "test", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const request = createMockRequest( - { prompt: "Hello", accountId: "target-account-456" }, - { "x-api-key": "org-api-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "target-account-456", - }), - ); - }); - }); -}); diff --git a/lib/chat/handleChatStream.ts b/lib/chat/handleChatStream.ts deleted file mode 100644 index 6a562d22..00000000 --- a/lib/chat/handleChatStream.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { validateChatRequest } from "./validateChatRequest"; -import { streamChatResponse } from "./streamChatResponse"; - -/** - * Handles a streaming chat request. - * - * This function: - * 1. Validates the request (auth, body schema) - * 2. Delegates to streamChatResponse for the actual streaming - * - * @param request - The incoming NextRequest - * @returns A streaming response or error NextResponse - */ -export async function handleChatStream(request: NextRequest): Promise { - const validatedBodyOrError = await validateChatRequest(request); - if (validatedBodyOrError instanceof NextResponse) { - return validatedBodyOrError; - } - - return streamChatResponse(validatedBodyOrError, "/api/chat"); -} From f03540a97d62fa938de99de44f27a6abc2100eb7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 18:03:03 -0500 Subject: [PATCH 4/7] fix: restrict x402 middleware to /api/x402/* routes only The middleware matcher was running on all /api/* routes, which caused CORS preflight (OPTIONS) requests to /api/chat to be intercepted and redirected by the x402 middleware. Now the middleware only runs on /api/x402/* routes where x402 payment protection is needed. Co-Authored-By: Claude Opus 4.5 --- middleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 86519a1c..8109535d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -111,7 +111,8 @@ export const middleware = paymentMiddleware( ); // Configure which paths the middleware should run on +// Only run x402 middleware on x402-protected routes to avoid interfering with CORS preflight export const config = { - matcher: ["/protected/:path*", "/api/:path*"], + matcher: ["/protected/:path*", "/api/x402/:path*"], runtime: "nodejs", }; From eb7daf86c2787bd3a585004f1fd978a9083b557b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 18:07:17 -0500 Subject: [PATCH 5/7] fix: filter duplicate CORS headers from internal x402 response The x402 internal response already contains CORS headers, and we were adding our own on top, resulting in 'Access-Control-Allow-Origin: *, *'. Now we filter out any access-control-* headers from the internal response before adding our own CORS headers. Co-Authored-By: Claude Opus 4.5 --- app/api/chat/route.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 39464711..0e0c68c2 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -64,12 +64,20 @@ export async function POST(request: NextRequest): Promise { // Route through x402 endpoint (handles credit deduction and payment) const response = await x402Chat(chatBody, baseUrl); - // Return the streaming response with CORS headers + // Filter out CORS headers from internal response to avoid duplicates + const responseHeaders = Object.fromEntries(response.headers.entries()); + const filteredHeaders = Object.fromEntries( + Object.entries(responseHeaders).filter( + ([key]) => !key.toLowerCase().startsWith("access-control-"), + ), + ); + + // Return the streaming response with our CORS headers return new Response(response.body, { status: response.status, statusText: response.statusText, headers: { - ...Object.fromEntries(response.headers.entries()), + ...filteredHeaders, ...getCorsHeaders(), }, }); From 8aaf87ead3e802ed95ccf2fd9025e6f72594ae96 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 18:18:38 -0500 Subject: [PATCH 6/7] fix: pass correct price to loadAccount instead of hardcoded $0.15 loadAccount was hardcoded to use IMAGE_GENERATE_PRICE ($0.15) for all transfers. Now it accepts a price parameter so chat uses CHAT_PRICE ($0.01) and image generation uses IMAGE_GENERATE_PRICE ($0.15). Changes: - Update loadAccount to accept price parameter - Update fetchWithPayment to pass IMAGE_GENERATE_PRICE - Update fetchWithPaymentStream to pass the price parameter - Add tests for loadAccount - Update fetchWithPaymentStream tests Co-Authored-By: Claude Opus 4.5 --- .../__tests__/fetchWithPaymentStream.test.ts | 8 +- lib/x402/__tests__/loadAccount.test.ts | 76 +++++++++++++++++++ lib/x402/fetchWithPayment.ts | 2 +- lib/x402/fetchWithPaymentStream.ts | 2 +- lib/x402/loadAccount.ts | 6 +- 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 lib/x402/__tests__/loadAccount.test.ts diff --git a/lib/x402/__tests__/fetchWithPaymentStream.test.ts b/lib/x402/__tests__/fetchWithPaymentStream.test.ts index b1e3d481..626d620c 100644 --- a/lib/x402/__tests__/fetchWithPaymentStream.test.ts +++ b/lib/x402/__tests__/fetchWithPaymentStream.test.ts @@ -86,7 +86,7 @@ describe("fetchWithPaymentStream", () => { }); }); - it("loads the account wallet", async () => { + it("loads the account wallet with correct price", async () => { const mockFetch = vi.fn().mockResolvedValue(new Response("test")); mockWrapFetchWithPayment.mockReturnValue(mockFetch); mockGetAccount.mockResolvedValue({ @@ -95,7 +95,7 @@ describe("fetchWithPaymentStream", () => { await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); - expect(mockLoadAccount).toHaveBeenCalledWith("0xWalletAddress123"); + expect(mockLoadAccount).toHaveBeenCalledWith("0xWalletAddress123", CHAT_PRICE); }); it("makes POST request with JSON body", async () => { @@ -131,6 +131,9 @@ describe("fetchWithPaymentStream", () => { it("uses custom price when provided", async () => { const mockFetch = vi.fn().mockResolvedValue(new Response("test")); mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetAccount.mockResolvedValue({ + address: "0xCustomAddress", + } as any); await fetchWithPaymentStream( "https://example.com/api", @@ -140,6 +143,7 @@ describe("fetchWithPaymentStream", () => { ); expect(mockGetCreditsForPrice).toHaveBeenCalledWith("0.05"); + expect(mockLoadAccount).toHaveBeenCalledWith("0xCustomAddress", "0.05"); }); it("throws error if account retrieval fails", async () => { diff --git a/lib/x402/__tests__/loadAccount.test.ts b/lib/x402/__tests__/loadAccount.test.ts new file mode 100644 index 00000000..83324394 --- /dev/null +++ b/lib/x402/__tests__/loadAccount.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loadAccount } from "../loadAccount"; +import { sendUserOpAndWait } from "@/lib/coinbase/sendUserOpAndWait"; +import { getTransferCalls } from "@/lib/x402/getTransferCalls"; +import { IMAGE_GENERATE_PRICE, CHAT_PRICE } from "@/lib/const"; + +// Mock dependencies +vi.mock("@/lib/coinbase/sendUserOpAndWait", () => ({ + sendUserOpAndWait: vi.fn(), +})); + +vi.mock("@/lib/x402/getTransferCalls", () => ({ + getTransferCalls: vi.fn(), +})); + +const mockSendUserOpAndWait = vi.mocked(sendUserOpAndWait); +const mockGetTransferCalls = vi.mocked(getTransferCalls); + +describe("loadAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSendUserOpAndWait.mockResolvedValue("0xTransactionHash123"); + mockGetTransferCalls.mockReturnValue([{ to: "0x123", data: "0x" }] as never); + }); + + it("sends USDC to the recipient address", async () => { + const recipientAddress = "0xRecipient123" as `0x${string}`; + + await loadAccount(recipientAddress, IMAGE_GENERATE_PRICE); + + expect(mockGetTransferCalls).toHaveBeenCalledWith(recipientAddress, IMAGE_GENERATE_PRICE); + expect(mockSendUserOpAndWait).toHaveBeenCalled(); + }); + + it("uses the provided price for the transfer", async () => { + const recipientAddress = "0xRecipient456" as `0x${string}`; + + await loadAccount(recipientAddress, CHAT_PRICE); + + expect(mockGetTransferCalls).toHaveBeenCalledWith(recipientAddress, CHAT_PRICE); + }); + + it("returns the transaction hash", async () => { + mockSendUserOpAndWait.mockResolvedValue("0xSuccessHash"); + const recipientAddress = "0xRecipient789" as `0x${string}`; + + const result = await loadAccount(recipientAddress, IMAGE_GENERATE_PRICE); + + expect(result).toBe("0xSuccessHash"); + }); + + it("throws error when sendUserOpAndWait fails", async () => { + mockSendUserOpAndWait.mockRejectedValue(new Error("Transaction failed")); + const recipientAddress = "0xRecipientFail" as `0x${string}`; + + await expect(loadAccount(recipientAddress, IMAGE_GENERATE_PRICE)).rejects.toThrow( + "Failed to load account and send USDC: Transaction failed", + ); + }); + + it("passes different prices correctly", async () => { + const recipientAddress = "0xRecipient" as `0x${string}`; + + // Test with image price + await loadAccount(recipientAddress, "0.15"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.15"); + + // Test with chat price + await loadAccount(recipientAddress, "0.01"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.01"); + + // Test with custom price + await loadAccount(recipientAddress, "0.50"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.50"); + }); +}); diff --git a/lib/x402/fetchWithPayment.ts b/lib/x402/fetchWithPayment.ts index 8a518aa0..a1958351 100644 --- a/lib/x402/fetchWithPayment.ts +++ b/lib/x402/fetchWithPayment.ts @@ -18,7 +18,7 @@ export async function fetchWithPayment(url: string, accountId: string): Promise< const account = await getAccount(accountId); const creditsToDeduct = getCreditsForPrice(IMAGE_GENERATE_PRICE); await deductCredits({ accountId, creditsToDeduct }); - await loadAccount(account.address); + await loadAccount(account.address, IMAGE_GENERATE_PRICE); const fetchWithPaymentWrapper = wrapFetchWithPayment( fetch, toAccount(account), diff --git a/lib/x402/fetchWithPaymentStream.ts b/lib/x402/fetchWithPaymentStream.ts index f2835503..303b3d50 100644 --- a/lib/x402/fetchWithPaymentStream.ts +++ b/lib/x402/fetchWithPaymentStream.ts @@ -32,7 +32,7 @@ export async function fetchWithPaymentStream( const account = await getAccount(accountId); const creditsToDeduct = getCreditsForPrice(price); await deductCredits({ accountId, creditsToDeduct }); - await loadAccount(account.address); + await loadAccount(account.address, price); const fetchWithPaymentWrapper = wrapFetchWithPayment( fetch, toAccount(account), diff --git a/lib/x402/loadAccount.ts b/lib/x402/loadAccount.ts index 80736ddf..220db697 100644 --- a/lib/x402/loadAccount.ts +++ b/lib/x402/loadAccount.ts @@ -1,17 +1,17 @@ import { sendUserOpAndWait } from "@/lib/coinbase/sendUserOpAndWait"; import { getTransferCalls } from "@/lib/x402/getTransferCalls"; import type { Address } from "viem"; -import { IMAGE_GENERATE_PRICE } from "@/lib/const"; /** * Loads an account, gets or creates a smart account, and sends USDC to the specified address. * * @param recipientAddress - The address to send USDC to. + * @param price - The price in USDC to send (e.g., "0.01" for $0.01). * @returns Promise resolving to the transaction hash. */ -export async function loadAccount(recipientAddress: Address): Promise { +export async function loadAccount(recipientAddress: Address, price: string): Promise { try { - const calls = getTransferCalls(recipientAddress, IMAGE_GENERATE_PRICE); + const calls = getTransferCalls(recipientAddress, price); const transactionHash = await sendUserOpAndWait(calls); From b5b70852d1c2c02594f994d9ab8e82a77de37a1a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 29 Jan 2026 18:20:53 -0500 Subject: [PATCH 7/7] fix: add Content-Encoding: none header to fix glitchy streaming When streaming responses go through proxy middleware, they can be buffered/compressed causing glitchy streaming. Adding Content-Encoding: none header prevents this. See: https://ai-sdk.dev/docs/troubleshooting/streaming-not-working-when-proxied Co-Authored-By: Claude Opus 4.5 --- app/api/chat/route.ts | 2 ++ lib/chat/streamChatResponse.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0e0c68c2..c5c8f429 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -73,12 +73,14 @@ export async function POST(request: NextRequest): Promise { ); // Return the streaming response with our CORS headers + // Add Content-Encoding: none to prevent proxy middleware from buffering the stream return new Response(response.body, { status: response.status, statusText: response.statusText, headers: { ...filteredHeaders, ...getCorsHeaders(), + "Content-Encoding": "none", }, }); } catch (error) { diff --git a/lib/chat/streamChatResponse.ts b/lib/chat/streamChatResponse.ts index 5a8a3eea..bad4411a 100644 --- a/lib/chat/streamChatResponse.ts +++ b/lib/chat/streamChatResponse.ts @@ -51,7 +51,15 @@ export async function streamChatResponse( }, }); - return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); + // Add Content-Encoding: none to prevent proxy middleware from buffering the stream + // See: https://ai-sdk.dev/docs/troubleshooting/streaming-not-working-when-proxied + return createUIMessageStreamResponse({ + stream, + headers: { + ...getCorsHeaders(), + "Content-Encoding": "none", + }, + }); } catch (e) { console.error(`${logPrefix} Global error:`, e); return NextResponse.json(