@sprqvntrs/llm 3.13.1 → 3.14.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 +61 -0
- package/index.ts +6 -0
- package/package.json +1 -1
- package/src/clients/openrouter-client.ts +17 -1
- package/src/llm.ts +8 -0
- package/src/utils/openrouter-base-url.ts +71 -0
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ const client = LLM.getClient(provider, model, options?);
|
|
|
95
95
|
debug?: boolean; // Enable debug logging
|
|
96
96
|
useReasoningMode?: boolean; // Enable reasoning endpoints (OpenAI only)
|
|
97
97
|
openaiApiKey?: string; // OpenAI key for Anthropic formatting
|
|
98
|
+
baseUrl?: string; // OpenRouter endpoint (OpenRouter only), see "OpenRouter endpoints"
|
|
98
99
|
}
|
|
99
100
|
```
|
|
100
101
|
|
|
@@ -247,6 +248,66 @@ try {
|
|
|
247
248
|
}
|
|
248
249
|
```
|
|
249
250
|
|
|
251
|
+
## OpenRouter endpoints
|
|
252
|
+
|
|
253
|
+
The OpenRouter client can talk to two hosts:
|
|
254
|
+
|
|
255
|
+
| Host | Base URL | Use |
|
|
256
|
+
|------|----------|-----|
|
|
257
|
+
| Global (default) | `https://openrouter.ai/api/v1` | All models |
|
|
258
|
+
| EU in-region | `https://eu.openrouter.ai/api/v1` | OpenRouter Business. Fails closed and routes only to EU endpoints. |
|
|
259
|
+
|
|
260
|
+
Pick the host with the `baseUrl` option or the `OPENROUTER_BASE_URL` environment variable.
|
|
261
|
+
The order is: the `baseUrl` option, then `OPENROUTER_BASE_URL`, then the global default.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { LLM, OpenRouterClient, EU_OPENROUTER_BASE_URL } from '@sprqvntrs/llm';
|
|
265
|
+
|
|
266
|
+
// Through the factory (or set OPENROUTER_BASE_URL=https://eu.openrouter.ai/api/v1)
|
|
267
|
+
const client = LLM.getClient('openrouter', 'openai/gpt-4o-mini', {
|
|
268
|
+
baseUrl: EU_OPENROUTER_BASE_URL,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Or directly. The resolved value is exposed read-only, for logging.
|
|
272
|
+
const direct = new OpenRouterClient({ apiKey, model: 'openai/gpt-4o-mini', baseUrl: EU_OPENROUTER_BASE_URL });
|
|
273
|
+
direct.baseUrl; // 'https://eu.openrouter.ai/api/v1'
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The OpenAI and Anthropic clients ignore `baseUrl`.
|
|
277
|
+
|
|
278
|
+
### Validation
|
|
279
|
+
|
|
280
|
+
The value fails closed. The client throws at construction, and the message names the offending
|
|
281
|
+
value (never the API key), when the value is not all of these:
|
|
282
|
+
|
|
283
|
+
- a URL with the `https:` protocol
|
|
284
|
+
- a host that is exactly `openrouter.ai` or ends with `.openrouter.ai`
|
|
285
|
+
- free of credentials, a port, a query and a fragment
|
|
286
|
+
- on the path `/api/v1` (one trailing slash is accepted and stripped)
|
|
287
|
+
|
|
288
|
+
An empty string is also rejected. It never falls back to the global host.
|
|
289
|
+
|
|
290
|
+
Apps can check their configuration at boot with the same validator:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { resolveOpenRouterBaseUrl } from '@sprqvntrs/llm';
|
|
294
|
+
|
|
295
|
+
// Throws on a bad value. Returns the normalised URL otherwise.
|
|
296
|
+
const baseUrl = resolveOpenRouterBaseUrl(config.openrouterBaseUrl); // falls back to process.env, then the default
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Signature: `resolveOpenRouterBaseUrl(explicit?: string, env?: NodeJS.ProcessEnv): string`.
|
|
300
|
+
|
|
301
|
+
### What changes on the EU host
|
|
302
|
+
|
|
303
|
+
- **Fewer models.** The EU host lacks some models. For example there is no Gemini 3.7 or 3.8 Flash,
|
|
304
|
+
and no preview models. `openai/gpt-5.4-mini` has zero data retention (ZDR) endpoints only on Azure US.
|
|
305
|
+
- **Price.** The EU host carries about a 10 percent surcharge.
|
|
306
|
+
- **Missing model means HTTP 404.** A model with no endpoint on the chosen host fails with
|
|
307
|
+
HTTP 404 and the message "no endpoints available". Retrying never helps. Treat it as a
|
|
308
|
+
**configuration error** (wrong model for this host), not as a retryable error. Check the model
|
|
309
|
+
list for the host before you switch.
|
|
310
|
+
|
|
250
311
|
## Helper Functions
|
|
251
312
|
|
|
252
313
|
### isContentInLanguage()
|
package/index.ts
CHANGED
|
@@ -49,5 +49,11 @@ export {
|
|
|
49
49
|
} from './src/helpers';
|
|
50
50
|
|
|
51
51
|
// Response normalization utilities
|
|
52
|
+
export {
|
|
53
|
+
resolveOpenRouterBaseUrl,
|
|
54
|
+
DEFAULT_OPENROUTER_BASE_URL,
|
|
55
|
+
EU_OPENROUTER_BASE_URL,
|
|
56
|
+
OPENROUTER_BASE_URL_ENV,
|
|
57
|
+
} from './src/utils/openrouter-base-url';
|
|
52
58
|
export { stripJsonArtifacts } from './src/utils/strip-json-artifacts';
|
|
53
59
|
export type { SanitizationResult } from './src/utils/strip-json-artifacts';
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type LlmErrorContext,
|
|
20
20
|
} from '../utils/errors';
|
|
21
21
|
import { normalizeNullStrings } from '../utils/normalize-null-strings';
|
|
22
|
+
import { resolveOpenRouterBaseUrl } from '../utils/openrouter-base-url';
|
|
22
23
|
import { resolveRefs } from '../utils/resolve-refs';
|
|
23
24
|
import { stripJsonArtifacts } from '../utils/strip-json-artifacts';
|
|
24
25
|
|
|
@@ -76,6 +77,16 @@ export interface OpenRouterClientConfig extends Omit<BaseLlmClientConfig, 'model
|
|
|
76
77
|
* This parameter is kept for backward compatibility but has no effect.
|
|
77
78
|
*/
|
|
78
79
|
openaiApiKey?: string;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* OpenRouter endpoint. Resolution order: this option, then the `OPENROUTER_BASE_URL`
|
|
83
|
+
* environment variable, then `https://openrouter.ai/api/v1`.
|
|
84
|
+
*
|
|
85
|
+
* Must be `https://openrouter.ai/api/v1` or `https://<sub>.openrouter.ai/api/v1`
|
|
86
|
+
* (for example the EU host `https://eu.openrouter.ai/api/v1`). Anything else throws.
|
|
87
|
+
* See `resolveOpenRouterBaseUrl`.
|
|
88
|
+
*/
|
|
89
|
+
baseUrl?: string;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
/**
|
|
@@ -101,6 +112,9 @@ export class OpenRouterClient implements LlmClientInterface {
|
|
|
101
112
|
private retryConfig: OpenRouterRetryConfig;
|
|
102
113
|
private _lastUsage: LlmTokenUsage | null = null;
|
|
103
114
|
|
|
115
|
+
/** The validated endpoint this client talks to, without a trailing slash. Read-only. */
|
|
116
|
+
readonly baseUrl: string;
|
|
117
|
+
|
|
104
118
|
get lastUsage(): LlmTokenUsage | null {
|
|
105
119
|
return this._lastUsage;
|
|
106
120
|
}
|
|
@@ -109,9 +123,10 @@ export class OpenRouterClient implements LlmClientInterface {
|
|
|
109
123
|
* Creates a new OpenRouterClient instance
|
|
110
124
|
*
|
|
111
125
|
* @param config Configuration options
|
|
112
|
-
* @throws Error if the
|
|
126
|
+
* @throws Error if the base URL is invalid (see `resolveOpenRouterBaseUrl`)
|
|
113
127
|
*/
|
|
114
128
|
constructor(config: OpenRouterClientConfig) {
|
|
129
|
+
this.baseUrl = resolveOpenRouterBaseUrl(config.baseUrl);
|
|
115
130
|
// Use config timeout or default to 120 seconds (2 minutes)
|
|
116
131
|
// OpenRouter acts as a proxy, so we use a more conservative default
|
|
117
132
|
this.timeout = config.timeout ?? 120000;
|
|
@@ -126,6 +141,7 @@ export class OpenRouterClient implements LlmClientInterface {
|
|
|
126
141
|
|
|
127
142
|
this.client = new OpenRouter({
|
|
128
143
|
apiKey: config.apiKey,
|
|
144
|
+
serverURL: this.baseUrl,
|
|
129
145
|
timeoutMs: this.timeout,
|
|
130
146
|
retryConfig: this.retryConfig,
|
|
131
147
|
});
|
package/src/llm.ts
CHANGED
|
@@ -24,6 +24,13 @@ export type LlmClientOptions = {
|
|
|
24
24
|
* If provided, these clients will use OpenAI for reliable structured output formatting.
|
|
25
25
|
*/
|
|
26
26
|
openaiApiKey?: string;
|
|
27
|
+
/**
|
|
28
|
+
* OpenRouter endpoint (OpenRouter only). Falls back to the `OPENROUTER_BASE_URL`
|
|
29
|
+
* environment variable, then `https://openrouter.ai/api/v1`. Must be an
|
|
30
|
+
* `openrouter.ai` host with the path `/api/v1`, for example the EU host
|
|
31
|
+
* `https://eu.openrouter.ai/api/v1`. Any other value throws.
|
|
32
|
+
*/
|
|
33
|
+
baseUrl?: string;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
/**
|
|
@@ -101,6 +108,7 @@ export class LLM {
|
|
|
101
108
|
apiKey,
|
|
102
109
|
model,
|
|
103
110
|
openaiApiKey: options?.openaiApiKey,
|
|
111
|
+
baseUrl: options?.baseUrl,
|
|
104
112
|
debug: options?.debug,
|
|
105
113
|
});
|
|
106
114
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Global OpenRouter endpoint. Used when no base URL is configured. */
|
|
2
|
+
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
3
|
+
|
|
4
|
+
/** EU in-region OpenRouter endpoint (OpenRouter Business, fails closed, EU endpoints only). */
|
|
5
|
+
export const EU_OPENROUTER_BASE_URL = 'https://eu.openrouter.ai/api/v1';
|
|
6
|
+
|
|
7
|
+
/** Environment variable read when no explicit `baseUrl` is given. */
|
|
8
|
+
export const OPENROUTER_BASE_URL_ENV = 'OPENROUTER_BASE_URL';
|
|
9
|
+
|
|
10
|
+
const REQUIRED_PATH = '/api/v1';
|
|
11
|
+
const ROOT_HOST = 'openrouter.ai';
|
|
12
|
+
|
|
13
|
+
/** Hides userinfo (`user:pass@`) so a credential never reaches an error message. */
|
|
14
|
+
function redact(raw: string): string {
|
|
15
|
+
return raw.replace(/\/\/[^/?#]*@/, '//***@');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reject(raw: string, source: string, reason: string): never {
|
|
19
|
+
throw new Error(`Invalid OpenRouter base URL ${JSON.stringify(redact(raw))} (from ${source}): ${reason}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validate(raw: string, source: string): string {
|
|
23
|
+
const trimmed = raw.trim();
|
|
24
|
+
if (trimmed === '') reject(raw, source, 'the value is empty');
|
|
25
|
+
|
|
26
|
+
let url: URL;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(trimmed);
|
|
29
|
+
} catch {
|
|
30
|
+
return reject(raw, source, 'it is not a valid URL');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (url.protocol !== 'https:') reject(raw, source, 'the protocol must be https');
|
|
34
|
+
if (url.hostname !== ROOT_HOST && !url.hostname.endsWith(`.${ROOT_HOST}`)) {
|
|
35
|
+
reject(raw, source, `the host must be ${ROOT_HOST} or a subdomain of it`);
|
|
36
|
+
}
|
|
37
|
+
if (url.username !== '' || url.password !== '') reject(raw, source, 'credentials in the URL are not allowed');
|
|
38
|
+
if (url.port !== '') reject(raw, source, 'a port is not allowed');
|
|
39
|
+
if (url.pathname !== REQUIRED_PATH && url.pathname !== `${REQUIRED_PATH}/`) {
|
|
40
|
+
reject(raw, source, `the path must be ${REQUIRED_PATH}`);
|
|
41
|
+
}
|
|
42
|
+
if (url.search !== '' || url.hash !== '') reject(raw, source, 'a query or fragment is not allowed');
|
|
43
|
+
|
|
44
|
+
// Catch-all: the parser normalises away a default port (:443), an empty "?" or "#" and other
|
|
45
|
+
// oddities. The only accepted input is the canonical form, with at most one trailing slash.
|
|
46
|
+
const canonical = `https://${url.hostname}${REQUIRED_PATH}`;
|
|
47
|
+
const withoutSlash = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
|
|
48
|
+
if (withoutSlash.toLowerCase() !== canonical) {
|
|
49
|
+
reject(raw, source, `the value must be exactly ${canonical}`);
|
|
50
|
+
}
|
|
51
|
+
return canonical;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves and validates the OpenRouter base URL. Fails closed.
|
|
56
|
+
*
|
|
57
|
+
* Order: the explicit value, then `OPENROUTER_BASE_URL`, then the global default. An empty string
|
|
58
|
+
* counts as a value and is rejected, so a blank setting cannot silently fall back to the global host.
|
|
59
|
+
*
|
|
60
|
+
* Accepted: `https://openrouter.ai/api/v1` and `https://<sub>.openrouter.ai/api/v1`, with an optional
|
|
61
|
+
* trailing slash (stripped). Anything else throws, and the message names the offending value but
|
|
62
|
+
* never an API key.
|
|
63
|
+
*
|
|
64
|
+
* Apps can call this at boot to check their configuration.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveOpenRouterBaseUrl(explicit?: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
67
|
+
if (explicit !== undefined) return validate(explicit, 'the baseUrl option');
|
|
68
|
+
const fromEnv = env[OPENROUTER_BASE_URL_ENV];
|
|
69
|
+
if (fromEnv !== undefined) return validate(fromEnv, `the ${OPENROUTER_BASE_URL_ENV} environment variable`);
|
|
70
|
+
return DEFAULT_OPENROUTER_BASE_URL;
|
|
71
|
+
}
|