Skip to content

Commit 9f73b5e

Browse files
committed
Release v0.21.0
Origin-SHA: 7e9761a4a316bd5a8167be67a5ad4f5d8f2f8dc9
1 parent f93ae96 commit 9f73b5e

9 files changed

Lines changed: 168 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# @opensea/tool-sdk
22

3+
## 0.21.0
4+
5+
### Minor Changes
6+
7+
- 6c1be9c: `createToolHandler` now fires the usage report through a platform `waitUntil` (keep-alive-after-response) when one is available, instead of awaiting it inline. This removes reporting latency from every successful call and closes a billing edge case: because x402 settlement runs before the report, awaiting the report meant a function freeze in that window could charge a paid caller without returning a result. With `waitUntil` the response flushes first and the report runs after.
8+
9+
It's automatic for most tools: the Vercel request-context `waitUntil` is auto-detected (no dependency on `@vercel/functions`), and `toCloudflareHandler` now wires the per-request `ctx.waitUntil` (its `fetch` signature gains the optional `ctx` argument). A new optional `waitUntil` option on `ToolHandlerConfig` lets you override detection or support another runtime. When no `waitUntil` is available (long-running servers, or serverless lacking it), the report is awaited as before so it still fires before any freeze.
10+
11+
### Patch Changes
12+
13+
- 09f20a7: The `init` Vercel template now sets `export const maxDuration = 60` on the tool entrypoint. Tools that call an LLM or other slow upstream routinely exceed Vercel's 10s Hobby default, which returns a 502 to the caller; because x402 settlement runs after the handler succeeds, a timeout in the settle/report window can also charge the caller without returning a result. 60s is the Hobby maximum and valid on Pro/Enterprise.
14+
315
## 0.20.1
416

517
### Patch Changes

examples/wallet-personality-tool/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const vercelHandler = toVercelHandler(
2727
buildToolHandler({ manifest, gates: [paywall.gate] }),
2828
)
2929

30+
// The LLM persona synthesis can take longer than the Hobby default (10s);
31+
// allow up to the Hobby maximum so slow generations don't 502 (gateway timeout).
32+
export const maxDuration = 60
33+
3034
export default function (req: VercelRequest, res: VercelResponse) {
3135
return vercelHandler(req, res)
3236
}

examples/wallet-personality-tool/api/subscriber.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ const manifest = buildSubscriberManifest({
4242
})
4343
const vercelHandler = toVercelHandler(buildToolHandler({ manifest, gates }))
4444

45+
// The LLM persona synthesis can take longer than the Hobby default (10s);
46+
// allow up to the Hobby maximum so slow generations don't 502 (gateway timeout).
47+
export const maxDuration = 60
48+
4549
export default function (req: VercelRequest, res: VercelResponse) {
4650
return vercelHandler(req, res)
4751
}

examples/wallet-personality-tool/src/personality.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type WalletDigest,
99
} from "./schemas.js"
1010

11-
const DEFAULT_MODEL = "claude-sonnet-4-6"
11+
const DEFAULT_MODEL = "claude-haiku-4-5"
1212

1313
export interface SynthesizePersonalityOptions {
1414
/** Override the model. Falls back to env, then DEFAULT_MODEL. */

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/tool-sdk",
3-
"version": "0.20.1",
3+
"version": "0.21.0",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

src/__tests__/handler.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { extractSettlementTxHash } from "../lib/client/x402-payment.js"
55
import { createToolHandler } from "../lib/handler/index.js"
66
import type { ManifestDefinition } from "../lib/manifest/index.js"
77
import type { Eip3009UsageReporterConfig } from "../lib/usage/eip3009-reporter.js"
8+
import { createEip3009UsageReporter } from "../lib/usage/eip3009-reporter.js"
89
import type { GateMiddleware, InvocationEvent } from "../types.js"
910

1011
vi.mock("../lib/usage/eip3009-reporter.js", () => ({
@@ -284,6 +285,54 @@ describe("createToolHandler", () => {
284285
expect(extractSettlementTxHash(response)).toBeUndefined()
285286
})
286287

288+
it("returns the response via waitUntil without awaiting the report", async () => {
289+
// A report that stays pending until we release it, so we can prove the
290+
// handler returns the response before the report settles.
291+
let releaseReport: () => void = () => {}
292+
const reportSettled = vi.fn()
293+
const pendingReport = new Promise<void>(resolve => {
294+
releaseReport = () => {
295+
reportSettled()
296+
resolve()
297+
}
298+
})
299+
vi.mocked(createEip3009UsageReporter).mockReturnValueOnce(
300+
vi.fn().mockReturnValue(pendingReport),
301+
)
302+
303+
const registered: Promise<unknown>[] = []
304+
const waitUntil = vi.fn((p: Promise<unknown>) => {
305+
registered.push(p)
306+
})
307+
const handler = createToolHandler({
308+
manifest: testManifest,
309+
inputSchema: InputSchema,
310+
outputSchema: OutputSchema,
311+
usageReporting: {} as Eip3009UsageReporterConfig,
312+
waitUntil,
313+
handler: async input => ({ result: `Echo: ${input.query}` }),
314+
})
315+
const request = new Request("https://test.example.com/api", {
316+
method: "POST",
317+
headers: { "Content-Type": "application/json" },
318+
body: JSON.stringify({ query: "test" }),
319+
})
320+
321+
const response = await handler(request)
322+
323+
expect(response.status).toBe(200)
324+
// The report was handed to waitUntil, not awaited: the response resolved
325+
// while the report is still pending.
326+
expect(waitUntil).toHaveBeenCalledOnce()
327+
expect(registered[0]).toBeInstanceOf(Promise)
328+
expect(reportSettled).not.toHaveBeenCalled()
329+
330+
// Releasing it completes the backgrounded report.
331+
releaseReport()
332+
await registered[0]
333+
expect(reportSettled).toHaveBeenCalledOnce()
334+
})
335+
287336
it("does not call settle() when a gate short-circuits with a response", async () => {
288337
const settle = vi.fn()
289338
const gate: GateMiddleware = {

src/lib/adapters/cloudflare.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import type { ToolHandlerConfig } from "../handler/index.js"
22
import { createToolHandler } from "../handler/index.js"
33

4+
/** Minimal shape of the Cloudflare Workers `ExecutionContext`. */
5+
interface CloudflareExecutionContext {
6+
waitUntil?: (promise: Promise<unknown>) => void
7+
}
8+
49
interface CloudflareWorkerExportedHandler {
5-
fetch: (request: Request, env: Record<string, string | undefined>) => Promise<Response>
10+
fetch: (
11+
request: Request,
12+
env: Record<string, string | undefined>,
13+
ctx?: CloudflareExecutionContext,
14+
) => Promise<Response>
615
}
716

817
export function toCloudflareHandler<TIn, TOut>(
918
config: Omit<ToolHandlerConfig<TIn, TOut>, "env">,
1019
): CloudflareWorkerExportedHandler {
1120
return {
12-
fetch: (request, env) => {
13-
const handler = createToolHandler({ ...config, env })
21+
fetch: (request, env, ctx) => {
22+
// Wire the per-request `ctx.waitUntil` so the fire-and-forget usage
23+
// report runs as keep-alive-after-response work rather than blocking the
24+
// response (or being killed at flush). An explicit `config.waitUntil`
25+
// still wins.
26+
const waitUntil =
27+
config.waitUntil ??
28+
(ctx?.waitUntil ? ctx.waitUntil.bind(ctx) : undefined)
29+
const handler = createToolHandler({ ...config, env, waitUntil })
1430
return handler(request)
1531
},
1632
}

src/lib/handler/index.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,44 @@ import type { Eip3009UsageReporterConfig } from "../usage/eip3009-reporter.js"
1111
import { createEip3009UsageReporter } from "../usage/eip3009-reporter.js"
1212
import { ToolHandlerError } from "./error.js"
1313

14+
type WaitUntil = (promise: Promise<unknown>) => void
15+
16+
/**
17+
* Best-effort detection of a platform "keep-alive after response" primitive,
18+
* so fire-and-forget usage reports fire reliably without the tool author
19+
* wiring anything. Returns `undefined` when none is available (the caller
20+
* then awaits the report instead).
21+
*
22+
* Vercel populates a request-context global (the same one `@vercel/functions`
23+
* reads) holding a `waitUntil`. We read it directly to avoid a hard dependency
24+
* on `@vercel/functions`. Cloudflare's `ctx.waitUntil` is per-request and is
25+
* wired by `toCloudflareHandler` (passed via the `waitUntil` config option),
26+
* so it isn't detected here.
27+
*
28+
* Reading a global symbol is an accepted, bounded risk: anything could shadow
29+
* it, so we only trust it when it resolves to a callable `waitUntil` (the
30+
* `typeof === "function"` guard below) and otherwise fall back to awaiting the
31+
* report. The exposure is no worse than depending on `@vercel/functions`, which
32+
* reads the same global.
33+
*/
34+
function detectPlatformWaitUntil(): WaitUntil | undefined {
35+
try {
36+
const store = (
37+
globalThis as Record<symbol, unknown>
38+
)[Symbol.for("@vercel/request-context")] as
39+
| { get?: () => { waitUntil?: WaitUntil } | undefined }
40+
| undefined
41+
const ctx = store?.get?.()
42+
if (typeof ctx?.waitUntil === "function") {
43+
// Call through `ctx` to preserve `this` binding.
44+
return (promise) => ctx.waitUntil?.(promise)
45+
}
46+
} catch {
47+
// Detection is best-effort; fall back to awaiting the report.
48+
}
49+
return undefined
50+
}
51+
1452
export interface ToolHandlerConfig<TIn, TOut> {
1553
manifest: ManifestDefinition
1654
env?: Record<string, string | undefined>
@@ -34,6 +72,20 @@ export interface ToolHandlerConfig<TIn, TOut> {
3472
* analytics or rate limiting. Errors are caught and logged.
3573
*/
3674
onInvocation?: (event: InvocationEvent) => void | Promise<void>
75+
/**
76+
* Platform "keep-alive after response" primitive (e.g. Vercel/Cloudflare
77+
* `waitUntil`). When available, the fire-and-forget usage report is
78+
* registered through it and the response is returned without awaiting the
79+
* report — so reporting never adds latency and a function freeze in the
80+
* report window can't leave a paid caller charged with no result.
81+
*
82+
* Usually you don't set this: it's auto-detected from the Vercel request
83+
* context, and `toCloudflareHandler` wires `ctx.waitUntil` for you. Pass it
84+
* explicitly only to override detection or to support another runtime. When
85+
* no `waitUntil` is available (long-running servers, or serverless without
86+
* it), the report is awaited instead so it still fires before the freeze.
87+
*/
88+
waitUntil?: (promise: Promise<unknown>) => void
3789
}
3890

3991
export function createToolHandler<TIn, TOut>(
@@ -148,17 +200,27 @@ export function createToolHandler<TIn, TOut>(
148200
}
149201
}
150202

151-
// Await the report so it completes before the response is returned.
152-
// On serverless runtimes (Vercel/AWS Lambda) the function is frozen
153-
// once the response flushes, which kills any fire-and-forget request
154-
// still in flight — the report would silently never arrive. The
155-
// reporter has its own AbortController timeout (default 5s) and never
156-
// throws; the catch is belt-and-suspenders so a misbehaving reporter
157-
// can never turn a successful tool call into a failure.
203+
// Fire the usage report. The reporter has its own AbortController
204+
// timeout (default 5s) and never throws; the catch is belt-and-suspenders.
205+
//
206+
// With a platform `waitUntil` (auto-detected, or supplied via config /
207+
// `toCloudflareHandler`), register the report as keep-alive-after-response
208+
// work and return immediately — reporting adds no latency, and because the
209+
// response flushes before the report runs, a freeze in the report window
210+
// can't leave a paid caller charged with no result. Without `waitUntil`
211+
// (long-running servers, or serverless lacking it), await instead: on a
212+
// frozen runtime a fire-and-forget report would be killed at flush and
213+
// never arrive.
158214
if (usageReporter) {
159-
await usageReporter(event).catch((err) => {
215+
const reportPromise = usageReporter(event).catch((err) => {
160216
console.error("[tool-sdk] usageReporting failed:", err)
161217
})
218+
const waitUntil = config.waitUntil ?? detectPlatformWaitUntil()
219+
if (waitUntil) {
220+
waitUntil(reportPromise)
221+
} else {
222+
await reportPromise
223+
}
162224
}
163225

164226
// When the call was paid and settled onchain, echo the settlement tx

src/templates/vercel/api/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { toolHandler } from "../src/handler.js"
77

88
const handler = toVercelHandler(toolHandler)
99

10+
// Raise the function timeout above Vercel's 10s Hobby default. Tools that call
11+
// an LLM or other slow upstream can exceed 10s, which returns a 502 to the
12+
// caller. Worse, x402 settlement runs after your handler succeeds, so a kill
13+
// in the settle/report window can charge the caller without returning a result.
14+
// 60s is the Hobby maximum and is valid on Pro/Enterprise too. Lower it if your
15+
// tool is fast, or raise it on paid plans.
16+
export const maxDuration = 60
17+
1018
export default function (req: VercelRequest, res: VercelResponse) {
1119
return handler(req, res)
1220
}

0 commit comments

Comments
 (0)