diff --git a/packages/app/server/package.json b/packages/app/server/package.json index 63df492d1..38fa448f6 100644 --- a/packages/app/server/package.json +++ b/packages/app/server/package.json @@ -42,6 +42,7 @@ "author": "", "license": "ISC", "dependencies": { + "neverthrow": "^7.2.0", "@coinbase/cdp-sdk": "^1.34.0", "@e2b/code-interpreter": "^2.0.1", "@google-cloud/storage": "^7.17.1", diff --git a/packages/app/server/src/errors/results.ts b/packages/app/server/src/errors/results.ts new file mode 100644 index 000000000..8f2de684f --- /dev/null +++ b/packages/app/server/src/errors/results.ts @@ -0,0 +1,33 @@ +/** + * Typed error variants for neverthrow Result types. + * Each error variant carries structured context for downstream handling. + */ + +export type DbError = + | { type: 'DB_NOT_FOUND'; entity: string; id?: string } + | { type: 'DB_VALIDATION_FAILED'; message: string } + | { type: 'DB_TRANSACTION_FAILED'; cause: unknown } + | { type: 'DB_QUERY_FAILED'; cause: unknown }; + +export type AuthError = + | { type: 'AUTH_INVALID_API_KEY' } + | { type: 'AUTH_EXPIRED_JWT' } + | { type: 'AUTH_JWT_VERIFICATION_FAILED'; cause: unknown } + | { type: 'AUTH_MISSING_FIELDS'; fields: string[] } + | { type: 'AUTH_MISSING_CREDENTIALS' }; + +export type SettleError = + | { type: 'SETTLE_SMART_ACCOUNT_FAILED'; cause: unknown } + | { type: 'SETTLE_INVALID_PAYMENT_HEADER'; cause: unknown } + | { type: 'SETTLE_INVALID_PAYLOAD'; cause: unknown } + | { type: 'SETTLE_INSUFFICIENT_PAYMENT'; required: bigint; provided: bigint } + | { type: 'SETTLE_FACILITATOR_FAILED' }; + +export type RefundError = + | { type: 'REFUND_TRANSFER_FAILED'; cause: unknown }; + +export type ResourceError = + | { type: 'RESOURCE_EXECUTION_FAILED'; cause: unknown } + | { type: 'RESOURCE_AUTHENTICATION_FAILED'; cause: unknown } + | { type: 'RESOURCE_PAYMENT_FAILED'; cause: SettleError } + | { type: 'RESOURCE_TRANSACTION_FAILED'; cause: unknown }; diff --git a/packages/app/server/src/handlers.ts b/packages/app/server/src/handlers.ts index 131d35d90..002e5a7b0 100644 --- a/packages/app/server/src/handlers.ts +++ b/packages/app/server/src/handlers.ts @@ -1,7 +1,7 @@ import { TransactionEscrowMiddleware } from 'middleware/transaction-escrow-middleware'; import { modelRequestService } from 'services/ModelRequestService'; import { ApiKeyHandlerInput, X402HandlerInput } from 'types'; -import { calculateRefundAmount } from 'utils'; +import { calculateRefundAmount, buildX402Response } from 'utils'; import { checkBalance } from 'services/BalanceCheckService'; import { prisma } from 'server'; import { makeProxyPassthroughRequest } from 'services/ProxyPassthroughService'; @@ -25,7 +25,7 @@ export async function handleX402Request({ return await makeProxyPassthroughRequest(req, res, provider, headers); } - const settlePromise = settle(req, res, headers, maxCost); + const settlePromise = settle(req, headers, maxCost); const modelResultPromise = modelRequestService .executeModelRequest(req, res, headers, provider, isStream) @@ -37,13 +37,16 @@ export async function handleX402Request({ modelResultPromise, ]); + const settleOk = settleResult.isOk(); + // Case 1: Settle failed and model failed - if (!settleResult && !modelResult.success) { + if (!settleOk && !modelResult.success) { + buildX402Response(req, res, maxCost); return; } // Case 2: Settle failed but model succeeded - if (!settleResult && modelResult.success) { + if (!settleOk && modelResult.success) { const { data } = modelResult; logger.error('Settle failed but model request succeeded', { provider: provider.getType(), @@ -62,16 +65,19 @@ export async function handleX402Request({ return; } - // At this point, settleResult is guaranteed to exist - if (!settleResult) { + // At this point, settleResult is guaranteed to be ok + if (settleResult.isErr()) { + buildX402Response(req, res, maxCost); return; } - const { payload, paymentAmountDecimal } = settleResult; + const { payload, paymentAmountDecimal } = settleResult.value; // Case 3: Settle succeeded but model failed if (!modelResult.success) { - await refund(paymentAmountDecimal, payload); + refund(paymentAmountDecimal, payload).mapErr(refundErr => { + logger.error('Failed to refund', refundErr); + }); return; } diff --git a/packages/app/server/src/handlers/refund.ts b/packages/app/server/src/handlers/refund.ts index 9279303e4..8de46b652 100644 --- a/packages/app/server/src/handlers/refund.ts +++ b/packages/app/server/src/handlers/refund.ts @@ -2,17 +2,18 @@ import { decimalToUsdcBigInt } from 'utils'; import { transfer } from 'transferWithAuth'; import { ExactEvmPayload } from 'services/facilitator/x402-types'; import { Decimal } from '@prisma/client/runtime/library'; -import logger from 'logger'; +import { ResultAsync } from 'neverthrow'; +import type { RefundError } from '../errors/results'; -export async function refund( +export function refund( paymentAmountDecimal: Decimal, payload: ExactEvmPayload -) { - try { - const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal); - const authPayload = payload.authorization; - await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt); - } catch (error) { - logger.error('Failed to refund', error); - } +): ResultAsync { + const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal); + const authPayload = payload.authorization; + + return ResultAsync.fromPromise( + transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt), + (cause): RefundError => ({ type: 'REFUND_TRANSFER_FAILED', cause }) + ).map(() => undefined); } diff --git a/packages/app/server/src/handlers/settle.ts b/packages/app/server/src/handlers/settle.ts index 935797c3a..360433a69 100644 --- a/packages/app/server/src/handlers/settle.ts +++ b/packages/app/server/src/handlers/settle.ts @@ -1,7 +1,6 @@ import { usdcBigIntToDecimal, decimalToUsdcBigInt, - buildX402Response, getSmartAccount, validateXPaymentHeader, } from 'utils'; @@ -10,91 +9,107 @@ import { FacilitatorClient } from 'services/facilitator/facilitatorService'; import { ExactEvmPayload, ExactEvmPayloadSchema, - PaymentPayload, PaymentRequirementsSchema, SettleRequestSchema, Network, } from 'services/facilitator/x402-types'; import { Decimal } from '@prisma/client/runtime/library'; import logger from 'logger'; -import { Request, Response } from 'express'; +import { Request } from 'express'; +import { ResultAsync, fromThrowable, err, ok } from 'neverthrow'; import { env } from '../env'; +import type { SettleError } from '../errors/results'; -export async function settle( +export type SettleSuccess = { + payload: ExactEvmPayload; + paymentAmountDecimal: Decimal; +}; + +const parseXPaymentHeader = fromThrowable( + validateXPaymentHeader, + (cause): SettleError => ({ type: 'SETTLE_INVALID_PAYMENT_HEADER', cause }) +); + +export function settle( req: Request, - res: Response, headers: Record, maxCost: Decimal -): Promise< - { payload: ExactEvmPayload; paymentAmountDecimal: Decimal } | undefined -> { +): ResultAsync { const network = env.NETWORK as Network; - let recipient: string; - try { - recipient = (await getSmartAccount()).smartAccount.address; - } catch (error) { - buildX402Response(req, res, maxCost); - return undefined; - } + return ResultAsync.fromPromise( + getSmartAccount().then(({ smartAccount }) => smartAccount.address), + (cause): SettleError => ({ type: 'SETTLE_SMART_ACCOUNT_FAILED', cause }) + ) + .andThen(recipient => + parseXPaymentHeader(headers, req).map(xPaymentData => ({ + recipient, + xPaymentData, + })) + ) + .andThen(({ recipient, xPaymentData }) => { + const payloadResult = ExactEvmPayloadSchema.safeParse(xPaymentData.payload); + if (!payloadResult.success) { + logger.error('Invalid ExactEvmPayload in settle', { + error: payloadResult.error, + payload: xPaymentData.payload, + }); + return err< + { recipient: string; xPaymentData: typeof xPaymentData; payload: ExactEvmPayload; paymentAmountDecimal: Decimal }, + SettleError + >({ type: 'SETTLE_INVALID_PAYLOAD', cause: payloadResult.error }); + } - let xPaymentData: PaymentPayload; - try { - xPaymentData = validateXPaymentHeader(headers, req); - } catch (error) { - buildX402Response(req, res, maxCost); - return undefined; - } - - const payloadResult = ExactEvmPayloadSchema.safeParse(xPaymentData.payload); - if (!payloadResult.success) { - logger.error('Invalid ExactEvmPayload in settle', { - error: payloadResult.error, - payload: xPaymentData.payload, - }); - buildX402Response(req, res, maxCost); - return undefined; - } - const payload = payloadResult.data; + const payload = payloadResult.data; + const paymentAmount = payload.authorization.value; + const paymentAmountDecimal = usdcBigIntToDecimal(paymentAmount); - const paymentAmount = payload.authorization.value; - const paymentAmountDecimal = usdcBigIntToDecimal(paymentAmount); + // Note(shafu, alvaro): Edge case where client sends the x402-challenge + // but the payment amount is less than what we returned in the first response + if (BigInt(paymentAmount) < decimalToUsdcBigInt(maxCost)) { + return err< + { recipient: string; xPaymentData: typeof xPaymentData; payload: ExactEvmPayload; paymentAmountDecimal: Decimal }, + SettleError + >({ + type: 'SETTLE_INSUFFICIENT_PAYMENT', + required: decimalToUsdcBigInt(maxCost), + provided: BigInt(paymentAmount), + }); + } - // Note(shafu, alvaro): Edge case where client sends the x402-challenge - // but the payment amount is less than what we returned in the first response - if (BigInt(paymentAmount) < decimalToUsdcBigInt(maxCost)) { - buildX402Response(req, res, maxCost); - return undefined; - } + return ok({ recipient, xPaymentData, payload, paymentAmountDecimal }); + }) + .andThen(({ recipient, xPaymentData, payload, paymentAmountDecimal }) => { + const facilitatorClient = new FacilitatorClient(); + const paymentRequirements = PaymentRequirementsSchema.parse({ + scheme: 'exact', + network, + maxAmountRequired: payload.authorization.value, + resource: `${req.protocol}://${req.get('host')}${req.url}`, + description: 'Echo x402', + mimeType: 'application/json', + payTo: recipient, + maxTimeoutSeconds: 60, + asset: USDC_ADDRESS, + extra: { + name: 'USD Coin', + version: '2', + }, + }); - const facilitatorClient = new FacilitatorClient(); - const paymentRequirements = PaymentRequirementsSchema.parse({ - scheme: 'exact', - network, - maxAmountRequired: paymentAmount, - resource: `${req.protocol}://${req.get('host')}${req.url}`, - description: 'Echo x402', - mimeType: 'application/json', - payTo: recipient, - maxTimeoutSeconds: 60, - asset: USDC_ADDRESS, - extra: { - name: 'USD Coin', - version: '2', - }, - }); + const settleRequest = SettleRequestSchema.parse({ + paymentPayload: xPaymentData, + paymentRequirements, + }); - const settleRequest = SettleRequestSchema.parse({ - paymentPayload: xPaymentData, - paymentRequirements, - }); - - const settleResult = await facilitatorClient.settle(settleRequest); - - if (!settleResult.success || !settleResult.transaction) { - buildX402Response(req, res, maxCost); - return undefined; - } - - return { payload, paymentAmountDecimal }; + return ResultAsync.fromPromise( + facilitatorClient.settle(settleRequest), + (): SettleError => ({ type: 'SETTLE_FACILITATOR_FAILED' }) + ).andThen(settleResult => { + if (!settleResult.success || !settleResult.transaction) { + return err({ type: 'SETTLE_FACILITATOR_FAILED' }); + } + return ok({ payload, paymentAmountDecimal }); + }); + }); } diff --git a/packages/app/server/src/providers/AnthropicGPTProvider.ts b/packages/app/server/src/providers/AnthropicGPTProvider.ts index bfb0da03a..c94bb08a4 100644 --- a/packages/app/server/src/providers/AnthropicGPTProvider.ts +++ b/packages/app/server/src/providers/AnthropicGPTProvider.ts @@ -5,11 +5,13 @@ import { GPTProvider } from './GPTProvider'; import { ProviderType } from './ProviderType'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; const parseSSEAnthropicGPTFormat = (data: string): StreamingChunkBody[] => { // Split by double newlines to separate events const events = data.split('\n\n'); const chunks: StreamingChunkBody[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const event of events) { if (!event.trim()) continue; @@ -26,21 +28,18 @@ const parseSSEAnthropicGPTFormat = (data: string): StreamingChunkBody[] => { // Skip ping events if (trimmed.includes('"type": "ping"')) continue; - try { - const parsed = JSON.parse(trimmed); - // Only add valid chunks that have the expected structure - if ( - parsed !== null && - typeof parsed === 'object' && - 'choices' in parsed - ) { - chunks.push(parsed); - } - } catch (error) { + const parsedResult = parseJson(trimmed); + if (parsedResult.isErr()) { // Log error but continue processing other events - logger.warn(`Error parsing SSE chunk: ${error}`); + logger.warn(`Error parsing SSE chunk: ${parsedResult.error}`); continue; } + + const parsed = parsedResult.value; + // Only add valid chunks that have the expected structure + if (parsed !== null && typeof parsed === 'object' && 'choices' in parsed) { + chunks.push(parsed as StreamingChunkBody); + } } return chunks; @@ -62,55 +61,63 @@ 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; + return ResultAsync.fromPromise( + (async () => { + 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; } - providerId = chunk.id; + } 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; } - } 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; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + 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; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/providers/AnthropicNativeProvider.ts b/packages/app/server/src/providers/AnthropicNativeProvider.ts index 132ba72f2..7b3cbe98d 100644 --- a/packages/app/server/src/providers/AnthropicNativeProvider.ts +++ b/packages/app/server/src/providers/AnthropicNativeProvider.ts @@ -4,6 +4,7 @@ import { BaseProvider } from './BaseProvider'; import { ProviderType } from './ProviderType'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync } from 'neverthrow'; interface AnthropicUsage { input_tokens: number; @@ -118,75 +119,83 @@ export class AnthropicNativeProvider extends BaseProvider { } override async handleBody(data: string): Promise { - try { - if (this.getIsStream()) { - const usage = parseSSEAnthropicFormat(data); + return ResultAsync.fromPromise( + (async () => { + 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; - 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(), + logger.info( + 'Usage tokens (input/output/total): ', inputTokens, - outputTokens - ), - status: 'success', - }; + 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; + return transaction; + } + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; } - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } override ensureStreamUsage( diff --git a/packages/app/server/src/providers/GPTProvider.ts b/packages/app/server/src/providers/GPTProvider.ts index 453e7098f..a42e5e5f9 100644 --- a/packages/app/server/src/providers/GPTProvider.ts +++ b/packages/app/server/src/providers/GPTProvider.ts @@ -4,6 +4,7 @@ import { BaseProvider } from './BaseProvider'; import { ProviderType } from './ProviderType'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; export interface CompletionStateBody { id: string; @@ -34,6 +35,7 @@ export const parseSSEGPTFormat = (data: string): StreamingChunkBody[] => { // Split by double newlines to separate events const events = data.split('\n\n'); const chunks: StreamingChunkBody[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const event of events) { if (!event.trim()) continue; @@ -45,12 +47,13 @@ export const parseSSEGPTFormat = (data: string): StreamingChunkBody[] => { // Skip [DONE] marker if (jsonStr.trim() === '[DONE]') continue; - try { - const parsed = JSON.parse(jsonStr); - chunks.push(parsed); - } catch (error) { - logger.error(`Error parsing SSE chunk: ${error}`); + const parsedResult = parseJson(jsonStr); + if (parsedResult.isErr()) { + logger.error(`Error parsing SSE chunk: ${parsedResult.error}`); + continue; } + + chunks.push(parsedResult.value as StreamingChunkBody); } } @@ -71,56 +74,64 @@ 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; + return ResultAsync.fromPromise( + (async () => { + 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'; } - 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'; } - } 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; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + 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; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/providers/GeminiGPTProvider.ts b/packages/app/server/src/providers/GeminiGPTProvider.ts index c5e641b13..986ea6bfd 100644 --- a/packages/app/server/src/providers/GeminiGPTProvider.ts +++ b/packages/app/server/src/providers/GeminiGPTProvider.ts @@ -5,11 +5,13 @@ import { GPTProvider } from './GPTProvider'; import { ProviderType } from './ProviderType'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; const parseSSEGeminiGPTFormat = (data: string): StreamingChunkBody[] => { // Split by double newlines to separate events const events = data.split('\n\n'); const chunks: StreamingChunkBody[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const event of events) { if (!event.trim()) continue; @@ -26,21 +28,18 @@ const parseSSEGeminiGPTFormat = (data: string): StreamingChunkBody[] => { // Skip ping events if any if (trimmed.includes('"type": "ping"')) continue; - try { - const parsed = JSON.parse(trimmed); - // Only add valid chunks that have the expected structure - if ( - parsed !== null && - typeof parsed === 'object' && - 'choices' in parsed - ) { - chunks.push(parsed); - } - } catch (error) { + const parsedResult = parseJson(trimmed); + if (parsedResult.isErr()) { // Log error but continue processing other events - logger.warn(`Error parsing SSE chunk: ${error}`); + logger.warn(`Error parsing SSE chunk: ${parsedResult.error}`); continue; } + + const parsed = parsedResult.value; + // Only add valid chunks that have the expected structure + if (parsed !== null && typeof parsed === 'object' && 'choices' in parsed) { + chunks.push(parsed as StreamingChunkBody); + } } return chunks; @@ -61,54 +60,62 @@ 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; + return ResultAsync.fromPromise( + (async () => { + 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; } - providerId = chunk.id; + } 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; } - } 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; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + 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; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/providers/GeminiProvider.ts b/packages/app/server/src/providers/GeminiProvider.ts index a97e5dc47..58daa0729 100644 --- a/packages/app/server/src/providers/GeminiProvider.ts +++ b/packages/app/server/src/providers/GeminiProvider.ts @@ -4,6 +4,7 @@ import { LlmTransactionMetadata, Transaction } from '../types'; import { BaseProvider } from './BaseProvider'; import { ProviderType } from './ProviderType'; import { env } from '../env'; +import { ResultAsync } from 'neverthrow'; interface GeminiUsage { promptTokenCount: number; @@ -126,68 +127,76 @@ 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'); - } + return ResultAsync.fromPromise( + (async () => { + 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; + promptTokens = usage.promptTokenCount; + candidatesTokens = usage.candidatesTokenCount; + totalTokens = usage.totalTokenCount; + } else { + const parsed = JSON.parse(data) as GeminiResponse; - if (parsed?.usageMetadata) { - promptTokens = parsed.usageMetadata.promptTokenCount || 0; - candidatesTokens = parsed.usageMetadata.candidatesTokenCount || 0; - totalTokens = parsed.usageMetadata.totalTokenCount || 0; - } + if (parsed?.usageMetadata) { + promptTokens = parsed.usageMetadata.promptTokenCount || 0; + candidatesTokens = parsed.usageMetadata.candidatesTokenCount || 0; + totalTokens = parsed.usageMetadata.totalTokenCount || 0; + } - // 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, '')}`; + // 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, '')}`; + } } - } - 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; - } catch (error) { - logger.error(`Error processing Gemini response data: ${error}`); - throw error; - } + 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; + })(), + error => { + logger.error(`Error processing Gemini response data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } override ensureStreamUsage( diff --git a/packages/app/server/src/providers/GroqProvider.ts b/packages/app/server/src/providers/GroqProvider.ts index dcc269a02..ddc51ed98 100644 --- a/packages/app/server/src/providers/GroqProvider.ts +++ b/packages/app/server/src/providers/GroqProvider.ts @@ -5,6 +5,7 @@ import { ProviderType } from './ProviderType'; import { CompletionStateBody, parseSSEGPTFormat } from './GPTProvider'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync } from 'neverthrow'; export class GroqProvider extends BaseProvider { private readonly GROQ_BASE_URL = 'https://api.groq.com/openai/v1'; @@ -26,56 +27,64 @@ 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'; + return ResultAsync.fromPromise( + (async () => { + 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; + 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'; } - 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'; } - } 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; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/providers/OpenAIImageProvider.ts b/packages/app/server/src/providers/OpenAIImageProvider.ts index cb6861803..29bc99b85 100644 --- a/packages/app/server/src/providers/OpenAIImageProvider.ts +++ b/packages/app/server/src/providers/OpenAIImageProvider.ts @@ -6,6 +6,7 @@ import { Decimal } from '@prisma/client/runtime/library'; import logger from '../logger'; import { getImageModelCost } from '../services/AccountingService'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; // Use OpenAI SDK's ResponseUsage for non-streaming responses @@ -13,6 +14,7 @@ const parseSSEImageGenerationFormat = (data: string): ImagesResponse[] => { // Split by double newlines to separate complete events const eventBlocks = data.split('\n\n'); const chunks: ImagesResponse[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const eventBlock of eventBlocks) { if (!eventBlock.trim()) continue; @@ -33,21 +35,23 @@ 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) { + const parsedResult = parseJson(eventData); + if (parsedResult.isErr()) { logger.error( 'Error parsing SSE image generation chunk:', - error, + parsedResult.error, 'Event type:', eventType, 'Data:', eventData ); + continue; } + + const parsed = parsedResult.value as ImagesResponse & { eventType?: string }; + // Add the event type to the parsed object for easier identification + parsed.eventType = eventType; + chunks.push(parsed); } return chunks; @@ -70,74 +74,82 @@ 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; - } + return ResultAsync.fromPromise( + (async () => { + 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 + ); + } - // 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; + // Extract provider ID if available + if (parsed.created) { + providerId = parsed.created.toString(); } - cost = getImageModelCost( - this.getModel(), - textTokens, - imageInputTokens, - imageOutputTokens + 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; + })(), + error => { + logger.error( + `Error processing OpenAI Image Generation API data: ${error}` ); + return error; } - - // Extract provider ID if available - if (parsed.created) { - providerId = parsed.created.toString(); + ).match( + transaction => transaction, + error => { + throw error; } - - 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}` - ); - throw error; - } + ); } } diff --git a/packages/app/server/src/providers/OpenAIResponsesProvider.ts b/packages/app/server/src/providers/OpenAIResponsesProvider.ts index 61664f970..760a615ad 100644 --- a/packages/app/server/src/providers/OpenAIResponsesProvider.ts +++ b/packages/app/server/src/providers/OpenAIResponsesProvider.ts @@ -13,11 +13,13 @@ import { ProviderType } from './ProviderType'; import { Decimal } from '@prisma/client/runtime/library'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; const parseSSEResponsesFormat = (data: string): ResponseStreamEvent[] => { // Split by double newlines to separate complete events const eventBlocks = data.split('\n\n'); const chunks: ResponseStreamEvent[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const eventBlock of eventBlocks) { if (!eventBlock.trim()) continue; @@ -38,21 +40,25 @@ 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) { + const parsedResult = parseJson(eventData); + if (parsedResult.isErr()) { logger.error( 'Error parsing SSE chunk:', - error, + parsedResult.error, 'Event type:', eventType, 'Data:', eventData ); + continue; } + + const parsed = parsedResult.value as ResponseStreamEvent & { + eventType?: string; + }; + // Add the event type to the parsed object for easier identification + parsed.eventType = eventType; + chunks.push(parsed); } return chunks; @@ -72,76 +78,84 @@ 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'; + return ResultAsync.fromPromise( + (async () => { + 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'; + } } + } 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)); } - } 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; - } + 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; + })(), + error => { + logger.error(`Error processing OpenAI Responses API data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } // Override ensureStreamUsage since Responses API doesn't use stream_options diff --git a/packages/app/server/src/providers/OpenAIVideoProvider.ts b/packages/app/server/src/providers/OpenAIVideoProvider.ts index 0d870d2a2..4338d1936 100644 --- a/packages/app/server/src/providers/OpenAIVideoProvider.ts +++ b/packages/app/server/src/providers/OpenAIVideoProvider.ts @@ -7,7 +7,7 @@ import { Response } from 'express'; import { transfer } from 'transferWithAuth'; import { getVideoModelPrice } from 'services/AccountingService'; import { HttpError, UnknownModelError } from 'errors/http'; -import { Decimal } from 'generated/prisma/runtime/library'; +import { Decimal } from '../generated/prisma/runtime/library'; import { Transaction } from '../types'; import { prisma } from '../server'; import { EchoDbService } from '../services/DbService'; diff --git a/packages/app/server/src/providers/OpenRouterProvider.ts b/packages/app/server/src/providers/OpenRouterProvider.ts index 4d7fceb65..805e5312e 100644 --- a/packages/app/server/src/providers/OpenRouterProvider.ts +++ b/packages/app/server/src/providers/OpenRouterProvider.ts @@ -4,6 +4,7 @@ import { ProviderType } from './ProviderType'; import { getCostPerToken } from '../services/AccountingService'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync, fromThrowable } from 'neverthrow'; interface CompletionStateBody { id: string; @@ -34,6 +35,7 @@ const parseSSEGPTFormat = (data: string): StreamingChunkBody[] => { // Split by double newlines to separate events const events = data.split('\n\n'); const chunks: StreamingChunkBody[] = []; + const parseJson = fromThrowable(JSON.parse, error => error); for (const event of events) { if (!event.trim()) continue; @@ -45,12 +47,13 @@ const parseSSEGPTFormat = (data: string): StreamingChunkBody[] => { // Skip [DONE] marker if (jsonStr.trim() === '[DONE]') continue; - try { - const parsed = JSON.parse(jsonStr); - chunks.push(parsed); - } catch (error) { - logger.error(`Error parsing SSE chunk: ${error}`); + const parsedResult = parseJson(jsonStr); + if (parsedResult.isErr()) { + logger.error(`Error parsing SSE chunk: ${parsedResult.error}`); + continue; } + + chunks.push(parsedResult.value as StreamingChunkBody); } } @@ -71,54 +74,62 @@ 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; + return ResultAsync.fromPromise( + (async () => { + 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'; } - 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'; } - } 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', - }; - } catch (error) { - logger.error(`Error processing data: ${error}`); - throw error; - } + 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', + }; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/providers/XAIProvider.ts b/packages/app/server/src/providers/XAIProvider.ts index 2525b16bf..e732847ef 100644 --- a/packages/app/server/src/providers/XAIProvider.ts +++ b/packages/app/server/src/providers/XAIProvider.ts @@ -5,6 +5,7 @@ import { ProviderType } from './ProviderType'; import { CompletionStateBody, parseSSEGPTFormat } from './GPTProvider'; import logger from '../logger'; import { env } from '../env'; +import { ResultAsync } from 'neverthrow'; export class XAIProvider extends BaseProvider { private readonly XAI_BASE_URL = 'https://api.x.ai/v1'; @@ -26,56 +27,64 @@ 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'; + return ResultAsync.fromPromise( + (async () => { + 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; + 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'; } - 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'; } - } 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; + })(), + error => { + logger.error(`Error processing data: ${error}`); + return error; + } + ).match( + transaction => transaction, + error => { + throw error; + } + ); } } diff --git a/packages/app/server/src/resources/e2b/e2b.ts b/packages/app/server/src/resources/e2b/e2b.ts index 3614f24e5..7c5ec1a41 100644 --- a/packages/app/server/src/resources/e2b/e2b.ts +++ b/packages/app/server/src/resources/e2b/e2b.ts @@ -6,6 +6,7 @@ import { Decimal } from '@prisma/client/runtime/library'; import { Transaction } from '../../types'; import { HttpError } from 'errors/http'; import { env } from '../../env'; +import { ResultAsync } from 'neverthrow'; dotenv.config(); export const calculateE2BExecuteCost = (): Decimal => { @@ -38,37 +39,45 @@ export const createE2BTransaction = ( export const e2bExecutePythonSnippet = async ( snippet: string ): Promise => { - if (!env.E2B_API_KEY) { + const apiKey = env.E2B_API_KEY; + if (!apiKey) { throw new Error('E2B_API_KEY environment variable is required but not set'); } - try { - const startTime = performance.now(); - const sandbox = await Sandbox.create({ - apiKey: env.E2B_API_KEY, - }); - const { results, logs, error, executionCount } = await sandbox.runCode( - snippet, - { - timeoutMs: 10000, - requestTimeoutMs: 15000, - } - ); - await sandbox.kill(); - const endTime = performance.now(); - const durationMs = endTime - startTime; - const duration = durationMs / 1000; - const cost = duration * PRICE_PER_VCPU_PER_SECOND * DEFAULT_VCPU_COUNT; - return { - results: results, - logs: logs, - error: error, - executionCount: executionCount, - cost: cost, - sandboxId: sandbox.sandboxId, - duration: duration, - }; - } catch (error) { - const errorText = error instanceof Error ? error.message : 'Unknown error'; - throw new HttpError(400, `E2B API request failed: ${errorText}`); - } + + return ResultAsync.fromPromise( + (async () => { + const startTime = performance.now(); + const sandbox = await Sandbox.create({ + apiKey, + }); + const { results, logs, error, executionCount } = await sandbox.runCode( + snippet, + { + timeoutMs: 10000, + requestTimeoutMs: 15000, + } + ); + await sandbox.kill(); + const endTime = performance.now(); + const durationMs = endTime - startTime; + const duration = durationMs / 1000; + const cost = duration * PRICE_PER_VCPU_PER_SECOND * DEFAULT_VCPU_COUNT; + return { + results: results, + logs: logs, + error: error, + executionCount: executionCount, + cost: cost, + sandboxId: sandbox.sandboxId, + duration: duration, + }; + })(), + error => error + ).match( + result => result, + error => { + const errorText = error instanceof Error ? error.message : 'Unknown error'; + throw new HttpError(400, `E2B API request failed: ${errorText}`); + } + ); }; diff --git a/packages/app/server/src/resources/handler.ts b/packages/app/server/src/resources/handler.ts index 411f9c6ba..38cc58593 100644 --- a/packages/app/server/src/resources/handler.ts +++ b/packages/app/server/src/resources/handler.ts @@ -9,7 +9,9 @@ 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 { ResultAsync, ok, err } from 'neverthrow'; +import type { ResourceError } from '../errors/results'; type ResourceHandlerConfig = { inputSchema: ZodSchema; @@ -20,72 +22,83 @@ type ResourceHandlerConfig = { errorMessage: string; }; -async function handleApiRequest( +function handleApiRequest( parsedBody: TInput, headers: Record, config: ResourceHandlerConfig -) { +): ResultAsync { const { executeResource, calculateActualCost, createTransaction } = config; - const { echoControlService } = await authenticateRequest(headers, prisma); - - const output = await executeResource(parsedBody); - - const actualCost = calculateActualCost(parsedBody, output); - const transaction = createTransaction(parsedBody, output, actualCost); - - await echoControlService.createTransaction(transaction); + return ResultAsync.fromPromise( + authenticateRequest(headers, prisma), + (cause): ResourceError => ({ type: 'RESOURCE_AUTHENTICATION_FAILED', cause }) + ).andThen(({ echoControlService }) => + ResultAsync.fromPromise( + executeResource(parsedBody), + (cause): ResourceError => ({ type: 'RESOURCE_EXECUTION_FAILED', cause }) + ).andThen(output => { + const actualCost = calculateActualCost(parsedBody, output); + const transaction = createTransaction(parsedBody, output, actualCost); + return ResultAsync.fromPromise( + echoControlService.createTransaction(transaction), + (cause): ResourceError => ({ type: 'RESOURCE_TRANSACTION_FAILED', cause }) + ).map(() => output); + }) + ); +} - return output; +function executeResourceWithRefund( + parsedBody: TInput, + executeResource: (input: TInput) => Promise, + paymentAmountDecimal: Decimal, + payload: ExactEvmPayload +): ResultAsync { + return ResultAsync.fromPromise( + executeResource(parsedBody), + (cause): ResourceError => ({ type: 'RESOURCE_EXECUTION_FAILED', cause }) + ).mapErr(resourceErr => { + // Attempt refund on execution failure; log but don't block the error propagation + refund(paymentAmountDecimal, payload).mapErr(refundErr => { + logger.error('Failed to refund after resource execution failure', refundErr); + }); + return resourceErr; + }); } -async function handle402Request( +function handle402Request( req: Request, res: Response, parsedBody: TInput, headers: Record, safeMaxCost: Decimal, config: ResourceHandlerConfig -): Promise { +): ResultAsync { const { executeResource, calculateActualCost, createTransaction } = config; - const settleResult = await settle(req, res, headers, safeMaxCost); - if (!settleResult) { - throw new PaymentRequiredError('Payment required, settle failed'); - } - - const { payload, paymentAmountDecimal } = settleResult; - - const output = await executeResourceWithRefund( - parsedBody, - executeResource, - paymentAmountDecimal, - payload - ); - - const actualCost = calculateActualCost(parsedBody, output); - const transaction = createTransaction(parsedBody, output, actualCost); - - finalizeResource(paymentAmountDecimal, transaction, payload).catch(error => { - logger.error('Failed to finalize transaction', error); - }); - - return output; -} - -async function executeResourceWithRefund( - parsedBody: TInput, - executeResource: (input: TInput) => Promise, - paymentAmountDecimal: Decimal, - payload: ExactEvmPayload -): Promise { - try { - const output = await executeResource(parsedBody); - return output; - } catch (error) { - await refund(paymentAmountDecimal, payload); - throw error; - } + return settle(req, headers, safeMaxCost) + .mapErr((cause): ResourceError => ({ type: 'RESOURCE_PAYMENT_FAILED', cause })) + .andThen(({ payload, paymentAmountDecimal }) => + executeResourceWithRefund( + parsedBody, + executeResource, + paymentAmountDecimal, + payload + ).map(output => ({ + output, + payload, + paymentAmountDecimal, + })) + ) + .map(({ output, payload, paymentAmountDecimal }) => { + const actualCost = calculateActualCost(parsedBody, output); + const transaction = createTransaction(parsedBody, output, actualCost); + + finalizeResource(paymentAmountDecimal, transaction, payload).catch(error => { + logger.error('Failed to finalize transaction', error); + }); + + return output; + }); } async function handleResourceRequest( @@ -114,34 +127,27 @@ async function handleResourceRequest( const safeMaxCost = calculateMaxCost(parsedBody); if (isApiRequest(headers)) { - try { - const output = await handleApiRequest(parsedBody, headers, config); - return res.status(200).json(output); - } catch (error) { - logger.error('Failed to handle API request', error); - return res.status(500).json({ error: 'Internal server error' }); - } + return handleApiRequest(parsedBody, headers, config).match( + output => res.status(200).json(output), + error => { + logger.error('Failed to handle API request', error); + return res.status(500).json({ error: 'Internal server error' }); + } + ); } if (isX402Request(headers)) { - try { - const result = await handle402Request( - req, - res, - parsedBody, - headers, - safeMaxCost, - config - ); - return res.status(200).json(result); - } catch (error) { - if (error instanceof PaymentRequiredError) { + return handle402Request(req, res, parsedBody, headers, safeMaxCost, config).match( + result => res.status(200).json(result), + error => { + if (error.type === 'RESOURCE_PAYMENT_FAILED') { + logger.error('Failed to handle 402 request: payment failed', error.cause); + return buildX402Response(req, res, safeMaxCost); + } logger.error('Failed to handle 402 request', error); - return buildX402Response(req, res, safeMaxCost); + return res.status(500).json({ error: 'Internal server error' }); } - logger.error('Failed to handle 402 request', error); - return res.status(500).json({ error: 'Internal server error' }); - } + ); } return buildX402Response(req, res, safeMaxCost); @@ -152,17 +158,17 @@ export async function handleResourceRequestWithErrorHandling( res: Response, config: ResourceHandlerConfig ) { - try { - return await handleResourceRequest(req, res, config); - } catch (error) { - const { errorMessage } = config; - if (error instanceof HttpError) { - logger.error(errorMessage, error); - return res.status(error.statusCode).json({ error: errorMessage }); + return ResultAsync.fromPromise( + handleResourceRequest(req, res, config), + (error): HttpError => + error instanceof HttpError + ? error + : new HttpError(500, config.errorMessage || 'Internal server error') + ).match( + result => result, + (error: HttpError) => { + logger.error(config.errorMessage, error); + return res.status(error.statusCode).json({ error: config.errorMessage }); } - logger.error(errorMessage, error); - return res - .status(500) - .json({ error: errorMessage || 'Internal server error' }); - } + ); } diff --git a/packages/app/server/src/services/DbService.ts b/packages/app/server/src/services/DbService.ts index 93e85592c..c77c62b8d 100644 --- a/packages/app/server/src/services/DbService.ts +++ b/packages/app/server/src/services/DbService.ts @@ -19,6 +19,8 @@ import { import { Decimal } from '@prisma/client/runtime/library'; import logger from '../logger'; import { env } from '../env'; +import { Result, ResultAsync, ok, err } from 'neverthrow'; +import type { DbError, AuthError } from '../errors/results'; /** * Secret key for deterministic API key hashing (should match echo-control) @@ -44,102 +46,111 @@ export class EchoDbService { } /** - * Validate an API key and return user/app information - * Centralized logic previously duplicated in echo-control and echo-server + * Validate an API key and return user/app information. + * Returns a ResultAsync with the validation result or a typed AuthError. + * Centralized logic previously duplicated in echo-control and echo-server. */ - async validateApiKey(apiKey: string): Promise { - try { - // Remove Bearer prefix if present - const cleanApiKey = apiKey.replace('Bearer ', ''); - - const isJWT = cleanApiKey.split('.').length === 3; - - if (isJWT) { - const verifyResult = await jwtVerify(cleanApiKey, this.apiJwtSecret); + validateApiKey(apiKey: string): ResultAsync { + const cleanApiKey = apiKey.replace('Bearer ', ''); + const isJWT = cleanApiKey.split('.').length === 3; + + if (isJWT) { + return ResultAsync.fromPromise( + jwtVerify(cleanApiKey, this.apiJwtSecret), + (cause): AuthError => ({ type: 'AUTH_JWT_VERIFICATION_FAILED', cause }) + ).andThen((verifyResult): Result => { const payload = verifyResult.payload as unknown as EchoAccessJwtPayload; if (!payload) { - return null; + return err({ type: 'AUTH_INVALID_API_KEY' }); } - // Validate required fields exist if (!payload.user_id || !payload.app_id) { logger.error( `JWT missing required fields: user_id=${payload.user_id}, app_id=${payload.app_id}` ); - return null; + return err({ + type: 'AUTH_MISSING_FIELDS', + fields: [ + ...(!payload.user_id ? ['user_id'] : []), + ...(!payload.app_id ? ['app_id'] : []), + ], + }); } if (payload.exp && payload.exp < Date.now() / 1000) { - return null; + return err({ type: 'AUTH_EXPIRED_JWT' }); } - const user = await this.db.user.findUnique({ - where: { - id: payload.user_id, - }, - select: { - id: true, - email: true, - name: true, - createdAt: true, - updatedAt: true, - totalPaid: true, - totalSpent: true, - }, - }); - - const app = await this.db.echoApp.findUnique({ - where: { - id: payload.app_id, - }, - select: { - id: true, - name: true, - description: true, - isArchived: true, - createdAt: true, - updatedAt: true, - }, - }); - - if (!user || !app) { - return null; - } + return ok(payload); + }).andThen((payload: EchoAccessJwtPayload) => + ResultAsync.fromPromise( + Promise.all([ + this.db.user.findUnique({ + where: { id: payload.user_id }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + totalPaid: true, + totalSpent: true, + }, + }), + this.db.echoApp.findUnique({ + where: { id: payload.app_id }, + select: { + id: true, + name: true, + description: true, + isArchived: true, + createdAt: true, + updatedAt: true, + }, + }), + ]), + (cause): AuthError => ({ type: 'AUTH_JWT_VERIFICATION_FAILED', cause }) + ).andThen(([user, app]: [any, any]): Result => { + if (!user || !app) { + return err({ type: 'AUTH_INVALID_API_KEY' }); + } + + return ok({ + userId: payload.user_id, + echoAppId: payload.app_id, + user: { + id: user.id, + email: user.email, + ...(user.name && { name: user.name }), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }, + echoApp: { + id: app.id, + name: app.name, + ...(app.description && { description: app.description }), + createdAt: app.createdAt.toISOString(), + updatedAt: app.updatedAt.toISOString(), + }, + }); + }) + ); + } - return { - userId: payload.user_id, - echoAppId: payload.app_id, - user: { - id: user.id, - email: user.email, - ...(user.name && { name: user.name }), - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - }, - echoApp: { - id: app.id, - name: app.name, - ...(app.description && { description: app.description }), - createdAt: app.createdAt.toISOString(), - updatedAt: app.updatedAt.toISOString(), - }, - }; - } - // Hash the provided API key for direct O(1) lookup - const keyHash = hashApiKey(cleanApiKey); + // Hash the provided API key for direct O(1) lookup + const keyHash = hashApiKey(cleanApiKey); - // Direct lookup by keyHash - O(1) operation! - const apiKeyRecord = await this.db.apiKey.findUnique({ - where: { - keyHash, - }, + return ResultAsync.fromPromise( + this.db.apiKey.findUnique({ + where: { keyHash }, include: { user: true, echoApp: true, }, - }); - + }), + (cause): AuthError => ({ type: 'AUTH_JWT_VERIFICATION_FAILED', cause }) + ).andThen((apiKeyRecord: any): Result => { // Verify the API key is valid and all related entities are active if ( !apiKeyRecord || @@ -147,10 +158,10 @@ export class EchoDbService { apiKeyRecord.user.isArchived || apiKeyRecord.echoApp.isArchived ) { - return null; + return err({ type: 'AUTH_INVALID_API_KEY' }); } - return { + return ok({ userId: apiKeyRecord.userId, echoAppId: apiKeyRecord.echoAppId, user: { @@ -170,11 +181,8 @@ export class EchoDbService { updatedAt: apiKeyRecord.echoApp.updatedAt.toISOString(), }, apiKeyId: apiKeyRecord.id, - }; - } catch (error) { - logger.error(`Error validating API key: ${error}`); - return null; - } + }); + }); } async getReferralCodeForUser( @@ -201,12 +209,13 @@ export class EchoDbService { } /** - * Calculate total balance for a user across all apps - * Uses User.totalPaid and User.totalSpent for consistent balance calculation + * Calculate total balance for a user across all apps. + * Returns a ResultAsync with the Balance or a typed DbError. + * Uses User.totalPaid and User.totalSpent for consistent balance calculation. */ - async getBalance(userId: string): Promise { - try { - const user = await this.db.user.findUnique({ + getBalance(userId: string): ResultAsync { + return ResultAsync.fromPromise( + this.db.user.findUnique({ where: { id: userId }, select: { id: true, @@ -217,34 +226,20 @@ export class EchoDbService { totalPaid: true, totalSpent: true, }, - }); - + }), + (cause): DbError => ({ type: 'DB_QUERY_FAILED', cause }) + ).andThen((user: any): Result => { if (!user) { logger.error(`User not found: ${userId}`); - return { - balance: 0, - totalPaid: 0, - totalSpent: 0, - }; + return err({ type: 'DB_NOT_FOUND', entity: 'User', id: userId }); } const totalPaid = Number(user.totalPaid); const totalSpent = Number(user.totalSpent); const balance = totalPaid - totalSpent; - return { - balance, - totalPaid, - totalSpent, - }; - } catch (error) { - logger.error(`Error fetching balance: ${error}`); - return { - balance: 0, - totalPaid: 0, - totalSpent: 0, - }; - } + return ok({ balance, totalPaid, totalSpent }); + }); } /** @@ -400,28 +395,21 @@ export class EchoDbService { } /** - * Create an LLM transaction record and atomically update user's totalSpent - * Centralized logic for transaction creation with atomic balance updates + * Create an LLM transaction record and atomically update user's totalSpent. + * Returns a ResultAsync with the created Transaction or a typed DbError. + * Centralized logic for transaction creation with atomic balance updates. */ - async createPaidTransaction( + createPaidTransaction( transaction: TransactionRequest - ): Promise { - try { - // Use a database transaction to atomically create the LLM transaction and update user balance - const result = await this.db.$transaction(async tx => { + ): ResultAsync { + return ResultAsync.fromPromise( + this.db.$transaction(async (tx: Prisma.TransactionClient) => { // Create the LLM transaction record - const dbTransaction = await this.createTransactionRecord( - tx, - transaction - ); + const dbTransaction = await this.createTransactionRecord(tx, transaction); if (transaction.userId) { // Update user's total spent amount - await this.updateUserTotalSpent( - tx, - transaction.userId, - transaction.totalCost - ); + await this.updateUserTotalSpent(tx, transaction.userId, transaction.totalCost); } // Update API key's last used timestamp if provided if (transaction.apiKeyId) { @@ -429,34 +417,29 @@ export class EchoDbService { } return dbTransaction; - }); - + }), + (cause): DbError => ({ type: 'DB_TRANSACTION_FAILED', cause }) + ).map((result: Transaction) => { logger.info( `Created transaction for model ${transaction.metadata.model}: $${transaction.totalCost}, updated user totalSpent`, result.id ); return result; - } catch (error) { - logger.error(`Error creating transaction and updating balance: ${error}`); - return null; - } + }); } /** - * Create a free tier transaction and update all related records atomically - * @param userId - The user ID - * @param spendPoolId - The spend pool ID + * Create a free tier transaction and update all related records atomically. + * Returns a ResultAsync with the transaction and usage records or a typed DbError. * @param transactionData - The transaction data to create + * @param spendPoolId - The spend pool ID */ - async createFreeTierTransaction( + createFreeTierTransaction( transactionData: TransactionRequest, spendPoolId: string - ): Promise<{ - transaction: Transaction; - userSpendPoolUsage: UserSpendPoolUsage | null; - }> { - try { - return await this.db.$transaction(async tx => { + ): ResultAsync<{ transaction: Transaction; userSpendPoolUsage: UserSpendPoolUsage | null }, DbError> { + return ResultAsync.fromPromise<{ transaction: Transaction; userSpendPoolUsage: UserSpendPoolUsage | null }, DbError>( + this.db.$transaction(async (tx: Prisma.TransactionClient) => { // 1. Verify the spend pool exists const spendPool = await tx.spendPool.findUnique({ where: { id: spendPoolId }, @@ -476,10 +459,7 @@ export class EchoDbService { ) : null; // 3. Create the transaction record - const transaction = await this.createTransactionRecord( - tx, - transactionData - ); + const transaction = await this.createTransactionRecord(tx, transactionData); // 4. Update API key lastUsed if apiKeyId is provided if (transactionData.apiKeyId) { @@ -487,26 +467,17 @@ export class EchoDbService { } // 5. Update totalSpent on the SpendPool using helper - await this.updateSpendPoolTotalSpent( - tx, - spendPoolId, - transactionData.totalCost - ); + await this.updateSpendPoolTotalSpent(tx, spendPoolId, transactionData.totalCost); logger.info( `Created free tier transaction for model ${transactionData.metadata.model}: $${transactionData.totalCost}`, transaction.id ); - return { - transaction, - userSpendPoolUsage, - }; - }); - } catch (error) { - logger.error(`Error creating free tier transaction: ${error}`); - throw error; - } + return { transaction, userSpendPoolUsage }; + }), + (cause): DbError => ({ type: 'DB_TRANSACTION_FAILED', cause }) + ); } async confirmAccessControl( diff --git a/packages/app/server/src/services/EchoControlService.ts b/packages/app/server/src/services/EchoControlService.ts index 861db6a62..db72f5245 100644 --- a/packages/app/server/src/services/EchoControlService.ts +++ b/packages/app/server/src/services/EchoControlService.ts @@ -57,17 +57,17 @@ export class EchoControlService { } /** - * Verify API key against the database and cache the authentication result - * Uses centralized logic from EchoDbService + * Verify API key against the database and cache the authentication result. + * Uses centralized logic from EchoDbService via ResultAsync. */ async verifyApiKey(): Promise { - try { - if (this.apiKey) { - this.authResult = await this.dbService.validateApiKey(this.apiKey); + if (this.apiKey) { + const result = await this.dbService.validateApiKey(this.apiKey); + if (result.isErr()) { + logger.error(`Error verifying API key: ${result.error.type}`); + return null; } - } catch (error) { - logger.error(`Error verifying API key: ${error}`); - return null; + this.authResult = result.value; } const markupData = await this.earningsService.getEarningsData( @@ -152,51 +152,45 @@ export class EchoControlService { } /** - * Get balance for the authenticated user directly from the database - * Uses centralized logic from EchoDbService + * Get balance for the authenticated user directly from the database. + * Uses centralized logic from EchoDbService via ResultAsync. */ async getBalance(): Promise { - try { - if (!this.authResult) { - logger.error('No authentication result available'); - return 0; - } + if (!this.authResult) { + logger.error('No authentication result available'); + return 0; + } - const { userId } = this.authResult; - const balance = await this.dbService.getBalance(userId); + const { userId } = this.authResult; + const result = await this.dbService.getBalance(userId); - return balance.balance; - } catch (error) { - logger.error(`Error fetching balance: ${error}`); + if (result.isErr()) { + logger.error(`Error fetching balance: ${result.error.type}`); return 0; } + + return result.value.balance; } /** - * Create an LLM transaction record directly in the database - * Uses centralized logic from EchoDbService + * Create an LLM transaction record directly in the database. + * Uses centralized logic from EchoDbService via ResultAsync. */ async createTransaction(transaction: Transaction): Promise { - try { - if (!this.authResult) { - logger.error('No authentication result available'); - return; - } + if (!this.authResult) { + logger.error('No authentication result available'); + return; + } - if (!this.markUpAmount) { - logger.error('Error Fetching Markup Amount'); - return; - } + if (!this.markUpAmount) { + logger.error('Error Fetching Markup Amount'); + return; + } - if (this.freeTierSpendPool) { - await this.createFreeTierTransaction(transaction); - return; - } else { - await this.createPaidTransaction(transaction); - return; - } - } catch (error) { - logger.error(`Error creating transaction: ${error}`); + if (this.freeTierSpendPool) { + await this.createFreeTierTransaction(transaction); + } else { + await this.createPaidTransaction(transaction); } } @@ -275,6 +269,7 @@ export class EchoControlService { echoProfit: echoProfitDecimal, }; } + async createFreeTierTransaction(transaction: Transaction): Promise { if (!this.authResult) { logger.error('No authentication result available'); @@ -321,10 +316,17 @@ export class EchoControlService { ...(this.referrerRewardId && { referrerRewardId: this.referrerRewardId }), }; - await this.freeTierService.createFreeTierTransaction( + const result = await this.freeTierService.createFreeTierTransaction( transactionData, this.freeTierSpendPool.id ); + + if (result.isErr()) { + const dbErr = result.error; + logger.error(`Error creating free tier transaction: ${dbErr.type}`); + const cause = 'cause' in dbErr ? dbErr.cause : undefined; + throw cause instanceof Error ? cause : new Error(String(dbErr.type)); + } } async createPaidTransaction(transaction: Transaction): Promise { @@ -361,7 +363,14 @@ export class EchoControlService { ...(this.referrerRewardId && { referrerRewardId: this.referrerRewardId }), }; - await this.dbService.createPaidTransaction(transactionData); + const result = await this.dbService.createPaidTransaction(transactionData); + + if (result.isErr()) { + const dbErr = result.error; + logger.error(`Error creating paid transaction: ${dbErr.type}`); + const cause = 'cause' in dbErr ? dbErr.cause : undefined; + throw cause instanceof Error ? cause : new Error(String(dbErr.type)); + } } async identifyX402Transaction( @@ -402,7 +411,14 @@ export class EchoControlService { transactionType: EnumTransactionType.X402, }; - await this.dbService.createPaidTransaction(transactionData); + const result = await this.dbService.createPaidTransaction(transactionData); + + if (result.isErr()) { + const dbErr = result.error; + logger.error(`Error creating X402 transaction: ${dbErr.type}`); + const cause = 'cause' in dbErr ? dbErr.cause : undefined; + throw cause instanceof Error ? cause : new Error(String(dbErr.type)); + } return transactionCosts; } diff --git a/packages/app/server/src/services/FreeTierService.ts b/packages/app/server/src/services/FreeTierService.ts index d4a8376fe..d239aa3bf 100644 --- a/packages/app/server/src/services/FreeTierService.ts +++ b/packages/app/server/src/services/FreeTierService.ts @@ -96,15 +96,12 @@ class FreeTierService { * Create a free tier transaction and update all related records atomically * Delegates to DbService for shared transaction logic */ - async createFreeTierTransaction( + createFreeTierTransaction( transactionData: TransactionRequest, spendPoolId: string ) { // Delegate to the centralized DbService method - return await this.dbService.createFreeTierTransaction( - transactionData, - spendPoolId - ); + return this.dbService.createFreeTierTransaction(transactionData, spendPoolId); } } diff --git a/packages/app/server/src/services/HandleStreamService.ts b/packages/app/server/src/services/HandleStreamService.ts index 80e8832f4..9fb4de736 100644 --- a/packages/app/server/src/services/HandleStreamService.ts +++ b/packages/app/server/src/services/HandleStreamService.ts @@ -7,6 +7,7 @@ import logger from '../logger'; import { BaseProvider } from '../providers/BaseProvider'; import { Transaction } from '../types'; import { Request } from 'express'; +import { ResultAsync } from 'neverthrow'; class HandleStreamService { /** @@ -53,19 +54,19 @@ class HandleStreamService { ); // 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' }); + return ResultAsync.fromPromise( + Promise.all([streamToClientPromise, transactionPromise]), + error => error + ).match( + ([_, transaction]) => transaction, + 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 } - throw error; // Re-throw to be handled by error middleware - } + ); } /** @@ -92,20 +93,27 @@ 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 }); + + return ResultAsync.fromPromise( + (async () => { + 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); + })(), + error => error + ).match( + transaction => transaction, + error => { + logger.error(`Error processing stream: ${error}`); + throw error; } - // 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; - } + ); } } diff --git a/packages/app/server/src/services/ModelRequestService.ts b/packages/app/server/src/services/ModelRequestService.ts index a662c36ae..d04322752 100644 --- a/packages/app/server/src/services/ModelRequestService.ts +++ b/packages/app/server/src/services/ModelRequestService.ts @@ -6,6 +6,7 @@ import { Transaction } from '../types'; import { handleNonStreamingService } from './HandleNonStreamingService'; import { handleStreamService } from './HandleStreamService'; import { formatUpstreamUrl } from './RequestDataService'; +import { fromThrowable } from 'neverthrow'; class ModelRequestService { /** @@ -166,11 +167,14 @@ class ModelRequestService { return { message: `HTTP ${status} error` }; } - try { - return JSON.parse(errorBody); - } catch { + const parseJson = fromThrowable(JSON.parse, () => ({ message: errorBody })); + const parsed = parseJson(errorBody); + + if (parsed.isErr()) { return { message: errorBody }; } + + return parsed.value as object; } } diff --git a/packages/app/server/src/types.ts b/packages/app/server/src/types.ts index e6febc9d0..b42182411 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; diff --git a/packages/app/server/src/utils.ts b/packages/app/server/src/utils.ts index 21d709cef..afaa61dfc 100644 --- a/packages/app/server/src/utils.ts +++ b/packages/app/server/src/utils.ts @@ -22,7 +22,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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06985ade8..560b13bc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 10.1.5(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-import: specifier: ^2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)) + version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-prefer-arrow: specifier: ^1.2.3 version: 1.2.3(eslint@9.35.0(jiti@2.5.1)) @@ -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: ^7.2.0 + version: 7.2.0 next: specifier: ^15.5.9 version: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -7955,11 +7958,13 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@11.12.0: @@ -10998,6 +11003,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} @@ -11730,6 +11736,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -12688,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' @@ -17194,6 +17201,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)) @@ -17204,6 +17215,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)) @@ -17212,6 +17227,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) @@ -17367,6 +17386,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) @@ -18777,14 +18821,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: @@ -18832,7 +18876,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: @@ -21903,8 +21947,8 @@ snapshots: '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.35.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0(jiti@2.5.1)) @@ -21962,21 +22006,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 9.35.0(jiti@2.5.1) - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.9.0 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)): dependencies: debug: 3.2.7 @@ -21988,17 +22017,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -22028,35 +22046,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.35.0(jiti@2.5.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.35.0(jiti@2.5.1)): dependencies: aria-query: 5.3.2 @@ -27767,7 +27756,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 @@ -28349,14 +28338,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