@pkent/aigateway 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,7 @@ new AIGateway(model, key, options?)
51
51
  |-------------|--------|-----------------|------------------|-------------|
52
52
  | `baseURL` | string | all | provider default | Override the upstream endpoint (regional endpoints, proxies, self-hosted). |
53
53
  | `maxTokens` | number | all | unset (see below)| Default max output tokens; a per-call `maxTokens` overrides it. |
54
+ | `timeout` | number | all | unset (SDK default) | Default per-call request timeout in ms; a per-call `timeout` overrides it. |
54
55
  | `referer` | string | OpenRouter only | omitted | Sent as the `HTTP-Referer` attribution header. |
55
56
  | `title` | string | OpenRouter only | omitted | Sent as the `X-Title` attribution header. |
56
57
  | `client` | object | advanced | — | Inject a pre-built SDK client (or a compatible fake for testing). When set, `key`/`baseURL`/`referer`/`title` are not used to build a client. |
@@ -81,8 +82,9 @@ AIGateway.providers()
81
82
 
82
83
  `vision` is `chat` with image content blocks in `messages`. Both return the
83
84
  [v2 response shape](#response-shape). `options` may include `temperature`,
84
- `maxTokens`, and `responseFormat` (mapped to OpenAI-compatible `response_format`;
85
- ignored by Anthropic).
85
+ `maxTokens`, `responseFormat`, `signal` (an `AbortSignal` to cancel the
86
+ request), and `timeout` (ms). (`responseFormat` is mapped to OpenAI-compatible
87
+ `response_format`; ignored by Anthropic.)
86
88
 
87
89
  ```js
88
90
  const g = new AIGateway('openai/gpt-4o', OPENAI_KEY);
@@ -118,6 +120,9 @@ If the upstream errors, the iterator throws and `.final` rejects with the same
118
120
  error. You may await `.final` without iterating (deltas buffer in memory), or
119
121
  iterate without awaiting `.final`.
120
122
 
123
+ Accepts the same `signal`/`timeout` options; aborting the signal rejects both
124
+ the iterator and `.final`.
125
+
121
126
  ## Model routing
122
127
 
123
128
  Every model is addressed as `<provider>/<model>`. The leading provider segment
@@ -206,6 +211,32 @@ behavior:
206
211
  Supply `maxTokens` (in the constructor or per call) to apply a cap to every
207
212
  provider. A per-call value overrides the constructor default.
208
213
 
214
+ ## Cancellation & timeouts
215
+
216
+ Pass an `AbortSignal` as `signal` to cancel an in-flight request; pass
217
+ `timeout` (ms) to bound it. Both are forwarded to the underlying provider
218
+ SDK's request options and apply to `chat`, `vision`, and `stream`.
219
+
220
+ ```js
221
+ const controller = new AbortController();
222
+ const res = g.chat(messages, { signal: controller.signal });
223
+ // ...later:
224
+ controller.abort(); // res rejects with the SDK's APIUserAbortError
225
+ ```
226
+
227
+ For a hard wall-clock cap that also honors an external cancel, combine a
228
+ timeout signal with your own:
229
+
230
+ ```js
231
+ const signal = AbortSignal.any([AbortSignal.timeout(120_000), runSignal]);
232
+ const res = await g.chat(messages, { signal });
233
+ ```
234
+
235
+ The `timeout` option is a per-attempt connection timeout and may be retried
236
+ by the SDK; prefer a combined `signal` (as above) when you need a guaranteed
237
+ wall-clock bound. Aborting a stream rejects both the async iterator and the
238
+ `.final` promise.
239
+
209
240
  ## Errors
210
241
 
211
242
  - **Input errors** (invalid model/key/messages) throw `AIGatewayError` with a
@@ -214,6 +245,9 @@ provider. A per-call value overrides the constructor default.
214
245
  `invalid_message_content`).
215
246
  - **Upstream/provider errors** propagate as the underlying SDK error — `chat` /
216
247
  `vision` reject; `stream` throws on the iterator and rejects `.final`.
248
+ - An aborted request rejects with the SDK's `APIUserAbortError`; a timed-out
249
+ request with `APIConnectionTimeoutError`. Like other provider errors, these
250
+ propagate unchanged (not wrapped in `AIGatewayError`).
217
251
 
218
252
  ```js
219
253
  import AIGateway, { AIGatewayError } from '@pkent/aigateway';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pkent/aigateway",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A provider-neutral LLM client library for OpenAI, Anthropic, Qwen, GLM, and OpenRouter",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "node --test",
12
- "prepublishOnly": "node --test test/aigateway.test.js test/cache-hints.test.js test/usage-normalization.test.js"
12
+ "prepublishOnly": "node --test test/aigateway.test.js test/cache-hints.test.js test/usage-normalization.test.js test/cancellation.test.js"
13
13
  },
14
14
  "files": [
15
15
  "src",
package/src/AIGateway.js CHANGED
@@ -27,7 +27,7 @@ export class AIGateway {
27
27
  );
28
28
  }
29
29
 
30
- const { baseURL, maxTokens, referer, title, client } = options;
30
+ const { baseURL, maxTokens, timeout, referer, title, client } = options;
31
31
 
32
32
  if (!client && (typeof key !== 'string' || key.trim() === '')) {
33
33
  throw new AIGatewayError('Missing or invalid API key', { code: 'invalid_api_key' });
@@ -36,7 +36,7 @@ export class AIGateway {
36
36
  this.#model = model;
37
37
  this.#providerId = entry.id;
38
38
  this.#provider = entry.create({ apiKey: key, baseURL, referer, title, client });
39
- this.#defaults = { maxTokens };
39
+ this.#defaults = { maxTokens, timeout };
40
40
  }
41
41
 
42
42
  get model() {
@@ -57,6 +57,8 @@ export class AIGateway {
57
57
  maxTokens: callOptions.maxTokens ?? this.#defaults.maxTokens,
58
58
  temperature: callOptions.temperature,
59
59
  responseFormat: callOptions.responseFormat,
60
+ signal: callOptions.signal,
61
+ timeout: callOptions.timeout ?? this.#defaults.timeout,
60
62
  };
61
63
  }
62
64
 
@@ -1,6 +1,7 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import { convertMessagesForAnthropic } from './messageTransforms.js';
3
3
  import { stripProviderPrefix } from './modelId.js';
4
+ import { buildRequestOptions } from './requestOptions.js';
4
5
  import {
5
6
  buildV2Response,
6
7
  extractAnthropicUsage,
@@ -40,15 +41,18 @@ function createProvider({ apiKey, baseURL, client }) {
40
41
  id: 'anthropic',
41
42
 
42
43
  async chat(model, messages, options = {}) {
43
- return normalize(await anthropic.messages.create(buildRequest(model, messages, options)), model);
44
+ return normalize(await anthropic.messages.create(buildRequest(model, messages, options), buildRequestOptions(options)), model);
44
45
  },
45
46
 
46
47
  async vision(model, messages, options = {}) {
47
- return normalize(await anthropic.messages.create(buildRequest(model, messages, options)), model);
48
+ return normalize(await anthropic.messages.create(buildRequest(model, messages, options), buildRequestOptions(options)), model);
48
49
  },
49
50
 
50
51
  async stream(model, messages, options = {}, { emitDelta }) {
51
- const upstream = await anthropic.messages.create({ ...buildRequest(model, messages, options), stream: true });
52
+ const upstream = await anthropic.messages.create(
53
+ { ...buildRequest(model, messages, options), stream: true },
54
+ buildRequestOptions(options),
55
+ );
52
56
 
53
57
  let streamId = `anthropic_${Date.now()}`;
54
58
  const created = Math.floor(Date.now() / 1000);
@@ -1,6 +1,7 @@
1
1
  import OpenAI from 'openai';
2
2
  import { stripCacheHintsFromMessages } from './messageTransforms.js';
3
3
  import { stripProviderPrefix } from './modelId.js';
4
+ import { buildRequestOptions } from './requestOptions.js';
4
5
  import {
5
6
  buildV2Response,
6
7
  extractOpenAIUsage,
@@ -53,15 +54,24 @@ export function createOpenAICompatibleProvider({
53
54
  id,
54
55
 
55
56
  async chat(model, messages, options = {}) {
56
- return normalize(await openai.chat.completions.create(buildRequest(model, messages, options)), model);
57
+ return normalize(
58
+ await openai.chat.completions.create(buildRequest(model, messages, options), buildRequestOptions(options)),
59
+ model,
60
+ );
57
61
  },
58
62
 
59
63
  async vision(model, messages, options = {}) {
60
- return normalize(await openai.chat.completions.create(buildRequest(model, messages, options)), model);
64
+ return normalize(
65
+ await openai.chat.completions.create(buildRequest(model, messages, options), buildRequestOptions(options)),
66
+ model,
67
+ );
61
68
  },
62
69
 
63
70
  async stream(model, messages, options = {}, { emitDelta }) {
64
- const upstream = await openai.chat.completions.create(buildRequest(model, messages, options, { stream: true }));
71
+ const upstream = await openai.chat.completions.create(
72
+ buildRequest(model, messages, options, { stream: true }),
73
+ buildRequestOptions(options),
74
+ );
65
75
 
66
76
  let streamId = `${id}_${Date.now()}`;
67
77
  let created = Math.floor(Date.now() / 1000);
@@ -0,0 +1,14 @@
1
+ // Maps the provider-neutral transport options onto the SDK's per-call
2
+ // "request options" object — the SECOND argument to anthropic.messages.create /
3
+ // openai.chat.completions.create. Both SDKs accept { signal, timeout } there.
4
+ //
5
+ // These are transport concerns and must NEVER be placed in the request body.
6
+ // Each field is included only when set (mirroring the conditional-spread style
7
+ // used for body fields), so an absent signal/timeout leaves SDK defaults intact
8
+ // and an omitted-options call sends an empty {} — a no-op for both SDKs.
9
+ export function buildRequestOptions({ signal, timeout } = {}) {
10
+ return {
11
+ ...(signal != null && { signal }),
12
+ ...(timeout != null && { timeout }),
13
+ };
14
+ }