omniharness-cli 0.1.83 → 0.1.85
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/dist/config/omniRoute.js +87 -5
- package/dist/ui/modelWindows.js +51 -5
- package/dist/ui/terminalInterface.js +19 -5
- package/package.json +1 -1
package/dist/config/omniRoute.js
CHANGED
|
@@ -32,6 +32,7 @@ export class OmniRouteClient {
|
|
|
32
32
|
metrics = {
|
|
33
33
|
compression: { inputTokens: 0, compressedTokens: 0, ratio: 1, strategy: 'none', updatedAt: new Date().toISOString() },
|
|
34
34
|
fallback: { attempts: 0 },
|
|
35
|
+
usage: { contextTokens: 0, tokensIn: 0, tokensOut: 0, costUsd: 0 },
|
|
35
36
|
requestCount: 0,
|
|
36
37
|
};
|
|
37
38
|
constructor(config = {}) {
|
|
@@ -144,17 +145,27 @@ export class OmniRouteClient {
|
|
|
144
145
|
}
|
|
145
146
|
/** List every model id the gateway exposes, including `auto/*` virtual combos and individual providers. */
|
|
146
147
|
async listModels(signal) {
|
|
148
|
+
return (await this.listCatalog(signal)).map((entry) => entry.id);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The catalog with the context window each entry advertises. OmniRoute
|
|
152
|
+
* states `context_length` on providers' models, on `auto/*` engines and on
|
|
153
|
+
* combos alike; `max_input_tokens` stands in when an entry carries only that.
|
|
154
|
+
*/
|
|
155
|
+
async listCatalog(signal) {
|
|
147
156
|
const response = await this.requestWithRetry('/v1/models', { method: 'GET', signal });
|
|
148
157
|
const payload = await response.json();
|
|
149
158
|
const data = this.isRecord(payload) && Array.isArray(payload.data) ? payload.data : [];
|
|
150
|
-
const
|
|
159
|
+
const entries = [];
|
|
151
160
|
for (const entry of data) {
|
|
152
|
-
if (this.isRecord(entry)
|
|
153
|
-
|
|
161
|
+
if (!this.isRecord(entry) || typeof entry.id !== 'string' || entry.id.trim() === '')
|
|
162
|
+
continue;
|
|
163
|
+
const window = this.positive(entry.context_length) ?? this.positive(entry.max_input_tokens);
|
|
164
|
+
entries.push(window !== undefined ? { id: entry.id, contextLength: window } : { id: entry.id });
|
|
154
165
|
}
|
|
155
|
-
if (
|
|
166
|
+
if (entries.length === 0)
|
|
156
167
|
throw new OmniRouteError(response.status, 'invalid models response');
|
|
157
|
-
return
|
|
168
|
+
return entries;
|
|
158
169
|
}
|
|
159
170
|
/** Retry transient responses for idempotent metadata reads without touching chat/tool requests. */
|
|
160
171
|
async requestWithRetry(path, init) {
|
|
@@ -186,7 +197,19 @@ export class OmniRouteClient {
|
|
|
186
197
|
let lineBuffer = '';
|
|
187
198
|
// Partially-accumulated tool calls keyed by stream index.
|
|
188
199
|
const toolStreams = new Map();
|
|
200
|
+
// OmniRoute's response headers are sent at stream start, before the
|
|
201
|
+
// latency, usage and cost are known, so on a stream they carry zeros. With
|
|
202
|
+
// OMNIROUTE_SSE_COMMENTS on, the gateway ends the stream with the same
|
|
203
|
+
// fields as `: x-omniroute-<name>=<value>` comment lines — the values that
|
|
204
|
+
// were final. They are collected here and read like a second header set.
|
|
205
|
+
const trailer = new Headers();
|
|
189
206
|
const flushData = (line) => {
|
|
207
|
+
if (line.startsWith(':')) {
|
|
208
|
+
const meta = /^:\s*(x-omniroute-[a-z0-9-]+)=(.*)$/i.exec(line);
|
|
209
|
+
if (meta)
|
|
210
|
+
trailer.set(meta[1].toLowerCase(), meta[2].trim());
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
190
213
|
const sep = line.indexOf(':');
|
|
191
214
|
if (sep === -1)
|
|
192
215
|
return;
|
|
@@ -274,6 +297,10 @@ export class OmniRouteClient {
|
|
|
274
297
|
const toolCalls = [...toolStreams.values()]
|
|
275
298
|
.filter((entry) => entry.id !== '' && entry.name !== '')
|
|
276
299
|
.map((entry) => ({ id: entry.id, type: 'function', function: { name: entry.name, arguments: entry.argsFragments.join('') } }));
|
|
300
|
+
// The trailer names the provider and model that finished the stream, so it
|
|
301
|
+
// supersedes the routing picture taken from the initial headers.
|
|
302
|
+
this.updateMetrics(trailer);
|
|
303
|
+
this.recordCompletion(response.headers, usage, trailer);
|
|
277
304
|
return { content, model: answered, finishReason, reasoning: reasoning || undefined, toolCalls, usage, headers: response.headers, compression: this.compressionFrom(response.headers) };
|
|
278
305
|
}
|
|
279
306
|
async chat(model, messages, options = {}) {
|
|
@@ -294,6 +321,11 @@ export class OmniRouteClient {
|
|
|
294
321
|
// (the combo may have routed anywhere); fall back to the requested id.
|
|
295
322
|
const answered = typeof payload.model === 'string' && payload.model.trim() !== '' ? payload.model : model;
|
|
296
323
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls.map((call) => this.asToolCall(call)).filter((call) => call !== null) : undefined;
|
|
324
|
+
this.recordCompletion(response.headers, usage ? {
|
|
325
|
+
inputTokens: this.number(usage.prompt_tokens),
|
|
326
|
+
outputTokens: this.number(usage.completion_tokens),
|
|
327
|
+
totalTokens: this.number(usage.total_tokens),
|
|
328
|
+
} : undefined);
|
|
297
329
|
return {
|
|
298
330
|
content: typeof message.content === 'string' ? message.content : '',
|
|
299
331
|
model: answered,
|
|
@@ -309,6 +341,50 @@ export class OmniRouteClient {
|
|
|
309
341
|
compression: this.compressionFrom(response.headers),
|
|
310
342
|
};
|
|
311
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* OmniRoute's cost-telemetry set for one completion, or undefined when the
|
|
346
|
+
* headers carry no token counts. A stream's initial headers hold zeros for
|
|
347
|
+
* every field the gateway could not know yet, and zeros are treated as
|
|
348
|
+
* absent so they never overwrite a count read elsewhere.
|
|
349
|
+
*/
|
|
350
|
+
usageFromHeaders(headers) {
|
|
351
|
+
const inputTokens = this.headerNumber(headers, 'x-omniroute-tokens-in') ?? 0;
|
|
352
|
+
const outputTokens = this.headerNumber(headers, 'x-omniroute-tokens-out') ?? 0;
|
|
353
|
+
if (inputTokens <= 0 && outputTokens <= 0)
|
|
354
|
+
return undefined;
|
|
355
|
+
const costUsd = this.headerNumber(headers, 'x-omniroute-response-cost');
|
|
356
|
+
const latencyMs = this.headerNumber(headers, 'x-omniroute-latency-ms');
|
|
357
|
+
return {
|
|
358
|
+
inputTokens: Math.max(0, inputTokens),
|
|
359
|
+
outputTokens: Math.max(0, outputTokens),
|
|
360
|
+
costUsd: costUsd !== undefined && costUsd > 0 ? costUsd : undefined,
|
|
361
|
+
latencyMs: latencyMs !== undefined && latencyMs > 0 ? latencyMs : undefined,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Fold one completion into the session's usage. Sources, most authoritative
|
|
366
|
+
* first: the SSE metadata trailer (final values of a stream), the response
|
|
367
|
+
* headers (final on a non-streaming reply, zeros on a stream), then the
|
|
368
|
+
* `usage` object in the body. Token counts come from one source only, so a
|
|
369
|
+
* reply that reports itself twice is still counted once.
|
|
370
|
+
*/
|
|
371
|
+
recordCompletion(headers, body, trailer) {
|
|
372
|
+
const usage = this.metrics.usage;
|
|
373
|
+
const measured = (trailer && this.usageFromHeaders(trailer))
|
|
374
|
+
?? this.usageFromHeaders(headers)
|
|
375
|
+
?? (body && (body.inputTokens > 0 || body.outputTokens > 0) ? { inputTokens: body.inputTokens, outputTokens: body.outputTokens } : undefined);
|
|
376
|
+
if (!measured)
|
|
377
|
+
return;
|
|
378
|
+
if (measured.inputTokens > 0)
|
|
379
|
+
usage.contextTokens = measured.inputTokens;
|
|
380
|
+
usage.tokensIn += measured.inputTokens;
|
|
381
|
+
usage.tokensOut += measured.outputTokens;
|
|
382
|
+
if (measured.costUsd !== undefined)
|
|
383
|
+
usage.costUsd += measured.costUsd;
|
|
384
|
+
if (measured.latencyMs !== undefined)
|
|
385
|
+
usage.latencyMs = measured.latencyMs;
|
|
386
|
+
usage.updatedAt = new Date().toISOString();
|
|
387
|
+
}
|
|
312
388
|
compressionFrom(headers) {
|
|
313
389
|
const input = this.headerNumber(headers, 'x-omniroute-input-tokens');
|
|
314
390
|
const compressed = this.headerNumber(headers, 'x-omniroute-compressed-tokens');
|
|
@@ -365,6 +441,9 @@ export class OmniRouteClient {
|
|
|
365
441
|
const provider = headers.get('x-omniroute-provider') ?? decision.provider;
|
|
366
442
|
if (provider)
|
|
367
443
|
fallback.activeProvider = provider;
|
|
444
|
+
const model = headers.get('x-omniroute-model');
|
|
445
|
+
if (model && model.trim() !== '')
|
|
446
|
+
fallback.model = model.trim();
|
|
368
447
|
if (decision.strategy)
|
|
369
448
|
fallback.strategy = decision.strategy;
|
|
370
449
|
if (decision.latencyMs !== undefined)
|
|
@@ -419,6 +498,9 @@ export class OmniRouteClient {
|
|
|
419
498
|
number(value) {
|
|
420
499
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
421
500
|
}
|
|
501
|
+
positive(value) {
|
|
502
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
503
|
+
}
|
|
422
504
|
safeParse(text) {
|
|
423
505
|
try {
|
|
424
506
|
return JSON.parse(text);
|
package/dist/ui/modelWindows.js
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* a model id (or the provider name OmniRoute reports) to a token budget;
|
|
7
7
|
* `contextMeter` turns "tokens in" into a fill fraction and a zone the UI
|
|
8
8
|
* colours (ok / warn / danger) using the playbook's 70 / 90 thresholds.
|
|
9
|
+
*
|
|
10
|
+
* The gateway's catalog is the first source: `/v1/models` states a
|
|
11
|
+
* `context_length` per entry, and `windowIndex` turns that into a lookup the
|
|
12
|
+
* meter consults before the substring table below. The table remains the
|
|
13
|
+
* answer for a model the catalog does not size, and when the catalog could
|
|
14
|
+
* not be read at all.
|
|
9
15
|
*/
|
|
10
16
|
/** Known windows keyed by a substring of the model / provider id (longest match wins). */
|
|
11
17
|
const WINDOWS = [
|
|
@@ -35,12 +41,52 @@ const WINDOWS = [
|
|
|
35
41
|
];
|
|
36
42
|
/** Fallback window when nothing matches — conservative so the meter warns early rather than late. */
|
|
37
43
|
export const DEFAULT_WINDOW = 128_000;
|
|
44
|
+
/**
|
|
45
|
+
* Build the lookup from a catalog. Each entry is keyed by its full id and,
|
|
46
|
+
* when the id carries a provider prefix, by the bare model name after it —
|
|
47
|
+
* `X-OmniRoute-Model` reports the upstream name (`claude-sonnet-4-6`) while
|
|
48
|
+
* the catalog lists it under a prefix (`cc/claude-sonnet-4-6`). The first
|
|
49
|
+
* entry to claim a bare name keeps it, so a `dual`-mode mirror never
|
|
50
|
+
* contradicts its primary.
|
|
51
|
+
*/
|
|
52
|
+
export function windowIndex(entries) {
|
|
53
|
+
const index = new Map();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const tokens = entry.contextLength;
|
|
56
|
+
if (tokens === undefined || !Number.isFinite(tokens) || tokens <= 0)
|
|
57
|
+
continue;
|
|
58
|
+
const id = entry.id.trim().toLowerCase();
|
|
59
|
+
if (id === '')
|
|
60
|
+
continue;
|
|
61
|
+
if (!index.has(id))
|
|
62
|
+
index.set(id, tokens);
|
|
63
|
+
const slash = id.indexOf('/');
|
|
64
|
+
if (slash > 0 && slash < id.length - 1) {
|
|
65
|
+
const bare = id.slice(slash + 1);
|
|
66
|
+
if (!index.has(bare))
|
|
67
|
+
index.set(bare, tokens);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return index;
|
|
71
|
+
}
|
|
38
72
|
/**
|
|
39
73
|
* Resolve a context window for a model id and/or the provider OmniRoute
|
|
40
|
-
* reported for the turn.
|
|
41
|
-
*
|
|
74
|
+
* reported for the turn. A catalog `known` answers first, by the exact id and
|
|
75
|
+
* then by the bare name after a provider prefix. Otherwise matching is
|
|
76
|
+
* case-insensitive substring; the longest matching pattern wins so
|
|
77
|
+
* `gpt-4o-mini` beats `gpt-4o`.
|
|
42
78
|
*/
|
|
43
|
-
export function windowFor(modelId, provider) {
|
|
79
|
+
export function windowFor(modelId, provider, known) {
|
|
80
|
+
if (known && modelId) {
|
|
81
|
+
const id = modelId.trim().toLowerCase();
|
|
82
|
+
const exact = known.get(id);
|
|
83
|
+
if (exact !== undefined)
|
|
84
|
+
return exact;
|
|
85
|
+
const slash = id.indexOf('/');
|
|
86
|
+
const bare = slash > 0 ? known.get(id.slice(slash + 1)) : undefined;
|
|
87
|
+
if (bare !== undefined)
|
|
88
|
+
return bare;
|
|
89
|
+
}
|
|
44
90
|
const haystack = `${modelId ?? ''} ${provider ?? ''}`.toLowerCase();
|
|
45
91
|
let best;
|
|
46
92
|
let bestLen = 0;
|
|
@@ -53,8 +99,8 @@ export function windowFor(modelId, provider) {
|
|
|
53
99
|
return best ?? DEFAULT_WINDOW;
|
|
54
100
|
}
|
|
55
101
|
/** Build the meter. `used` below 0 clamps to 0; the fraction is capped at 1. */
|
|
56
|
-
export function contextMeter(used, modelId, provider) {
|
|
57
|
-
const window = windowFor(modelId, provider);
|
|
102
|
+
export function contextMeter(used, modelId, provider, known) {
|
|
103
|
+
const window = windowFor(modelId, provider, known);
|
|
58
104
|
const safeUsed = Math.max(0, used);
|
|
59
105
|
const fraction = Math.min(1, safeUsed / window);
|
|
60
106
|
const zone = fraction >= 0.9 ? 'danger' : fraction >= 0.7 ? 'warn' : 'ok';
|
|
@@ -12,7 +12,7 @@ import { capabilityLine, recentRows, shortenPath, twoColumn } from './home.js';
|
|
|
12
12
|
import { conversationWidth, overflowCount, sidebarMode, SIDEBAR_WIDTH, todoRows, usageRows, clip as clipRow } from './sidebar.js';
|
|
13
13
|
import { planViewport } from './viewport.js';
|
|
14
14
|
import { statusMarker, toolHead } from './toolrow.js';
|
|
15
|
-
import { contextMeter, meterBar } from './modelWindows.js';
|
|
15
|
+
import { contextMeter, meterBar, windowIndex } from './modelWindows.js';
|
|
16
16
|
import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
|
|
17
17
|
import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
|
|
18
18
|
import { ownVersion } from '../update.js';
|
|
@@ -271,6 +271,9 @@ export function TerminalInterface({ engine }) {
|
|
|
271
271
|
const [pickerItems, setPickerItems] = useState([]);
|
|
272
272
|
const [pickerIndex, setPickerIndex] = useState(0);
|
|
273
273
|
const [pickerError, setPickerError] = useState();
|
|
274
|
+
// Context windows the catalog states, keyed by model id. Empty until the
|
|
275
|
+
// catalog has been read; the meter falls back to its own table meanwhile.
|
|
276
|
+
const [windows, setWindows] = useState(() => new Map());
|
|
274
277
|
const [mode, setMode] = useState(engine.state.mode);
|
|
275
278
|
const [permMode, setPermMode] = useState(engine.state.permissionMode ?? 'ask');
|
|
276
279
|
const [approval, setApproval] = useState(null);
|
|
@@ -310,6 +313,10 @@ export function TerminalInterface({ engine }) {
|
|
|
310
313
|
if (alive)
|
|
311
314
|
setRecentSessions(found);
|
|
312
315
|
}).catch(() => { });
|
|
316
|
+
void Promise.resolve().then(() => engine.client.listCatalog()).then((catalog) => {
|
|
317
|
+
if (alive)
|
|
318
|
+
setWindows(windowIndex(catalog));
|
|
319
|
+
}).catch(() => { });
|
|
313
320
|
return () => { alive = false; };
|
|
314
321
|
}, []);
|
|
315
322
|
useEffect(() => {
|
|
@@ -434,7 +441,9 @@ export function TerminalInterface({ engine }) {
|
|
|
434
441
|
const loadPicker = async () => {
|
|
435
442
|
setPickerError(undefined);
|
|
436
443
|
try {
|
|
437
|
-
const [accountCombos,
|
|
444
|
+
const [accountCombos, catalog] = await Promise.all([engine.client.listCombos(), engine.client.listCatalog()]);
|
|
445
|
+
setWindows(windowIndex(catalog));
|
|
446
|
+
const modelIds = catalog.map((entry) => entry.id);
|
|
438
447
|
const items = [];
|
|
439
448
|
for (const combo of accountCombos) {
|
|
440
449
|
if (combo.name.trim() !== '' && !items.some((item) => item.id === combo.name)) {
|
|
@@ -981,9 +990,14 @@ export function TerminalInterface({ engine }) {
|
|
|
981
990
|
const contentWidth = Math.max(20, convoWidth - 6);
|
|
982
991
|
const terminalRows = rows;
|
|
983
992
|
const metrics = engine.client.snapshotMetrics();
|
|
984
|
-
|
|
993
|
+
// The prompt tokens of the last completion, as the gateway counted them,
|
|
994
|
+
// are the size of the context the next turn will carry.
|
|
995
|
+
const contextTokens = metrics.usage?.contextTokens ?? 0;
|
|
996
|
+
// Sized to the model that answered, when the gateway named one: an `auto/*`
|
|
997
|
+
// engine or a combo can land anywhere, and the window is that model's.
|
|
998
|
+
const meter = contextMeter(contextTokens, metrics.fallback.model ?? engine.state.activeModel, metrics.fallback.activeProvider, windows);
|
|
985
999
|
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
986
|
-
const contextLabel =
|
|
1000
|
+
const contextLabel = contextTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
|
|
987
1001
|
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
|
|
988
1002
|
const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
|
|
989
1003
|
const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
|
|
@@ -1009,7 +1023,7 @@ export function TerminalInterface({ engine }) {
|
|
|
1009
1023
|
const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
|
|
1010
1024
|
const liveAnswerView = liveAnswerLines.slice(-liveBudget);
|
|
1011
1025
|
const doneAgents = agents.filter((lane) => lane.status === 'done').length;
|
|
1012
|
-
const panel = _jsx(SidebarPanel, { width: SIDEBAR_WIDTH, model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, usage: { tokensIn: metrics.
|
|
1026
|
+
const panel = _jsx(SidebarPanel, { width: SIDEBAR_WIDTH, model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, usage: { tokensIn: metrics.usage?.tokensIn, tokensOut: metrics.usage?.tokensOut, costUSD: metrics.usage?.costUsd, requests: metrics.requestCount }, agents: agents, todos: taskQueue, skills: engine.skills.length, plugins: new Set(engine.skills.map((skill) => skill.source).filter((source) => source !== undefined)).size });
|
|
1013
1027
|
return _jsxs(Box, { flexDirection: "column", width: width, paddingLeft: gutter, paddingRight: gutter, children: [_jsx(Static, { items: lines, children: (line, index) => _jsx(TranscriptEntry, { line: line, width: contentWidth, fallbackModel: engine.state.activeModel }, index) }, staticKey), sideMode === 'replace' && _jsx(Box, { marginBottom: 1, children: panel }), _jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Box, { flexDirection: "column", width: sideMode === 'split' ? convoWidth : undefined, flexGrow: sideMode === 'split' ? 0 : 1, children: [view.showHero && _jsx(Hero, { width: contentWidth + 2, endpoint: engine.client.endpoint ?? 'omniroute', model: engine.state.activeModel, mode: mode, perm: permMode, workspace: engine.state.workspace.root, sessions: recentSessions, skills: engine.skills.length, plugins: new Set(engine.skills.map((skill) => skill.source).filter((source) => source !== undefined)).size, mcpTools: engine.mcpTools.length }), liveThink !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.warn, children: "\u00B7 thinking" }), liveThinkView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "thinking" }, index))] }), liveAnswer !== '' && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: PALETTE.accent, children: engine.state.activeModel }), liveAnswerView.map((segments, index) => _jsx(SegmentText, { segments: segments, role: "assistant" }, index))] }), (view.toolCards > 0 ? toolCards.slice(-view.toolCards) : []).map((card) => {
|
|
1014
1028
|
const expanded = expandedTool === card.id;
|
|
1015
1029
|
const status = card.status === 'running' ? 'running' : card.status === 'error' ? 'error' : 'done';
|