@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprqvntrs/llm",
3
- "version": "3.13.1",
3
+ "version": "3.14.0",
4
4
  "type": "module",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",
@@ -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 API key is not configured
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
+ }