@maci0/dsh-omniroute 0.0.0-stage → 0.8.3

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/lib/index.js ADDED
@@ -0,0 +1,403 @@
1
+ /**
2
+ * dsh-omniroute: OmniRoute's own API surface, inside DeepSeek Harness.
3
+ *
4
+ * Three read-only routes, all behind the composition's trust fence and all
5
+ * serving JSON:
6
+ *
7
+ * - `GET /omniroute/models`: the live model catalog from OmniRoute's
8
+ * `GET /api/models`: which models exist, which ones the router can serve
9
+ * right now, and which take images. `?available=1`, `?q=<text>` and
10
+ * `?provider=<key>` narrow it. `?sync=1` also writes the available models
11
+ * into a configured provider route, which is how the model picker learns
12
+ * them.
13
+ * - `GET /omniroute/connections`: the upstream connections from
14
+ * `GET /api/providers`.
15
+ * - `GET /omniroute/quota`: the plan and windows of every connection that
16
+ * publishes one, from `GET /api/usage/<connectionId>`.
17
+ *
18
+ * The API key stays in this process: the browser or the model only ever reads
19
+ * the JSON these routes return. Readings are cached for a few seconds per
20
+ * route, so a refresh loop cannot multiply requests to the router.
21
+ *
22
+ * @module dsh-omniroute
23
+ */
24
+ import Schema from '@deepseek-ai/schemastery';
25
+ import { createOmniRouteApi, OmniRouteError, BASE_URL_PATTERN, originOf, } from './omniroute.js';
26
+ /** Plugin name as it appears in the loader. */
27
+ export const name = 'omniroute';
28
+ /**
29
+ * The route carrier, and the trust fence every route checks first: without
30
+ * `connection` nothing would refuse a cross-origin or unauthenticated caller,
31
+ * so the plugin waits for it rather than serving unfenced.
32
+ */
33
+ export const inject = ['webServer', 'connection'];
34
+ /** The live model catalog. */
35
+ export const ROUTE_MODELS = '/omniroute/models';
36
+ /** The upstream connections. */
37
+ export const ROUTE_CONNECTIONS = '/omniroute/connections';
38
+ /** The quota every connection publishes. */
39
+ export const ROUTE_QUOTA = '/omniroute/quota';
40
+ /** Where an OmniRoute deployment listens unless configuration says otherwise. */
41
+ export const DEFAULT_BASE_URL = 'http://localhost:20128';
42
+ /** Credential reference tried when the plugin row names none. */
43
+ export const DEFAULT_API_KEY_ENV = 'OMNIROUTE_API_KEY';
44
+ /** Per-request deadline for one OmniRoute call, in milliseconds. */
45
+ export const DEFAULT_TIMEOUT_MS = 10_000;
46
+ /** Seconds one reading is served without asking OmniRoute again. */
47
+ export const DEFAULT_CACHE_SECONDS = 30;
48
+ /** Profile entry id of the provider route a model sync writes into. */
49
+ export const DEFAULT_SYNC_NAMESPACE = 'llm-pi-ai';
50
+ /** Provider route a model sync writes into. */
51
+ export const DEFAULT_SYNC_PROVIDER = 'omniroute';
52
+ /**
53
+ * Field defaults and bounds with no volatility wrapper. {@link resolveRow}
54
+ * parses a plain row through this schema, so its output is plain values; the
55
+ * loader-facing {@link Config} below is the same shape with every field made
56
+ * `volatile()`. The pair is asserted equal in the suite.
57
+ */
58
+ const ValueSchema = Schema.object({
59
+ baseURL: Schema.string().default(DEFAULT_BASE_URL),
60
+ apiKeyEnv: Schema.string().default(DEFAULT_API_KEY_ENV),
61
+ timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS),
62
+ cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS),
63
+ syncNamespace: Schema.string().default(DEFAULT_SYNC_NAMESPACE),
64
+ syncProvider: Schema.string().default(DEFAULT_SYNC_PROVIDER),
65
+ });
66
+ /**
67
+ * Row schema as Cordis resolves it: what this plugin's `config` is validated
68
+ * against, and where each default lives. Every field is editable from the
69
+ * Plugins page, so every one is volatile.
70
+ */
71
+ export const Config = Schema.object({
72
+ baseURL: Schema.string().pattern(BASE_URL_PATTERN).default(DEFAULT_BASE_URL).volatile(),
73
+ apiKeyEnv: Schema.string().default(DEFAULT_API_KEY_ENV).volatile(),
74
+ timeoutMs: Schema.number().min(1).max(60_000).default(DEFAULT_TIMEOUT_MS).volatile(),
75
+ cacheSeconds: Schema.number().min(0).max(3_600).default(DEFAULT_CACHE_SECONDS).volatile(),
76
+ syncNamespace: Schema.string().default(DEFAULT_SYNC_NAMESPACE).volatile(),
77
+ syncProvider: Schema.string().default(DEFAULT_SYNC_PROVIDER).volatile(),
78
+ });
79
+ /**
80
+ * Read one configured field as a plain value.
81
+ *
82
+ * The loader hands a `volatile()` field a live reference; a direct caller (a
83
+ * test, another plugin composing this one) hands the value itself. Both are
84
+ * accepted, so one read path serves both.
85
+ * @param value - the configured value, live or plain.
86
+ * @returns the current plain value, or `undefined` when a reference holds none.
87
+ */
88
+ function readLive(value) {
89
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
90
+ // A scalar snapshot is the value; the generic cannot narrow that itself.
91
+ return value.get();
92
+ }
93
+ return value;
94
+ }
95
+ /**
96
+ * Turn a row (live references or plain values) into validated plain options.
97
+ * @param row - the configured row.
98
+ * @returns the resolved options, defaults filled by the schema.
99
+ */
100
+ export function resolveRow(row = {}) {
101
+ return ValueSchema({
102
+ baseURL: readLive(row.baseURL),
103
+ apiKeyEnv: readLive(row.apiKeyEnv),
104
+ timeoutMs: readLive(row.timeoutMs),
105
+ cacheSeconds: readLive(row.cacheSeconds),
106
+ syncNamespace: readLive(row.syncNamespace),
107
+ syncProvider: readLive(row.syncProvider),
108
+ });
109
+ }
110
+ /** Write a JSON reply; readings are live facts and are never browser-cached. */
111
+ function sendJson(res, status, payload) {
112
+ res.statusCode = status;
113
+ res.setHeader('content-type', 'application/json; charset=utf-8');
114
+ res.setHeader('cache-control', 'no-store');
115
+ res.end(JSON.stringify(payload));
116
+ }
117
+ /**
118
+ * Query parameters of a request target.
119
+ *
120
+ * Only the query matters here, so only the query is parsed: building a `URL`
121
+ * for every poll pays for an origin the routes never read.
122
+ * @param url - request target, path and query.
123
+ * @returns the decoded parameters.
124
+ */
125
+ function searchParamsOf(url) {
126
+ // The fragment is not part of the request target a `URL` would parse either,
127
+ // and a target whose `#` precedes its `?` must not have the fragment read as
128
+ // query text.
129
+ const hash = url.indexOf('#');
130
+ const target = hash < 0 ? url : url.slice(0, hash);
131
+ const start = target.indexOf('?');
132
+ if (start < 0)
133
+ return new URLSearchParams();
134
+ return new URLSearchParams(target.slice(start + 1));
135
+ }
136
+ /**
137
+ * Resolve the OmniRoute API key: the configured reference first, then the
138
+ * launcher's own environment for that name.
139
+ * @param ctx - host context.
140
+ * @param envName - the reference to resolve.
141
+ * @returns the key, or `undefined` when neither source has one.
142
+ */
143
+ async function apiKeyOf(ctx, envName) {
144
+ const credentials = ctx.get('credentials');
145
+ const resolved = await credentials?.resolve(envName);
146
+ const stored = resolved?.value;
147
+ if (stored !== undefined && stored.length > 0)
148
+ return stored;
149
+ const ambient = process.env[envName];
150
+ return ambient !== undefined && ambient.length > 0 ? ambient : undefined;
151
+ }
152
+ /**
153
+ * Copy the live catalog into a provider route's settings.
154
+ * @param ctx - host context.
155
+ * @param namespace - profile entry id to merge into.
156
+ * @param provider - provider route to write models for.
157
+ * @param models - the catalog to write, already filtered to what is servable.
158
+ * @returns the count written.
159
+ */
160
+ async function syncModels(ctx, namespace, provider, models) {
161
+ const settings = ctx.get('settings');
162
+ if (settings === undefined)
163
+ throw new OmniRouteError('the settings service is not mounted, so nothing can be written');
164
+ const entries = models.map(model => {
165
+ const entry = { id: model.id };
166
+ if (model.name !== undefined)
167
+ entry['name'] = model.name;
168
+ return entry;
169
+ });
170
+ try {
171
+ await settings.update(namespace, { providers: { [provider]: { models: entries } } });
172
+ }
173
+ catch (error) {
174
+ ctx.logger.warn(`omniroute: the model sync into ${namespace} failed (${String(error)})`);
175
+ throw new OmniRouteError(`the settings service refused the model sync into ${namespace}; the host log names the cause`);
176
+ }
177
+ return entries.length;
178
+ }
179
+ /**
180
+ * Mount the host half.
181
+ * @param ctx - host context carrying the route carrier.
182
+ * @param config - this plugin's row configuration.
183
+ */
184
+ export function apply(ctx, row = {}) {
185
+ // Read the row at every use: each field is volatile, so a save from the
186
+ // Plugins card has to reach the next request, not a mount-time copy.
187
+ const live = () => resolveRow(row);
188
+ /**
189
+ * The origin the live `baseURL` names, derived once per distinct value.
190
+ *
191
+ * Deriving it per request is what makes an edit take effect; doing it more
192
+ * than once per distinct value is what the served-read cost guard forbids,
193
+ * because parsing an origin is not free and a settled row never changes.
194
+ */
195
+ let originKey;
196
+ let originValue = '';
197
+ const liveOrigin = () => {
198
+ const { baseURL } = live();
199
+ if (baseURL !== originKey) {
200
+ // Remembered only once it parsed, so a malformed value fails every read.
201
+ originValue = originOf(baseURL);
202
+ originKey = baseURL;
203
+ }
204
+ return originValue;
205
+ };
206
+ // A row passed by a direct caller skipped the loader's schema: validate it
207
+ // at mount, so a malformed baseURL fails here rather than on the first poll.
208
+ liveOrigin();
209
+ /**
210
+ * The live origin for a reply that reports a failure, or nothing while the
211
+ * baseURL is malformed: that malformation is then the failure reported.
212
+ */
213
+ const knownOrigin = () => {
214
+ try {
215
+ return { origin: liveOrigin() };
216
+ }
217
+ catch {
218
+ return {};
219
+ }
220
+ };
221
+ const cache = new Map();
222
+ const inflight = new Map();
223
+ // Bumped by a settings write. A read that started before one belongs to the
224
+ // old row, so it must not fill the cache the write just emptied: doing so
225
+ // would serve the previous origin or cache window for another whole window.
226
+ let generation = 0;
227
+ /** The client for one request, built after its credential resolved. */
228
+ const apiFor = async (origin, { apiKeyEnv, timeoutMs }) => {
229
+ const key = await apiKeyOf(ctx, apiKeyEnv);
230
+ if (key === undefined)
231
+ throw new OmniRouteError(`no OmniRoute API key is configured (${apiKeyEnv})`);
232
+ return createOmniRouteApi({ origin, key, timeoutMs });
233
+ };
234
+ /** Serve one cached read, keyed by route name. */
235
+ const read = async (key, refresh, load) => {
236
+ const hit = cache.get(key);
237
+ if (!refresh && hit !== undefined && Date.now() - hit.at < live().cacheSeconds * 1_000)
238
+ return hit.value;
239
+ const running = inflight.get(key);
240
+ if (running !== undefined)
241
+ return running;
242
+ const started = generation;
243
+ const pending = load().then((value) => {
244
+ // A write that landed mid-read emptied the cache; the value it fetched
245
+ // belongs to the row before the write, so it is answered but not kept.
246
+ if (started === generation)
247
+ cache.set(key, { at: Date.now(), value });
248
+ return value;
249
+ }).finally(() => {
250
+ // Only this read's own entry: a write that landed mid-read cleared the
251
+ // map, a later poll installed its own read under the same key, and
252
+ // retracting that one would leave the next poll with nothing to join.
253
+ if (inflight.get(key) === pending)
254
+ inflight.delete(key);
255
+ });
256
+ inflight.set(key, pending);
257
+ return pending;
258
+ };
259
+ /** The catalog, filtered the way the query string asked for. */
260
+ const filtered = (models, params) => {
261
+ const query = (params.get('q') ?? '').toLowerCase();
262
+ const provider = params.get('provider');
263
+ const onlyAvailable = params.get('available') === '1';
264
+ // Nothing narrows the read: hand back the cached catalog rather than
265
+ // walking it once per request to build an identical copy.
266
+ if (query === '' && (provider === null || provider === '') && !onlyAvailable)
267
+ return models;
268
+ return models.filter((model) => {
269
+ if (onlyAvailable && !model.available)
270
+ return false;
271
+ if (provider !== null && provider !== '' && model.provider !== provider)
272
+ return false;
273
+ if (query === '')
274
+ return true;
275
+ return model.id.toLowerCase().includes(query)
276
+ || (model.name ?? '').toLowerCase().includes(query)
277
+ || (model.alias ?? '').toLowerCase().includes(query);
278
+ });
279
+ };
280
+ const modelsRoute = async (params, refresh) => {
281
+ const row = live();
282
+ const { syncNamespace, syncProvider } = row;
283
+ const origin = liveOrigin();
284
+ // The available count is a property of the reading, not of the request, so
285
+ // it is counted once per fetch and cached beside the catalog.
286
+ const catalog = await read('models', refresh, async () => {
287
+ const api = await apiFor(origin, row);
288
+ const models = await api.models();
289
+ let available = 0;
290
+ for (const model of models)
291
+ if (model.available)
292
+ available += 1;
293
+ return { models, available, fetchedAt: Date.now() };
294
+ });
295
+ const shown = filtered(catalog.models, params);
296
+ const reply = {
297
+ status: 'ok',
298
+ provider: syncProvider,
299
+ origin,
300
+ fetchedAt: catalog.fetchedAt,
301
+ counts: {
302
+ total: catalog.models.length,
303
+ available: catalog.available,
304
+ shown: shown.length,
305
+ },
306
+ models: shown,
307
+ };
308
+ if (params.get('sync') === '1') {
309
+ const written = await syncModels(ctx, syncNamespace, syncProvider, shown.filter(model => model.available));
310
+ reply['synced'] = {
311
+ namespace: syncNamespace,
312
+ path: `providers.${syncProvider}.models`,
313
+ count: written,
314
+ };
315
+ ctx.logger.warn(`omniroute: wrote ${String(written)} models into ${syncNamespace}.providers.${syncProvider}.models`);
316
+ }
317
+ return reply;
318
+ };
319
+ /**
320
+ * The connection list and the windows read from it are one reading: cached
321
+ * apart, a refresh of one alone would answer with windows for connections the
322
+ * same reply does not list. Both routes therefore serve this pair, so the two
323
+ * halves can never disagree, and `/api/providers` is asked once per miss.
324
+ */
325
+ const readConnections = (origin, row, refresh) => read('connections', refresh, async () => {
326
+ const api = await apiFor(origin, row);
327
+ const listed = await api.connections();
328
+ const windows = await api.quota(listed);
329
+ return { connections: listed, windows, fetchedAt: Date.now() };
330
+ });
331
+ const connectionsRoute = async (refresh) => {
332
+ const row = live();
333
+ const origin = liveOrigin();
334
+ const { connections, fetchedAt } = await readConnections(origin, row, refresh);
335
+ return { status: 'ok', origin, fetchedAt, total: connections.length, connections };
336
+ };
337
+ const quotaRoute = async (refresh) => {
338
+ const row = live();
339
+ const origin = liveOrigin();
340
+ const { connections, windows, fetchedAt } = await readConnections(origin, row, refresh);
341
+ return {
342
+ status: 'ok',
343
+ origin,
344
+ fetchedAt,
345
+ connections,
346
+ windows,
347
+ limitReached: windows.filter(window => window.limitReached).map(window => window.plan ?? window.connection),
348
+ };
349
+ };
350
+ /** One shared GET wrapper around every route handler. */
351
+ const handlerFor = (path, run) => async (req, res) => {
352
+ const rejection = ctx.connection.requestRejection(req);
353
+ if (rejection !== undefined) {
354
+ res.statusCode = rejection;
355
+ res.end();
356
+ return;
357
+ }
358
+ if ((req.method ?? 'GET').toUpperCase() !== 'GET') {
359
+ res.setHeader('allow', 'GET');
360
+ sendJson(res, 405, { status: 'error', message: 'this route answers GET only' });
361
+ return;
362
+ }
363
+ const params = searchParamsOf(String(req.url));
364
+ try {
365
+ sendJson(res, 200, await run(params, params.get('refresh') === '1'));
366
+ }
367
+ catch (error) {
368
+ // A refusal never carries the key, the router's own body, or another
369
+ // service's exception text: only this plugin's own sentence is sent,
370
+ // and anything else is logged here and answered generically.
371
+ let message;
372
+ if (error instanceof OmniRouteError) {
373
+ message = error.message;
374
+ }
375
+ else {
376
+ ctx.logger.warn(`omniroute: GET ${path} failed (${String(error)})`);
377
+ message = 'the read failed; the host log names the cause';
378
+ }
379
+ sendJson(res, 502, { status: 'error', message, ...knownOrigin() });
380
+ }
381
+ };
382
+ const routes = [
383
+ [ROUTE_MODELS, modelsRoute],
384
+ [ROUTE_CONNECTIONS, (_params, refresh) => connectionsRoute(refresh)],
385
+ [ROUTE_QUOTA, (_params, refresh) => quotaRoute(refresh)],
386
+ ];
387
+ for (const [path, run] of routes) {
388
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path, handler: handlerFor(path, run) }), `omniroute: GET ${path}`);
389
+ }
390
+ // A settings write moves the live references in place. Dropping the served
391
+ // readings makes the next poll answer from the edited row instead of a
392
+ // cached body fetched from the previous origin, and the log line records it.
393
+ ctx.on('loader/volatile-update', () => {
394
+ cache.clear();
395
+ // A poll that starts after the write must ask the new row, so the reads the
396
+ // old row started stop being handed out. Their callers still get an answer.
397
+ inflight.clear();
398
+ generation += 1;
399
+ const { apiKeyEnv, timeoutMs, cacheSeconds, syncNamespace, syncProvider } = live();
400
+ ctx.logger.warn(`omniroute: configuration updated: ${knownOrigin().origin ?? 'an invalid baseURL'} as ${apiKeyEnv},`
401
+ + ` ${timeoutMs}ms deadline, ${cacheSeconds}s cache, sync into ${syncNamespace}.providers.${syncProvider}`);
402
+ });
403
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * OmniRoute management-API client: the live model catalog, the provider
3
+ * connections, and the quota each connection reports.
4
+ *
5
+ * The endpoints are OmniRoute's own, read from a running `v3.8.50` server's
6
+ * `/api/openapi/spec` and `/api/agent-skills` directory:
7
+ *
8
+ * - `GET /api/models`: every model across the configured providers, each with
9
+ * an `available` flag and a `supportsVision` flag. This is the management
10
+ * listing, not the OpenAI-shaped `GET /v1/models`: it is the one that says
11
+ * which models the router can actually serve *now*.
12
+ * - `GET /api/providers`: the upstream connections. Each carries the `id` that
13
+ * the per-connection usage route addresses, plus `quotaVisible` and
14
+ * `isActive`.
15
+ * - `GET /api/usage/<connectionId>`: that connection's plan, its windows
16
+ * (`session`, `weekly`, `credits_usd`, …), and whether the limit is reached.
17
+ *
18
+ * OmniRoute routes to accounts it holds, so it publishes no figure for the
19
+ * router itself; quota is read per connection and flattened into one list.
20
+ * `/api/quota/plans` resolves plans but carries no consumption, and
21
+ * `/api/quota/pools` is empty unless the deployment defines pools, so neither
22
+ * is used here.
23
+ *
24
+ * Everything below the `createOmniRouteApi` line is pure parsing; the tests
25
+ * drive it with payloads copied from a live server.
26
+ *
27
+ * @module dsh-omniroute/omniroute
28
+ */
29
+ import { numberOf, record, stringOf } from './util.js';
30
+ /**
31
+ * A failure this plugin phrased itself. Only its message reaches a route
32
+ * reply; any other error is logged on the host and answered generically.
33
+ */
34
+ export class OmniRouteError extends Error {
35
+ name = 'OmniRouteError';
36
+ }
37
+ /**
38
+ * Parse a `GET /api/models` body.
39
+ * @param payload - the decoded body.
40
+ * @returns one entry per model the body carried.
41
+ */
42
+ export function parseModels(payload) {
43
+ const entries = record(payload)?.['models'];
44
+ if (!Array.isArray(entries))
45
+ return [];
46
+ const models = [];
47
+ for (const entry of entries) {
48
+ const model = record(entry);
49
+ if (model === undefined)
50
+ continue;
51
+ const id = stringOf(model['fullModel']) ?? stringOf(model['model']);
52
+ if (id === undefined)
53
+ continue;
54
+ models.push({
55
+ id,
56
+ provider: stringOf(model['provider']) ?? 'omniroute',
57
+ name: stringOf(model['name']),
58
+ alias: stringOf(model['alias']),
59
+ available: model['available'] === true,
60
+ vision: model['supportsVision'] === true,
61
+ });
62
+ }
63
+ return models;
64
+ }
65
+ /**
66
+ * Parse a `GET /api/providers` body.
67
+ * @param payload - the decoded body.
68
+ * @returns one entry per connection the body carried.
69
+ */
70
+ export function parseConnections(payload) {
71
+ const entries = record(payload)?.['connections'];
72
+ if (!Array.isArray(entries))
73
+ return [];
74
+ const connections = [];
75
+ for (const entry of entries) {
76
+ const connection = record(entry);
77
+ if (connection === undefined)
78
+ continue;
79
+ const id = stringOf(connection['id']);
80
+ if (id === undefined)
81
+ continue;
82
+ connections.push({
83
+ id,
84
+ provider: stringOf(connection['provider']) ?? 'omniroute',
85
+ name: stringOf(connection['name']),
86
+ active: connection['isActive'] !== false,
87
+ quotaVisible: connection['quotaVisible'] !== false,
88
+ });
89
+ }
90
+ return connections;
91
+ }
92
+ /**
93
+ * Parse one `GET /api/usage/<connectionId>` body.
94
+ * @param payload - the decoded body.
95
+ * @param connection - the connection it was asked about.
96
+ * @returns one entry per window the body carried.
97
+ */
98
+ export function parseUsage(payload, connection) {
99
+ const usage = record(payload);
100
+ if (usage === undefined)
101
+ return [];
102
+ const quotas = record(usage['quotas']);
103
+ if (quotas === undefined)
104
+ return [];
105
+ const plan = stringOf(usage['plan']) ?? connection.name ?? connection.provider;
106
+ const limitReached = usage['limitReached'] === true;
107
+ const windows = [];
108
+ for (const [window, raw] of Object.entries(quotas)) {
109
+ const meter = record(raw);
110
+ if (meter === undefined)
111
+ continue;
112
+ windows.push({
113
+ connection: connection.id,
114
+ provider: connection.provider,
115
+ plan,
116
+ window,
117
+ used: numberOf(meter['used']),
118
+ total: numberOf(meter['total']),
119
+ remaining: numberOf(meter['remaining']),
120
+ remainingPercent: numberOf(meter['remainingPercentage']),
121
+ unlimited: meter['unlimited'] === true,
122
+ resetAt: stringOf(meter['resetAt']),
123
+ currency: stringOf(meter['currency']),
124
+ limitReached,
125
+ });
126
+ }
127
+ return windows;
128
+ }
129
+ /**
130
+ * What a configured base URL must start with: an http(s) scheme and a host.
131
+ * A scheme-less `box:20128` parses as a URL whose origin is the string "null".
132
+ */
133
+ export const BASE_URL_PATTERN = /^https?:\/\/[^\s/?#]+/iu;
134
+ /**
135
+ * Origin of a configured base URL, which is what carries OmniRoute's own API.
136
+ * @param baseURL - the configured value, with or without its `/v1` path.
137
+ * @returns the origin.
138
+ * @throws OmniRouteError when the value is not an absolute http(s) URL: no
139
+ * other origin is guessed, so the key never goes to a host nobody configured.
140
+ */
141
+ export function originOf(baseURL) {
142
+ if (BASE_URL_PATTERN.test(baseURL) && URL.canParse(baseURL))
143
+ return new URL(baseURL).origin;
144
+ throw new OmniRouteError('baseURL is not an absolute http(s) URL; fix the omniroute row');
145
+ }
146
+ /**
147
+ * Build the client for one deployment.
148
+ * @param options - origin, credential, deadline, and transport.
149
+ * @returns the three reads, each failing loud on a refused request.
150
+ */
151
+ export function createOmniRouteApi(options) {
152
+ const transport = options.fetchImpl ?? globalThis.fetch;
153
+ const headers = { authorization: `Bearer ${options.key}`, accept: 'application/json' };
154
+ const get = async (path) => {
155
+ let response;
156
+ try {
157
+ response = await transport(`${options.origin}${path}`, {
158
+ headers,
159
+ signal: AbortSignal.timeout(options.timeoutMs),
160
+ });
161
+ }
162
+ catch (error) {
163
+ // `AbortSignal.timeout` rejects with a `TimeoutError`; anything else is
164
+ // a connection that never produced a response.
165
+ throw new OmniRouteError(error instanceof Error && error.name === 'TimeoutError'
166
+ ? `OmniRoute did not answer ${path} within ${String(options.timeoutMs)}ms`
167
+ : `OmniRoute could not be reached for ${path}`);
168
+ }
169
+ if (!response.ok) {
170
+ throw new OmniRouteError(`OmniRoute answered HTTP ${String(response.status)} for ${path}`);
171
+ }
172
+ // A body that is not JSON is reported as that and nothing more: the parse
173
+ // error a JSON reader raises quotes the first bytes of whatever the router
174
+ // answered, which would put the router's own body in this plugin's reply.
175
+ try {
176
+ return await response.json();
177
+ }
178
+ catch {
179
+ throw new OmniRouteError(`OmniRoute answered a non-JSON body for ${path}`);
180
+ }
181
+ };
182
+ const connections = async () => parseConnections(await get('/api/providers'));
183
+ return {
184
+ models: async () => parseModels(await get('/api/models')),
185
+ connections,
186
+ quota: async (listed) => {
187
+ const all = listed ?? await connections();
188
+ // A connection that is off, or hides its quota, is not asked at all; one
189
+ // that fails to answer contributes no window rather than failing the read.
190
+ // An id of `.` or `..` is asked neither: it survives `encodeURIComponent`
191
+ // and the URL parser then climbs out of `/api/usage/` into a different
192
+ // router endpoint, so no path can address it.
193
+ const asked = all.filter(connection => connection.active && connection.quotaVisible
194
+ && connection.id !== '.' && connection.id !== '..');
195
+ const answers = await Promise.all(asked.map(async (connection) => {
196
+ try {
197
+ return parseUsage(await get(`/api/usage/${encodeURIComponent(connection.id)}`), connection);
198
+ }
199
+ catch {
200
+ return [];
201
+ }
202
+ }));
203
+ return answers.flat();
204
+ },
205
+ };
206
+ }