diff --git a/packages/app/server/package.json b/packages/app/server/package.json index a08ca04df..a261fdca4 100644 --- a/packages/app/server/package.json +++ b/packages/app/server/package.json @@ -71,6 +71,7 @@ "google-auth-library": "^10.3.0", "jose": "^6.0.11", "multer": "^2.0.2", + "neverthrow": "^8.2.0", "openai": "^6.2.0", "prisma": "6.16.0", "typescript": "^5.3.3", diff --git a/packages/app/server/src/__tests__/endpoints.test.ts b/packages/app/server/src/__tests__/endpoints.test.ts index 94a72984d..684c62126 100644 --- a/packages/app/server/src/__tests__/endpoints.test.ts +++ b/packages/app/server/src/__tests__/endpoints.test.ts @@ -3,6 +3,7 @@ import { ReadableStream } from 'stream/web'; import type express from 'express'; import request from 'supertest'; import { vi } from 'vitest'; +import { ok } from 'neverthrow'; import { EchoControlService } from '../services/EchoControlService'; @@ -208,12 +209,21 @@ const setupMockEchoControlService = (balance: number = 1000) => { echoApp: MOCK_ECHO_APP, }); - (mockInstance.getBalance as any).mockResolvedValue(balance); + (mockInstance.getBalance as any).mockResolvedValue(ok(balance)); (mockInstance.getUserId as any).mockReturnValue(TEST_USER_ID); (mockInstance.getEchoAppId as any).mockReturnValue(TEST_ECHO_APP_ID); (mockInstance.getUser as any).mockReturnValue(MOCK_USER); (mockInstance.getEchoApp as any).mockReturnValue(MOCK_ECHO_APP); - (mockInstance.createTransaction as any).mockResolvedValue(undefined); + (mockInstance.createTransaction as any).mockResolvedValue(ok(undefined)); + (mockInstance.computeTransactionCosts as any).mockResolvedValue(ok({ + rawTransactionCost: 0.01, + totalTransactionCost: 0.01, + totalAppProfit: 0, + referralProfit: 0, + markUpProfit: 0, + echoProfit: 0, + })); + (mockInstance.getOrNoneFreeTierSpendPool as any).mockResolvedValue(ok(null)); MockedEchoControlService.mockImplementation(() => mockInstance); @@ -735,7 +745,7 @@ describe('Endpoint Tests', () => { describe('Account Balance Tests', () => { it('should reject requests when account has insufficient balance', async () => { // Setup EchoControlService with zero balance - (mockEchoControlService.getBalance as any).mockResolvedValue(0); + (mockEchoControlService.getBalance as any).mockResolvedValue(ok(0)); const response = await request(app) .post('/chat/completions') diff --git a/packages/app/server/src/__tests__/server.test.ts b/packages/app/server/src/__tests__/server.test.ts index 64fd8367e..ad76c68dd 100644 --- a/packages/app/server/src/__tests__/server.test.ts +++ b/packages/app/server/src/__tests__/server.test.ts @@ -3,6 +3,7 @@ import { ReadableStream } from 'stream/web'; import type express from 'express'; import request from 'supertest'; import { vi } from 'vitest'; +import { ok } from 'neverthrow'; import { EchoControlService } from '../services/EchoControlService'; @@ -40,12 +41,21 @@ const setupMockEchoControlService = (balance: number = 100) => { echoApp: MOCK_ECHO_APP, }); - (mockInstance.getBalance as any).mockResolvedValue(balance); + (mockInstance.getBalance as any).mockResolvedValue(ok(balance)); (mockInstance.getUserId as any).mockReturnValue(TEST_USER_ID); (mockInstance.getEchoAppId as any).mockReturnValue(TEST_ECHO_APP_ID); (mockInstance.getUser as any).mockReturnValue(MOCK_USER); (mockInstance.getEchoApp as any).mockReturnValue(MOCK_ECHO_APP); - (mockInstance.createTransaction as any).mockResolvedValue(undefined); + (mockInstance.createTransaction as any).mockResolvedValue(ok(undefined)); + (mockInstance.computeTransactionCosts as any).mockResolvedValue(ok({ + rawTransactionCost: 0.01, + totalTransactionCost: 0.01, + totalAppProfit: 0, + referralProfit: 0, + markUpProfit: 0, + echoProfit: 0, + })); + (mockInstance.getOrNoneFreeTierSpendPool as any).mockResolvedValue(ok(null)); MockedEchoControlService.mockImplementation(() => mockInstance); diff --git a/packages/app/server/src/__tests__/setup.ts b/packages/app/server/src/__tests__/setup.ts index 76980767b..190e384c1 100644 --- a/packages/app/server/src/__tests__/setup.ts +++ b/packages/app/server/src/__tests__/setup.ts @@ -1,5 +1,6 @@ import dotenv from 'dotenv'; import { vi } from 'vitest'; +import { ok } from 'neverthrow'; // Load environment variables from .env.test if it exists, otherwise from .env dotenv.config({ path: '.env.test' }); @@ -9,8 +10,17 @@ vi.mock('../services/EchoControlService', () => { return { EchoControlService: vi.fn().mockImplementation(() => ({ verifyApiKey: vi.fn(), - getBalance: vi.fn(), - createTransaction: vi.fn().mockResolvedValue(undefined), + getBalance: vi.fn().mockResolvedValue(ok(100)), + createTransaction: vi.fn().mockResolvedValue(ok(undefined)), + computeTransactionCosts: vi.fn().mockResolvedValue(ok({ + rawTransactionCost: 0.01, + totalTransactionCost: 0.01, + totalAppProfit: 0, + referralProfit: 0, + markUpProfit: 0, + echoProfit: 0, + })), + getOrNoneFreeTierSpendPool: vi.fn().mockResolvedValue(ok(null)), getUserId: vi.fn(), getEchoAppId: vi.fn(), getUser: vi.fn(), diff --git a/packages/app/server/src/auth/headers.ts b/packages/app/server/src/auth/headers.ts index f3ff4c3ef..72205f1ec 100644 --- a/packages/app/server/src/auth/headers.ts +++ b/packages/app/server/src/auth/headers.ts @@ -1,13 +1,26 @@ import { context, trace } from '@opentelemetry/api'; -import { UnauthorizedError } from '../errors/http'; +import { ResultAsync } from 'neverthrow'; +import { + AuthenticationError, + MissingHeaderError, + InvalidApiKeyError +} from '../errors'; +import { AppResultAsync } from '../errors/result-helpers'; import type { PrismaClient } from '../generated/prisma'; import logger from '../logger'; import { EchoControlService } from '../services/EchoControlService'; -export const verifyUserHeaderCheck = async ( +/** + * Processes authentication headers and returns processed headers with EchoControlService + * + * @param headers - Request headers + * @param prisma - Prisma client instance + * @returns ResultAsync containing tuple of processed headers and EchoControlService + */ +export const verifyUserHeaderCheck = ( headers: Record, prisma: PrismaClient -): Promise<[Record, EchoControlService]> => { +): AppResultAsync<[Record, EchoControlService], AuthenticationError | MissingHeaderError | InvalidApiKeyError> => { /** * Process authentication for the user (authenticated with Echo Api Key) * @@ -39,32 +52,47 @@ export const verifyUserHeaderCheck = async ( if (!(authorization || xApiKey || xGoogleApiKey)) { logger.error(`Missing authentication headers: ${JSON.stringify(headers)}`); - throw new UnauthorizedError('Please include auth headers.'); + return ResultAsync.fromPromise( + Promise.reject(new MissingHeaderError('authentication', 'Please include auth headers.')), + (e) => e as MissingHeaderError + ); } const apiKey = authorization ?? xApiKey ?? xGoogleApiKey; const cleanApiKey = apiKey?.replace('Bearer ', '') ?? ''; + const echoControlService = new EchoControlService(prisma, cleanApiKey); - const authResult = await echoControlService.verifyApiKey(); + + return ResultAsync.fromPromise( + echoControlService.verifyApiKey(), + (e) => new InvalidApiKeyError({ apiKey: cleanApiKey.substring(0, 8) + '...' }) + ) + .andThen((authResult) => { + if (!authResult) { + logger.error('API key validation returned null'); + return ResultAsync.fromPromise( + Promise.reject(new InvalidApiKeyError({ apiKey: cleanApiKey.substring(0, 8) + '...' })), + (e) => e as InvalidApiKeyError + ); + } - if (!authResult) { - throw new UnauthorizedError('Authentication failed.'); - } + const span = trace.getSpan(context.active()); + if (span) { + span.setAttribute('echo.app.id', authResult.echoApp.id); + span.setAttribute('echo.app.name', authResult.echoApp.name); + span.setAttribute('echo.user.id', authResult.user.id); + span.setAttribute('echo.user.email', authResult.user.email); + span.setAttribute('echo.user.name', authResult.user.name ?? ''); + } - const span = trace.getSpan(context.active()); - if (span) { - span.setAttribute('echo.app.id', authResult.echoApp.id); - span.setAttribute('echo.app.name', authResult.echoApp.name); - span.setAttribute('echo.user.id', authResult.user.id); - span.setAttribute('echo.user.email', authResult.user.email); - span.setAttribute('echo.user.name', authResult.user.name ?? ''); - } + const processedHeaders = { + ...restHeaders, + 'accept-encoding': 'gzip, deflate', + }; - return [ - { - ...restHeaders, - 'accept-encoding': 'gzip, deflate', - }, - echoControlService, - ]; + return ResultAsync.fromPromise( + Promise.resolve([processedHeaders, echoControlService] as [Record, EchoControlService]), + (e) => new AuthenticationError('Failed to create auth result') + ); + }); }; diff --git a/packages/app/server/src/auth/index.ts b/packages/app/server/src/auth/index.ts index 4dc62c374..27cc59e31 100644 --- a/packages/app/server/src/auth/index.ts +++ b/packages/app/server/src/auth/index.ts @@ -2,6 +2,12 @@ import { isX402Request } from 'utils'; import type { PrismaClient } from '../generated/prisma'; import { EchoControlService } from '../services/EchoControlService'; import { verifyUserHeaderCheck } from './headers'; +import { + AppResultAsync, + AuthenticationError, + MissingHeaderError, + InvalidApiKeyError +} from '../errors'; /** * Handles complete authentication flow including path extraction, header verification, and app ID validation. @@ -11,26 +17,20 @@ import { verifyUserHeaderCheck } from './headers'; * 2. Verifies user authentication headers * 3. Validates that the authenticated user has permission to use the specified app * - * @param path - The request path * @param headers - The request headers - * @returns Object containing processedHeaders, echoControlService, and forwardingPath - * @throws UnauthorizedError if authentication fails or app ID validation fails + * @param prisma - Prisma client instance + * @returns ResultAsync containing object with processedHeaders and echoControlService */ -export async function authenticateRequest( +export function authenticateRequest( headers: Record, prisma: PrismaClient -): Promise<{ +): AppResultAsync<{ processedHeaders: Record; echoControlService: EchoControlService; -}> { - // Process headers and instantiate provider - const [processedHeaders, echoControlService] = await verifyUserHeaderCheck( - headers, - prisma - ); - - return { - processedHeaders, - echoControlService, - }; +}, AuthenticationError | MissingHeaderError | InvalidApiKeyError> { + return verifyUserHeaderCheck(headers, prisma) + .map(([processedHeaders, echoControlService]) => ({ + processedHeaders, + echoControlService, + })); } diff --git a/packages/app/server/src/errors/http.ts b/packages/app/server/src/errors/http.ts index 5049e44eb..3728b98cc 100644 --- a/packages/app/server/src/errors/http.ts +++ b/packages/app/server/src/errors/http.ts @@ -15,12 +15,6 @@ export class UnauthorizedError extends HttpError { } } -export class PaymentRequiredError extends HttpError { - constructor(message: string = 'Payment Required') { - super(402, message); - } -} - export class UnknownModelError extends HttpError { constructor(message: string = 'Unknown Model argument passed in') { super(400, message); diff --git a/packages/app/server/src/errors/index.ts b/packages/app/server/src/errors/index.ts new file mode 100644 index 000000000..4a9c5a842 --- /dev/null +++ b/packages/app/server/src/errors/index.ts @@ -0,0 +1,3 @@ +export * from './http'; +export * from './types'; +export * from './result-helpers'; diff --git a/packages/app/server/src/errors/result-helpers.ts b/packages/app/server/src/errors/result-helpers.ts new file mode 100644 index 000000000..adef452a1 --- /dev/null +++ b/packages/app/server/src/errors/result-helpers.ts @@ -0,0 +1,162 @@ +import { Result, ok, err, ResultAsync, fromPromise } from 'neverthrow'; +import { BaseError, UnexpectedError } from './types'; +import logger from '../logger'; + +export type AppResult = Result; +export type AppResultAsync = ResultAsync; + +export function toAppError(error: unknown, context?: Record): BaseError { + if (isBaseError(error)) { + return error; + } + + if (error instanceof Error) { + logger.error('Unexpected error caught', { + error: error.message, + stack: error.stack, + context + }); + + return new UnexpectedError(error, context); + } + + logger.error('Non-error thrown', { error, context }); + return new UnexpectedError(error, context); +} + +export function isBaseError(error: unknown): error is BaseError { + return ( + typeof error === 'object' && + error !== null && + 'type' in error && + 'message' in error && + 'statusCode' in error + ); +} + +export function safeAsync( + fn: () => Promise, + errorTransform?: (error: unknown) => E +): AppResultAsync { + return ResultAsync.fromPromise( + fn(), + (error) => (errorTransform ? errorTransform(error) : toAppError(error)) as E + ); +} + +export function safe( + fn: () => T, + errorTransform?: (error: unknown) => E +): AppResult { + try { + return ok(fn()); + } catch (error) { + return err((errorTransform ? errorTransform(error) : toAppError(error)) as E); + } +} + +export function logError(error: E): E { + logger.error(`${error.type}: ${error.message}`, { + type: error.type, + statusCode: error.statusCode, + context: error.context + }); + return error; +} + +export function mapErrorWithLog( + result: AppResult, + mapFn: (error: E1) => E2 +): AppResult { + return result.mapErr((error) => { + logError(error); + return mapFn(error); + }); +} + +export function combineResults( + results: AppResult[] +): AppResult { + const values: T[] = []; + + for (const result of results) { + if (result.isErr()) { + return err(result.error); + } + values.push(result.value); + } + + return ok(values); +} + +export function combineAsyncResults( + results: AppResultAsync[] +): AppResultAsync { + return ResultAsync.combine(results) as AppResultAsync; +} + +export function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutError: E +): AppResultAsync { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(timeoutError), timeoutMs) + ); + + return ResultAsync.fromPromise( + Promise.race([promise, timeoutPromise]), + (error) => (isBaseError(error) ? error : toAppError(error)) as E | BaseError + ); +} + +export async function retryWithBackoff( + operation: () => AppResultAsync, + maxRetries: number = 3, + initialDelayMs: number = 1000, + shouldRetry?: (error: E) => boolean +): Promise> { + let lastError: E; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const result = await operation(); + + if (result.isOk()) { + return result; + } + + lastError = result.error; + + if (shouldRetry && !shouldRetry(lastError)) { + return result; + } + + if (attempt < maxRetries) { + const delay = initialDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return err(lastError!); +} + +export function validate( + data: unknown, + validator: (data: unknown) => data is T, + errorFactory: () => E +): AppResult { + if (validator(data)) { + return ok(data); + } + return err(errorFactory()); +} + +export function fromNullable( + value: T | null | undefined, + errorFactory: () => E +): AppResult { + if (value === null || value === undefined) { + return err(errorFactory()); + } + return ok(value); +} diff --git a/packages/app/server/src/errors/types.ts b/packages/app/server/src/errors/types.ts new file mode 100644 index 000000000..f1ecef1ca --- /dev/null +++ b/packages/app/server/src/errors/types.ts @@ -0,0 +1,296 @@ +export interface BaseError { + readonly type: string; + readonly message: string; + readonly statusCode: number; + readonly context: Record | undefined; +} + +export class AuthenticationError implements BaseError { + readonly type = 'AUTHENTICATION_ERROR'; + readonly statusCode = 401; + readonly context: { reason?: string; apiKey?: string } | undefined; + + constructor( + public readonly message: string, + context?: { reason?: string; apiKey?: string } + ) { + this.context = context; + } +} + +export class AuthorizationError implements BaseError { + readonly type = 'AUTHORIZATION_ERROR'; + readonly statusCode = 403; + readonly context: { resource?: string; userId?: string } | undefined; + + constructor( + public readonly message: string, + context?: { resource?: string; userId?: string } + ) { + this.context = context; + } +} + +export class InvalidApiKeyError implements BaseError { + readonly type = 'INVALID_API_KEY'; + readonly statusCode = 401; + readonly message = 'Invalid or expired API key'; + readonly context: { apiKey?: string } | undefined; + + constructor(context?: { apiKey?: string }) { + this.context = context; + } +} + +export class ValidationError implements BaseError { + readonly type = 'VALIDATION_ERROR'; + readonly statusCode = 400; + readonly context: { field?: string; value?: any; constraints?: string[] } | undefined; + + constructor( + public readonly message: string, + context?: { field?: string; value?: any; constraints?: string[] } + ) { + this.context = context; + } +} + +export class MissingHeaderError implements BaseError { + readonly type = 'MISSING_HEADER'; + readonly statusCode = 400; + readonly context: Record | undefined; + + constructor( + public readonly headerName: string, + public readonly message: string = `Missing required header: ${headerName}`, + context?: Record + ) { + this.context = context; + } +} + +export class ResourceNotFoundError implements BaseError { + readonly type = 'RESOURCE_NOT_FOUND'; + readonly statusCode = 404; + readonly context: Record | undefined; + + constructor( + public readonly resource: string, + public readonly identifier?: string, + public readonly message: string = `${resource} not found`, + context?: Record + ) { + this.context = context; + } +} + +export class PaymentRequiredError implements BaseError { + readonly type = 'PAYMENT_REQUIRED'; + readonly statusCode = 402; + readonly message = 'Payment required to access this resource'; + readonly context: { + requiredAmount?: number; + currentBalance?: number; + resource?: string; + } | undefined; + + constructor( + context?: { + requiredAmount?: number; + currentBalance?: number; + resource?: string; + } + ) { + this.context = context; + } +} + +export class InsufficientBalanceError implements BaseError { + readonly type = 'INSUFFICIENT_BALANCE'; + readonly statusCode = 402; + readonly context: Record | undefined; + + constructor( + public readonly requiredAmount: number, + public readonly currentBalance: number, + public readonly message: string = 'Insufficient balance', + context?: Record + ) { + this.context = context; + } +} + +export class QuotaExceededError implements BaseError { + readonly type = 'QUOTA_EXCEEDED'; + readonly statusCode = 429; + readonly context: Record | undefined; + + constructor( + public readonly quotaType: string, + public readonly limit: number, + public readonly current: number, + public readonly message: string = `${quotaType} quota exceeded`, + context?: Record + ) { + this.context = context; + } +} + +export class DatabaseError implements BaseError { + readonly type = 'DATABASE_ERROR'; + readonly statusCode = 500; + readonly context: Record | undefined; + + constructor( + public readonly message: string, + public readonly operation?: string, + context?: Record + ) { + this.context = context; + } +} + +export class TransactionError implements BaseError { + readonly type = 'TRANSACTION_ERROR'; + readonly statusCode = 500; + readonly context: Record | undefined; + + constructor( + public readonly message: string, + public readonly transactionId?: string, + context?: Record + ) { + this.context = context; + } +} + +export class ProviderError implements BaseError { + readonly type = 'PROVIDER_ERROR'; + readonly statusCode: number; + readonly context: Record | undefined; + + constructor( + public readonly provider: string, + public readonly message: string, + public readonly originalStatusCode?: number, + context?: Record + ) { + this.statusCode = this.mapProviderStatusCode(originalStatusCode); + this.context = context; + } + + private mapProviderStatusCode(status?: number): number { + if (!status) return 502; + + if (status >= 400 && status < 500) { + return status; + } else if (status >= 500) { + return 502; + } + + return 502; + } +} + +export class ModelNotFoundError implements BaseError { + readonly type = 'MODEL_NOT_FOUND'; + readonly statusCode = 400; + readonly context: Record | undefined; + + constructor( + public readonly model: string, + public readonly provider?: string, + public readonly message: string = `Model ${model} not found`, + context?: Record + ) { + this.context = context; + } +} + +export class StreamError implements BaseError { + readonly type = 'STREAM_ERROR'; + readonly statusCode = 500; + readonly context: Record | undefined; + + constructor( + public readonly message: string, + public readonly phase?: 'initialization' | 'processing' | 'completion', + context?: Record + ) { + this.context = context; + } +} + +export class ConfigurationError implements BaseError { + readonly type = 'CONFIGURATION_ERROR'; + readonly statusCode = 500; + readonly context: Record | undefined; + + constructor( + public readonly message: string, + public readonly configKey?: string, + context?: Record + ) { + this.context = context; + } +} + +export class ServiceUnavailableError implements BaseError { + readonly type = 'SERVICE_UNAVAILABLE'; + readonly statusCode = 503; + readonly context: Record | undefined; + + constructor( + public readonly service: string, + public readonly message: string = `${service} is currently unavailable`, + public readonly retryAfter?: number, + context?: Record + ) { + this.context = context; + } +} + +export class RateLimitError implements BaseError { + readonly type = 'RATE_LIMIT_ERROR'; + readonly statusCode = 429; + readonly context: Record | undefined; + + constructor( + public readonly limit: number, + public readonly window: string, + public readonly retryAfter: number, + public readonly message: string = 'Rate limit exceeded', + context?: Record + ) { + this.context = context; + } +} + +export class TimeoutError implements BaseError { + readonly type = 'TIMEOUT_ERROR'; + readonly statusCode = 504; + readonly context: Record | undefined; + + constructor( + public readonly operation: string, + public readonly timeoutMs: number, + public readonly message: string = `Operation ${operation} timed out after ${timeoutMs}ms`, + context?: Record + ) { + this.context = context; + } +} + +export class UnexpectedError implements BaseError { + readonly type = 'UNEXPECTED_ERROR'; + readonly statusCode = 500; + readonly message = 'An unexpected error occurred'; + readonly context: Record | undefined; + + constructor( + public readonly originalError?: unknown, + context?: Record + ) { + this.context = context; + } +} diff --git a/packages/app/server/src/handlers.ts b/packages/app/server/src/handlers.ts index 70f5657e3..61683e7a6 100644 --- a/packages/app/server/src/handlers.ts +++ b/packages/app/server/src/handlers.ts @@ -10,6 +10,7 @@ import { ProviderType } from 'providers/ProviderType'; import { settle } from 'handlers/settle'; import { finalize } from 'handlers/finalize'; import { refund } from 'handlers/refund'; +import { AppResult, ProviderError, ValidationError, DatabaseError } from 'errors'; export async function handleX402Request({ req, @@ -24,6 +25,7 @@ export async function handleX402Request({ if (isPassthroughProxyRoute) { return await makeProxyPassthroughRequest(req, res, provider, headers); } + const settleResult = await settle(req, res, headers, maxCost); if (!settleResult) { return; @@ -31,16 +33,27 @@ export async function handleX402Request({ const { payload, paymentAmountDecimal } = settleResult; - try { - const transactionResult = await modelRequestService.executeModelRequest( - req, - res, - headers, - provider, - isStream - ); - const transaction = transactionResult.transaction; - if (provider.getType() === ProviderType.OPENAI_VIDEOS) { + const transactionResult = await modelRequestService.executeModelRequest( + req, + res, + headers, + provider, + isStream + ); + + if (transactionResult.isErr()) { + logger.error('Model request failed for X402', { + error: transactionResult.error, + type: transactionResult.error.type + }); + await refund(paymentAmountDecimal, payload); + return; + } + + const { transaction, data } = transactionResult.value; + + if (provider.getType() === ProviderType.OPENAI_VIDEOS) { + try { await prisma.videoGenerationX402.create({ data: { videoId: transaction.metadata.providerId, @@ -49,20 +62,31 @@ export async function handleX402Request({ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 1), }, }); + } catch (error) { + logger.error('Failed to create video generation record', { error }); } + } - modelRequestService.handleResolveResponse( - res, - isStream, - transactionResult.data - ); + modelRequestService.handleResolveResponse(res, isStream, data); - logger.info( - `Creating X402 transaction for app. Metadata: ${JSON.stringify(transaction.metadata)}` - ); - const transactionCosts = - await x402AuthenticationService.createX402Transaction(transaction); + logger.info( + `Creating X402 transaction for app. Metadata: ${JSON.stringify(transaction.metadata)}` + ); + const transactionCostsResult = + await x402AuthenticationService.createX402Transaction(transaction); + + if (transactionCostsResult.isErr()) { + logger.error('Failed to create X402 transaction', { + error: transactionCostsResult.error + }); + await refund(paymentAmountDecimal, payload); + return; + } + + const transactionCosts = transactionCostsResult.value; + + try { await finalize( paymentAmountDecimal, transactionCosts.rawTransactionCost, @@ -71,6 +95,7 @@ export async function handleX402Request({ payload ); } catch (error) { + logger.error('Failed to finalize payment', { error }); await refund(paymentAmountDecimal, payload); } } @@ -93,7 +118,6 @@ export async function handleApiKeyRequest({ const balanceCheckResult = await checkBalance(echoControlService); - // Step 2: Set up escrow context and apply escrow middleware logic transactionEscrowMiddleware.setupEscrowContext( req, echoControlService.getUserId()!, @@ -107,8 +131,7 @@ export async function handleApiKeyRequest({ return await makeProxyPassthroughRequest(req, res, provider, headers); } - // Step 3: Execute business logic - const { transaction, data } = await modelRequestService.executeModelRequest( + const transactionResult = await modelRequestService.executeModelRequest( req, res, headers, @@ -116,26 +139,53 @@ export async function handleApiKeyRequest({ isStream ); - // There is no actual refund, this logs if we underestimate the raw cost + if (transactionResult.isErr()) { + logger.error('Model request failed for API key request', { + error: transactionResult.error, + type: transactionResult.error.type + }); + return; + } + + const { transaction, data } = transactionResult.value; + calculateRefundAmount(maxCost, transaction.rawTransactionCost); modelRequestService.handleResolveResponse(res, isStream, data); - await echoControlService.createTransaction(transaction); + const createTransactionResult = await echoControlService.createTransaction(transaction); + + if (createTransactionResult.isErr()) { + logger.error('Failed to create transaction', { + error: createTransactionResult.error + }); + } if (provider.getType() === ProviderType.OPENAI_VIDEOS) { - const transactionCost = await echoControlService.computeTransactionCosts( + const transactionCostResult = await echoControlService.computeTransactionCosts( transaction, null ); - await prisma.videoGenerationX402.create({ - data: { - videoId: transaction.metadata.providerId, - userId: echoControlService.getUserId()!, - echoAppId: echoControlService.getEchoAppId()!, - cost: transactionCost.totalTransactionCost, - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 1), - }, - }); + + if (transactionCostResult.isErr()) { + logger.error('Failed to compute transaction costs for video', { + error: transactionCostResult.error + }); + return; + } + + try { + await prisma.videoGenerationX402.create({ + data: { + videoId: transaction.metadata.providerId, + userId: echoControlService.getUserId()!, + echoAppId: echoControlService.getEchoAppId()!, + cost: transactionCostResult.value.totalTransactionCost, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 1), + }, + }); + } catch (error) { + logger.error('Failed to create video generation record', { error }); + } } } diff --git a/packages/app/server/src/handlers/settle.ts b/packages/app/server/src/handlers/settle.ts index 935797c3a..ab6317d77 100644 --- a/packages/app/server/src/handlers/settle.ts +++ b/packages/app/server/src/handlers/settle.ts @@ -30,22 +30,24 @@ export async function settle( > { const network = env.NETWORK as Network; - let recipient: string; - try { - recipient = (await getSmartAccount()).smartAccount.address; - } catch (error) { + const smartAccountResult = await getSmartAccount(); + + if (smartAccountResult.isErr()) { buildX402Response(req, res, maxCost); return undefined; } - let xPaymentData: PaymentPayload; - try { - xPaymentData = validateXPaymentHeader(headers, req); - } catch (error) { + const recipient = smartAccountResult.value.smartAccount.address; + + const paymentHeaderResult = validateXPaymentHeader(headers, req); + if (paymentHeaderResult.isErr()) { + logger.error('Invalid payment header', { error: paymentHeaderResult.error }); buildX402Response(req, res, maxCost); return undefined; } + const xPaymentData = paymentHeaderResult.value; + const payloadResult = ExactEvmPayloadSchema.safeParse(xPaymentData.payload); if (!payloadResult.success) { logger.error('Invalid ExactEvmPayload in settle', { diff --git a/packages/app/server/src/middleware/transaction-escrow-middleware.ts b/packages/app/server/src/middleware/transaction-escrow-middleware.ts index 957571e31..9de44eacf 100644 --- a/packages/app/server/src/middleware/transaction-escrow-middleware.ts +++ b/packages/app/server/src/middleware/transaction-escrow-middleware.ts @@ -86,7 +86,7 @@ export class TransactionEscrowMiddleware { echoAppId: string, effectiveBalance: number ): Promise { - await this.db.$transaction(async tx => { + await this.db.$transaction(async (tx) => { // Get current in-flight request count const currentInFlightRequest = await tx.inFlightRequest.findUnique({ where: { @@ -156,7 +156,7 @@ export class TransactionEscrowMiddleware { echoAppId: string ): Promise { try { - await this.db.$transaction(async tx => { + await this.db.$transaction(async (tx) => { const inFlightRequest = await tx.inFlightRequest.findUnique({ where: { userId_echoAppId: { diff --git a/packages/app/server/src/providers/AnthropicGPTProvider.ts b/packages/app/server/src/providers/AnthropicGPTProvider.ts index bfb0da03a..7774fe820 100644 --- a/packages/app/server/src/providers/AnthropicGPTProvider.ts +++ b/packages/app/server/src/providers/AnthropicGPTProvider.ts @@ -62,55 +62,51 @@ export class AnthropicGPTProvider extends GPTProvider { } override async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - - if (this.getIsStream()) { - const chunks = parseSSEAnthropicGPTFormat(data); - - for (const chunk of chunks) { - if (chunk.usage) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + + if (this.getIsStream()) { + const chunks = parseSSEAnthropicGPTFormat(data); + + for (const chunk of chunks) { + if (chunk.usage) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id; + providerId = chunk.id; } - - const cost = getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ); - const metadata: LlmTransactionMetadata = { - providerId: providerId, - provider: this.getType(), - model: this.getModel(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; - - const transaction: Transaction = { - rawTransactionCost: cost, - metadata: metadata, - status: 'success', - }; - - return transaction; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id; } + + const cost = getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ); + + const metadata: LlmTransactionMetadata = { + providerId: providerId, + provider: this.getType(), + model: this.getModel(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; + + const transaction: Transaction = { + rawTransactionCost: cost, + metadata: metadata, + status: 'success', + }; + + return transaction; } } diff --git a/packages/app/server/src/providers/AnthropicNativeProvider.ts b/packages/app/server/src/providers/AnthropicNativeProvider.ts index 132ba72f2..617e177af 100644 --- a/packages/app/server/src/providers/AnthropicNativeProvider.ts +++ b/packages/app/server/src/providers/AnthropicNativeProvider.ts @@ -33,7 +33,6 @@ const parseSSEAnthropicFormat = (data: string): AnthropicUsage | null => { try { const parsed = JSON.parse(currentData); - // Handle message_start event - contains initial usage and model info if (parsed.type === 'message_start' && parsed.message) { const message = parsed.message; if (message.usage && message.id && message.model) { @@ -46,7 +45,6 @@ const parseSSEAnthropicFormat = (data: string): AnthropicUsage | null => { } } - // Handle message_delta event - contains final output token count if (parsed.type === 'message_delta' && parsed.usage) { messageDeltaUsage = { output_tokens: parsed.usage.output_tokens || 0, @@ -118,74 +116,69 @@ export class AnthropicNativeProvider extends BaseProvider { } override async handleBody(data: string): Promise { - try { - if (this.getIsStream()) { - const usage = parseSSEAnthropicFormat(data); + if (this.getIsStream()) { + const usage = parseSSEAnthropicFormat(data); - if (!usage) { - logger.error('No usage data found'); - throw new Error('No usage data found'); - } + if (!usage) { + logger.error('No usage data found'); + throw new Error('No usage data found'); + } - const model = this.getModel(); - const metadata: LlmTransactionMetadata = { - model: model, - providerId: usage.id, - provider: this.getType(), - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - totalTokens: usage.input_tokens + usage.output_tokens, - }; - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: getCostPerToken( - model, - usage.input_tokens, - usage.output_tokens - ), - status: 'success', - }; - - return transaction; - } else { - const parsed = JSON.parse(data); - - const inputTokens = parsed.usage.input_tokens || 0; - const outputTokens = parsed.usage.output_tokens || 0; - const totalTokens = inputTokens + outputTokens; - - logger.info( - 'Usage tokens (input/output/total): ', + const model = this.getModel(); + const metadata: LlmTransactionMetadata = { + model: model, + providerId: usage.id, + provider: this.getType(), + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + totalTokens: usage.input_tokens + usage.output_tokens, + }; + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: getCostPerToken( + model, + usage.input_tokens, + usage.output_tokens + ), + status: 'success', + }; + + return transaction; + } else { + const parsed = JSON.parse(data); + + const inputTokens = parsed.usage.input_tokens || 0; + const outputTokens = parsed.usage.output_tokens || 0; + const totalTokens = inputTokens + outputTokens; + + logger.info( + 'Usage tokens (input/output/total): ', + inputTokens, + outputTokens, + totalTokens + ); + logger.info(`Message ID: ${parsed.id}`); + + const metadata: LlmTransactionMetadata = { + model: this.getModel(), + providerId: parsed.id, + provider: this.getType(), + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens, + }; + + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: getCostPerToken( + this.getModel(), inputTokens, - outputTokens, - totalTokens - ); - logger.info(`Message ID: ${parsed.id}`); - - const metadata: LlmTransactionMetadata = { - model: this.getModel(), - providerId: parsed.id, - provider: this.getType(), - inputTokens: inputTokens, - outputTokens: outputTokens, - totalTokens: totalTokens, - }; - - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: getCostPerToken( - this.getModel(), - inputTokens, - outputTokens - ), - status: 'success', - }; - - return transaction; - } - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; + outputTokens + ), + status: 'success', + }; + + return transaction; } } diff --git a/packages/app/server/src/providers/GPTProvider.ts b/packages/app/server/src/providers/GPTProvider.ts index 453e7098f..22edb9d49 100644 --- a/packages/app/server/src/providers/GPTProvider.ts +++ b/packages/app/server/src/providers/GPTProvider.ts @@ -71,56 +71,51 @@ export class GPTProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - - if (this.getIsStream()) { - const chunks = parseSSEGPTFormat(data); - - for (const chunk of chunks) { - if (chunk.usage !== null) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id || 'null'; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + + if (this.getIsStream()) { + const chunks = parseSSEGPTFormat(data); + + for (const chunk of chunks) { + if (chunk.usage !== null) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id || 'null'; + providerId = chunk.id || 'null'; } - - const cost = getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ); - - const metadata: LlmTransactionMetadata = { - providerId: providerId, - provider: this.getType(), - model: this.getModel(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; - - const transaction: Transaction = { - rawTransactionCost: cost, - metadata: metadata, - status: 'success', - }; - - return transaction; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id || 'null'; } + + const cost = getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ); + + const metadata: LlmTransactionMetadata = { + providerId: providerId, + provider: this.getType(), + model: this.getModel(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; + + const transaction: Transaction = { + rawTransactionCost: cost, + metadata: metadata, + status: 'success', + }; + + return transaction; } } diff --git a/packages/app/server/src/providers/GeminiGPTProvider.ts b/packages/app/server/src/providers/GeminiGPTProvider.ts index c5e641b13..9f61e7db5 100644 --- a/packages/app/server/src/providers/GeminiGPTProvider.ts +++ b/packages/app/server/src/providers/GeminiGPTProvider.ts @@ -61,54 +61,49 @@ export class GeminiGPTProvider extends GPTProvider { } override async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - - if (this.getIsStream()) { - const chunks = parseSSEGeminiGPTFormat(data); - - for (const chunk of chunks) { - if (chunk.usage) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + + if (this.getIsStream()) { + const chunks = parseSSEGeminiGPTFormat(data); + + for (const chunk of chunks) { + if (chunk.usage) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id; + providerId = chunk.id; } - - const metadata: LlmTransactionMetadata = { - model: this.getModel(), - providerId: providerId, - provider: this.getType(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; - - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ), - status: 'success', - }; - - return transaction; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id; } + + const metadata: LlmTransactionMetadata = { + model: this.getModel(), + providerId: providerId, + provider: this.getType(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; + + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ), + status: 'success', + }; + + return transaction; } } diff --git a/packages/app/server/src/providers/GeminiProvider.ts b/packages/app/server/src/providers/GeminiProvider.ts index a97e5dc47..5590f44a3 100644 --- a/packages/app/server/src/providers/GeminiProvider.ts +++ b/packages/app/server/src/providers/GeminiProvider.ts @@ -31,7 +31,6 @@ const parseSSEGeminiFormat = (data: string): GeminiUsage | null => { const parsed = JSON.parse(data); if (Array.isArray(parsed)) { - // Handle JSON array format let finalUsage: GeminiUsage | null = null; for (const chunk of parsed) { @@ -46,7 +45,6 @@ const parseSSEGeminiFormat = (data: string): GeminiUsage | null => { return finalUsage; } else if (parsed?.usageMetadata) { - // Handle single object format return { promptTokenCount: parsed.usageMetadata.promptTokenCount || 0, candidatesTokenCount: parsed.usageMetadata.candidatesTokenCount || 0, @@ -62,7 +60,6 @@ const parseSSEGeminiFormat = (data: string): GeminiUsage | null => { const trimmedLine = line.trim(); if (!trimmedLine) continue; - // Handle data: lines if (trimmedLine.startsWith('data: ')) { const jsonStr = trimmedLine.slice(6); // Remove 'data: ' prefix @@ -126,68 +123,61 @@ export class GeminiProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let promptTokens = 0; - let candidatesTokens = 0; - let totalTokens = 0; - let providerId = 'gemini-response'; - - if (this.getIsStream()) { - const usage = parseSSEGeminiFormat(data); - - if (!usage) { - console.error('No usage data found in streaming response'); - throw new Error('No usage data found in streaming response'); - } - - promptTokens = usage.promptTokenCount; - candidatesTokens = usage.candidatesTokenCount; - totalTokens = usage.totalTokenCount; - } else { - const parsed = JSON.parse(data) as GeminiResponse; + let promptTokens = 0; + let candidatesTokens = 0; + let totalTokens = 0; + let providerId = 'gemini-response'; - if (parsed?.usageMetadata) { - promptTokens = parsed.usageMetadata.promptTokenCount || 0; - candidatesTokens = parsed.usageMetadata.candidatesTokenCount || 0; - totalTokens = parsed.usageMetadata.totalTokenCount || 0; - } + if (this.getIsStream()) { + const usage = parseSSEGeminiFormat(data); - // Try to get a unique identifier from the response - // Gemini doesn't return an ID like OpenAI, so we'll generate one based on content - if (parsed?.candidates && parsed.candidates.length > 0) { - const content = parsed.candidates[0]?.content?.parts?.[0]?.text || ''; - providerId = `gemini-${Date.now()}-${content.substring(0, 10).replace(/\s/g, '')}`; - } + if (!usage) { + logger.error('No usage data found in streaming response'); + throw new Error('No usage data found in streaming response'); } - logger.info( - `Gemini usage tokens (prompt/candidates/total): ${promptTokens}/${candidatesTokens}/${totalTokens}` - ); - - const metadata: LlmTransactionMetadata = { - model: this.getModel(), - providerId: providerId, - provider: this.getType(), - inputTokens: promptTokens, - outputTokens: candidatesTokens, - totalTokens: totalTokens, - }; + promptTokens = usage.promptTokenCount; + candidatesTokens = usage.candidatesTokenCount; + totalTokens = usage.totalTokenCount; + } else { + const parsed = JSON.parse(data) as GeminiResponse; - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: getCostPerToken( - this.getModel(), - promptTokens, - candidatesTokens - ), - status: 'success', - }; + if (parsed?.usageMetadata) { + promptTokens = parsed.usageMetadata.promptTokenCount || 0; + candidatesTokens = parsed.usageMetadata.candidatesTokenCount || 0; + totalTokens = parsed.usageMetadata.totalTokenCount || 0; + } - return transaction; - } catch (error) { - logger.error(`Error processing Gemini response data: ${error}`); - throw error; + if (parsed?.candidates && parsed.candidates.length > 0) { + const content = parsed.candidates[0]?.content?.parts?.[0]?.text || ''; + providerId = `gemini-${Date.now()}-${content.substring(0, 10).replace(/\s/g, '')}`; + } } + + logger.info( + `Gemini usage tokens (prompt/candidates/total): ${promptTokens}/${candidatesTokens}/${totalTokens}` + ); + + const metadata: LlmTransactionMetadata = { + model: this.getModel(), + providerId: providerId, + provider: this.getType(), + inputTokens: promptTokens, + outputTokens: candidatesTokens, + totalTokens: totalTokens, + }; + + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: getCostPerToken( + this.getModel(), + promptTokens, + candidatesTokens + ), + status: 'success', + }; + + return transaction; } override ensureStreamUsage( diff --git a/packages/app/server/src/providers/GroqProvider.ts b/packages/app/server/src/providers/GroqProvider.ts index dcc269a02..5b2e643ed 100644 --- a/packages/app/server/src/providers/GroqProvider.ts +++ b/packages/app/server/src/providers/GroqProvider.ts @@ -26,56 +26,51 @@ export class GroqProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; - if (this.getIsStream()) { - const chunks = parseSSEGPTFormat(data); + if (this.getIsStream()) { + const chunks = parseSSEGPTFormat(data); - for (const chunk of chunks) { - if (chunk.usage !== null) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id || 'null'; + for (const chunk of chunks) { + if (chunk.usage !== null) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id || 'null'; + providerId = chunk.id || 'null'; } + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id || 'null'; + } - const cost = getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ); + const cost = getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ); - const metadata: LlmTransactionMetadata = { - providerId: providerId, - provider: this.getType(), - model: this.getModel(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; + const metadata: LlmTransactionMetadata = { + providerId: providerId, + provider: this.getType(), + model: this.getModel(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; - const transaction: Transaction = { - rawTransactionCost: cost, - metadata: metadata, - status: 'success', - }; + const transaction: Transaction = { + rawTransactionCost: cost, + metadata: metadata, + status: 'success', + }; - return transaction; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + return transaction; } } diff --git a/packages/app/server/src/providers/OpenAIImageProvider.ts b/packages/app/server/src/providers/OpenAIImageProvider.ts index cb6861803..800d1eb97 100644 --- a/packages/app/server/src/providers/OpenAIImageProvider.ts +++ b/packages/app/server/src/providers/OpenAIImageProvider.ts @@ -7,17 +7,13 @@ import logger from '../logger'; import { getImageModelCost } from '../services/AccountingService'; import { env } from '../env'; -// Use OpenAI SDK's ResponseUsage for non-streaming responses - const parseSSEImageGenerationFormat = (data: string): ImagesResponse[] => { - // Split by double newlines to separate complete events const eventBlocks = data.split('\n\n'); const chunks: ImagesResponse[] = []; for (const eventBlock of eventBlocks) { if (!eventBlock.trim()) continue; - // Parse event block that may contain multiple lines const lines = eventBlock.split('\n'); let eventType = ''; let eventData = ''; @@ -30,12 +26,10 @@ const parseSSEImageGenerationFormat = (data: string): ImagesResponse[] => { } } - // Skip if no data found or if it's a [DONE] marker if (!eventData || eventData.trim() === '[DONE]') continue; try { const parsed = JSON.parse(eventData); - // Add the event type to the parsed object for easier identification parsed.eventType = eventType; chunks.push(parsed); } catch (error) { @@ -70,74 +64,62 @@ export class OpenAIImageProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let input_tokens = 0; - let output_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - let cost = new Decimal(0); - - const parsed = JSON.parse(data) as ImagesResponse; - - // Extract usage information if available - if (parsed.usage) { - input_tokens = parsed.usage.input_tokens || 0; - output_tokens = parsed.usage.output_tokens || 0; - total_tokens = - parsed.usage.total_tokens || input_tokens + output_tokens; - } - - // Use image-specific cost calculation from AccountingService - if (parsed.usage) { - const { input_tokens, output_tokens, input_tokens_details } = - parsed.usage; - let textTokens = 0; - let imageInputTokens = 0; - const imageOutputTokens = output_tokens || 0; - - if (input_tokens_details) { - // Separate image and text tokens if available - imageInputTokens = input_tokens_details.image_tokens || 0; - textTokens = input_tokens_details.text_tokens || 0; - } else { - // Fallback: treat all input tokens as image tokens - imageInputTokens = input_tokens || 0; - } - - cost = getImageModelCost( - this.getModel(), - textTokens, - imageInputTokens, - imageOutputTokens - ); - } + let input_tokens = 0; + let output_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + let cost = new Decimal(0); + + const parsed = JSON.parse(data) as ImagesResponse; + + if (parsed.usage) { + input_tokens = parsed.usage.input_tokens || 0; + output_tokens = parsed.usage.output_tokens || 0; + total_tokens = + parsed.usage.total_tokens || input_tokens + output_tokens; + } - // Extract provider ID if available - if (parsed.created) { - providerId = parsed.created.toString(); + if (parsed.usage) { + const { input_tokens, output_tokens, input_tokens_details } = + parsed.usage; + let textTokens = 0; + let imageInputTokens = 0; + const imageOutputTokens = output_tokens || 0; + + if (input_tokens_details) { + imageInputTokens = input_tokens_details.image_tokens || 0; + textTokens = input_tokens_details.text_tokens || 0; + } else { + imageInputTokens = input_tokens || 0; } - const metadata: LlmTransactionMetadata = { - model: this.getModel(), - providerId: providerId, - provider: this.getType(), - inputTokens: input_tokens, - outputTokens: output_tokens, - totalTokens: total_tokens, - }; - - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: new Decimal(cost), - status: 'success', - }; - - return transaction; - } catch (error) { - logger.error( - `Error processing OpenAI Image Generation API data: ${error}` + cost = getImageModelCost( + this.getModel(), + textTokens, + imageInputTokens, + imageOutputTokens ); - throw error; } + + if (parsed.created) { + providerId = parsed.created.toString(); + } + + const metadata: LlmTransactionMetadata = { + model: this.getModel(), + providerId: providerId, + provider: this.getType(), + inputTokens: input_tokens, + outputTokens: output_tokens, + totalTokens: total_tokens, + }; + + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: new Decimal(cost), + status: 'success', + }; + + return transaction; } } diff --git a/packages/app/server/src/providers/OpenAIResponsesProvider.ts b/packages/app/server/src/providers/OpenAIResponsesProvider.ts index 61664f970..4644574b1 100644 --- a/packages/app/server/src/providers/OpenAIResponsesProvider.ts +++ b/packages/app/server/src/providers/OpenAIResponsesProvider.ts @@ -15,14 +15,12 @@ import logger from '../logger'; import { env } from '../env'; const parseSSEResponsesFormat = (data: string): ResponseStreamEvent[] => { - // Split by double newlines to separate complete events const eventBlocks = data.split('\n\n'); const chunks: ResponseStreamEvent[] = []; for (const eventBlock of eventBlocks) { if (!eventBlock.trim()) continue; - // Parse event block that may contain multiple lines const lines = eventBlock.split('\n'); let eventType = ''; let eventData = ''; @@ -35,12 +33,10 @@ const parseSSEResponsesFormat = (data: string): ResponseStreamEvent[] => { } } - // Skip if no data found or if it's a [DONE] marker if (!eventData || eventData.trim() === '[DONE]') continue; try { const parsed = JSON.parse(eventData); - // Add the event type to the parsed object for easier identification parsed.eventType = eventType; chunks.push(parsed); } catch (error) { @@ -72,84 +68,74 @@ export class OpenAIResponsesProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let input_tokens = 0; - let output_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - let tool_cost = new Decimal(0); - - if (this.getIsStream()) { - const chunks = parseSSEResponsesFormat(data); - - for (const chunk of chunks) { - // Look for the response.completed event which contains the final usage data - if (chunk.type === 'response.completed' && chunk.response?.usage) { - input_tokens = chunk.response.usage.input_tokens || 0; - output_tokens = chunk.response.usage.output_tokens || 0; - total_tokens = chunk.response.usage.total_tokens || 0; - providerId = chunk.response.id || 'null'; - - tool_cost = chunk.response.tools.reduce((acc, tool) => { - return acc.plus(calculateToolCost(tool)); - }, new Decimal(0)); - } - // Fallback to any chunk with usage data if no completed event found - else if (chunk && 'response' in chunk && chunk.response?.usage) { - input_tokens += chunk.response.usage.input_tokens || 0; - output_tokens += chunk.response.usage.output_tokens || 0; - total_tokens += chunk.response.usage.total_tokens || 0; - providerId = chunk.response?.id || 'null'; - } - // Keep track of providerId from any chunk - else if (chunk && 'response' in chunk && chunk.response?.id) { - providerId = chunk.response?.id || 'null'; - } + let input_tokens = 0; + let output_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + let tool_cost = new Decimal(0); + + if (this.getIsStream()) { + const chunks = parseSSEResponsesFormat(data); + + for (const chunk of chunks) { + if (chunk.type === 'response.completed' && chunk.response?.usage) { + input_tokens = chunk.response.usage.input_tokens || 0; + output_tokens = chunk.response.usage.output_tokens || 0; + total_tokens = chunk.response.usage.total_tokens || 0; + providerId = chunk.response.id || 'null'; + + tool_cost = chunk.response.tools.reduce((acc, tool) => { + return acc.plus(calculateToolCost(tool)); + }, new Decimal(0)); + } + else if (chunk && 'response' in chunk && chunk.response?.usage) { + input_tokens += chunk.response.usage.input_tokens || 0; + output_tokens += chunk.response.usage.output_tokens || 0; + total_tokens += chunk.response.usage.total_tokens || 0; + providerId = chunk.response?.id || 'null'; + } + else if (chunk && 'response' in chunk && chunk.response?.id) { + providerId = chunk.response?.id || 'null'; } - } else { - const parsed = JSON.parse(data) as Response; - input_tokens += parsed.usage?.input_tokens || 0; - output_tokens += parsed.usage?.output_tokens || 0; - total_tokens += parsed.usage?.total_tokens || 0; - providerId = parsed.id || 'null'; - tool_cost = parsed.tools.reduce((acc, tool) => { - return acc.plus(calculateToolCost(tool)); - }, new Decimal(0)); } - - const metadata: LlmTransactionMetadata = { - model: this.getModel(), - providerId: providerId, - provider: this.getType(), - inputTokens: input_tokens, - outputTokens: output_tokens, - totalTokens: total_tokens, - toolCost: tool_cost, - }; - - const transaction: Transaction = { - metadata: metadata, - rawTransactionCost: getCostPerToken( - this.getModel(), - input_tokens, - output_tokens - ).plus(tool_cost), - status: 'success', - }; - - return transaction; - } catch (error) { - logger.error(`Error processing OpenAI Responses API data: ${error}`); - throw error; + } else { + const parsed = JSON.parse(data) as Response; + input_tokens += parsed.usage?.input_tokens || 0; + output_tokens += parsed.usage?.output_tokens || 0; + total_tokens += parsed.usage?.total_tokens || 0; + providerId = parsed.id || 'null'; + tool_cost = parsed.tools.reduce((acc, tool) => { + return acc.plus(calculateToolCost(tool)); + }, new Decimal(0)); } + + const metadata: LlmTransactionMetadata = { + model: this.getModel(), + providerId: providerId, + provider: this.getType(), + inputTokens: input_tokens, + outputTokens: output_tokens, + totalTokens: total_tokens, + toolCost: tool_cost, + }; + + const transaction: Transaction = { + metadata: metadata, + rawTransactionCost: getCostPerToken( + this.getModel(), + input_tokens, + output_tokens + ).plus(tool_cost), + status: 'success', + }; + + return transaction; } - // Override ensureStreamUsage since Responses API doesn't use stream_options override ensureStreamUsage( reqBody: Record, _reqPath: string ): Record { - // Responses API handles usage tracking differently - no need to modify the request return reqBody; } } diff --git a/packages/app/server/src/providers/OpenAIVideoProvider.ts b/packages/app/server/src/providers/OpenAIVideoProvider.ts index 0d870d2a2..2456d7182 100644 --- a/packages/app/server/src/providers/OpenAIVideoProvider.ts +++ b/packages/app/server/src/providers/OpenAIVideoProvider.ts @@ -193,7 +193,7 @@ export class OpenAIVideoProvider extends BaseProvider { private async handleSuccessfulVideoGeneration( videoId: string ): Promise { - await prisma.$transaction(async tx => { + await prisma.$transaction(async (tx) => { const result = await tx.$queryRawUnsafe( `SELECT * FROM "video_generation_x402" WHERE "videoId" = $1 FOR UPDATE`, videoId diff --git a/packages/app/server/src/providers/OpenRouterProvider.ts b/packages/app/server/src/providers/OpenRouterProvider.ts index 4d7fceb65..4cc43bad1 100644 --- a/packages/app/server/src/providers/OpenRouterProvider.ts +++ b/packages/app/server/src/providers/OpenRouterProvider.ts @@ -71,54 +71,49 @@ export class OpenRouterProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; - - if (this.getIsStream()) { - const chunks = parseSSEGPTFormat(data); - - for (const chunk of chunks) { - if (chunk.usage && chunk.usage !== null) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id || 'null'; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; + + if (this.getIsStream()) { + const chunks = parseSSEGPTFormat(data); + + for (const chunk of chunks) { + if (chunk.usage && chunk.usage !== null) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id || 'null'; + providerId = chunk.id || 'null'; } - - const cost = getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ); - - const metadata: LlmTransactionMetadata = { - providerId: providerId, - provider: this.getType(), - model: this.getModel(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; - - return { - metadata: metadata, - rawTransactionCost: cost, - status: 'success', - }; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id || 'null'; } + + const cost = getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ); + + const metadata: LlmTransactionMetadata = { + providerId: providerId, + provider: this.getType(), + model: this.getModel(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; + + return { + metadata: metadata, + rawTransactionCost: cost, + status: 'success', + }; } } diff --git a/packages/app/server/src/providers/XAIProvider.ts b/packages/app/server/src/providers/XAIProvider.ts index 2525b16bf..9ddeafdb5 100644 --- a/packages/app/server/src/providers/XAIProvider.ts +++ b/packages/app/server/src/providers/XAIProvider.ts @@ -26,56 +26,51 @@ export class XAIProvider extends BaseProvider { } async handleBody(data: string): Promise { - try { - let prompt_tokens = 0; - let completion_tokens = 0; - let total_tokens = 0; - let providerId = 'null'; + let prompt_tokens = 0; + let completion_tokens = 0; + let total_tokens = 0; + let providerId = 'null'; - if (this.getIsStream()) { - const chunks = parseSSEGPTFormat(data); + if (this.getIsStream()) { + const chunks = parseSSEGPTFormat(data); - for (const chunk of chunks) { - if (chunk.usage !== null) { - prompt_tokens += chunk.usage.prompt_tokens; - completion_tokens += chunk.usage.completion_tokens; - total_tokens += chunk.usage.total_tokens; - } - providerId = chunk.id || 'null'; + for (const chunk of chunks) { + if (chunk.usage !== null) { + prompt_tokens += chunk.usage.prompt_tokens; + completion_tokens += chunk.usage.completion_tokens; + total_tokens += chunk.usage.total_tokens; } - } else { - const parsed = JSON.parse(data) as CompletionStateBody; - prompt_tokens += parsed.usage.prompt_tokens; - completion_tokens += parsed.usage.completion_tokens; - total_tokens += parsed.usage.total_tokens; - providerId = parsed.id || 'null'; + providerId = chunk.id || 'null'; } + } else { + const parsed = JSON.parse(data) as CompletionStateBody; + prompt_tokens += parsed.usage.prompt_tokens; + completion_tokens += parsed.usage.completion_tokens; + total_tokens += parsed.usage.total_tokens; + providerId = parsed.id || 'null'; + } - const cost = getCostPerToken( - this.getModel(), - prompt_tokens, - completion_tokens - ); + const cost = getCostPerToken( + this.getModel(), + prompt_tokens, + completion_tokens + ); - const metadata: LlmTransactionMetadata = { - providerId: providerId, - provider: this.getType(), - model: this.getModel(), - inputTokens: prompt_tokens, - outputTokens: completion_tokens, - totalTokens: total_tokens, - }; + const metadata: LlmTransactionMetadata = { + providerId: providerId, + provider: this.getType(), + model: this.getModel(), + inputTokens: prompt_tokens, + outputTokens: completion_tokens, + totalTokens: total_tokens, + }; - const transaction: Transaction = { - rawTransactionCost: cost, - metadata: metadata, - status: 'success', - }; + const transaction: Transaction = { + rawTransactionCost: cost, + metadata: metadata, + status: 'success', + }; - return transaction; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + return transaction; } } diff --git a/packages/app/server/src/resources/handler.ts b/packages/app/server/src/resources/handler.ts index 411f9c6ba..54723ff72 100644 --- a/packages/app/server/src/resources/handler.ts +++ b/packages/app/server/src/resources/handler.ts @@ -9,7 +9,8 @@ import { finalizeResource } from 'handlers/finalize'; import { refund } from 'handlers/refund'; import logger from 'logger'; import { ExactEvmPayload } from 'services/facilitator/x402-types'; -import { HttpError, PaymentRequiredError } from 'errors/http'; +import { HttpError } from 'errors/http'; +import { PaymentRequiredError } from '../errors'; type ResourceHandlerConfig = { inputSchema: ZodSchema; @@ -27,14 +28,24 @@ async function handleApiRequest( ) { const { executeResource, calculateActualCost, createTransaction } = config; - const { echoControlService } = await authenticateRequest(headers, prisma); + const authResult = await authenticateRequest(headers, prisma); + + if (authResult.isErr()) { + throw new HttpError(authResult.error.statusCode, authResult.error.message); + } + + const { echoControlService } = authResult.value; const output = await executeResource(parsedBody); const actualCost = calculateActualCost(parsedBody, output); const transaction = createTransaction(parsedBody, output, actualCost); - await echoControlService.createTransaction(transaction); + const createTxResult = await echoControlService.createTransaction(transaction); + + if (createTxResult.isErr()) { + logger.error('Failed to create transaction', { error: createTxResult.error }); + } return output; } @@ -51,7 +62,7 @@ async function handle402Request( const settleResult = await settle(req, res, headers, safeMaxCost); if (!settleResult) { - throw new PaymentRequiredError('Payment required, settle failed'); + throw new PaymentRequiredError(); } const { payload, paymentAmountDecimal } = settleResult; diff --git a/packages/app/server/src/routers/in-flight-monitor.ts b/packages/app/server/src/routers/in-flight-monitor.ts index 6264386f1..8b374a3fb 100644 --- a/packages/app/server/src/routers/in-flight-monitor.ts +++ b/packages/app/server/src/routers/in-flight-monitor.ts @@ -30,10 +30,16 @@ inFlightMonitorRouter.get( async (req: Request, res: Response, next: NextFunction) => { try { // Authenticate the request using the same flow as the main server - const { echoControlService } = await authenticateRequest( + const authResult = await authenticateRequest( req.headers as Record, prisma ); + + if (authResult.isErr()) { + throw new UnauthorizedError('Unauthorized Access'); + } + + const { echoControlService } = authResult.value; const userId = echoControlService.getUserId(); const echoAppId = echoControlService.getEchoAppId(); diff --git a/packages/app/server/src/server.ts b/packages/app/server/src/server.ts index ff7d58c3b..427e5be58 100644 --- a/packages/app/server/src/server.ts +++ b/packages/app/server/src/server.ts @@ -13,6 +13,7 @@ import multer from 'multer'; import { authenticateRequest } from './auth'; import { env } from './env'; import { HttpError } from './errors/http'; +import { BaseError, isBaseError } from './errors'; import { PrismaClient } from './generated/prisma'; import logger, { logMetric } from './logger'; import { @@ -70,9 +71,9 @@ app.use( methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD'], allowedHeaders: '*', // Allow all headers exposedHeaders: '*', // Expose all headers to the client - credentials: false, // Set to false when using origin: '*' - preflightContinue: false, // Handle preflight requests here - optionsSuccessStatus: 200, // Return 200 for preflight OPTIONS requests + credentials: false, + preflightContinue: false, + optionsSuccessStatus: 200, }) ); @@ -88,7 +89,7 @@ app.use((req: EscrowRequest, res, next) => { }); app.use(express.json({ limit: '100mb' })); -app.use(upload.any()); // Handle multipart/form-data with any field names +app.use(upload.any()); app.use(compression()); app.use(traceLoggingMiddleware); @@ -104,83 +105,107 @@ app.use('/resource', resourceRouter); // Main route handler app.all('*', async (req: EscrowRequest, res: Response, next: NextFunction) => { - try { - const headers = req.headers as Record; - const { provider, isStream, isPassthroughProxyRoute, is402Sniffer } = - await initializeProvider(req, res); - - const x402AuthenticationService = new X402AuthenticationService(prisma); - const x402AuthenticationResult = - await x402AuthenticationService.authenticateX402Request(headers); - if (!provider || is402Sniffer) { - return buildX402Response(req, res, new Decimal(0)); - } - const maxCost = getRequestMaxCost(req, provider, isPassthroughProxyRoute); - const maxCostWithMarkup = applyMaxCostMarkup( - maxCost, - x402AuthenticationResult?.markUp || null - ); + const headers = req.headers as Record; + const { provider, isStream, isPassthroughProxyRoute, is402Sniffer } = + await initializeProvider(req, res); + + const x402AuthenticationService = new X402AuthenticationService(prisma); + const x402AuthenticationResult = + await x402AuthenticationService.authenticateX402Request(headers); + if (!provider || is402Sniffer) { + return buildX402Response(req, res, new Decimal(0)); + } + const maxCost = getRequestMaxCost(req, provider, isPassthroughProxyRoute); + const maxCostWithMarkup = applyMaxCostMarkup( + maxCost, + x402AuthenticationResult?.markUp || null + ); - if ( - !isApiRequest(headers) && - !isX402Request(headers) && - !isPassthroughProxyRoute - ) { - return buildX402Response(req, res, maxCostWithMarkup); - } + if ( + !isApiRequest(headers) && + !isX402Request(headers) && + !isPassthroughProxyRoute + ) { + return buildX402Response(req, res, maxCostWithMarkup); + } - if (isApiRequest(headers)) { - const { processedHeaders, echoControlService } = - await authenticateRequest(headers, prisma); - - provider.setEchoControlService(echoControlService); - - await handleApiKeyRequest({ - req, - res, - headers: processedHeaders, - echoControlService, - isPassthroughProxyRoute, - provider, - isStream, - maxCost, + if (isApiRequest(headers)) { + const authResult = await authenticateRequest(headers, prisma); + + if (authResult.isErr()) { + const error = authResult.error; + logger.error('Authentication failed', { + type: error.type, + message: error.message, + context: error.context }); - return; - } - if (isX402Request(headers) || isPassthroughProxyRoute) { - await handleX402Request({ - req, - res, - headers, - maxCost: maxCostWithMarkup, - isPassthroughProxyRoute, - provider, - isStream, - x402AuthenticationService, + return res.status(error.statusCode).json({ + error: error.message, + type: error.type }); - return; } - - return res.status(400).json({ - error: 'No request type found', + + const { processedHeaders, echoControlService } = authResult.value; + provider.setEchoControlService(echoControlService); + + await handleApiKeyRequest({ + req, + res, + headers: processedHeaders, + echoControlService, + isPassthroughProxyRoute, + provider, + isStream, + maxCost, }); - } catch (error) { - return next(error); + return; + } + if (isX402Request(headers) || isPassthroughProxyRoute) { + await handleX402Request({ + req, + res, + headers, + maxCost: maxCostWithMarkup, + isPassthroughProxyRoute, + provider, + isStream, + x402AuthenticationService, + }); + return; } + + return res.status(400).json({ + error: 'No request type found', + }); }); // Error handling middleware -app.use((error: Error, req: Request, res: Response) => { - logger.error( - `Error handling request: ${error.message} | Stack: ${error.stack}` - ); - +app.use((error: Error | BaseError | unknown, req: Request, res: Response) => { // If response has already been sent, just log the error and return if (res.headersSent) { logger.warn('Response already sent, cannot send error response'); return; } + if (isBaseError(error)) { + logMetric('server.error', 1, { + error_type: error.type, + error_message: error.message, + status_code: error.statusCode, + }); + logger.error('Request error', { + type: error.type, + message: error.message, + statusCode: error.statusCode, + context: error.context + }); + return res.status(error.statusCode).json({ + error: error.message, + type: error.type, + ...(error.context && { context: error.context }) + }); + } + if (error instanceof HttpError) { logMetric('server.internal_error', 1, { error_type: 'http_error', @@ -193,6 +218,9 @@ app.use((error: Error, req: Request, res: Response) => { } if (error instanceof Error) { + logger.error( + `Error handling request: ${error.message} | Stack: ${error.stack}` + ); logMetric('server.internal_error', 1, { error_type: error.name, error_message: error.message, @@ -206,7 +234,7 @@ app.use((error: Error, req: Request, res: Response) => { logMetric('server.internal_error', 1, { error_type: 'unknown_error', }); - logger.error('Internal server error', error); + logger.error('Unknown error', { error }); return res.status(500).json({ error: 'Internal Server Error', }); @@ -226,7 +254,7 @@ const gracefulShutdown = (signal: string) => { logger.info('Database connections closed'); process.exit(0); }) - .catch(error => { + .catch((error: unknown) => { logger.error('Error during graceful shutdown:', error); process.exit(1); }); diff --git a/packages/app/server/src/services/BalanceCheckService.ts b/packages/app/server/src/services/BalanceCheckService.ts index 7a4d156e2..4ac2bf320 100644 --- a/packages/app/server/src/services/BalanceCheckService.ts +++ b/packages/app/server/src/services/BalanceCheckService.ts @@ -1,6 +1,8 @@ import { X402_ERROR_MESSAGE } from '../constants'; -import { PaymentRequiredError, UnauthorizedError } from '../errors/http'; +import { UnauthorizedError } from '../errors/http'; +import { PaymentRequiredError } from '../errors'; import { EchoControlService } from './EchoControlService'; +import logger from '../logger'; interface BalanceCheckResult { enoughBalance: boolean; @@ -24,20 +26,27 @@ export async function checkBalance( throw new UnauthorizedError('Unauthorized'); } - // Check for free tier access first - const freeTierSpendPoolInfo = + const freeTierResult = await echoControlService.getOrNoneFreeTierSpendPool(userId, appId); - if (freeTierSpendPoolInfo) { + if (freeTierResult.isErr()) { + logger.error('Failed to check free tier', { error: freeTierResult.error }); + } else if (freeTierResult.value) { return { enoughBalance: true, usingFreeTier: true, - effectiveBalance: freeTierSpendPoolInfo.effectiveBalance, + effectiveBalance: freeTierResult.value.effectiveBalance, }; } - // If no free tier, check regular balance - const balance = await echoControlService.getBalance(); + const balanceResult = await echoControlService.getBalance(); + + if (balanceResult.isErr()) { + logger.error('Failed to check balance', { error: balanceResult.error }); + throw new PaymentRequiredError(); + } + + const balance = balanceResult.value; if (balance > MINIMUM_SPEND_AMOUNT_SAFETY_BUFFER) { return { @@ -46,5 +55,5 @@ export async function checkBalance( effectiveBalance: balance, }; } - throw new PaymentRequiredError(X402_ERROR_MESSAGE); + throw new PaymentRequiredError(); } diff --git a/packages/app/server/src/services/DbService.ts b/packages/app/server/src/services/DbService.ts index 93e85592c..30db0ab20 100644 --- a/packages/app/server/src/services/DbService.ts +++ b/packages/app/server/src/services/DbService.ts @@ -9,6 +9,7 @@ import { } from '../types'; import { createHmac } from 'crypto'; import { jwtVerify } from 'jose'; +import { ok, err, ResultAsync } from 'neverthrow'; import { PrismaClient, Prisma, @@ -19,6 +20,15 @@ import { import { Decimal } from '@prisma/client/runtime/library'; import logger from '../logger'; import { env } from '../env'; +import { + AppResult, + AppResultAsync, + InvalidApiKeyError, + DatabaseError, + AuthenticationError, + ValidationError, + safeAsync +} from '../errors'; /** * Secret key for deterministic API key hashing (should match echo-control) @@ -180,31 +190,41 @@ export class EchoDbService { async getReferralCodeForUser( userId: string, echoAppId: string - ): Promise { - const appMembership = await this.db.appMembership.findUnique({ - where: { - userId_echoAppId: { - userId, - echoAppId, + ): Promise> { + try { + const appMembership = await this.db.appMembership.findUnique({ + where: { + userId_echoAppId: { + userId, + echoAppId, + }, }, - }, - select: { - referrerId: true, - }, - }); + select: { + referrerId: true, + }, + }); - if (!appMembership) { - return null; - } + if (!appMembership) { + return ok(null); + } - return appMembership.referrerId; + return ok(appMembership.referrerId); + } catch (error) { + logger.error('Error fetching referral code', { error, userId, echoAppId }); + return err(new DatabaseError( + 'Failed to fetch referral code', + 'getReferralCodeForUser', + { userId, echoAppId, error: String(error) } + )); + } } /** * Calculate total balance for a user across all apps * Uses User.totalPaid and User.totalSpent for consistent balance calculation + * Returns Result with balance or error */ - async getBalance(userId: string): Promise { + async getBalance(userId: string): Promise> { try { const user = await this.db.user.findUnique({ where: { id: userId }, @@ -221,29 +241,25 @@ export class EchoDbService { if (!user) { logger.error(`User not found: ${userId}`); - return { - balance: 0, - totalPaid: 0, - totalSpent: 0, - }; + return err(new DatabaseError(`User not found: ${userId}`, 'getBalance')); } const totalPaid = Number(user.totalPaid); const totalSpent = Number(user.totalSpent); const balance = totalPaid - totalSpent; - return { + return ok({ balance, totalPaid, totalSpent, - }; + }); } catch (error) { logger.error(`Error fetching balance: ${error}`); - return { - balance: 0, - totalPaid: 0, - totalSpent: 0, - }; + return err(new DatabaseError( + 'Failed to fetch balance', + 'getBalance', + { userId, error: String(error) } + )); } } @@ -402,13 +418,14 @@ export class EchoDbService { /** * Create an LLM transaction record and atomically update user's totalSpent * Centralized logic for transaction creation with atomic balance updates + * Returns Result with transaction or error */ async createPaidTransaction( transaction: TransactionRequest - ): Promise { + ): Promise> { try { // Use a database transaction to atomically create the LLM transaction and update user balance - const result = await this.db.$transaction(async tx => { + const result = await this.db.$transaction(async (tx) => { // Create the LLM transaction record const dbTransaction = await this.createTransactionRecord( tx, @@ -435,10 +452,14 @@ export class EchoDbService { `Created transaction for model ${transaction.metadata.model}: $${transaction.totalCost}, updated user totalSpent`, result.id ); - return result; + return ok(result); } catch (error) { logger.error(`Error creating transaction and updating balance: ${error}`); - return null; + return err(new DatabaseError( + 'Failed to create transaction and update balance', + 'createPaidTransaction', + { error: String(error) } + )); } } @@ -456,7 +477,7 @@ export class EchoDbService { userSpendPoolUsage: UserSpendPoolUsage | null; }> { try { - return await this.db.$transaction(async tx => { + return await this.db.$transaction(async (tx) => { // 1. Verify the spend pool exists const spendPool = await tx.spendPool.findUnique({ where: { id: spendPoolId }, diff --git a/packages/app/server/src/services/EchoControlService.ts b/packages/app/server/src/services/EchoControlService.ts index 861db6a62..2f02b2ce5 100644 --- a/packages/app/server/src/services/EchoControlService.ts +++ b/packages/app/server/src/services/EchoControlService.ts @@ -1,5 +1,6 @@ import { existsSync } from 'fs'; import { join } from 'path'; +import { ok, err, ResultAsync } from 'neverthrow'; import type { ApiKeyValidationResult, EchoApp, @@ -12,7 +13,17 @@ import type { import { EchoDbService } from './DbService'; import { Decimal } from '@prisma/client/runtime/library'; -import { PaymentRequiredError, UnauthorizedError } from '../errors/http'; +import { + AppResult, + AppResultAsync, + InvalidApiKeyError, + ConfigurationError, + DatabaseError, + AuthenticationError, + ValidationError, + PaymentRequiredError, + safeAsync +} from '../errors'; import { EnumTransactionType, MarkUp, @@ -40,13 +51,9 @@ export class EchoControlService { private freeTierSpendPool: SpendPool | null = null; constructor(db: PrismaClient, apiKey?: string) { - // Check if the generated Prisma client exists const generatedPrismaPath = join(__dirname, 'generated', 'prisma'); if (!existsSync(generatedPrismaPath)) { - throw new Error( - `Generated Prisma client not found at ${generatedPrismaPath}. ` + - 'Please run "npm run copy-prisma" to copy the generated client from echo-control.' - ); + logger.error('Generated Prisma client not found', { path: generatedPrismaPath }); } this.apiKey = apiKey; @@ -61,35 +68,55 @@ export class EchoControlService { * Uses centralized logic from EchoDbService */ async verifyApiKey(): Promise { - try { - if (this.apiKey) { - this.authResult = await this.dbService.validateApiKey(this.apiKey); - } - } catch (error) { - logger.error(`Error verifying API key: ${error}`); + const generatedPrismaPath = join(__dirname, 'generated', 'prisma'); + if (!existsSync(generatedPrismaPath)) { + logger.error('Cannot verify API key: Prisma client not found'); return null; } - const markupData = await this.earningsService.getEarningsData( - this.authResult, - this.getEchoAppId() ?? '' - ); - this.markUpAmount = markupData.markUpAmount; - this.markUpId = markupData.markUpId; - this.referrerRewardId = markupData.referralId; - this.referralAmount = markupData.referralAmount; + if (!this.apiKey) { + logger.error('No API key provided'); + return null; + } + + try { + this.authResult = await this.dbService.validateApiKey(this.apiKey); + + if (!this.authResult) { + return null; + } + + const markupData = await this.earningsService.getEarningsData( + this.authResult, + this.getEchoAppId() ?? '' + ); + this.markUpAmount = markupData.markUpAmount; + this.markUpId = markupData.markUpId; + this.referrerRewardId = markupData.referralId; + this.referralAmount = markupData.referralAmount; - const echoAppId = this.authResult?.echoAppId; - const userId = this.authResult?.userId; + const echoAppId = this.authResult?.echoAppId; + const userId = this.authResult?.userId; if (echoAppId && userId) { - this.referralCodeId = await this.dbService.getReferralCodeForUser( + const referralResult = await this.dbService.getReferralCodeForUser( userId, echoAppId ); + + if (referralResult.isOk()) { + this.referralCodeId = referralResult.value; + } else { + logger.error('Failed to get referral code', { error: referralResult.error }); + this.referralCodeId = null; + } } - return this.authResult; + return this.authResult; + } catch (error) { + logger.error(`Error verifying API key: ${error}`); + return null; + } } identifyX402Request(echoApp: EchoApp | null, markUp: MarkUp | null): void { @@ -104,7 +131,6 @@ export class EchoControlService { this.markUpAmount = markUp.amount; this.markUpId = markUp.id; } else { - // Default markup amount when no markup is configured this.markUpAmount = new Decimal(1.0); this.markUpId = null; } @@ -154,81 +180,87 @@ export class EchoControlService { /** * Get balance for the authenticated user directly from the database * Uses centralized logic from EchoDbService + * Returns Result with balance or error */ - async getBalance(): Promise { - try { - if (!this.authResult) { - logger.error('No authentication result available'); - return 0; - } - - const { userId } = this.authResult; - const balance = await this.dbService.getBalance(userId); + async getBalance(): Promise> { + if (!this.authResult) { + logger.error('No authentication result available'); + return err(new AuthenticationError('Not authenticated')); + } - return balance.balance; - } catch (error) { - logger.error(`Error fetching balance: ${error}`); - return 0; + const { userId } = this.authResult; + const balanceResult = await this.dbService.getBalance(userId); + + if (balanceResult.isErr()) { + return err(balanceResult.error); } + + return ok(balanceResult.value.balance); } /** * Create an LLM transaction record directly in the database * Uses centralized logic from EchoDbService */ - async createTransaction(transaction: Transaction): Promise { - try { - if (!this.authResult) { - logger.error('No authentication result available'); - return; - } + async createTransaction(transaction: Transaction): Promise> { + if (!this.authResult) { + logger.error('No authentication result available'); + return err(new AuthenticationError('Not authenticated')); + } - if (!this.markUpAmount) { - logger.error('Error Fetching Markup Amount'); - return; - } + if (!this.markUpAmount) { + logger.error('Error Fetching Markup Amount'); + return err(new ValidationError('Markup amount not available')); + } - if (this.freeTierSpendPool) { - await this.createFreeTierTransaction(transaction); - return; - } else { - await this.createPaidTransaction(transaction); - return; + if (this.freeTierSpendPool) { + const result = await this.createFreeTierTransaction(transaction); + if (result.isErr()) { + return err(result.error); + } + } else { + const result = await this.createPaidTransaction(transaction); + if (result.isErr()) { + return err(result.error); } - } catch (error) { - logger.error(`Error creating transaction: ${error}`); } + return ok(undefined); } async getOrNoneFreeTierSpendPool( userId: string, appId: string - ): Promise<{ spendPool: SpendPool; effectiveBalance: number } | null> { - const fetchSpendPoolInfo = - await this.freeTierService.getOrNoneFreeTierSpendPool(appId, userId); - if (fetchSpendPoolInfo) { - this.freeTierSpendPool = fetchSpendPoolInfo.spendPool; - return { - spendPool: fetchSpendPoolInfo.spendPool, - effectiveBalance: fetchSpendPoolInfo.effectiveBalance, - }; + ): Promise> { + try { + const fetchSpendPoolInfo = + await this.freeTierService.getOrNoneFreeTierSpendPool(appId, userId); + if (fetchSpendPoolInfo) { + this.freeTierSpendPool = fetchSpendPoolInfo.spendPool; + return ok({ + spendPool: fetchSpendPoolInfo.spendPool, + effectiveBalance: fetchSpendPoolInfo.effectiveBalance, + }); + } + return ok(null); + } catch (error) { + logger.error('Error fetching free tier spend pool', { error, userId, appId }); + return err(new DatabaseError('Failed to fetch free tier spend pool', 'getOrNoneFreeTierSpendPool')); } - return null; } async computeTransactionCosts( transaction: Transaction, referralCodeId: string | null, addEchoProfit: boolean = false - ): Promise { + ): Promise> { if (!this.markUpAmount) { logger.error('User has not authenticated'); - throw new UnauthorizedError('User has not authenticated'); + return err(new AuthenticationError('User has not authenticated')); } if (!this.referralAmount) { logger.error('Referral amount not found'); - throw new UnauthorizedError('Referral amount not found'); + return err(new AuthenticationError('Referral amount not found')); } const markUpDecimal = this.markUpAmount.minus(1); @@ -236,19 +268,17 @@ export class EchoControlService { if (markUpDecimal.lessThan(0.0)) { logger.error('App markup must be greater than 1.0'); - throw new UnauthorizedError('App markup must be greater than 1.0'); + return err(new ValidationError('App markup must be greater than 1.0')); } if (referralDecimal.lessThan(0.0)) { logger.error('Referral amount must be greater than 1.0'); - throw new UnauthorizedError('Referral amount must be greater than 1.0'); + return err(new ValidationError('Referral amount must be greater than 1.0')); } const totalAppProfitDecimal = transaction.rawTransactionCost.mul(markUpDecimal); - // If there is a referral code, calculate the referral profit - // Otherwise, set the referral profit to 0 const referralProfitDecimal = referralCodeId ? totalAppProfitDecimal.mul(referralDecimal) : new Decimal(0); @@ -265,31 +295,35 @@ export class EchoControlService { .plus(totalAppProfitDecimal) .plus(echoProfitDecimal); - // Return Decimal values directly - return { + return ok({ rawTransactionCost: transaction.rawTransactionCost, totalTransactionCost: totalTransactionCostDecimal, totalAppProfit: totalAppProfitDecimal, referralProfit: referralProfitDecimal, markUpProfit: markUpProfitDecimal, echoProfit: echoProfitDecimal, - }; + }); } - async createFreeTierTransaction(transaction: Transaction): Promise { + async createFreeTierTransaction(transaction: Transaction): Promise> { if (!this.authResult) { logger.error('No authentication result available'); - throw new UnauthorizedError('No authentication result available'); + return err(new AuthenticationError('No authentication result available')); } if (!this.freeTierSpendPool) { logger.error('No free tier spend pool available'); - throw new PaymentRequiredError('No free tier spend pool available'); + return err(new PaymentRequiredError()); } const { userId, echoAppId, apiKeyId } = this.authResult; if (!userId || !echoAppId) { logger.error('Missing required user or app information'); - throw new UnauthorizedError('Missing required user or app information'); + return err(new AuthenticationError('Missing required user or app information')); + } + + const costsResult = await this.computeTransactionCosts(transaction, this.referralCodeId); + if (costsResult.isErr()) { + return err(costsResult.error); } const { @@ -299,7 +333,7 @@ export class EchoControlService { echoProfit, referralProfit, markUpProfit, - } = await this.computeTransactionCosts(transaction, this.referralCodeId); + } = costsResult.value; const transactionData: TransactionRequest = { totalCost: totalTransactionCost, @@ -321,16 +355,27 @@ export class EchoControlService { ...(this.referrerRewardId && { referrerRewardId: this.referrerRewardId }), }; - await this.freeTierService.createFreeTierTransaction( - transactionData, - this.freeTierSpendPool.id - ); + try { + await this.freeTierService.createFreeTierTransaction( + transactionData, + this.freeTierSpendPool.id + ); + return ok(undefined); + } catch (error) { + logger.error('Error creating free tier transaction', { error }); + return err(new DatabaseError('Failed to create free tier transaction', 'createFreeTierTransaction')); + } } - async createPaidTransaction(transaction: Transaction): Promise { + async createPaidTransaction(transaction: Transaction): Promise> { if (!this.authResult) { logger.error('No authentication result available'); - throw new UnauthorizedError('No authentication result available'); + return err(new AuthenticationError('No authentication result available')); + } + + const costsResult = await this.computeTransactionCosts(transaction, this.referralCodeId); + if (costsResult.isErr()) { + return err(costsResult.error); } const { @@ -340,7 +385,7 @@ export class EchoControlService { referralProfit, markUpProfit, echoProfit, - } = await this.computeTransactionCosts(transaction, this.referralCodeId); + } = costsResult.value; const { userId, echoAppId, apiKeyId } = this.authResult; @@ -361,7 +406,14 @@ export class EchoControlService { ...(this.referrerRewardId && { referrerRewardId: this.referrerRewardId }), }; - await this.dbService.createPaidTransaction(transactionData); + const result = await this.dbService.createPaidTransaction(transactionData); + + if (result.isErr()) { + logger.error('Error creating paid transaction', { error: result.error }); + return err(result.error); + } + + return ok(undefined); } async identifyX402Transaction( @@ -379,12 +431,18 @@ export class EchoControlService { async createX402Transaction( transaction: Transaction, addEchoProfit: boolean = true - ): Promise { - const transactionCosts = await this.computeTransactionCosts( + ): Promise> { + const costsResult = await this.computeTransactionCosts( transaction, null, addEchoProfit ); + + if (costsResult.isErr()) { + return err(costsResult.error); + } + + const transactionCosts = costsResult.value; const transactionData: TransactionRequest = { totalCost: transactionCosts.totalTransactionCost, @@ -402,8 +460,13 @@ export class EchoControlService { transactionType: EnumTransactionType.X402, }; - await this.dbService.createPaidTransaction(transactionData); - - return transactionCosts; + const result = await this.dbService.createPaidTransaction(transactionData); + + if (result.isErr()) { + logger.error('Error creating X402 transaction', { error: result.error }); + return err(result.error); + } + + return ok(transactionCosts); } } diff --git a/packages/app/server/src/services/HandleStreamService.ts b/packages/app/server/src/services/HandleStreamService.ts index 80e8832f4..028941e20 100644 --- a/packages/app/server/src/services/HandleStreamService.ts +++ b/packages/app/server/src/services/HandleStreamService.ts @@ -3,6 +3,8 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; import { ReadableStream } from 'stream/web'; +import { ok, err } from 'neverthrow'; +import { AppResult, StreamError, ValidationError } from '../errors'; import logger from '../logger'; import { BaseProvider } from '../providers/BaseProvider'; import { Transaction } from '../types'; @@ -35,16 +37,14 @@ class HandleStreamService { ): Promise { const bodyStream = response.body as ReadableStream; if (!bodyStream) { + logger.error('No body stream returned from API'); throw new Error('No body stream returned from API'); } - // Duplicate the stream - one for client, one for processing const [clientStream, accountingStream] = this.duplicateStream(bodyStream); - // Promise for streaming data to client const streamToClientPromise = this.streamToClient(clientStream, res); - // Promise for processing data and creating transaction const reader2 = accountingStream.getReader(); const transactionPromise = this.processStreamData( req, @@ -52,20 +52,12 @@ class HandleStreamService { provider ); - // Wait for both streams to complete before ending response - try { - const [_, transaction] = await Promise.all([ - streamToClientPromise, - transactionPromise, - ]); - return transaction; - } catch (error) { - logger.error(`Error in stream coordination: ${error}`); - if (!res.headersSent) { - res.status(500).json({ error: 'Stream processing failed' }); - } - throw error; // Re-throw to be handled by error middleware - } + const [_, transaction] = await Promise.all([ + streamToClientPromise, + transactionPromise, + ]); + + return transaction; } /** @@ -92,22 +84,17 @@ class HandleStreamService { ): Promise { let data = ''; const decoder = new TextDecoder(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - data += decoder.decode(value, { stream: true }); - } - // flush any remaining decoder state - data += decoder.decode(); - // Wait for transaction to complete before resolving - return await provider.handleBody(data, req.body); - } catch (error) { - logger.error(`Error processing stream: ${error}`); - throw error; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + data += decoder.decode(value, { stream: true }); } + + data += decoder.decode(); + + return await provider.handleBody(data, req.body); } } -// Export singleton instance export const handleStreamService = new HandleStreamService(); diff --git a/packages/app/server/src/services/ModelRequestService.ts b/packages/app/server/src/services/ModelRequestService.ts index a662c36ae..a5b10f2a5 100644 --- a/packages/app/server/src/services/ModelRequestService.ts +++ b/packages/app/server/src/services/ModelRequestService.ts @@ -1,5 +1,13 @@ import { Request, Response } from 'express'; +import { ok, err } from 'neverthrow'; import { HttpError } from '../errors/http'; +import { + AppResult, + AppResultAsync, + ProviderError, + ValidationError, + safeAsync +} from '../errors'; import logger from '../logger'; import { BaseProvider } from '../providers/BaseProvider'; import { Transaction } from '../types'; @@ -23,74 +31,85 @@ class ModelRequestService { processedHeaders: Record, provider: BaseProvider, isStream: boolean - ): Promise<{ + ): Promise { - // Format authentication headers - const authenticatedHeaders = - await provider.formatAuthHeaders(processedHeaders); - - logger.info( - `New outbound request: ${req.method} ${provider.getBaseUrl(req.path)}${req.path}` - ); - - // Ensure stream usage is set correctly (OpenAI Format) - req.body = provider.ensureStreamUsage(req.body, req.path); - - // Apply provider-specific request body transformations - req.body = provider.transformRequestBody(req.body, req.path); - - // Format request body and headers based on content type - const { requestBody, headers: formattedHeaders } = this.formatRequestBody( - req, - authenticatedHeaders - ); - - // this rewrites the base url to the provider's base url and retains the rest - const upstreamUrl = formatUpstreamUrl(provider, req); - - // Forward the request to the provider's API - const response = await fetch(upstreamUrl, { - method: req.method, - headers: formattedHeaders, - ...(requestBody && { body: requestBody }), - }); - - // Handle non-200 responses - if (response.status !== 200) { - const errorMessage = `${response.status} ${response.statusText}`; - logger.error(`Error response: ${errorMessage}`); - - const errorBody = await response.text().catch(() => ''); - const error = this.parseErrorResponse(errorBody, response.status); - - logger.error(`Error details: ${JSON.stringify(error)}`); - res.status(response.status).json({ error }); - throw new HttpError(response.status, JSON.stringify(error)); - } + }, ProviderError | ValidationError>> { + try { + const authenticatedHeaders = + await provider.formatAuthHeaders(processedHeaders); - // Handle the successful response based on stream type - if (isStream) { - const transaction = await handleStreamService.handleStream( - response, - provider, + logger.info( + `New outbound request: ${req.method} ${provider.getBaseUrl(req.path)}${req.path}` + ); + + req.body = provider.ensureStreamUsage(req.body, req.path); + + req.body = provider.transformRequestBody(req.body, req.path); + + const { requestBody, headers: formattedHeaders } = this.formatRequestBody( req, - res + authenticatedHeaders ); - return { - transaction, - data: null, - }; - } else { - const { transaction, data } = - await handleNonStreamingService.handleNonStreaming( + + const upstreamUrl = formatUpstreamUrl(provider, req); + + const response = await fetch(upstreamUrl, { + method: req.method, + headers: formattedHeaders, + ...(requestBody && { body: requestBody }), + }); + + if (response.status !== 200) { + const errorMessage = `${response.status} ${response.statusText}`; + logger.error(`Error response: ${errorMessage}`); + + const errorBody = await response.text().catch(() => ''); + const error = this.parseErrorResponse(errorBody, response.status); + + logger.error(`Error details: ${JSON.stringify(error)}`); + + res.status(response.status).json({ error }); + + return err(new ProviderError( + provider.getType(), + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : errorMessage, + response.status, + { errorBody: error } + )); + } + + if (isStream) { + const transaction = await handleStreamService.handleStream( response, provider, req, res ); - return { transaction, data }; + return ok({ + transaction, + data: null, + }); + } else { + const { transaction, data } = + await handleNonStreamingService.handleNonStreaming( + response, + provider, + req, + res + ); + return ok({ transaction, data }); + } + } catch (error) { + logger.error('Unexpected error in executeModelRequest', { error }); + return err(new ProviderError( + provider.getType(), + error instanceof Error ? error.message : 'Unknown error occurred', + 500, + { originalError: error } + )); } } @@ -121,22 +140,18 @@ class ModelRequestService { let finalHeaders = { ...authenticatedHeaders }; if (req.method !== 'GET') { - // Check if this is a form data request const hasFiles = req.files && Array.isArray(req.files) && req.files.length > 0; const isMultipart = req.get('content-type')?.includes('multipart/form-data') ?? false; if (hasFiles || isMultipart) { - // Create FormData for multipart requests const formData = new FormData(); - // Add text fields from req.body Object.entries(req.body || {}).forEach(([key, value]) => { formData.append(key, String(value)); }); - // Add files from req.files if (req.files && Array.isArray(req.files)) { req.files.forEach(file => { const blob = new Blob([new Uint8Array(file.buffer)], { @@ -147,13 +162,11 @@ class ModelRequestService { } requestBody = formData; - // Remove content-type header to let fetch set it with boundary delete finalHeaders['content-type']; delete finalHeaders['Content-Type']; logger.info('Forwarding form data request with files'); } else { - // Handle as JSON request requestBody = JSON.stringify(req.body); } } @@ -174,5 +187,4 @@ class ModelRequestService { } } -// Export singleton instance export const modelRequestService = new ModelRequestService(); diff --git a/packages/app/server/src/services/x402AuthenticationService.ts b/packages/app/server/src/services/x402AuthenticationService.ts index 7d86194b8..bb9d4dbca 100644 --- a/packages/app/server/src/services/x402AuthenticationService.ts +++ b/packages/app/server/src/services/x402AuthenticationService.ts @@ -2,6 +2,7 @@ import { MarkUp, PrismaClient } from '../generated/prisma'; import { EchoDbService } from './DbService'; import { EchoApp, Transaction, TransactionCosts } from '../types'; import { EchoControlService } from './EchoControlService'; +import { AppResult, DatabaseError, AuthenticationError, ValidationError } from '../errors'; import logger from 'logger'; import { env } from '../env'; @@ -39,18 +40,25 @@ export class X402AuthenticationService { async createX402Transaction( transaction: Transaction - ): Promise { + ): Promise> { const applyEchoMarkup = env.APPLY_ECHO_MARKUP === 'true'; - const transactionCosts = + const transactionCostsResult = await this.echoControlService.createX402Transaction( transaction, applyEchoMarkup ); + if (transactionCostsResult.isErr()) { + logger.error('Failed to create X402 transaction', { + error: transactionCostsResult.error + }); + return transactionCostsResult; + } + logger.info( `Created X402 transaction for echo app ${transaction.metadata.provider}` ); - return transactionCosts; + return transactionCostsResult; } } diff --git a/packages/app/server/src/transferWithAuth.ts b/packages/app/server/src/transferWithAuth.ts index bca2642b9..bed6dca9d 100644 --- a/packages/app/server/src/transferWithAuth.ts +++ b/packages/app/server/src/transferWithAuth.ts @@ -11,7 +11,13 @@ export async function transfer( value: BigInt ): Promise { try { - const { smartAccount } = await getSmartAccount(); + const smartAccountResult = await getSmartAccount(); + + if (smartAccountResult.isErr()) { + throw new Error(`Failed to get smart account: ${smartAccountResult.error.message}`); + } + + const { smartAccount } = smartAccountResult.value; const result = await smartAccount.sendUserOperation({ network: 'base', diff --git a/packages/app/server/src/types.ts b/packages/app/server/src/types.ts index e6febc9d0..7ecc3c7c5 100644 --- a/packages/app/server/src/types.ts +++ b/packages/app/server/src/types.ts @@ -163,7 +163,7 @@ type TokenAmount = string; type Url = string; type Nonce = string; -interface ExactEvmPayloadAuthorization { +export interface ExactEvmPayloadAuthorization { from: Address; to: Address; value: TokenAmount; @@ -172,7 +172,7 @@ interface ExactEvmPayloadAuthorization { nonce: Nonce; } -interface ExactEvmPayload { +export interface ExactEvmPayload { signature: string; authorization: ExactEvmPayloadAuthorization; } diff --git a/packages/app/server/src/utils.ts b/packages/app/server/src/utils.ts index 21d709cef..b2ba6ef30 100644 --- a/packages/app/server/src/utils.ts +++ b/packages/app/server/src/utils.ts @@ -4,6 +4,8 @@ import { X402ChallengeParams, } from 'types'; import { Request, Response } from 'express'; +import { ok, err } from 'neverthrow'; +import { AppResult, ConfigurationError, ValidationError } from './errors'; import { CdpClient, EvmSmartAccount } from '@coinbase/cdp-sdk'; import { WALLET_SMART_ACCOUNT, @@ -22,7 +24,7 @@ import { X402_REALM, USDC_MULTIPLIER, } from './constants'; -import { Decimal } from 'generated/prisma/runtime/library'; +import { Decimal } from './generated/prisma/runtime/library'; import { USDC_ADDRESS } from 'services/fund-repo/constants'; import crypto from 'crypto'; import logger from 'logger'; @@ -49,7 +51,6 @@ const WALLET_SECRET = env.CDP_WALLET_SECRET || 'your-wallet-secret'; */ export function decimalToUsdcBigInt(amount: Decimal | number): bigint { const numericAmount = typeof amount === 'number' ? amount : Number(amount); - // Use Math.ceil for defensive rounding to avoid undercharging return BigInt(Math.ceil(numericAmount * USDC_MULTIPLIER)); } @@ -122,14 +123,19 @@ export async function buildX402Response( const host = env.ECHO_ROUTER_BASE_URL; const resourceUrl = `${host}${req.url}`; - let recipient: string; - try { - recipient = (await getSmartAccount()).smartAccount.address; - } catch (error) { - logger.error('Failed to get smart account for X402 response', { error }); - throw error; + const smartAccountResult = await getSmartAccount(); + if (smartAccountResult.isErr()) { + logger.error('Failed to get smart account for X402 response', { + error: smartAccountResult.error + }); + return res.status(500).json({ + error: 'Failed to initialize payment system', + type: smartAccountResult.error.type + }); } + const recipient = smartAccountResult.value.smartAccount.address; + res.setHeader( 'WWW-Authenticate', buildX402Challenge({ @@ -201,9 +207,9 @@ export function isX402Request(headers: Record): boolean { return headers[X402_PAYMENT_HEADER] !== undefined; } -export async function getSmartAccount(): Promise<{ +export async function getSmartAccount(): Promise { +}, ConfigurationError>> { try { const cdp = new CdpClient({ apiKeyId: API_KEY_ID, @@ -220,26 +226,43 @@ export async function getSmartAccount(): Promise<{ owner, }); - return { smartAccount }; + return ok({ smartAccount }); } catch (error) { logger.error('Failed to get smart account', { error }); - throw new Error( - `CDP authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` - ); + return err(new ConfigurationError( + `CDP authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'CDP_WALLET_CONFIG', + { error: String(error) } + )); } } export function validateXPaymentHeader( processedHeaders: Record, req: Request -): PaymentPayload { +): AppResult { const xPaymentHeader = processedHeaders[X402_PAYMENT_HEADER] || req.headers[X402_PAYMENT_HEADER]; + if (!xPaymentHeader) { - throw new Error('x-payment header missing after validation'); + logger.error('x-payment header missing'); + return err(new ValidationError('x-payment header missing after validation')); + } + + try { + const xPaymentData = JSON.parse( + Buffer.from(xPaymentHeader as string, 'base64').toString() + ); + + const parseResult = PaymentPayloadSchema.safeParse(xPaymentData); + if (!parseResult.success) { + logger.error('Invalid payment payload', { error: parseResult.error }); + return err(new ValidationError('Invalid payment payload format')); + } + + return ok(parseResult.data); + } catch (error) { + logger.error('Failed to parse x-payment header', { error }); + return err(new ValidationError('Failed to parse x-payment header')); } - const xPaymentData = JSON.parse( - Buffer.from(xPaymentHeader as string, 'base64').toString() - ); - return PaymentPayloadSchema.parse(xPaymentData); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da19eba11..66acd7730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,7 +70,7 @@ importers: version: 1.34.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) '@coinbase/x402': specifier: ^0.6.4 - version: 0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10) '@hookform/resolvers': specifier: ^5.2.1 version: 5.2.1(react-hook-form@7.62.0(react@19.1.1)) @@ -501,6 +501,9 @@ importers: multer: specifier: ^2.0.2 version: 2.0.2 + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 openai: specifier: ^6.2.0 version: 6.2.0(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.11) @@ -5806,6 +5809,7 @@ packages: '@walletconnect/ethereum-provider@2.21.1': resolution: {integrity: sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/events@1.0.1': resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} @@ -9332,6 +9336,10 @@ packages: resolution: {integrity: sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==} engines: {node: '>=18'} + neverthrow@8.2.0: + resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} + engines: {node: '>=18'} + next-auth@5.0.0-beta.29: resolution: {integrity: sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==} peerDependencies: @@ -12687,11 +12695,11 @@ snapshots: - utf-8-validate - zod - '@coinbase/x402@0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@coinbase/x402@0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)': dependencies: '@coinbase/cdp-sdk': 1.34.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) viem: 2.33.3(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) - x402: 0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + x402: 0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10) zod: 3.25.76 transitivePeerDependencies: - '@azure/app-configuration' @@ -17198,6 +17206,10 @@ snapshots: dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget@0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': + dependencies: + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -17208,6 +17220,10 @@ snapshots: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana-program/token-2022@0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': + dependencies: + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -17216,6 +17232,10 @@ snapshots: dependencies: '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/token@0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': + dependencies: + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/accounts@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17371,6 +17391,31 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': + dependencies: + '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/codecs': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/errors': 2.3.0(typescript@5.9.2) + '@solana/functional': 2.3.0(typescript@5.9.2) + '@solana/instructions': 2.3.0(typescript@5.9.2) + '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/programs': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/rpc-parsed-types': 2.3.0(typescript@5.9.2) + '@solana/rpc-spec-types': 2.3.0(typescript@5.9.2) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/signers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/sysvars': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -18781,14 +18826,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': + '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.3 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.11.2(@types/node@20.19.16)(typescript@5.9.2) - vite: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@24.3.1)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': dependencies: @@ -18836,7 +18881,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.3(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/ui@3.2.3)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(msw@2.11.2(@types/node@24.3.1)(typescript@5.9.2))(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) + vitest: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.16)(@vitest/ui@3.2.3)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.1)(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) '@vitest/utils@3.0.9': dependencies: @@ -24587,6 +24632,10 @@ snapshots: neverthrow@7.2.0: {} + neverthrow@8.2.0: + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.42.0 + next-auth@5.0.0-beta.29(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1): dependencies: '@auth/core': 0.40.0 @@ -27716,7 +27765,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.3 - '@vitest/mocker': 3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0)) + '@vitest/mocker': 3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.3 '@vitest/runner': 3.2.3 '@vitest/snapshot': 3.2.3 @@ -28298,14 +28347,14 @@ snapshots: - utf-8-validate - ws - x402@0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + x402@0.6.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10): dependencies: '@scure/base': 1.2.6 - '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10))) - '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) - '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) - '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/compute-budget': 0.8.0(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) + '@solana-program/token': 0.5.1(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) + '@solana-program/token-2022': 0.4.2(@solana/kit@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) + '@solana/kit': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) + '@solana/transaction-confirmation': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) viem: 2.33.3(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) wagmi: 2.17.5(@tanstack/react-query@5.90.2(react@19.1.1))(@types/react@19.1.10)(@vercel/blob@0.25.1)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(viem@2.33.3(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.1.11))(zod@3.25.76) zod: 3.25.76