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.
@@ -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 ids = [];
159
+ const entries = [];
151
160
  for (const entry of data) {
152
- if (this.isRecord(entry) && typeof entry.id === 'string' && entry.id.trim() !== '')
153
- ids.push(entry.id);
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 (ids.length === 0)
166
+ if (entries.length === 0)
156
167
  throw new OmniRouteError(response.status, 'invalid models response');
157
- return ids;
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);
@@ -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. Matching is case-insensitive substring; the longest
41
- * matching pattern wins so `gpt-4o-mini` beats `gpt-4o`.
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, modelIds] = await Promise.all([engine.client.listCombos(), engine.client.listModels()]);
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
- const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
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 = metrics.compression.inputTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
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.compression.inputTokens, 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 });
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omniharness-cli",
3
- "version": "0.1.83",
3
+ "version": "0.1.85",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",