Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 113 additions & 104 deletions packages/app/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,30 @@ import {
} from 'services/facilitator/x402-types';
import { Decimal } from '@prisma/client/runtime/library';
import logger from 'logger';

export async function handleX402Request({
req,
res,
headers,
maxCost,
isPassthroughProxyRoute,
provider,
isStream,
}: X402HandlerInput) {
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
}

// Apply x402 payment middleware with the calculated maxCost
import { Request, Response } from 'express';

export async function settle(
req: Request,
res: Response,
headers: Record<string, string>,
maxCost: Decimal
): Promise<{ payload: ExactEvmPayload; paymentAmountDecimal: Decimal } | undefined> {
const network = process.env.NETWORK as Network;

let recipient: string;
try {
recipient = (await getSmartAccount()).smartAccount.address;
} catch (error) {
return buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
Comment on lines +42 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont get this change. we are no longer returning the 402 response?

}

let xPaymentData: PaymentPayload;
try {
xPaymentData = validateXPaymentHeader(headers, req);
} catch (error) {
return buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
}

const payload = xPaymentData.payload as ExactEvmPayload;
Expand All @@ -62,99 +58,112 @@ export async function handleX402Request({
// 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 buildX402Response(req, res, maxCost);
buildX402Response(req, res, maxCost);
return undefined;
}

const facilitatorClient = new FacilitatorClient();
try {
// Default to no refund
let refundAmount = new Decimal(0);
let transaction: Transaction | null = null;
let data: unknown = null;

// Construct and validate PaymentRequirements using Zod schema
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',
},
});
// Validate and execute settle request
const settleRequest = SettleRequestSchema.parse({
paymentPayload: xPaymentData,
paymentRequirements,
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 settleResult = await facilitatorClient.settle(settleRequest);

if (!settleResult.success || !settleResult.transaction) {
buildX402Response(req, res, maxCost);
return undefined;
}

return { payload, paymentAmountDecimal };
}

export async function finalize(
paymentAmountDecimal: Decimal,
transaction: Transaction,
payload: ExactEvmPayload
) {
const refundAmount = calculateRefundAmount(
paymentAmountDecimal,
transaction.rawTransactionCost
);

if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
logger.error('Failed to process refund', {
error: transferError,
refundAmount: refundAmount.toString(),
});
});
}
}

export async function handleX402Request({
req,
res,
headers,
maxCost,
isPassthroughProxyRoute,
provider,
isStream,
}: X402HandlerInput) {
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
}

const settleResult = await settle(req, res, headers, maxCost);
if (!settleResult) {
return;
}

const settleResult = await facilitatorClient.settle(settleRequest);

if (!settleResult.success || !settleResult.transaction) {
return buildX402Response(req, res, maxCost);
}

try {
const transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);
transaction = transactionResult.transaction;
data = transactionResult.data;

// Send the response - the middleware has intercepted res.end()/res.json()
// and will actually send it after settlement completes
modelRequestService.handleResolveResponse(res, isStream, data);

refundAmount = calculateRefundAmount(
paymentAmountDecimal,
transaction.rawTransactionCost
);

// Process refund if needed
if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
logger.error('Failed to process refund', {
error: transferError,
refundAmount: refundAmount.toString(),
});
});
}
} catch (error) {
// In case of error, do full refund
refundAmount = paymentAmountDecimal;

if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
logger.error('Failed to process full refund after error', {
error: transferError,
originalError: error,
refundAmount: refundAmount.toString(),
});
});
}
}
const { payload, paymentAmountDecimal } = settleResult;

try {
const transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);

modelRequestService.handleResolveResponse(res, isStream, transactionResult.data);

await finalize(paymentAmountDecimal, transactionResult.transaction, payload);
} catch (error) {
throw error;
const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal);
const authPayload = payload.authorization;
await transfer(
authPayload.from as `0x${string}`,
refundAmountUsdcBigInt
).catch(transferError => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just a promise returning a promise? why are we .catch()

logger.error('Failed to process full refund after error', {
error: transferError,
originalError: error,
refundAmount: paymentAmountDecimal.toString(),
});
});
}
}

Expand Down
66 changes: 66 additions & 0 deletions packages/app/server/src/resources/tavily/prices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export const CREDIT_PRICE = 0.008; // $0.008 per credit

// Tavily Search pricing
export const TAVILY_SEARCH_PRICING = {
basic: 1, // 1 credit per request
advanced: 2, // 2 credits per request
} as const;

// Tavily Extract pricing
export const TAVILY_EXTRACT_PRICING = {
basic: {
creditsPerUnit: 1,
urlsPerCredit: 5, // Every 5 successful URL extractions cost 1 credit
},
advanced: {
creditsPerUnit: 2,
urlsPerCredit: 5, // Every 5 successful URL extractions cost 2 credits
},
} as const;

// Tavily Map pricing
export const TAVILY_MAP_PRICING = {
regular: {
creditsPerUnit: 1,
pagesPerCredit: 10, // Every 10 successful pages cost 1 credit
},
withInstructions: {
creditsPerUnit: 2,
pagesPerCredit: 10, // Every 10 successful pages with instructions cost 2 credits
},
} as const;

// Calculate costs
export function calculateSearchCost(searchDepth: "basic" | "advanced" = "basic"): number {
return TAVILY_SEARCH_PRICING[searchDepth] * CREDIT_PRICE;
}

export function calculateExtractCost(
successfulUrls: number,
extractionDepth: "basic" | "advanced" = "basic"
): number {
const { creditsPerUnit, urlsPerCredit } = TAVILY_EXTRACT_PRICING[extractionDepth];
const credits = Math.ceil(successfulUrls / urlsPerCredit) * creditsPerUnit;
return credits * CREDIT_PRICE;
}

export function calculateMapCost(
successfulPages: number,
withInstructions: boolean = false
): number {
const pricing = withInstructions
? TAVILY_MAP_PRICING.withInstructions
: TAVILY_MAP_PRICING.regular;
const credits = Math.ceil(successfulPages / pricing.pagesPerCredit) * pricing.creditsPerUnit;
return credits * CREDIT_PRICE;
}

export function calculateCrawlCost(
successfulPages: number,
extractionDepth: "basic" | "advanced" = "basic"
): number {
// Crawl cost = Mapping cost + Extraction cost
const mappingCost = calculateMapCost(successfulPages, false);
const extractionCost = calculateExtractCost(successfulPages, extractionDepth);
return mappingCost + extractionCost;
}
53 changes: 53 additions & 0 deletions packages/app/server/src/resources/tavily/tavily.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Decimal } from "@prisma/client/runtime/library";
import { CREDIT_PRICE, TAVILY_SEARCH_PRICING } from "./prices";
import { TavilySearchInput, TavilySearchOutput, TavilySearchOutputSchema } from "./types";
import { Transaction } from "types";

export const calculateTavilySearchCost = (input: TavilySearchInput): Decimal => {
const price = TAVILY_SEARCH_PRICING[input.search_depth ?? "basic"];
return new Decimal(price).mul(CREDIT_PRICE);
}

export const createTavilyTransaction = (
input: TavilySearchInput,
output: TavilySearchOutput,
cost: Decimal
): Transaction => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole tx object is throwing me off. why do we need this for x402? (or are we allowing echo credits)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're allowing creds

return {
metadata: {
providerId: output.request_id,
provider: 'tavily',
model: input.search_depth ?? 'basic',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not, but it will get pretty annoying to always add n+1 resources if all usage is going to be A. recorded in echo tx and B. need it's own fields.

We should abandon supporting echo balance or just be okay with overloading

inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
toolCost: cost,
},
rawTransactionCost: cost,
status: 'completed',
};
}
const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
export async function tavilySearch(
input: TavilySearchInput,
): Promise<TavilySearchOutput> {
const response = await fetch("https://api.tavily.com/search", {
method: "POST",
headers: {
Authorization: `Bearer ${TAVILY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Tavily API request failed: ${response.status} ${response.statusText} - ${errorText}`
);
}

const data = await response.json();
return TavilySearchOutputSchema.parse(data);
}

Loading
Loading