@pkent/aigateway 1.0.0 → 1.2.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
@@ -2,8 +2,8 @@
2
2
 
3
3
  A provider-neutral LLM client library. One class routes chat / vision /
4
4
  streaming requests to OpenAI, Anthropic, Qwen (Alibaba DashScope), GLM (Zhipu
5
- AI), or OpenRouter based on the model name, and normalizes every response into a
6
- single shape.
5
+ AI), OpenRouter, or an OpenAI-compatible gateway (`aibroker`) based on the model
6
+ name, and normalizes every response into a single shape.
7
7
 
8
8
  No server, no `.env` — all configuration is passed into the constructor.
9
9
 
@@ -49,8 +49,9 @@ new AIGateway(model, key, options?)
49
49
 
50
50
  | Option | Type | Applies to | Default | Description |
51
51
  |-------------|--------|-----------------|------------------|-------------|
52
- | `baseURL` | string | all | provider default | Override the upstream endpoint (regional endpoints, proxies, self-hosted). |
52
+ | `baseURL` | string | all | provider default | Override the upstream endpoint (regional endpoints, proxies, self-hosted). **Required** for `aibroker` (no default). |
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. |
@@ -70,7 +71,8 @@ AIGateway.providers()
70
71
  // { id: 'anthropic', prefix: 'anthropic/' },
71
72
  // { id: 'qwen', prefix: 'qwen/' },
72
73
  // { id: 'glm', prefix: 'glm/' },
73
- // { id: 'openai', prefix: 'openai/' }
74
+ // { id: 'openai', prefix: 'openai/' },
75
+ // { id: 'aibroker', prefix: 'aibroker/' }
74
76
  // ]
75
77
  ```
76
78
 
@@ -81,8 +83,9 @@ AIGateway.providers()
81
83
 
82
84
  `vision` is `chat` with image content blocks in `messages`. Both return the
83
85
  [v2 response shape](#response-shape). `options` may include `temperature`,
84
- `maxTokens`, and `responseFormat` (mapped to OpenAI-compatible `response_format`;
85
- ignored by Anthropic).
86
+ `maxTokens`, `responseFormat`, `signal` (an `AbortSignal` to cancel the
87
+ request), and `timeout` (ms). (`responseFormat` is mapped to OpenAI-compatible
88
+ `response_format`; ignored by Anthropic.)
86
89
 
87
90
  ```js
88
91
  const g = new AIGateway('openai/gpt-4o', OPENAI_KEY);
@@ -118,6 +121,9 @@ If the upstream errors, the iterator throws and `.final` rejects with the same
118
121
  error. You may await `.final` without iterating (deltas buffer in memory), or
119
122
  iterate without awaiting `.final`.
120
123
 
124
+ Accepts the same `signal`/`timeout` options; aborting the signal rejects both
125
+ the iterator and `.final`.
126
+
121
127
  ## Model routing
122
128
 
123
129
  Every model is addressed as `<provider>/<model>`. The leading provider segment
@@ -132,10 +138,25 @@ catch-all — an unknown or missing segment throws `AIGatewayError`
132
138
  | `qwen/<model>` | `qwen` | `<model>` | OpenAI-compatible. Default base URL `https://dashscope-intl.aliyuncs.com/compatible-mode/v1`. |
133
139
  | `glm/<model>` | `glm` | `<model>` | OpenAI-compatible. Default base URL `https://open.bigmodel.cn/api/paas/v4`. |
134
140
  | `openrouter/<vendor>/<model>` | `openrouter` | `<vendor>/<model>` | Only the `openrouter/` segment is stripped, leaving OpenRouter's native id. Default base URL `https://openrouter.ai/api/v1`. |
141
+ | `aibroker/<remainder>` | `aibroker` | `<remainder>` | OpenAI-compatible gateway meta-provider. Only the `aibroker/` segment is stripped; the remainder is forwarded verbatim and the gateway does its own routing. **Requires** `baseURL` (no default) — the constructor throws `AIGatewayError` (`code: 'missing_base_url'`) without it. |
135
142
 
136
143
  OpenRouter is opt-in: send `openrouter/anthropic/claude-opus-4.8` to route
137
144
  through OpenRouter, or `anthropic/claude-opus-4.8` to hit Anthropic directly.
138
145
 
146
+ `aibroker` is a gateway meta-provider: it forwards to *any* OpenAI-compatible
147
+ gateway you point it at, so it embeds no host and **requires** a `baseURL`. Only
148
+ the leading `aibroker/` segment is stripped — everything after it is sent
149
+ upstream as the model id, letting the gateway do its own routing
150
+ (`aibroker/openai/chatgpt-5.5` → `openai/chatgpt-5.5`,
151
+ `aibroker/openrouter/openai/chatgpt-5.5` → `openrouter/openai/chatgpt-5.5`). The
152
+ key is passed straight through as the Bearer for the gateway.
153
+
154
+ ```js
155
+ const g = new AIGateway('aibroker/openai/chatgpt-5.5', GATEWAY_TOKEN, {
156
+ baseURL: 'https://your-gateway/v1',
157
+ });
158
+ ```
159
+
139
160
  **Adding a provider:** drop a module into [src/providers/](src/providers/)
140
161
  exporting `{ id, prefix, matches, create }` (where `matches` tests the
141
162
  `<id>/` segment) and register it in
@@ -206,6 +227,32 @@ behavior:
206
227
  Supply `maxTokens` (in the constructor or per call) to apply a cap to every
207
228
  provider. A per-call value overrides the constructor default.
208
229
 
230
+ ## Cancellation & timeouts
231
+
232
+ Pass an `AbortSignal` as `signal` to cancel an in-flight request; pass
233
+ `timeout` (ms) to bound it. Both are forwarded to the underlying provider
234
+ SDK's request options and apply to `chat`, `vision`, and `stream`.
235
+
236
+ ```js
237
+ const controller = new AbortController();
238
+ const res = g.chat(messages, { signal: controller.signal });
239
+ // ...later:
240
+ controller.abort(); // res rejects with the SDK's APIUserAbortError
241
+ ```
242
+
243
+ For a hard wall-clock cap that also honors an external cancel, combine a
244
+ timeout signal with your own:
245
+
246
+ ```js
247
+ const signal = AbortSignal.any([AbortSignal.timeout(120_000), runSignal]);
248
+ const res = await g.chat(messages, { signal });
249
+ ```
250
+
251
+ The `timeout` option is a per-attempt connection timeout and may be retried
252
+ by the SDK; prefer a combined `signal` (as above) when you need a guaranteed
253
+ wall-clock bound. Aborting a stream rejects both the async iterator and the
254
+ `.final` promise.
255
+
209
256
  ## Errors
210
257
 
211
258
  - **Input errors** (invalid model/key/messages) throw `AIGatewayError` with a
@@ -214,6 +261,9 @@ provider. A per-call value overrides the constructor default.
214
261
  `invalid_message_content`).
215
262
  - **Upstream/provider errors** propagate as the underlying SDK error — `chat` /
216
263
  `vision` reject; `stream` throws on the iterator and rejects `.final`.
264
+ - An aborted request rejects with the SDK's `APIUserAbortError`; a timed-out
265
+ request with `APIConnectionTimeoutError`. Like other provider errors, these
266
+ propagate unchanged (not wrapped in `AIGatewayError`).
217
267
 
218
268
  ```js
219
269
  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.2.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 test/aibroker.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
 
@@ -0,0 +1,22 @@
1
+ import { createOpenAICompatibleProvider } from './openaiCompatible.js';
2
+ import { AIGatewayError } from '../errors.js';
3
+
4
+ // "aibroker/<provider>/<model>" routes to an OpenAI-compatible gateway. The
5
+ // "aibroker/" segment is stripped (existing stripProviderPrefix) and the rest is
6
+ // forwarded verbatim as the model — the gateway does its own routing. The
7
+ // caller MUST supply the gateway URL via options.baseURL; there is no default.
8
+ export default {
9
+ id: 'aibroker',
10
+ prefix: 'aibroker/',
11
+ matches: model => typeof model === 'string' && model.startsWith('aibroker/'),
12
+ create({ apiKey, baseURL, client }) {
13
+ if (!client && !baseURL) {
14
+ throw new AIGatewayError(
15
+ 'The "aibroker" provider requires a baseURL (the gateway URL), e.g. ' +
16
+ 'new AIGateway(model, key, { baseURL: "https://your-gateway/v1" })',
17
+ { code: 'missing_base_url' },
18
+ );
19
+ }
20
+ return createOpenAICompatibleProvider({ id: 'aibroker', apiKey, baseURL, client });
21
+ },
22
+ };
@@ -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);
@@ -3,10 +3,11 @@ import anthropic from './anthropic.js';
3
3
  import qwen from './qwen.js';
4
4
  import glm from './glm.js';
5
5
  import openai from './openai.js';
6
+ import aibroker from './aibroker.js';
6
7
 
7
8
  // Every model is addressed as "<provider>/<model>". The provider segments are
8
9
  // disjoint, so resolution order does not matter; there is no catch-all.
9
- const ENTRIES = [openrouter, anthropic, qwen, glm, openai];
10
+ const ENTRIES = [openrouter, anthropic, qwen, glm, openai, aibroker];
10
11
 
11
12
  export function resolveProviderEntry(model) {
12
13
  for (const entry of ENTRIES) {
@@ -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
+ }