Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
e30228f
add-resource-tavily
zdql Oct 9, 2025
e53edfe
add tavily resource
zdql Oct 9, 2025
27dbd15
cleanup
zdql Oct 9, 2025
89b3451
db changes
alvaroechevarriacuesta Oct 10, 2025
3278e3f
implement refund logic for failed sora requests
alvaroechevarriacuesta Oct 10, 2025
af978d0
check in
alvaroechevarriacuesta Oct 13, 2025
54ecfe0
fix migrations
alvaroechevarriacuesta Oct 13, 2025
7c6a514
fix with new atomic logic
alvaroechevarriacuesta Oct 13, 2025
b4f7f93
tested
alvaroechevarriacuesta Oct 13, 2025
0ec1dc2
increase api route limit to 4mb
sragss Oct 14, 2025
56b1dc0
Merge pull request #562 from Merit-Systems/sragss/fix-img-template
sragss Oct 14, 2025
e799344
remove openrouter/auto
zdql Oct 14, 2025
8b1b227
fix model price
zdql Oct 14, 2025
b3998c5
Merge branch 'master' into br/add-resource-tavily
zdql Oct 14, 2025
0cded6c
format
zdql Oct 14, 2025
293fd59
Merge pull request #541 from Merit-Systems/br/add-resource-tavily
rsproule Oct 15, 2025
63a38e4
add E2b, fix tavily
zdql Oct 15, 2025
2ac0348
format
zdql Oct 15, 2025
c1ae0cf
Merge pull request #565 from Merit-Systems/br/add-resource-e2b
zdql Oct 15, 2025
9b98dc1
add long tail tavily routes
zdql Oct 15, 2025
3fd58d0
Merge pull request #567 from Merit-Systems/br/add-resource-e2b
zdql Oct 15, 2025
6facf82
fix laggy integ
zdql Oct 15, 2025
34d7c38
feat: add 'claude-sonnet-4-5' to Anthropic and OpenRouter provoder mo…
qibinlou Oct 15, 2025
949ea98
rm migrations until finalized
alvaroechevarriacuesta Oct 15, 2025
b0a9890
some additional work on the table. Refunds working with x402
alvaroechevarriacuesta Oct 15, 2025
a2aec60
keep track of echo video gen that are non x402
alvaroechevarriacuesta Oct 15, 2025
8392323
log errors on echo video gen, store total cost not rawtransaction cost
alvaroechevarriacuesta Oct 15, 2025
aa1f762
Merge remote-tracking branch 'origin/master' into aec/refund
alvaroechevarriacuesta Oct 15, 2025
05e59cf
download only available for 1 hour according to docs
alvaroechevarriacuesta Oct 15, 2025
30cc7a9
use shared object
fmhall Oct 15, 2025
926aff1
Merge pull request #570 from Merit-Systems/mason/cleanup-fix
rsproule Oct 15, 2025
6d16752
clean abstraction
zdql Oct 15, 2025
c610ce6
cleanup error handling
zdql Oct 15, 2025
afdaba6
Merge pull request #566 from Merit-Systems/br/add-resource-tavily
zdql Oct 16, 2025
600d6aa
Merge pull request #568 from qibinlou/leo/add-sonnet-4.5-support
rsproule Oct 16, 2025
447a30a
bump sdk versions for sonnet 4.5
rsproule Oct 16, 2025
22150b4
fix filename
rsproule Oct 16, 2025
5d63e2f
provider smoke test
rsproule Oct 16, 2025
877125d
increase timeout to 15m
rsproule Oct 16, 2025
4b1f540
Merge pull request #571 from Merit-Systems/rfs/bump-version-smoke
rsproule Oct 16, 2025
d5b2b05
Add Groq provider support
dhvll Oct 16, 2025
c0056c0
update the tests
rsproule Oct 16, 2025
d7b3728
path
rsproule Oct 16, 2025
208135b
optimize this docker a bit
rsproule Oct 16, 2025
f27762a
docker 1
rsproule Oct 16, 2025
793861c
revert docker
rsproule Oct 16, 2025
2eb0e31
Merge pull request #578 from Merit-Systems/rfs/bump-version-smoke
rsproule Oct 16, 2025
08c29d9
big boxes for gha
rsproule Oct 16, 2025
1b6daa1
revert docker
rsproule Oct 16, 2025
d98d37f
Merge pull request #579 from Merit-Systems/rfs/big-box
rsproule Oct 16, 2025
cfd90d6
Refactor GroqProvider and update Groq model costs
dhvll Oct 16, 2025
f6dc1b5
update test name or
rsproule Oct 16, 2025
ee90834
rm timeouts
rsproule Oct 16, 2025
17c237c
rm timeouts
rsproule Oct 16, 2025
28d9751
rm openai timeout
rsproule Oct 16, 2025
5dab845
Add Groq provider integration and update SDK dependencies
dhvll Oct 16, 2025
21cd217
Merge pull request #577 from dhvll/master
rsproule Oct 17, 2025
3719252
fix sdk build issues + add tests
rsproule Oct 17, 2025
46fc77b
lock
rsproule Oct 17, 2025
1847e34
bump versions
rsproule Oct 17, 2025
813fc82
mr lock
rsproule Oct 17, 2025
f2202a8
update groq models with correct names
rsproule Oct 17, 2025
395d070
gdmmit alv
rsproule Oct 17, 2025
9584440
Merge pull request #582 from Merit-Systems/rfs/groq-takeover
rsproule Oct 17, 2025
ca3ea92
Merge pull request #548 from Merit-Systems/aec/refund
rsproule Oct 17, 2025
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
69 changes: 69 additions & 0 deletions .github/workflows/provider-smoke-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Provider Smoke Tests

on:
push:
branches: [master]
paths:
- 'packages/tests/provider-smoke/**'
- 'packages/sdk/ts/**'
- 'packages/app/server/**'
- 'packages/app/control/**'
- '.github/workflows/provider-smoke-tests.yml'

jobs:
provider-smoke-tests:
name: Run Provider Smoke Tests
runs-on: ubuntu-latest
timeout-minutes: 40

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install pnpm
run: npm install -g pnpm

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- name: Install workspace dependencies
run: pnpm install --frozen-lockfile

- name: Wait for Railway Deployment
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
echo "Installing Railway CLI..."
npm install -g @railway/cli

echo "Waiting for Railway deployment to complete..."
railway status --service echo --environment staging || echo "Service status check failed, continuing..."

echo "Waiting for deployment to be ready..."
max_attempts=90
attempt=0
while [ $attempt -lt $max_attempts ]; do
if curl -f -s -o /dev/null https://echo-staging.up.railway.app/health 2>/dev/null; then
echo "Deployment is ready!"
break
fi
attempt=$((attempt + 1))
echo "Attempt $attempt/$max_attempts: Deployment not ready yet, waiting 10 seconds..."
sleep 10
done

if [ $attempt -eq $max_attempts ]; then
echo "Deployment did not become ready in time (waited 15 minutes)"
exit 1
fi

- name: Run Provider Smoke Tests
working-directory: ./packages/tests/provider-smoke
env:
ECHO_DATA_SERVER_URL: https://echo-staging.up.railway.app/
ECHO_API_KEY: ${{ secrets.ECHO_API_KEY }}
ECHO_APP_ID: a4e9b928-cac0-4952-9b4e-3be01aaff45b
run: pnpm run test
6 changes: 5 additions & 1 deletion Dockerfile.railway
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ COPY packages/sdk/component-registry/ ./packages/sdk/component-registry/
WORKDIR /app
RUN pnpm install

# Build SDK first to ensure latest version is used
WORKDIR /app
RUN pnpm exec turbo run build --filter=@merit-systems/echo-typescript-sdk

# Build only what's needed for the server
WORKDIR /app
RUN SKIP_ENV_VALIDATION=true pnpm exec turbo run build --filter=echo-server
Expand All @@ -103,7 +107,7 @@ WORKDIR /app/packages/app/server
RUN pnpm run copy-prisma

# Step 4: Install production dependencies only
RUN pnpm install
RUN pnpm install --prod

# Expose the port that echo-server runs on
EXPOSE 3069
Expand Down
1 change: 1 addition & 0 deletions packages/app/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dependencies": {
"@coinbase/cdp-sdk": "^1.34.0",
"@coinbase/x402": "^0.6.5",
"@e2b/code-interpreter": "^2.0.1",
"@google-cloud/storage": "^7.17.1",
"@google/genai": "^1.20.0",
"@merit-systems/echo-typescript-sdk": "workspace:*",
Expand Down
218 changes: 115 additions & 103 deletions packages/app/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,45 @@ import {
} from 'services/facilitator/x402-types';
import { Decimal } from '@prisma/client/runtime/library';
import logger from 'logger';
import { Request, Response } from 'express';

export async function handleX402Request({
req,
res,
headers,
maxCost,
isPassthroughProxyRoute,
provider,
isStream,
}: X402HandlerInput) {
if (isPassthroughProxyRoute) {
return await makeProxyPassthroughRequest(req, res, provider, headers);
export async 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);
}
}

// Apply x402 payment middleware with the calculated maxCost
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;
Expand All @@ -62,100 +73,101 @@ 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();
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);
Copy link
Contributor

@vercel vercel bot Oct 15, 2025

Choose a reason for hiding this comment

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

Missing error handling for transfer operations in the finalize function - transfer failures will now throw unhandled exceptions instead of being logged and handled gracefully as in the original code.

View Details
📝 Patch Details
diff --git a/packages/app/server/src/handlers.ts b/packages/app/server/src/handlers.ts
index 49d641e2..4ea7e4e7 100644
--- a/packages/app/server/src/handlers.ts
+++ b/packages/app/server/src/handlers.ts
@@ -109,7 +109,12 @@ export async function finalize(
   if (!refundAmount.equals(0) && refundAmount.greaterThan(0)) {
     const refundAmountUsdcBigInt = decimalToUsdcBigInt(refundAmount);
     const authPayload = payload.authorization;
-    await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt);
+    await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt).catch(transferError => {
+      logger.error('Failed to process refund', {
+        error: transferError,
+        refundAmount: refundAmount.toString(),
+      });
+    });
   }
 }
 
@@ -156,7 +161,13 @@ export async function handleX402Request({
   } catch (error) {
     const refundAmountUsdcBigInt = decimalToUsdcBigInt(paymentAmountDecimal);
     const authPayload = payload.authorization;
-    await transfer(authPayload.from as `0x${string}`, refundAmountUsdcBigInt);
+    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(),
+      });
+    });
   }
 }
 

Analysis

Missing error handling for transfer operations causes unhandled exceptions

What fails: finalize() function and error handler in handleX402Request() lack error handling for transfer() calls, causing unhandled exceptions when blockchain transfers fail

How to reproduce:

# Simulate blockchain transfer failure (network issues, insufficient balance, etc.)
# The transfer() function will throw but no error handling exists
git show 0cded6c0 -- packages/app/server/src/handlers.ts

Result: Transfer failures now throw unhandled exceptions that can crash the request handler. Previously these were caught and logged gracefully.

Expected: Transfer failures should be caught and logged as they were before commit 0cded6c (Oct 14, 2025) which removed .catch() error handling from both transfer locations in the name of "formatting"

Evidence: Git history shows the original pattern: await transfer(...).catch(transferError => { logger.error('Failed to process refund', { error: transferError }); }); was removed and replaced with bare await transfer(...) calls.

}
}

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 { payload, paymentAmountDecimal } = settleResult;

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 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 transactionResult = await modelRequestService.executeModelRequest(
req,
res,
headers,
provider,
isStream
);

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

await finalize(
paymentAmountDecimal,
transactionResult.transaction,
payload
);
} catch (error) {
logger.error('Error in handleX402Request', { error });
throw error;
await refund(paymentAmountDecimal, payload);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,10 @@ export class TransactionEscrowMiddleware {
userId: string,
echoAppId: string,
requestId: string,
cleanupExecuted: boolean
cleanupState: { executed: boolean }
) => {
if (cleanupExecuted) return;
cleanupExecuted = true;
if (cleanupState.executed) return;
cleanupState.executed = true;

// decrementInFlightRequests now handles its own errors gracefully
await this.decrementInFlightRequests(userId, echoAppId);
Expand All @@ -215,21 +215,23 @@ export class TransactionEscrowMiddleware {
echoAppId: string,
requestId: string
) {
let cleanupExecuted = false;
// Use object to share state by reference across multiple event handlers
// This prevents duplicate cleanup execution when multiple events fire
const cleanupState = { executed: false };

// Cleanup on response finish (normal case)
res.on('finish', () =>
this.executeCleanup(userId, echoAppId, requestId, cleanupExecuted)
this.executeCleanup(userId, echoAppId, requestId, cleanupState)
);

// Cleanup on response close (client disconnect)
res.on('close', () =>
this.executeCleanup(userId, echoAppId, requestId, cleanupExecuted)
this.executeCleanup(userId, echoAppId, requestId, cleanupState)
);

// Cleanup on error (if response errors out)
res.on('error', () =>
this.executeCleanup(userId, echoAppId, requestId, cleanupExecuted)
this.executeCleanup(userId, echoAppId, requestId, cleanupState)
);
}

Expand Down
Loading
Loading