@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/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +10 -0
- package/icon.svg +8 -0
- package/lib/client.js +383 -0
- package/lib/host.js +12 -0
- package/lib/index.js +403 -0
- package/lib/omniroute.js +206 -0
- package/lib/types/host.d.ts +112 -0
- package/lib/types/index.d.ts +111 -0
- package/lib/types/omniroute.d.ts +159 -0
- package/lib/types/util.d.ts +15 -0
- package/lib/util.js +24 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +93 -4
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
|
+
}
|
package/lib/omniroute.js
ADDED
|
@@ -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
|
+
}
|