@namzu/openrouter 2.0.1 → 2.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/CHANGELOG.md +81 -0
- package/README.md +272 -40
- package/dist/client.d.ts +39 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -0
- package/dist/client.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 70f23bb: A driver can now say how large a model's context window is, and the kernel
|
|
8
|
+
ranks that above its hand-maintained table.
|
|
9
|
+
|
|
10
|
+
That table was the only source below an explicit host config, and its own
|
|
11
|
+
header records what it cost: every Claude entry carried 200k including the
|
|
12
|
+
1M-window models, so those runs compacted at roughly 14% full and threw
|
|
13
|
+
away the prompt-cache prefix to do it. Every model release drifts it again
|
|
14
|
+
until somebody edits it — while the OpenRouter driver was already parsing
|
|
15
|
+
the vendor's real `context_length` and discarding it, because there was no
|
|
16
|
+
member to return it through.
|
|
17
|
+
|
|
18
|
+
`LLMProvider.resolveContextWindow?(model, signal)` is three-state like
|
|
19
|
+
`effortLevelsFor`: absent means this driver cannot answer, a resolved
|
|
20
|
+
`undefined` means it asked and does not know, a number is the answer. A
|
|
21
|
+
driver resolving `undefined` falls through to the TABLE, not to the
|
|
22
|
+
assumed default — asking must never be worse than not asking.
|
|
23
|
+
|
|
24
|
+
Resolved once per run, at the door. Both consumers are synchronous and in
|
|
25
|
+
the hot loop, so this can never become an await inside it. A driver that
|
|
26
|
+
rejects or hangs does not fail the run: the window is an optimisation over
|
|
27
|
+
a working default.
|
|
28
|
+
|
|
29
|
+
`ResolvedContextWindow['source']` and the `windowSource` on
|
|
30
|
+
`token_usage_updated` gain `'provider'`, ranked between `'config'` and
|
|
31
|
+
`'model-table'`, so a host can see which route a number came from.
|
|
32
|
+
|
|
33
|
+
Also fixes a hole this exposed: `withProviderRetry` and
|
|
34
|
+
`withProviderFallback` forwarded `listModels`, `healthCheck` and
|
|
35
|
+
`doctorCheck` but not `effortLevelsFor`. A dropped optional member does not
|
|
36
|
+
fail — it reads as "this driver cannot answer" — and retry is on by
|
|
37
|
+
default, so a driver's declared effort levels were invisible on
|
|
38
|
+
essentially every run.
|
|
39
|
+
|
|
40
|
+
## 2.1.0
|
|
41
|
+
|
|
42
|
+
### Minor Changes
|
|
43
|
+
|
|
44
|
+
- d3bd080: A wrong API key is no longer reported as working
|
|
45
|
+
|
|
46
|
+
Typing a key into the picker ran a check that could not fail for two providers.
|
|
47
|
+
Measured against deliberately invalid keys, both said the key was good.
|
|
48
|
+
|
|
49
|
+
**With an OpenRouter key, any string at all passed.** A typo, the wrong
|
|
50
|
+
clipboard entry, a revoked key — all were accepted and reported as verified. The
|
|
51
|
+
check listed the model catalogue and treated a successful list as a passed
|
|
52
|
+
check, and OpenRouter's catalogue endpoint does not authenticate, so it answered
|
|
53
|
+
the same way whatever was sent. Nothing was wrong with that driver's listing; a
|
|
54
|
+
catalogue was simply never evidence about a key.
|
|
55
|
+
|
|
56
|
+
**With an Anthropic key, a real rejection was discarded.** The listing caught
|
|
57
|
+
the `401` and returned a hardcoded three-model list, which the check read as
|
|
58
|
+
success — so the truth existed, was thrown away, and was replaced by something
|
|
59
|
+
that looked like an answer.
|
|
60
|
+
|
|
61
|
+
A credential check is now a separate, declared capability. A driver that
|
|
62
|
+
declares no probe is reported as **not checked**, never as verified, so a driver
|
|
63
|
+
added in future cannot silently inherit a check it does not perform. Anthropic,
|
|
64
|
+
OpenRouter, OpenAI and Ollama declare one; OpenRouter's asks about the key
|
|
65
|
+
rather than the catalogue.
|
|
66
|
+
|
|
67
|
+
Refusal and doubt stay distinct. A `401` means the key is genuinely refused; a
|
|
68
|
+
timeout or a DNS failure means nothing was learned, and is reported that way —
|
|
69
|
+
telling someone on a broken connection to rotate a working key is a different
|
|
70
|
+
error, not a smaller one.
|
|
71
|
+
|
|
72
|
+
**Anthropic's model listing also never once ran.** The SDK method was pulled out
|
|
73
|
+
of its namespace and called bare, so it lost `this`, threw a `TypeError` on
|
|
74
|
+
every call, and was swallowed by the same catch — the hardcoded models were not
|
|
75
|
+
a fallback but the only answer the method could give. It now calls the live
|
|
76
|
+
endpoint, and falls back only when that genuinely fails.
|
|
77
|
+
|
|
78
|
+
The four driver packages are `minor` rather than `patch`: each gains a method
|
|
79
|
+
it did not have, and added functionality is a minor whatever the size of the
|
|
80
|
+
diff. Anthropic's earns it twice over, because its listing now returns the live
|
|
81
|
+
catalogue where it previously returned the same three hardcoded entries to every
|
|
82
|
+
caller - so the value every existing caller receives changes.
|
|
83
|
+
|
|
3
84
|
## 2.0.1
|
|
4
85
|
|
|
5
86
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- okf
|
|
2
|
+
type: Reference
|
|
3
|
+
title: "@namzu/openrouter"
|
|
4
|
+
description: >-
|
|
5
|
+
The OpenRouter driver for @namzu/sdk. Speaks OpenRouter's OpenAI-compatible
|
|
6
|
+
Chat Completions wire over native fetch with no runtime dependencies, maps
|
|
7
|
+
tool use, and answers the kernel's context-window question from the vendor's
|
|
8
|
+
own catalogue rather than from a hand-kept table.
|
|
9
|
+
tags: [readme, package, provider, openrouter]
|
|
10
|
+
timestamp: 2026-08-17T00:00:00Z
|
|
11
|
+
status: active
|
|
12
|
+
diataxis: reference
|
|
13
|
+
-->
|
|
2
14
|
|
|
3
|
-
|
|
15
|
+
<div align="center">
|
|
16
|
+
|
|
17
|
+
<h1>@namzu/openrouter</h1>
|
|
18
|
+
|
|
19
|
+
**The OpenRouter driver for [`@namzu/sdk`](https://www.npmjs.com/package/@namzu/sdk).**
|
|
20
|
+
|
|
21
|
+
[](https://github.com/cogitave/namzu/blob/main/LICENSE.md)
|
|
22
|
+
[](https://www.npmjs.com/package/@namzu/openrouter)
|
|
23
|
+
|
|
24
|
+
[Install](#install) · [Use it](#use-it) · [Models](#models-and-the-window) · [Credentials](#credentials-and-attribution) · [Config](#configuration) · [Capabilities](#capabilities) · [Health](#health) · [Errors](#errors)
|
|
25
|
+
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## What this is
|
|
31
|
+
|
|
32
|
+
One driver, one wire. `OpenRouterProvider` implements the kernel's
|
|
33
|
+
`LLMProvider` contract against OpenRouter's OpenAI-compatible Chat Completions
|
|
34
|
+
endpoint, and it sends exactly one request: `POST /chat/completions` with
|
|
35
|
+
`stream: true`. There is no non-streaming path, because the contract has no
|
|
36
|
+
non-streaming method — `chatStream` is the single model entry point, and a
|
|
37
|
+
caller who wants the whole answer collects the stream.
|
|
38
|
+
|
|
39
|
+
The transport is native `fetch` and nothing else. This package declares no
|
|
40
|
+
runtime dependencies at all: no vendor SDK, no HTTP client, no JSON tooling.
|
|
41
|
+
`@namzu/sdk` is a peer dependency (`>=1.3.0`), so your lockfile owns the kernel
|
|
42
|
+
version rather than this package.
|
|
43
|
+
|
|
44
|
+
What it buys over a driver bound to one vendor is the aggregation: OpenRouter
|
|
45
|
+
fronts hundreds of models from a dozen vendors behind one key and one bill, and
|
|
46
|
+
switching between them is a change to the `model` string rather than to which
|
|
47
|
+
package is installed. That also makes it the driver where a static model table
|
|
48
|
+
goes stale fastest, which is why nothing about the catalogue is hardcoded here
|
|
49
|
+
— see [Models and the window](#models-and-the-window).
|
|
4
50
|
|
|
5
51
|
## Install
|
|
6
52
|
|
|
@@ -8,63 +54,174 @@ OpenRouter LLM provider for [`@namzu/sdk`](https://www.npmjs.com/package/@namzu/
|
|
|
8
54
|
pnpm add @namzu/sdk @namzu/openrouter
|
|
9
55
|
```
|
|
10
56
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
## Usage
|
|
57
|
+
## Use it
|
|
14
58
|
|
|
15
59
|
```ts
|
|
16
60
|
import { ProviderRegistry } from '@namzu/sdk'
|
|
17
61
|
import { registerOpenRouter } from '@namzu/openrouter'
|
|
18
62
|
|
|
19
|
-
//
|
|
20
|
-
registerOpenRouter()
|
|
63
|
+
registerOpenRouter() // once, at startup
|
|
21
64
|
|
|
22
|
-
|
|
23
|
-
const { provider, capabilities } = ProviderRegistry.create({
|
|
65
|
+
const { provider } = ProviderRegistry.create({
|
|
24
66
|
type: 'openrouter',
|
|
25
|
-
apiKey: process.env.OPENROUTER_API_KEY
|
|
26
|
-
siteUrl: 'https://
|
|
27
|
-
siteName: '
|
|
67
|
+
apiKey: process.env.OPENROUTER_API_KEY ?? '',
|
|
68
|
+
siteUrl: 'https://example.com', // optional
|
|
69
|
+
siteName: 'Example', // optional
|
|
28
70
|
})
|
|
29
71
|
|
|
30
|
-
const
|
|
31
|
-
model: 'anthropic/claude-
|
|
72
|
+
for await (const chunk of provider.chatStream({
|
|
73
|
+
model: 'anthropic/claude-opus-5',
|
|
32
74
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
75
|
+
})) {
|
|
76
|
+
if (chunk.delta.content) process.stdout.write(chunk.delta.content)
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`registerOpenRouter()` also carries the module augmentation that adds
|
|
81
|
+
`'openrouter'` to the kernel's config union, so `ProviderRegistry.create({ type:
|
|
82
|
+
'openrouter', … })` narrows to `OpenRouterProviderConfig` and a typo in the
|
|
83
|
+
config is a compile error. Call it twice and it throws `DuplicateProviderError`;
|
|
84
|
+
pass `{ replace: true }` when you mean to take the slot over.
|
|
85
|
+
|
|
86
|
+
`create()` hands back the driver's capability declaration alongside it, which is
|
|
87
|
+
the same object as [`OPENROUTER_CAPABILITIES`](#capabilities):
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const { provider, capabilities } = ProviderRegistry.create({
|
|
91
|
+
type: 'openrouter',
|
|
92
|
+
apiKey: process.env.OPENROUTER_API_KEY ?? '',
|
|
33
93
|
})
|
|
34
94
|
```
|
|
35
95
|
|
|
36
|
-
|
|
96
|
+
When you want the aggregated response rather than the deltas, collect the same
|
|
97
|
+
stream:
|
|
37
98
|
|
|
38
99
|
```ts
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
100
|
+
import { collectChatCompletion } from '@namzu/sdk'
|
|
101
|
+
|
|
102
|
+
const response = await collectChatCompletion(
|
|
103
|
+
provider.chatStream({
|
|
104
|
+
model: 'anthropic/claude-opus-5',
|
|
105
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
console.log(response.message.content)
|
|
110
|
+
console.log(response.usage.totalTokens)
|
|
45
111
|
```
|
|
46
112
|
|
|
47
|
-
|
|
113
|
+
The terminal agent constructs this driver for you: `@namzu/cli` lists
|
|
114
|
+
`openrouter` among the provider types it can build, reads `OPENROUTER_API_KEY`
|
|
115
|
+
from the environment, and defaults to `https://openrouter.ai/api/v1` and the
|
|
116
|
+
model `anthropic/claude-opus-5`.
|
|
48
117
|
|
|
49
|
-
|
|
118
|
+
`listModels`, `resolveContextWindow`, `probeCredential` and `healthCheck` are
|
|
119
|
+
**optional** members of `LLMProvider`, so a caller holding the interface has to
|
|
120
|
+
guard them. The snippets below hold the class instead, where they are ordinary
|
|
121
|
+
methods:
|
|
50
122
|
|
|
51
|
-
|
|
123
|
+
```ts
|
|
124
|
+
import { OpenRouterProvider } from '@namzu/openrouter'
|
|
52
125
|
|
|
53
|
-
|
|
126
|
+
const driver = new OpenRouterProvider({
|
|
127
|
+
apiKey: process.env.OPENROUTER_API_KEY ?? '',
|
|
128
|
+
})
|
|
129
|
+
```
|
|
54
130
|
|
|
55
|
-
|
|
56
|
-
- `anthropic/claude-haiku-4`
|
|
57
|
-
- `openai/gpt-4o`
|
|
58
|
-
- `openai/gpt-4o-mini`
|
|
59
|
-
- `meta-llama/llama-3.3-70b-instruct`
|
|
60
|
-
- `mistralai/mixtral-8x22b-instruct`
|
|
61
|
-
- `google/gemini-pro-1.5`
|
|
131
|
+
## Models and the window
|
|
62
132
|
|
|
63
|
-
|
|
133
|
+
Model ids follow OpenRouter's `vendor/model-name` pattern —
|
|
134
|
+
`anthropic/claude-opus-5` — and the authority on which ones exist today is the
|
|
135
|
+
vendor, not this package. There is no hardcoded catalogue here to go stale.
|
|
64
136
|
|
|
65
|
-
|
|
137
|
+
```ts
|
|
138
|
+
await driver.listModels()
|
|
139
|
+
// id, name, contextWindow, maxOutputTokens, inputPrice, outputPrice,
|
|
140
|
+
// supportsToolUse, supportsStreaming
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`listModels()` returns exactly what `GET /models` sent, with no fallback list
|
|
144
|
+
behind it: `contextWindow` is the catalogue's `context_length`,
|
|
145
|
+
`maxOutputTokens` is `top_provider.max_completion_tokens` where the vendor
|
|
146
|
+
states one and `4096` where it does not, and `inputPrice` / `outputPrice` are
|
|
147
|
+
the catalogue's per-token prices scaled to per-million. If the listing call
|
|
148
|
+
fails, it throws rather than substituting a menu.
|
|
149
|
+
|
|
150
|
+
`resolveContextWindow(model)` answers the kernel's own question — how large is
|
|
151
|
+
this model's context — from that same listing. It matters because the fallback
|
|
152
|
+
below it is a hand-maintained prefix table, and this is the driver a hand-kept
|
|
153
|
+
table is least able to keep up with. The kernel's precedence is: a window you
|
|
154
|
+
configured, then the driver's answer, then the table, then the default.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
await driver.resolveContextWindow('anthropic/claude-opus-5')
|
|
158
|
+
// the vendor's own context_length, or undefined
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`undefined` for a model the listing does not contain, deliberately, rather than
|
|
162
|
+
a guess: "I asked and it is not there" leaves the table exactly as authoritative
|
|
163
|
+
as it was, while a substituted number would present a guess as a vendor answer.
|
|
164
|
+
The listing is resolved once per process, because several hundred models is a
|
|
165
|
+
real payload and a window does not change under a running run. A **failure** is
|
|
166
|
+
not cached — the next run asks again rather than inheriting one bad minute
|
|
167
|
+
forever.
|
|
168
|
+
|
|
169
|
+
## Credentials and attribution
|
|
170
|
+
|
|
171
|
+
`apiKey` is required and the constructor throws when it is empty. This driver
|
|
172
|
+
does not read the key from the environment itself — pass it, from wherever your
|
|
173
|
+
host keeps secrets. (`@namzu/cli` is the thing in this repository that reads
|
|
174
|
+
`OPENROUTER_API_KEY`; the library leaves that choice to you.)
|
|
175
|
+
|
|
176
|
+
`siteUrl` and `siteName` are optional and are sent verbatim as the
|
|
177
|
+
`HTTP-Referer` and `X-Title` headers, which is how OpenRouter attributes traffic
|
|
178
|
+
to an application. Omit them and the headers are not sent at all.
|
|
179
|
+
|
|
180
|
+
Every request also carries one `User-Agent` from the kernel's
|
|
181
|
+
`attributionHeaders()` — `namzu/<version> (+https://github.com/cogitave/namzu)`
|
|
182
|
+
— so a provider reading its own logs can tell this kernel's traffic apart from a
|
|
183
|
+
browser's. It is one header and only one, by design.
|
|
184
|
+
|
|
185
|
+
`probeCredential()` asks whether the key works, and it asks the endpoint that
|
|
186
|
+
can answer:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
await driver.probeCredential() // resolves if the key is accepted, throws if not
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
It calls `GET /key`, which requires the credential. The obvious alternative was
|
|
193
|
+
measured and does not work: `/models` does not authenticate, so **any** string
|
|
194
|
+
whatsoever — a typo, the wrong clipboard entry, a revoked key — came back with
|
|
195
|
+
the full catalogue and was reported as verified. Nothing was wrong with the
|
|
196
|
+
menu; a menu was simply never evidence about a key. The thrown error carries the
|
|
197
|
+
HTTP `status`, so a caller can separate a genuine `401` from a timeout that
|
|
198
|
+
taught it nothing.
|
|
66
199
|
|
|
67
|
-
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
| Option | Default | Notes |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `apiKey` | — | required; the constructor throws on an empty string |
|
|
205
|
+
| `baseUrl` | `OPENROUTER_BASE_URL`, else `https://openrouter.ai/api/v1` | any OpenAI-compatible endpoint |
|
|
206
|
+
| `siteUrl` | — | sent as `HTTP-Referer` |
|
|
207
|
+
| `siteName` | — | sent as `X-Title` |
|
|
208
|
+
| `timeout` | `120_000` | per-request timeout in ms |
|
|
209
|
+
|
|
210
|
+
That is the whole of `OpenRouterConfig`. `OpenRouterProviderConfig` is the same
|
|
211
|
+
shape plus the `type: 'openrouter'` discriminator the registry narrows on.
|
|
212
|
+
|
|
213
|
+
Two details about the defaults are worth knowing before you debug them. The
|
|
214
|
+
`OPENROUTER_BASE_URL` fallback is read **once, at module load**, into a module
|
|
215
|
+
constant — setting it after this package has been imported changes nothing, so
|
|
216
|
+
set it before the process starts or pass `baseUrl` explicitly. And `timeout`
|
|
217
|
+
does not replace the caller's cancellation: it is composed with
|
|
218
|
+
`params.signal`, so whichever fires first tears the request down and a Stop
|
|
219
|
+
still stops the turn mid-stream.
|
|
220
|
+
|
|
221
|
+
Because the wire is the OpenAI Chat Completions dialect, pointing `baseUrl` at
|
|
222
|
+
another server that speaks it works. If that is the whole reason you are here —
|
|
223
|
+
a self-hosted inference server rather than OpenRouter — `@namzu/http` is the
|
|
224
|
+
driver built for it and ships today.
|
|
68
225
|
|
|
69
226
|
## Capabilities
|
|
70
227
|
|
|
@@ -75,17 +232,92 @@ import { OPENROUTER_CAPABILITIES } from '@namzu/openrouter'
|
|
|
75
232
|
// supportsTools: true,
|
|
76
233
|
// supportsStreaming: true,
|
|
77
234
|
// supportsFunctionCalling: true,
|
|
235
|
+
// supportsVision: false,
|
|
236
|
+
// supportsDocuments: false,
|
|
78
237
|
// }
|
|
79
238
|
```
|
|
80
239
|
|
|
81
|
-
|
|
240
|
+
These describe what this **driver** does, not what OpenRouter could do, and the
|
|
241
|
+
runtime reads them before the request is built — it warns, or fails under
|
|
242
|
+
`strictCapabilities`, rather than letting content vanish quietly. So the two
|
|
243
|
+
`false` entries are load-bearing: this driver's message translation maps role
|
|
244
|
+
and content, plus the tool-call fields on assistant and tool turns, and nothing
|
|
245
|
+
else. A user message's image or document `attachments` are not mapped and never
|
|
246
|
+
reach the model.
|
|
247
|
+
|
|
248
|
+
Tool schemas pass through as the kernel built them, along with `toolChoice` and
|
|
249
|
+
`parallelToolCalls`. `enforceToolInputSchema` is a kernel-internal hint rather
|
|
250
|
+
than a wire field, and it is deliberately dropped instead of being serialised
|
|
251
|
+
into the request body.
|
|
252
|
+
|
|
253
|
+
The rest of `ChatCompletionParams` maps straight onto the OpenAI-dialect names:
|
|
254
|
+
`temperature`, `maxTokens` → `max_tokens`, `topP`, `topK`, `frequencyPenalty`,
|
|
255
|
+
`presencePenalty`, `repetitionPenalty`, `stop`, `responseFormat` →
|
|
256
|
+
`response_format`, and `cacheControl` → `cache_control`. A field you do not set
|
|
257
|
+
is not sent.
|
|
258
|
+
|
|
259
|
+
Token usage comes back on the chunk that carries it, and the cache counts are
|
|
260
|
+
read from whichever shape the upstream model used: `cachedTokens` from
|
|
261
|
+
`prompt_tokens_details.cached_tokens` or `cache_read_input_tokens`, and
|
|
262
|
+
`cacheWriteTokens` from `cache_creation_input_tokens`. Both are `0` when the
|
|
263
|
+
vendor reports neither, which is the honest answer rather than an absent one.
|
|
264
|
+
|
|
265
|
+
Extended thinking and `effort` are not implemented here, and they are
|
|
266
|
+
**refused** rather than dropped — setting `thinking: { type: 'enabled' }`,
|
|
267
|
+
`thinking: { type: 'adaptive' }` or any `effort` throws before the request is
|
|
268
|
+
built. `thinking: { type: 'disabled' }` passes, because asking for nothing is
|
|
269
|
+
something this driver can honour. A silently ignored `effort` would return an
|
|
270
|
+
ordinary completion indistinguishable from the model's default, including on the
|
|
271
|
+
bill.
|
|
272
|
+
|
|
273
|
+
## Health
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
await driver.healthCheck() // boolean
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
One `GET /models` with a five-second timeout, reduced to a single bit, and it
|
|
280
|
+
never throws — an unreachable service returns `false` rather than raising. Read
|
|
281
|
+
it as "is this endpoint answering", not as "is my key good": that endpoint does
|
|
282
|
+
not authenticate. [`probeCredential()`](#credentials-and-attribution) is the one
|
|
283
|
+
that answers the credential question.
|
|
284
|
+
|
|
285
|
+
## Errors
|
|
286
|
+
|
|
287
|
+
Every failure this driver raises is a `ProviderRequestError` from `@namzu/sdk`,
|
|
288
|
+
classified into the kernel's own kinds — `throttle`, `network`, `auth`,
|
|
289
|
+
`context_overflow`, `bad_request`, `server` — so a caller can decide whether to
|
|
290
|
+
retry, compact or give up without parsing a vendor payload.
|
|
291
|
+
|
|
292
|
+
| What happened | `kind` |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `401` / `403` | `auth` |
|
|
295
|
+
| `429` | `throttle`, with `retryAfterMs` parsed from the `retry-after` header |
|
|
296
|
+
| `5xx` | `server` |
|
|
297
|
+
| `4xx` whose body says the prompt is too long | `context_overflow` |
|
|
298
|
+
| any other `4xx` | `bad_request` |
|
|
299
|
+
| `fetch` itself rejected | `network`, unless the rejection names its own kind |
|
|
82
300
|
|
|
83
|
-
|
|
301
|
+
The error body is read to classify and is never interpolated into the thrown
|
|
302
|
+
message. That is not hygiene, it is a fix: the body used to be pasted into the
|
|
303
|
+
message verbatim, which is how a credential the upstream echoed back reached
|
|
304
|
+
every log that recorded the failure — proven with a planted fake token. What
|
|
305
|
+
survives is the vendor's own `error.message`, truncated and with
|
|
306
|
+
credential-shaped substrings replaced by a `[REDACTED:…]` marker, on the error's
|
|
307
|
+
`detail` field. The vendor error is not attached as `cause` either.
|
|
84
308
|
|
|
85
|
-
|
|
309
|
+
OpenRouter also reports some failures **after** a `200`, as an `error` field
|
|
310
|
+
inside a stream frame. Those are read and raised too, because ignoring them
|
|
311
|
+
makes a failed stream look like a clean end of turn. A frame that will not parse
|
|
312
|
+
raises as `server` without keeping the frame, since a JSON parse error quotes
|
|
313
|
+
the source text it choked on.
|
|
86
314
|
|
|
87
|
-
|
|
315
|
+
`params.signal` is composed with the request timeout and re-checked between
|
|
316
|
+
reads, so a Stop tears the in-flight response body down mid-turn, and it is the
|
|
317
|
+
caller's own abort reason that surfaces — not a `network` classification
|
|
318
|
+
manufactured from it.
|
|
88
319
|
|
|
89
320
|
## License
|
|
90
321
|
|
|
91
|
-
FSL-1.1-MIT. Same as
|
|
322
|
+
FSL-1.1-MIT, converting to MIT two years after each release. Same as
|
|
323
|
+
`@namzu/sdk`.
|
package/dist/client.d.ts
CHANGED
|
@@ -18,6 +18,45 @@ export declare class OpenRouterProvider implements LLMProvider {
|
|
|
18
18
|
private formatMessages;
|
|
19
19
|
private buildRequestBody;
|
|
20
20
|
chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk>;
|
|
21
|
+
/**
|
|
22
|
+
* Ask about the KEY, not about the catalogue.
|
|
23
|
+
*
|
|
24
|
+
* `listModels` here is already honest — it has no fallback and returns
|
|
25
|
+
* exactly what the server sent. It is still useless as a credential check,
|
|
26
|
+
* because `/models` does not authenticate: any string whatsoever, including
|
|
27
|
+
* a typo or a revoked key, came back with the full catalogue and was
|
|
28
|
+
* reported as verified. Nothing was wrong with the menu; the menu was simply
|
|
29
|
+
* never evidence about the key.
|
|
30
|
+
*
|
|
31
|
+
* `/key` is the endpoint that answers the question actually being asked. It
|
|
32
|
+
* requires the credential and returns its metadata, so a 401 here means the
|
|
33
|
+
* key is genuinely refused.
|
|
34
|
+
*/
|
|
35
|
+
probeCredential(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* The vendor's own `context_length` for this model.
|
|
38
|
+
*
|
|
39
|
+
* This driver already parsed the number and threw it away: `listModels`
|
|
40
|
+
* maps it into `contextWindow` and nothing downstream ever asked. The
|
|
41
|
+
* kernel meanwhile fell back to a hand-maintained prefix table whose own
|
|
42
|
+
* header records what that costs — every Claude entry carried 200k
|
|
43
|
+
* including the 1M-window models, so those runs compacted at roughly 14%
|
|
44
|
+
* full. OpenRouter fronts hundreds of models from a dozen vendors, so it
|
|
45
|
+
* is the driver where a static table drifts fastest.
|
|
46
|
+
*
|
|
47
|
+
* `undefined` for a model the listing does not contain, rather than a
|
|
48
|
+
* guess: "I asked and it is not there" leaves the table exactly as
|
|
49
|
+
* authoritative as it was, while a substituted number would present a
|
|
50
|
+
* guess as a vendor answer.
|
|
51
|
+
*
|
|
52
|
+
* Cached for the process, because a listing of several hundred models is
|
|
53
|
+
* a real payload and a model's window does not change under a running
|
|
54
|
+
* run. A failure is NOT cached — the next run asks again rather than
|
|
55
|
+
* inheriting one bad minute forever.
|
|
56
|
+
*/
|
|
57
|
+
resolveContextWindow(model: string, signal?: AbortSignal): Promise<number | undefined>;
|
|
58
|
+
/** Resolved once per process; see the note on `resolveContextWindow`. */
|
|
59
|
+
private contextWindows?;
|
|
21
60
|
listModels(): Promise<ModelInfo[]>;
|
|
22
61
|
healthCheck(): Promise<boolean>;
|
|
23
62
|
}
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,oBAAoB,EACpB,WAAW,EACX,SAAS,EACT,oBAAoB,EACpB,WAAW,EAGX,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,oBAAoB,EACpB,WAAW,EACX,SAAS,EACT,oBAAoB,EACpB,WAAW,EAGX,MAAM,YAAY,CAAA;AAUnB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAwClD;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,oBAOrC,CAAA;AAED,qBAAa,kBAAmB,YAAW,WAAW;IACrD,QAAQ,CAAC,EAAE,gBAAe;IAC1B,QAAQ,CAAC,IAAI,gBAAe;IAC5B,QAAQ,CAAC,YAAY,uBAA0B;IAE/C,OAAO,CAAC,MAAM,CAAkB;IAChC,OAAO,CAAC,OAAO,CAAQ;gBAEX,MAAM,EAAE,gBAAgB;IAQpC,OAAO,CAAC,UAAU;IAkBlB,OAAO,CAAC,cAAc;IAwBtB,OAAO,CAAC,gBAAgB;IAqCjB,UAAU,CAAC,MAAM,EAAE,oBAAoB,GAAG,aAAa,CAAC,WAAW,CAAC;IAmI3E;;;;;;;;;;;;;OAaG;IACG,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAWtC;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IA0B5F,yEAAyE;IACzE,OAAO,CAAC,cAAc,CAAC,CAA8B;IAE/C,UAAU,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;IA+BlC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;CAWrC"}
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProviderRequestError, assertThinkingUnsupported, isCallerAbortError, isProviderRequestError, providerHttpError, providerVendorError, } from '@namzu/sdk';
|
|
2
|
+
import { attributionHeaders } from '@namzu/sdk';
|
|
2
3
|
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1';
|
|
3
4
|
function parseUsage(raw) {
|
|
4
5
|
if (!raw) {
|
|
@@ -52,6 +53,10 @@ export class OpenRouterProvider {
|
|
|
52
53
|
}
|
|
53
54
|
getHeaders() {
|
|
54
55
|
const headers = {
|
|
56
|
+
// First, so anything a host sets below still wins. Attribution is
|
|
57
|
+
// what this kernel says about itself; a host overriding it has a
|
|
58
|
+
// reason and is not to be argued with.
|
|
59
|
+
...attributionHeaders(),
|
|
55
60
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
56
61
|
'Content-Type': 'application/json',
|
|
57
62
|
};
|
|
@@ -239,6 +244,71 @@ export class OpenRouterProvider {
|
|
|
239
244
|
reader.releaseLock();
|
|
240
245
|
}
|
|
241
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Ask about the KEY, not about the catalogue.
|
|
249
|
+
*
|
|
250
|
+
* `listModels` here is already honest — it has no fallback and returns
|
|
251
|
+
* exactly what the server sent. It is still useless as a credential check,
|
|
252
|
+
* because `/models` does not authenticate: any string whatsoever, including
|
|
253
|
+
* a typo or a revoked key, came back with the full catalogue and was
|
|
254
|
+
* reported as verified. Nothing was wrong with the menu; the menu was simply
|
|
255
|
+
* never evidence about the key.
|
|
256
|
+
*
|
|
257
|
+
* `/key` is the endpoint that answers the question actually being asked. It
|
|
258
|
+
* requires the credential and returns its metadata, so a 401 here means the
|
|
259
|
+
* key is genuinely refused.
|
|
260
|
+
*/
|
|
261
|
+
async probeCredential() {
|
|
262
|
+
const response = await fetch(`${this.baseUrl}/key`, { headers: this.getHeaders() });
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
const err = new Error(`Credential check failed: ${response.status}`);
|
|
265
|
+
err.status = response.status;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The vendor's own `context_length` for this model.
|
|
271
|
+
*
|
|
272
|
+
* This driver already parsed the number and threw it away: `listModels`
|
|
273
|
+
* maps it into `contextWindow` and nothing downstream ever asked. The
|
|
274
|
+
* kernel meanwhile fell back to a hand-maintained prefix table whose own
|
|
275
|
+
* header records what that costs — every Claude entry carried 200k
|
|
276
|
+
* including the 1M-window models, so those runs compacted at roughly 14%
|
|
277
|
+
* full. OpenRouter fronts hundreds of models from a dozen vendors, so it
|
|
278
|
+
* is the driver where a static table drifts fastest.
|
|
279
|
+
*
|
|
280
|
+
* `undefined` for a model the listing does not contain, rather than a
|
|
281
|
+
* guess: "I asked and it is not there" leaves the table exactly as
|
|
282
|
+
* authoritative as it was, while a substituted number would present a
|
|
283
|
+
* guess as a vendor answer.
|
|
284
|
+
*
|
|
285
|
+
* Cached for the process, because a listing of several hundred models is
|
|
286
|
+
* a real payload and a model's window does not change under a running
|
|
287
|
+
* run. A failure is NOT cached — the next run asks again rather than
|
|
288
|
+
* inheriting one bad minute forever.
|
|
289
|
+
*/
|
|
290
|
+
async resolveContextWindow(model, signal) {
|
|
291
|
+
const pending = this.contextWindows ??
|
|
292
|
+
(async () => {
|
|
293
|
+
const models = await this.listModels();
|
|
294
|
+
return new Map(models
|
|
295
|
+
.filter((m) => typeof m.contextWindow === 'number')
|
|
296
|
+
.map((m) => [m.id, m.contextWindow]));
|
|
297
|
+
})().catch((err) => {
|
|
298
|
+
// Not cached. A listing endpoint that was down for a minute must
|
|
299
|
+
// not leave every later run answering from that minute.
|
|
300
|
+
this.contextWindows = undefined;
|
|
301
|
+
throw err;
|
|
302
|
+
});
|
|
303
|
+
this.contextWindows = pending;
|
|
304
|
+
const windows = await pending;
|
|
305
|
+
if (signal?.aborted)
|
|
306
|
+
return undefined;
|
|
307
|
+
const reported = windows.get(model);
|
|
308
|
+
return typeof reported === 'number' && reported > 0 ? reported : undefined;
|
|
309
|
+
}
|
|
310
|
+
/** Resolved once per process; see the note on `resolveContextWindow`. */
|
|
311
|
+
contextWindows;
|
|
242
312
|
async listModels() {
|
|
243
313
|
const response = await fetch(`${this.baseUrl}/models`, {
|
|
244
314
|
headers: this.getHeaders(),
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AASA,OAAO,EACN,oBAAoB,EACpB,yBAAyB,EACzB,kBAAkB,EAClB,sBAAsB,EACtB,iBAAiB,EACjB,mBAAmB,GACnB,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namzu/openrouter",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "OpenRouter LLM provider for @namzu/sdk. Access to 200+ models (Claude, GPT-4, Llama, Mixtral) via OpenRouter's unified API.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@types/node": "^22.19.17",
|
|
53
53
|
"typescript": "^5.5.0",
|
|
54
54
|
"vitest": "^3.2.6",
|
|
55
|
-
"@namzu/sdk": "^
|
|
55
|
+
"@namzu/sdk": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "tsc --build",
|
package/src/client.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
providerHttpError,
|
|
16
16
|
providerVendorError,
|
|
17
17
|
} from '@namzu/sdk'
|
|
18
|
+
import { attributionHeaders } from '@namzu/sdk'
|
|
18
19
|
import type { OpenRouterConfig } from './types.js'
|
|
19
20
|
|
|
20
21
|
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1'
|
|
@@ -88,6 +89,10 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
88
89
|
|
|
89
90
|
private getHeaders(): Record<string, string> {
|
|
90
91
|
const headers: Record<string, string> = {
|
|
92
|
+
// First, so anything a host sets below still wins. Attribution is
|
|
93
|
+
// what this kernel says about itself; a host overriding it has a
|
|
94
|
+
// reason and is not to be argued with.
|
|
95
|
+
...attributionHeaders(),
|
|
91
96
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
92
97
|
'Content-Type': 'application/json',
|
|
93
98
|
}
|
|
@@ -292,6 +297,81 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
292
297
|
}
|
|
293
298
|
}
|
|
294
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Ask about the KEY, not about the catalogue.
|
|
302
|
+
*
|
|
303
|
+
* `listModels` here is already honest — it has no fallback and returns
|
|
304
|
+
* exactly what the server sent. It is still useless as a credential check,
|
|
305
|
+
* because `/models` does not authenticate: any string whatsoever, including
|
|
306
|
+
* a typo or a revoked key, came back with the full catalogue and was
|
|
307
|
+
* reported as verified. Nothing was wrong with the menu; the menu was simply
|
|
308
|
+
* never evidence about the key.
|
|
309
|
+
*
|
|
310
|
+
* `/key` is the endpoint that answers the question actually being asked. It
|
|
311
|
+
* requires the credential and returns its metadata, so a 401 here means the
|
|
312
|
+
* key is genuinely refused.
|
|
313
|
+
*/
|
|
314
|
+
async probeCredential(): Promise<void> {
|
|
315
|
+
const response = await fetch(`${this.baseUrl}/key`, { headers: this.getHeaders() })
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const err = new Error(`Credential check failed: ${response.status}`) as Error & {
|
|
318
|
+
status?: number
|
|
319
|
+
}
|
|
320
|
+
err.status = response.status
|
|
321
|
+
throw err
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The vendor's own `context_length` for this model.
|
|
327
|
+
*
|
|
328
|
+
* This driver already parsed the number and threw it away: `listModels`
|
|
329
|
+
* maps it into `contextWindow` and nothing downstream ever asked. The
|
|
330
|
+
* kernel meanwhile fell back to a hand-maintained prefix table whose own
|
|
331
|
+
* header records what that costs — every Claude entry carried 200k
|
|
332
|
+
* including the 1M-window models, so those runs compacted at roughly 14%
|
|
333
|
+
* full. OpenRouter fronts hundreds of models from a dozen vendors, so it
|
|
334
|
+
* is the driver where a static table drifts fastest.
|
|
335
|
+
*
|
|
336
|
+
* `undefined` for a model the listing does not contain, rather than a
|
|
337
|
+
* guess: "I asked and it is not there" leaves the table exactly as
|
|
338
|
+
* authoritative as it was, while a substituted number would present a
|
|
339
|
+
* guess as a vendor answer.
|
|
340
|
+
*
|
|
341
|
+
* Cached for the process, because a listing of several hundred models is
|
|
342
|
+
* a real payload and a model's window does not change under a running
|
|
343
|
+
* run. A failure is NOT cached — the next run asks again rather than
|
|
344
|
+
* inheriting one bad minute forever.
|
|
345
|
+
*/
|
|
346
|
+
async resolveContextWindow(model: string, signal?: AbortSignal): Promise<number | undefined> {
|
|
347
|
+
const pending =
|
|
348
|
+
this.contextWindows ??
|
|
349
|
+
(async () => {
|
|
350
|
+
const models = await this.listModels()
|
|
351
|
+
return new Map<string, number>(
|
|
352
|
+
models
|
|
353
|
+
.filter(
|
|
354
|
+
(m): m is typeof m & { contextWindow: number } => typeof m.contextWindow === 'number',
|
|
355
|
+
)
|
|
356
|
+
.map((m) => [m.id, m.contextWindow]),
|
|
357
|
+
)
|
|
358
|
+
})().catch((err: unknown) => {
|
|
359
|
+
// Not cached. A listing endpoint that was down for a minute must
|
|
360
|
+
// not leave every later run answering from that minute.
|
|
361
|
+
this.contextWindows = undefined
|
|
362
|
+
throw err
|
|
363
|
+
})
|
|
364
|
+
this.contextWindows = pending
|
|
365
|
+
|
|
366
|
+
const windows = await pending
|
|
367
|
+
if (signal?.aborted) return undefined
|
|
368
|
+
const reported = windows.get(model)
|
|
369
|
+
return typeof reported === 'number' && reported > 0 ? reported : undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Resolved once per process; see the note on `resolveContextWindow`. */
|
|
373
|
+
private contextWindows?: Promise<Map<string, number>>
|
|
374
|
+
|
|
295
375
|
async listModels(): Promise<ModelInfo[]> {
|
|
296
376
|
const response = await fetch(`${this.baseUrl}/models`, {
|
|
297
377
|
headers: this.getHeaders(),
|