@pkent/aigateway 1.0.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/LICENSE +21 -0
- package/README.md +241 -0
- package/package.json +46 -0
- package/src/AIGateway.js +97 -0
- package/src/errors.js +7 -0
- package/src/index.js +2 -0
- package/src/lib/v2.js +136 -0
- package/src/providers/anthropic.js +111 -0
- package/src/providers/glm.js +18 -0
- package/src/providers/messageTransforms.js +102 -0
- package/src/providers/modelId.js +8 -0
- package/src/providers/openai.js +11 -0
- package/src/providers/openaiCompatible.js +96 -0
- package/src/providers/openrouter.js +26 -0
- package/src/providers/qwen.js +18 -0
- package/src/providers/registry.js +20 -0
- package/src/stream/ChatStream.js +54 -0
- package/src/validation.js +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pkent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# AIGateway
|
|
2
|
+
|
|
3
|
+
A provider-neutral LLM client library. One class routes chat / vision /
|
|
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.
|
|
7
|
+
|
|
8
|
+
No server, no `.env` — all configuration is passed into the constructor.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @pkent/aigateway
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Node.js 18+ (uses native ESM and async iterators). The package is
|
|
17
|
+
ES-module only.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import AIGateway from '@pkent/aigateway';
|
|
23
|
+
|
|
24
|
+
const g = new AIGateway('anthropic/claude-opus-4.8', process.env.ANTHROPIC_API_KEY);
|
|
25
|
+
|
|
26
|
+
const res = await g.chat([{ role: 'user', content: 'Hello' }]);
|
|
27
|
+
console.log(res.content[0].text);
|
|
28
|
+
console.log(res.usage.total_tokens);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Models are addressed as **`<provider>/<model>`** (e.g. `anthropic/claude-opus-4.8`,
|
|
32
|
+
`openai/gpt-4o`). The leading provider segment selects the provider; the rest is
|
|
33
|
+
the model id sent upstream.
|
|
34
|
+
|
|
35
|
+
One instance is bound to one model and one API key. To use a different model,
|
|
36
|
+
construct another instance.
|
|
37
|
+
|
|
38
|
+
## Constructor
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
new AIGateway(model, key, options?)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **`model`** *(string, required)* — `<provider>/<model>`. The provider segment
|
|
45
|
+
selects the provider (see the [routing table](#model-routing)); an unknown or
|
|
46
|
+
missing segment throws `AIGatewayError` (`code: 'unsupported_model'`).
|
|
47
|
+
- **`key`** *(string, required)* — the API key for the resolved provider.
|
|
48
|
+
- **`options`** *(object, optional)*:
|
|
49
|
+
|
|
50
|
+
| Option | Type | Applies to | Default | Description |
|
|
51
|
+
|-------------|--------|-----------------|------------------|-------------|
|
|
52
|
+
| `baseURL` | string | all | provider default | Override the upstream endpoint (regional endpoints, proxies, self-hosted). |
|
|
53
|
+
| `maxTokens` | number | all | unset (see below)| Default max output tokens; a per-call `maxTokens` overrides it. |
|
|
54
|
+
| `referer` | string | OpenRouter only | omitted | Sent as the `HTTP-Referer` attribution header. |
|
|
55
|
+
| `title` | string | OpenRouter only | omitted | Sent as the `X-Title` attribution header. |
|
|
56
|
+
| `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. |
|
|
57
|
+
|
|
58
|
+
The constructor performs **no** network calls and throws `AIGatewayError` for an
|
|
59
|
+
invalid `model` (`code: 'invalid_model'`) or missing `key`
|
|
60
|
+
(`code: 'invalid_api_key'`, unless a `client` is injected).
|
|
61
|
+
|
|
62
|
+
### Read-only properties & discovery
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
g.model // the bound model string, e.g. 'anthropic/claude-opus-4.8'
|
|
66
|
+
g.provider // resolved provider id, e.g. 'anthropic'
|
|
67
|
+
AIGateway.providers()
|
|
68
|
+
// => [
|
|
69
|
+
// { id: 'openrouter', prefix: 'openrouter/' },
|
|
70
|
+
// { id: 'anthropic', prefix: 'anthropic/' },
|
|
71
|
+
// { id: 'qwen', prefix: 'qwen/' },
|
|
72
|
+
// { id: 'glm', prefix: 'glm/' },
|
|
73
|
+
// { id: 'openai', prefix: 'openai/' }
|
|
74
|
+
// ]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Methods
|
|
78
|
+
|
|
79
|
+
### `await g.chat(messages, options?)`
|
|
80
|
+
### `await g.vision(messages, options?)`
|
|
81
|
+
|
|
82
|
+
`vision` is `chat` with image content blocks in `messages`. Both return the
|
|
83
|
+
[v2 response shape](#response-shape). `options` may include `temperature`,
|
|
84
|
+
`maxTokens`, and `responseFormat` (mapped to OpenAI-compatible `response_format`;
|
|
85
|
+
ignored by Anthropic).
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const g = new AIGateway('openai/gpt-4o', OPENAI_KEY);
|
|
89
|
+
const res = await g.vision([
|
|
90
|
+
{
|
|
91
|
+
role: 'user',
|
|
92
|
+
content: [
|
|
93
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
94
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
], { temperature: 0.2 });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `g.stream(messages, options?)`
|
|
101
|
+
|
|
102
|
+
Returns a `ChatStream` (not a promise). It is async-iterable — yielding
|
|
103
|
+
`{ type: 'text_delta', text }` deltas — and exposes a `.final` promise that
|
|
104
|
+
resolves to the full response once streaming completes.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const stream = g.stream([{ role: 'user', content: 'Write a haiku' }], { temperature: 0.7 });
|
|
108
|
+
|
|
109
|
+
for await (const delta of stream) {
|
|
110
|
+
process.stdout.write(delta.text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const final = await stream.final;
|
|
114
|
+
console.log('\n', final.stop_reason, final.usage);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If the upstream errors, the iterator throws and `.final` rejects with the same
|
|
118
|
+
error. You may await `.final` without iterating (deltas buffer in memory), or
|
|
119
|
+
iterate without awaiting `.final`.
|
|
120
|
+
|
|
121
|
+
## Model routing
|
|
122
|
+
|
|
123
|
+
Every model is addressed as `<provider>/<model>`. The leading provider segment
|
|
124
|
+
selects the provider and is stripped before the upstream call. There is no
|
|
125
|
+
catch-all — an unknown or missing segment throws `AIGatewayError`
|
|
126
|
+
(`code: 'unsupported_model'`).
|
|
127
|
+
|
|
128
|
+
| Model id | Provider | Upstream model | Notes |
|
|
129
|
+
|--------------------------------|--------------|----------------|-------|
|
|
130
|
+
| `anthropic/<model>` | `anthropic` | `<model>` | Anthropic SDK. |
|
|
131
|
+
| `openai/<model>` | `openai` | `<model>` | OpenAI-compatible. |
|
|
132
|
+
| `qwen/<model>` | `qwen` | `<model>` | OpenAI-compatible. Default base URL `https://dashscope-intl.aliyuncs.com/compatible-mode/v1`. |
|
|
133
|
+
| `glm/<model>` | `glm` | `<model>` | OpenAI-compatible. Default base URL `https://open.bigmodel.cn/api/paas/v4`. |
|
|
134
|
+
| `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`. |
|
|
135
|
+
|
|
136
|
+
OpenRouter is opt-in: send `openrouter/anthropic/claude-opus-4.8` to route
|
|
137
|
+
through OpenRouter, or `anthropic/claude-opus-4.8` to hit Anthropic directly.
|
|
138
|
+
|
|
139
|
+
**Adding a provider:** drop a module into [src/providers/](src/providers/)
|
|
140
|
+
exporting `{ id, prefix, matches, create }` (where `matches` tests the
|
|
141
|
+
`<id>/` segment) and register it in
|
|
142
|
+
[src/providers/registry.js](src/providers/registry.js). `create(config)` returns
|
|
143
|
+
`{ id, chat, vision, stream }`.
|
|
144
|
+
|
|
145
|
+
## Response shape
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"id": "msg_01ABCXYZ",
|
|
150
|
+
"object": "response",
|
|
151
|
+
"created": 1776675600,
|
|
152
|
+
"provider": "anthropic",
|
|
153
|
+
"model": "claude-sonnet-4-5",
|
|
154
|
+
"role": "assistant",
|
|
155
|
+
"stop_reason": "end_turn",
|
|
156
|
+
"content": [{ "type": "text", "text": "Hello! How can I help you today?" }],
|
|
157
|
+
"usage": {
|
|
158
|
+
"input_tokens": 10,
|
|
159
|
+
"output_tokens": 9,
|
|
160
|
+
"total_tokens": 19,
|
|
161
|
+
"cached_input_tokens": 6,
|
|
162
|
+
"cache_creation_input_tokens": 0,
|
|
163
|
+
"cache_read_input_tokens": 6
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Cache-related `usage` fields are optional and only present when the upstream
|
|
169
|
+
provider reports them.
|
|
170
|
+
|
|
171
|
+
## Cache hints
|
|
172
|
+
|
|
173
|
+
Request content blocks may include a provider-neutral cache hint:
|
|
174
|
+
|
|
175
|
+
- `cache: true` — prefer caching this stable block.
|
|
176
|
+
- `cache: false` or omission — no cache hint.
|
|
177
|
+
|
|
178
|
+
Mapping:
|
|
179
|
+
- **Anthropic** — `cache: true` on a text block becomes `cache_control`.
|
|
180
|
+
- **OpenAI-compatible providers** — the hint is accepted but stripped before the
|
|
181
|
+
upstream request, so caller intent is preserved without breaking compatibility.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
await g.chat([
|
|
185
|
+
{
|
|
186
|
+
role: 'system',
|
|
187
|
+
content: [
|
|
188
|
+
{ type: 'text', text: 'Core instructions', cache: true },
|
|
189
|
+
{ type: 'text', text: 'Output valid JSON', cache: true },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{ role: 'user', content: [{ type: 'text', text: 'Live request data' }] },
|
|
193
|
+
]);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## `maxTokens` behavior
|
|
197
|
+
|
|
198
|
+
`maxTokens` defaults to **unset**, which preserves each provider's native
|
|
199
|
+
behavior:
|
|
200
|
+
|
|
201
|
+
- **Anthropic** requires `max_tokens`, so it falls back to `4096` when none is
|
|
202
|
+
supplied.
|
|
203
|
+
- **OpenAI-compatible providers** do not require a cap, so none is sent — output
|
|
204
|
+
is not truncated by the library.
|
|
205
|
+
|
|
206
|
+
Supply `maxTokens` (in the constructor or per call) to apply a cap to every
|
|
207
|
+
provider. A per-call value overrides the constructor default.
|
|
208
|
+
|
|
209
|
+
## Errors
|
|
210
|
+
|
|
211
|
+
- **Input errors** (invalid model/key/messages) throw `AIGatewayError` with a
|
|
212
|
+
`code` (`invalid_model`, `unsupported_model`, `invalid_api_key`,
|
|
213
|
+
`invalid_messages`, `invalid_message`, `invalid_message_role`,
|
|
214
|
+
`invalid_message_content`).
|
|
215
|
+
- **Upstream/provider errors** propagate as the underlying SDK error — `chat` /
|
|
216
|
+
`vision` reject; `stream` throws on the iterator and rejects `.final`.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
import AIGateway, { AIGatewayError } from '@pkent/aigateway';
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await g.chat(messages);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err instanceof AIGatewayError) {
|
|
225
|
+
console.error('Bad request:', err.code, err.message);
|
|
226
|
+
} else {
|
|
227
|
+
console.error('Provider error:', err);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Testing
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
npm test
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Tests run fully offline via injected fake clients (`options.client`). They cover
|
|
239
|
+
provider resolution, constructor validation, the v2 response shape for chat /
|
|
240
|
+
vision, streaming deltas + `.final`, error propagation, the OpenRouter prefix
|
|
241
|
+
strip, cache-hint mapping, and `maxTokens` behavior.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pkent/aigateway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A provider-neutral LLM client library for OpenAI, Anthropic, Qwen, GLM, and OpenRouter",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"prepublishOnly": "node --test test/aigateway.test.js test/cache-hints.test.js test/usage-normalization.test.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/pkent/aigateway.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/pkent/aigateway/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/pkent/aigateway#readme",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"llm",
|
|
32
|
+
"openai",
|
|
33
|
+
"anthropic",
|
|
34
|
+
"openrouter",
|
|
35
|
+
"qwen",
|
|
36
|
+
"glm",
|
|
37
|
+
"gateway",
|
|
38
|
+
"ai"
|
|
39
|
+
],
|
|
40
|
+
"author": "pkent",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@anthropic-ai/sdk": "^0.104.2",
|
|
44
|
+
"openai": "^6.34.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/AIGateway.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolveProviderEntry, listProviderEntries } from './providers/registry.js';
|
|
2
|
+
import { AIGatewayError } from './errors.js';
|
|
3
|
+
import { validateMessages } from './validation.js';
|
|
4
|
+
import { ChatStream } from './stream/ChatStream.js';
|
|
5
|
+
import { buildV2Response } from './lib/v2.js';
|
|
6
|
+
|
|
7
|
+
// A provider-neutral LLM client bound to one model + one API key. The model
|
|
8
|
+
// string selects the provider via prefix matching; the key is that provider's
|
|
9
|
+
// credential. No environment variables are read — all config is injected.
|
|
10
|
+
export class AIGateway {
|
|
11
|
+
#model;
|
|
12
|
+
#providerId;
|
|
13
|
+
#provider;
|
|
14
|
+
#defaults;
|
|
15
|
+
|
|
16
|
+
constructor(model, key, options = {}) {
|
|
17
|
+
if (typeof model !== 'string' || model.trim() === '') {
|
|
18
|
+
throw new AIGatewayError('Missing or invalid "model"', { code: 'invalid_model' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const entry = resolveProviderEntry(model);
|
|
22
|
+
if (!entry) {
|
|
23
|
+
const known = listProviderEntries().map(p => p.id).join(', ');
|
|
24
|
+
throw new AIGatewayError(
|
|
25
|
+
`Unsupported model "${model}". Use "<provider>/<model>" (e.g. "anthropic/claude-opus-4.8"). Known providers: ${known}.`,
|
|
26
|
+
{ code: 'unsupported_model' },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { baseURL, maxTokens, referer, title, client } = options;
|
|
31
|
+
|
|
32
|
+
if (!client && (typeof key !== 'string' || key.trim() === '')) {
|
|
33
|
+
throw new AIGatewayError('Missing or invalid API key', { code: 'invalid_api_key' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.#model = model;
|
|
37
|
+
this.#providerId = entry.id;
|
|
38
|
+
this.#provider = entry.create({ apiKey: key, baseURL, referer, title, client });
|
|
39
|
+
this.#defaults = { maxTokens };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get model() {
|
|
43
|
+
return this.#model;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get provider() {
|
|
47
|
+
return this.#providerId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Discovery helper: the registered providers and their model prefixes.
|
|
51
|
+
static providers() {
|
|
52
|
+
return listProviderEntries();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#mergeOptions(callOptions = {}) {
|
|
56
|
+
return {
|
|
57
|
+
maxTokens: callOptions.maxTokens ?? this.#defaults.maxTokens,
|
|
58
|
+
temperature: callOptions.temperature,
|
|
59
|
+
responseFormat: callOptions.responseFormat,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async chat(messages, callOptions = {}) {
|
|
64
|
+
validateMessages(messages);
|
|
65
|
+
return this.#provider.chat(this.#model, messages, this.#mergeOptions(callOptions));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async vision(messages, callOptions = {}) {
|
|
69
|
+
validateMessages(messages);
|
|
70
|
+
return this.#provider.vision(this.#model, messages, this.#mergeOptions(callOptions));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stream(messages, callOptions = {}) {
|
|
74
|
+
validateMessages(messages);
|
|
75
|
+
const options = this.#mergeOptions(callOptions);
|
|
76
|
+
const model = this.#model;
|
|
77
|
+
const providerId = this.#providerId;
|
|
78
|
+
const provider = this.#provider;
|
|
79
|
+
|
|
80
|
+
return new ChatStream(async (emit) => {
|
|
81
|
+
// Providers hand us raw text strings; wrap each into a delta object.
|
|
82
|
+
const emitDelta = (text) => emit({ type: 'text_delta', text });
|
|
83
|
+
const final = await provider.stream(model, messages, options, { emitDelta });
|
|
84
|
+
return buildV2Response({
|
|
85
|
+
id: final.id,
|
|
86
|
+
created: final.created,
|
|
87
|
+
provider: providerId,
|
|
88
|
+
model,
|
|
89
|
+
stopReason: final.stopReason,
|
|
90
|
+
content: final.content,
|
|
91
|
+
usage: final.usage,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default AIGateway;
|
package/src/errors.js
ADDED
package/src/index.js
ADDED
package/src/lib/v2.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export function buildUsage({
|
|
2
|
+
inputTokens = 0,
|
|
3
|
+
outputTokens = 0,
|
|
4
|
+
cachedInputTokens,
|
|
5
|
+
cacheCreationInputTokens,
|
|
6
|
+
cacheReadInputTokens,
|
|
7
|
+
} = {}) {
|
|
8
|
+
return {
|
|
9
|
+
input_tokens: inputTokens,
|
|
10
|
+
output_tokens: outputTokens,
|
|
11
|
+
total_tokens: inputTokens + outputTokens,
|
|
12
|
+
...(cachedInputTokens !== undefined && { cached_input_tokens: cachedInputTokens }),
|
|
13
|
+
...(cacheCreationInputTokens !== undefined && { cache_creation_input_tokens: cacheCreationInputTokens }),
|
|
14
|
+
...(cacheReadInputTokens !== undefined && { cache_read_input_tokens: cacheReadInputTokens }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function extractOpenAIUsage(usage = {}) {
|
|
19
|
+
return buildUsage({
|
|
20
|
+
inputTokens: usage.prompt_tokens ?? 0,
|
|
21
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
22
|
+
cachedInputTokens: usage.prompt_tokens_details?.cached_tokens,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractAnthropicUsage(usage = {}) {
|
|
27
|
+
return buildUsage({
|
|
28
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
29
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
30
|
+
cachedInputTokens: usage.cache_read_input_tokens,
|
|
31
|
+
cacheCreationInputTokens: usage.cache_creation_input_tokens,
|
|
32
|
+
cacheReadInputTokens: usage.cache_read_input_tokens,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeContentBlocks(provider, content) {
|
|
37
|
+
if (provider === 'anthropic') {
|
|
38
|
+
return (content || []).map(block => {
|
|
39
|
+
if (block.type === 'text') {
|
|
40
|
+
return { type: 'text', text: block.text };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (block.type === 'tool_use') {
|
|
44
|
+
return {
|
|
45
|
+
type: 'tool_use',
|
|
46
|
+
id: block.id,
|
|
47
|
+
name: block.name,
|
|
48
|
+
input: block.input,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (block.type === 'image') {
|
|
53
|
+
return {
|
|
54
|
+
type: 'image',
|
|
55
|
+
source: block.source,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return block;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof content === 'string') {
|
|
64
|
+
return [{ type: 'text', text: content }];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(content)) {
|
|
68
|
+
return content.map(block => {
|
|
69
|
+
if (block.type === 'text') {
|
|
70
|
+
return { type: 'text', text: block.text ?? '' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (block.type === 'image_url') {
|
|
74
|
+
return {
|
|
75
|
+
type: 'image',
|
|
76
|
+
source: {
|
|
77
|
+
type: 'image_url',
|
|
78
|
+
url: block.image_url?.url,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (block.type === 'tool_call') {
|
|
84
|
+
return {
|
|
85
|
+
type: 'tool_use',
|
|
86
|
+
id: block.id,
|
|
87
|
+
name: block.function?.name,
|
|
88
|
+
input: block.function?.arguments,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return block;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildV2Response({ id, created, provider, model, role = 'assistant', stopReason, content, usage }) {
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
object: 'response',
|
|
103
|
+
created,
|
|
104
|
+
provider,
|
|
105
|
+
model,
|
|
106
|
+
role,
|
|
107
|
+
stop_reason: stopReason,
|
|
108
|
+
content,
|
|
109
|
+
usage,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeOpenAIResponse(completion) {
|
|
114
|
+
const choice = completion.choices[0];
|
|
115
|
+
return buildV2Response({
|
|
116
|
+
id: completion.id,
|
|
117
|
+
created: completion.created,
|
|
118
|
+
provider: 'openai',
|
|
119
|
+
model: completion.model,
|
|
120
|
+
stopReason: choice.finish_reason,
|
|
121
|
+
content: normalizeContentBlocks('openai', choice.message.content),
|
|
122
|
+
usage: extractOpenAIUsage(completion.usage),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeAnthropicResponse(response) {
|
|
127
|
+
return buildV2Response({
|
|
128
|
+
id: response.id,
|
|
129
|
+
created: Math.floor(Date.now() / 1000),
|
|
130
|
+
provider: 'anthropic',
|
|
131
|
+
model: response.model,
|
|
132
|
+
stopReason: response.stop_reason,
|
|
133
|
+
content: normalizeContentBlocks('anthropic', response.content),
|
|
134
|
+
usage: extractAnthropicUsage(response.usage),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { convertMessagesForAnthropic } from './messageTransforms.js';
|
|
3
|
+
import { stripProviderPrefix } from './modelId.js';
|
|
4
|
+
import {
|
|
5
|
+
buildV2Response,
|
|
6
|
+
extractAnthropicUsage,
|
|
7
|
+
normalizeContentBlocks,
|
|
8
|
+
} from '../lib/v2.js';
|
|
9
|
+
|
|
10
|
+
function buildRequest(model, messages, options = {}) {
|
|
11
|
+
// The Anthropic SDK requires max_tokens, so fall back to 4096 when the caller
|
|
12
|
+
// does not supply one (OpenAI-compatible providers omit the cap instead).
|
|
13
|
+
const { maxTokens = 4096, temperature } = options;
|
|
14
|
+
const { system, messages: converted } = convertMessagesForAnthropic(messages);
|
|
15
|
+
return {
|
|
16
|
+
model: stripProviderPrefix(model),
|
|
17
|
+
max_tokens: maxTokens,
|
|
18
|
+
messages: converted,
|
|
19
|
+
...(system && { system }),
|
|
20
|
+
...(temperature != null && { temperature }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalize(response, originalModel) {
|
|
25
|
+
return buildV2Response({
|
|
26
|
+
id: response.id,
|
|
27
|
+
created: Math.floor(Date.now() / 1000),
|
|
28
|
+
provider: 'anthropic',
|
|
29
|
+
model: originalModel,
|
|
30
|
+
stopReason: response.stop_reason,
|
|
31
|
+
content: normalizeContentBlocks('anthropic', response.content),
|
|
32
|
+
usage: extractAnthropicUsage(response.usage),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createProvider({ apiKey, baseURL, client }) {
|
|
37
|
+
const anthropic = client || new Anthropic({ apiKey, ...(baseURL && { baseURL }) });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
id: 'anthropic',
|
|
41
|
+
|
|
42
|
+
async chat(model, messages, options = {}) {
|
|
43
|
+
return normalize(await anthropic.messages.create(buildRequest(model, messages, options)), model);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async vision(model, messages, options = {}) {
|
|
47
|
+
return normalize(await anthropic.messages.create(buildRequest(model, messages, options)), model);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async stream(model, messages, options = {}, { emitDelta }) {
|
|
51
|
+
const upstream = await anthropic.messages.create({ ...buildRequest(model, messages, options), stream: true });
|
|
52
|
+
|
|
53
|
+
let streamId = `anthropic_${Date.now()}`;
|
|
54
|
+
const created = Math.floor(Date.now() / 1000);
|
|
55
|
+
let outputText = '';
|
|
56
|
+
let stopReason = null;
|
|
57
|
+
let usage = extractAnthropicUsage();
|
|
58
|
+
|
|
59
|
+
for await (const event of upstream) {
|
|
60
|
+
if (event.type === 'message_start') {
|
|
61
|
+
streamId = event.message?.id || streamId;
|
|
62
|
+
if (event.message?.usage) {
|
|
63
|
+
usage = extractAnthropicUsage({
|
|
64
|
+
...event.message.usage,
|
|
65
|
+
output_tokens: usage.output_tokens,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (event.type === 'content_block_delta') {
|
|
72
|
+
const text = event.delta?.text || '';
|
|
73
|
+
if (!text) continue;
|
|
74
|
+
outputText += text;
|
|
75
|
+
emitDelta(text);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (event.type === 'message_delta') {
|
|
80
|
+
if (event.usage) {
|
|
81
|
+
usage = extractAnthropicUsage({
|
|
82
|
+
input_tokens: usage.input_tokens,
|
|
83
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
|
84
|
+
cache_read_input_tokens: usage.cache_read_input_tokens,
|
|
85
|
+
output_tokens: event.usage.output_tokens,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (event.type === 'message_stop') break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id: streamId,
|
|
97
|
+
created,
|
|
98
|
+
stopReason: stopReason || 'end_turn',
|
|
99
|
+
content: [{ type: 'text', text: outputText }],
|
|
100
|
+
usage,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default {
|
|
107
|
+
id: 'anthropic',
|
|
108
|
+
prefix: 'anthropic/',
|
|
109
|
+
matches: model => typeof model === 'string' && model.startsWith('anthropic/'),
|
|
110
|
+
create: createProvider,
|
|
111
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createOpenAICompatibleProvider } from './openaiCompatible.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4';
|
|
4
|
+
|
|
5
|
+
// Models addressed as "glm/<model>", e.g. "glm/glm-4".
|
|
6
|
+
export default {
|
|
7
|
+
id: 'glm',
|
|
8
|
+
prefix: 'glm/',
|
|
9
|
+
matches: model => typeof model === 'string' && model.startsWith('glm/'),
|
|
10
|
+
create({ apiKey, baseURL, client }) {
|
|
11
|
+
return createOpenAICompatibleProvider({
|
|
12
|
+
id: 'glm',
|
|
13
|
+
apiKey,
|
|
14
|
+
baseURL: baseURL || DEFAULT_BASE_URL,
|
|
15
|
+
client,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function stripCacheHintsFromContentBlock(block) {
|
|
2
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
3
|
+
return block;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { cache, ...rest } = block;
|
|
7
|
+
return rest;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function stripCacheHintsFromMessages(messages = []) {
|
|
11
|
+
return messages.map(message => {
|
|
12
|
+
if (!Array.isArray(message.content)) {
|
|
13
|
+
return message;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...message,
|
|
18
|
+
content: message.content.map(stripCacheHintsFromContentBlock),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toAnthropicTextBlock(block) {
|
|
24
|
+
const textBlock = {
|
|
25
|
+
type: 'text',
|
|
26
|
+
text: block.text,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (block.cache === true) {
|
|
30
|
+
textBlock.cache_control = { type: 'ephemeral' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return textBlock;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function convertMessagesForAnthropic(messages = []) {
|
|
37
|
+
const systemBlocks = [];
|
|
38
|
+
const convertedMessages = [];
|
|
39
|
+
|
|
40
|
+
for (const msg of messages) {
|
|
41
|
+
if (msg.role === 'system') {
|
|
42
|
+
if (typeof msg.content === 'string') {
|
|
43
|
+
systemBlocks.push({ type: 'text', text: msg.content });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const block of msg.content) {
|
|
48
|
+
if (block.type === 'text') {
|
|
49
|
+
systemBlocks.push(toAnthropicTextBlock(block));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof msg.content === 'string') {
|
|
56
|
+
convertedMessages.push({ role: msg.role, content: msg.content });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const convertedContent = msg.content.map(block => {
|
|
61
|
+
if (block.type === 'text') {
|
|
62
|
+
return toAnthropicTextBlock(block);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (block.type === 'image_url') {
|
|
66
|
+
const url = block.image_url?.url || '';
|
|
67
|
+
|
|
68
|
+
if (url.startsWith('data:')) {
|
|
69
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/s);
|
|
70
|
+
if (!match) {
|
|
71
|
+
throw new Error('Invalid base64 image data URL');
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
type: 'image',
|
|
75
|
+
source: {
|
|
76
|
+
type: 'base64',
|
|
77
|
+
media_type: match[1],
|
|
78
|
+
data: match[2],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
type: 'image',
|
|
85
|
+
source: {
|
|
86
|
+
type: 'url',
|
|
87
|
+
url,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return stripCacheHintsFromContentBlock(block);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
convertedMessages.push({ role: msg.role, content: convertedContent });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
system: systemBlocks.length > 0 ? systemBlocks : undefined,
|
|
100
|
+
messages: convertedMessages,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Models are addressed as "<provider>/<model>". The leading provider segment
|
|
2
|
+
// selects the provider and is stripped before the upstream request. For
|
|
3
|
+
// OpenRouter this leaves its native "<vendor>/<model>" id intact, e.g.
|
|
4
|
+
// "openrouter/anthropic/claude-opus-4.8" -> "anthropic/claude-opus-4.8".
|
|
5
|
+
export function stripProviderPrefix(model) {
|
|
6
|
+
const slash = model.indexOf('/');
|
|
7
|
+
return slash === -1 ? model : model.slice(slash + 1);
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createOpenAICompatibleProvider } from './openaiCompatible.js';
|
|
2
|
+
|
|
3
|
+
// Models addressed as "openai/<model>", e.g. "openai/gpt-4o".
|
|
4
|
+
export default {
|
|
5
|
+
id: 'openai',
|
|
6
|
+
prefix: 'openai/',
|
|
7
|
+
matches: model => typeof model === 'string' && model.startsWith('openai/'),
|
|
8
|
+
create({ apiKey, baseURL, client }) {
|
|
9
|
+
return createOpenAICompatibleProvider({ id: 'openai', apiKey, baseURL, client });
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { stripCacheHintsFromMessages } from './messageTransforms.js';
|
|
3
|
+
import { stripProviderPrefix } from './modelId.js';
|
|
4
|
+
import {
|
|
5
|
+
buildV2Response,
|
|
6
|
+
extractOpenAIUsage,
|
|
7
|
+
normalizeContentBlocks,
|
|
8
|
+
} from '../lib/v2.js';
|
|
9
|
+
|
|
10
|
+
// Shared implementation for every OpenAI-compatible provider (OpenAI, Qwen,
|
|
11
|
+
// GLM, OpenRouter). Builds an OpenAI SDK client from the injected config, or
|
|
12
|
+
// uses a pre-built `client` (testing / advanced wiring). The "<provider>/"
|
|
13
|
+
// segment is stripped from the model before the upstream call.
|
|
14
|
+
export function createOpenAICompatibleProvider({
|
|
15
|
+
id,
|
|
16
|
+
apiKey,
|
|
17
|
+
baseURL,
|
|
18
|
+
defaultHeaders,
|
|
19
|
+
client,
|
|
20
|
+
}) {
|
|
21
|
+
const openai = client || new OpenAI({
|
|
22
|
+
apiKey,
|
|
23
|
+
...(baseURL && { baseURL }),
|
|
24
|
+
...(defaultHeaders && { defaultHeaders }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function buildRequest(model, messages, options, { stream = false } = {}) {
|
|
28
|
+
const { maxTokens, temperature, responseFormat } = options;
|
|
29
|
+
return {
|
|
30
|
+
model: stripProviderPrefix(model),
|
|
31
|
+
messages: stripCacheHintsFromMessages(messages),
|
|
32
|
+
...(maxTokens != null && { max_completion_tokens: maxTokens }),
|
|
33
|
+
...(temperature != null && { temperature }),
|
|
34
|
+
...(responseFormat && { response_format: responseFormat }),
|
|
35
|
+
...(stream && { stream: true, stream_options: { include_usage: true } }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalize(completion, originalModel) {
|
|
40
|
+
const choice = completion.choices[0];
|
|
41
|
+
return buildV2Response({
|
|
42
|
+
id: completion.id,
|
|
43
|
+
created: completion.created,
|
|
44
|
+
provider: id,
|
|
45
|
+
model: originalModel,
|
|
46
|
+
stopReason: choice.finish_reason,
|
|
47
|
+
content: normalizeContentBlocks('openai', choice.message.content),
|
|
48
|
+
usage: extractOpenAIUsage(completion.usage),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
|
|
55
|
+
async chat(model, messages, options = {}) {
|
|
56
|
+
return normalize(await openai.chat.completions.create(buildRequest(model, messages, options)), model);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async vision(model, messages, options = {}) {
|
|
60
|
+
return normalize(await openai.chat.completions.create(buildRequest(model, messages, options)), model);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async stream(model, messages, options = {}, { emitDelta }) {
|
|
64
|
+
const upstream = await openai.chat.completions.create(buildRequest(model, messages, options, { stream: true }));
|
|
65
|
+
|
|
66
|
+
let streamId = `${id}_${Date.now()}`;
|
|
67
|
+
let created = Math.floor(Date.now() / 1000);
|
|
68
|
+
let outputText = '';
|
|
69
|
+
let finishReason = null;
|
|
70
|
+
let usage = extractOpenAIUsage();
|
|
71
|
+
|
|
72
|
+
for await (const chunk of upstream) {
|
|
73
|
+
if (chunk.id) streamId = chunk.id;
|
|
74
|
+
if (chunk.created) created = chunk.created;
|
|
75
|
+
if (chunk.usage) usage = extractOpenAIUsage(chunk.usage);
|
|
76
|
+
|
|
77
|
+
for (const choice of chunk.choices || []) {
|
|
78
|
+
const text = choice.delta?.content || '';
|
|
79
|
+
if (text) {
|
|
80
|
+
outputText += text;
|
|
81
|
+
emitDelta(text);
|
|
82
|
+
}
|
|
83
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: streamId,
|
|
89
|
+
created,
|
|
90
|
+
stopReason: finishReason || 'stop',
|
|
91
|
+
content: [{ type: 'text', text: outputText }],
|
|
92
|
+
usage,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createOpenAICompatibleProvider } from './openaiCompatible.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
4
|
+
|
|
5
|
+
// Meta-provider addressed as "openrouter/<vendor>/<model>", e.g.
|
|
6
|
+
// "openrouter/anthropic/claude-opus-4.8". The leading "openrouter/" segment is
|
|
7
|
+
// stripped before the upstream call, leaving OpenRouter's native "<vendor>/<model>"
|
|
8
|
+
// id. `referer`/`title` are optional OpenRouter attribution headers, sent only
|
|
9
|
+
// when provided.
|
|
10
|
+
export default {
|
|
11
|
+
id: 'openrouter',
|
|
12
|
+
prefix: 'openrouter/',
|
|
13
|
+
matches: model => typeof model === 'string' && model.startsWith('openrouter/'),
|
|
14
|
+
create({ apiKey, baseURL, referer, title, client }) {
|
|
15
|
+
return createOpenAICompatibleProvider({
|
|
16
|
+
id: 'openrouter',
|
|
17
|
+
apiKey,
|
|
18
|
+
baseURL: baseURL || DEFAULT_BASE_URL,
|
|
19
|
+
defaultHeaders: {
|
|
20
|
+
...(referer && { 'HTTP-Referer': referer }),
|
|
21
|
+
...(title && { 'X-Title': title }),
|
|
22
|
+
},
|
|
23
|
+
client,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createOpenAICompatibleProvider } from './openaiCompatible.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
|
|
4
|
+
|
|
5
|
+
// Models addressed as "qwen/<model>", e.g. "qwen/qwen-turbo".
|
|
6
|
+
export default {
|
|
7
|
+
id: 'qwen',
|
|
8
|
+
prefix: 'qwen/',
|
|
9
|
+
matches: model => typeof model === 'string' && model.startsWith('qwen/'),
|
|
10
|
+
create({ apiKey, baseURL, client }) {
|
|
11
|
+
return createOpenAICompatibleProvider({
|
|
12
|
+
id: 'qwen',
|
|
13
|
+
apiKey,
|
|
14
|
+
baseURL: baseURL || DEFAULT_BASE_URL,
|
|
15
|
+
client,
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import openrouter from './openrouter.js';
|
|
2
|
+
import anthropic from './anthropic.js';
|
|
3
|
+
import qwen from './qwen.js';
|
|
4
|
+
import glm from './glm.js';
|
|
5
|
+
import openai from './openai.js';
|
|
6
|
+
|
|
7
|
+
// Every model is addressed as "<provider>/<model>". The provider segments are
|
|
8
|
+
// disjoint, so resolution order does not matter; there is no catch-all.
|
|
9
|
+
const ENTRIES = [openrouter, anthropic, qwen, glm, openai];
|
|
10
|
+
|
|
11
|
+
export function resolveProviderEntry(model) {
|
|
12
|
+
for (const entry of ENTRIES) {
|
|
13
|
+
if (entry.matches(model)) return entry;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function listProviderEntries() {
|
|
19
|
+
return ENTRIES.map(({ id, prefix }) => ({ id, prefix }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Bridges the providers' push-based streaming (an `emitDelta` callback plus a
|
|
2
|
+
// returned final aggregate) to a pull-based async iterator, while also exposing
|
|
3
|
+
// a `.final` promise that resolves to the full v2 response envelope.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const stream = gateway.stream(messages);
|
|
7
|
+
// for await (const delta of stream) process.stdout.write(delta.text);
|
|
8
|
+
// const final = await stream.final;
|
|
9
|
+
//
|
|
10
|
+
// Each yielded delta is an object: { type: 'text_delta', text: string }.
|
|
11
|
+
export class ChatStream {
|
|
12
|
+
#queue = []; // buffered deltas not yet pulled
|
|
13
|
+
#waiters = []; // resolvers for pending next() calls
|
|
14
|
+
#done = false;
|
|
15
|
+
#error = null;
|
|
16
|
+
|
|
17
|
+
constructor(run) {
|
|
18
|
+
// run(emit) performs the upstream stream and resolves the final envelope.
|
|
19
|
+
// `emit` receives a delta OBJECT ({ type:'text_delta', text }) — the caller
|
|
20
|
+
// (AIGateway.stream) is responsible for wrapping provider raw strings.
|
|
21
|
+
this.final = run((delta) => this.#emit(delta)).then(
|
|
22
|
+
(finalResponse) => { this.#finish(null); return finalResponse; },
|
|
23
|
+
(err) => { this.#finish(err); throw err; },
|
|
24
|
+
);
|
|
25
|
+
// Prevent an unhandled rejection if the caller iterates but never awaits .final.
|
|
26
|
+
this.final.catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#emit(delta) {
|
|
30
|
+
if (this.#waiters.length) this.#waiters.shift()({ value: delta, done: false });
|
|
31
|
+
else this.#queue.push(delta);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#finish(err) {
|
|
35
|
+
this.#error = err;
|
|
36
|
+
this.#done = true;
|
|
37
|
+
while (this.#waiters.length) {
|
|
38
|
+
const resolve = this.#waiters.shift();
|
|
39
|
+
if (err) resolve(Promise.reject(err));
|
|
40
|
+
else resolve({ value: undefined, done: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
[Symbol.asyncIterator]() {
|
|
45
|
+
return {
|
|
46
|
+
next: () => {
|
|
47
|
+
if (this.#queue.length) return Promise.resolve({ value: this.#queue.shift(), done: false });
|
|
48
|
+
if (this.#error) return Promise.reject(this.#error);
|
|
49
|
+
if (this.#done) return Promise.resolve({ value: undefined, done: true });
|
|
50
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AIGatewayError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
const ALLOWED_ROLES = new Set(['user', 'assistant', 'system', 'tool']);
|
|
4
|
+
|
|
5
|
+
// Validates the provider-neutral message array. The model is bound at
|
|
6
|
+
// construction, so only messages are validated here. Throws AIGatewayError.
|
|
7
|
+
export function validateMessages(messages) {
|
|
8
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
9
|
+
throw new AIGatewayError('Missing or invalid "messages" field', { code: 'invalid_messages' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
for (const msg of messages) {
|
|
13
|
+
if (!msg || !msg.role) {
|
|
14
|
+
throw new AIGatewayError('Each message must have a "role" field', { code: 'invalid_message' });
|
|
15
|
+
}
|
|
16
|
+
if (!ALLOWED_ROLES.has(msg.role)) {
|
|
17
|
+
throw new AIGatewayError(
|
|
18
|
+
'Message role must be "user", "assistant", "system", or "tool"',
|
|
19
|
+
{ code: 'invalid_message_role' },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (msg.content === undefined || msg.content === null) {
|
|
23
|
+
throw new AIGatewayError('Each message must have a "content" field', { code: 'invalid_message' });
|
|
24
|
+
}
|
|
25
|
+
if (typeof msg.content !== 'string' && !Array.isArray(msg.content)) {
|
|
26
|
+
throw new AIGatewayError(
|
|
27
|
+
'"content" must be a string or an array of content blocks',
|
|
28
|
+
{ code: 'invalid_message_content' },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|