-
Notifications
You must be signed in to change notification settings - Fork 36
Add Tavily Tool-calls #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
|
||
| 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; | ||
|
|
@@ -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 => { | ||
|
||
| logger.error('Failed to process full refund after error', { | ||
| error: transferError, | ||
| originalError: error, | ||
| refundAmount: paymentAmountDecimal.toString(), | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| 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; | ||
| } |
| 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 => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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?