diff --git a/.changeset/sdk-floor-abort-rejection.md b/.changeset/sdk-floor-abort-rejection.md new file mode 100644 index 00000000..f1013b62 --- /dev/null +++ b/.changeset/sdk-floor-abort-rejection.md @@ -0,0 +1,5 @@ +--- +'@openrouter/agent': patch +--- + +Raise the `@openrouter/sdk` floor to `^0.13.59`. That release fixes an unhandled promise rejection raised when a request is aborted while the SDK is still reading a non-streamed response body (OpenRouterTeam/typescript-sdk#350). Each aborted `callModel` run used to emit one stray `unhandledRejection`, which can crash Node processes running with `--unhandled-rejections=strict`. diff --git a/packages/agent/package.json b/packages/agent/package.json index 12ec2d2c..05bd823d 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -178,7 +178,7 @@ "gen:version": "node scripts/gen-version.mjs" }, "dependencies": { - "@openrouter/sdk": "^0.13.7", + "@openrouter/sdk": "^0.13.59", "zod": "^4.0.0" }, "peerDependencies": { diff --git a/packages/agent/tests/unit/sdk-abort-body-read.test.ts b/packages/agent/tests/unit/sdk-abort-body-read.test.ts new file mode 100644 index 00000000..3362dfa6 --- /dev/null +++ b/packages/agent/tests/unit/sdk-abort-body-read.test.ts @@ -0,0 +1,83 @@ +/** + * Regression test for OpenRouterTeam/typescript-sdk#350. + * + * Before @openrouter/sdk 0.13.59, aborting a request while the SDK was still + * reading a non-streamed (JSON) response body left an internal APIPromise + * rejected with no handler, so each aborted call surfaced a process-level + * `unhandledRejection` even though the caller's own promise rejected and was + * handled. The agent can't intercept that from the outside, so this test pins + * the SDK floor: it runs the real SDK (no mocks) against a local server that + * sends JSON headers plus a partial body and then stalls. + */ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { OpenRouterCore } from '@openrouter/sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { callModel } from '../../src/inner-loop/call-model.js'; + +let server: http.Server; +let requestsReceived = 0; +let unhandledRejections: unknown[] = []; +const onUnhandled = (err: unknown) => { + unhandledRejections.push(err); +}; + +beforeEach(async () => { + requestsReceived = 0; + unhandledRejections = []; + process.on('unhandledRejection', onUnhandled); + server = http.createServer((req, res) => { + req.resume(); + requestsReceived++; + // JSON (non-event-stream) response whose body never completes. + res.writeHead(200, { + 'content-type': 'application/json', + }); + res.write('{"id":'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +}); + +afterEach(async () => { + process.off('unhandledRejection', onUnhandled); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe('aborting during a non-streamed body read (typescript-sdk#350)', () => { + it('rejects every parallel call without leaking unhandled rejections', async () => { + const { port } = server.address() as AddressInfo; + const client = new OpenRouterCore({ + apiKey: 'test-key', + serverURL: `http://127.0.0.1:${port}/api/v1`, + }); + const controller = new AbortController(); + + const calls = [ + 1, + 2, + 3, + ].map(() => + callModel(client, { + model: 'test-model', + input: 'hello', + signal: controller.signal, + }).getResponse(), + ); + + // Abort only once every request is mid-body, i.e. on the SDK's JSON read path. + await vi.waitFor(() => expect(requestsReceived).toBe(3)); + controller.abort(); + + const settled = await Promise.allSettled(calls); + // Give any dangling rejection a chance to reach the process handler. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(settled.map((s) => s.status)).toEqual([ + 'rejected', + 'rejected', + 'rejected', + ]); + expect(unhandledRejections).toHaveLength(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c5c086d..bd3ba0ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: packages/agent: dependencies: '@openrouter/sdk': - specifier: ^0.13.7 - version: 0.13.7 + specifier: ^0.13.59 + version: 0.13.59 zod: specifier: ^4.0.0 version: 4.3.6 @@ -403,8 +403,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openrouter/sdk@0.13.7': - resolution: {integrity: sha512-/QGoYdbFC2lqnVoCwtBX1+3Z5v6SF5hS1/dQisY0so3DzNAIQLHShA0OQVeAg20X0RG3CSKSCK0Ijnkt2rHDXA==} + '@openrouter/sdk@0.13.59': + resolution: {integrity: sha512-cQsewh5sctQAStPi1/1ExemjfvE9LZFcqZbukX4yOCP0gkbRKLjUzw53/PzYQAgyZp39L3acfmZF4O1hMGq4nA==} '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} @@ -1529,7 +1529,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@openrouter/sdk@0.13.7': + '@openrouter/sdk@0.13.59': dependencies: zod: 4.3.6