omniharness-cli 0.1.83 → 0.1.84

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 = {}) {
@@ -186,7 +187,19 @@ export class OmniRouteClient {
186
187
  let lineBuffer = '';
187
188
  // Partially-accumulated tool calls keyed by stream index.
188
189
  const toolStreams = new Map();
190
+ // OmniRoute's response headers are sent at stream start, before the
191
+ // latency, usage and cost are known, so on a stream they carry zeros. With
192
+ // OMNIROUTE_SSE_COMMENTS on, the gateway ends the stream with the same
193
+ // fields as `: x-omniroute-<name>=<value>` comment lines — the values that
194
+ // were final. They are collected here and read like a second header set.
195
+ const trailer = new Headers();
189
196
  const flushData = (line) => {
197
+ if (line.startsWith(':')) {
198
+ const meta = /^:\s*(x-omniroute-[a-z0-9-]+)=(.*)$/i.exec(line);
199
+ if (meta)
200
+ trailer.set(meta[1].toLowerCase(), meta[2].trim());
201
+ return;
202
+ }
190
203
  const sep = line.indexOf(':');
191
204
  if (sep === -1)
192
205
  return;
@@ -274,6 +287,10 @@ export class OmniRouteClient {
274
287
  const toolCalls = [...toolStreams.values()]
275
288
  .filter((entry) => entry.id !== '' && entry.name !== '')
276
289
  .map((entry) => ({ id: entry.id, type: 'function', function: { name: entry.name, arguments: entry.argsFragments.join('') } }));
290
+ // The trailer names the provider and model that finished the stream, so it
291
+ // supersedes the routing picture taken from the initial headers.
292
+ this.updateMetrics(trailer);
293
+ this.recordCompletion(response.headers, usage, trailer);
277
294
  return { content, model: answered, finishReason, reasoning: reasoning || undefined, toolCalls, usage, headers: response.headers, compression: this.compressionFrom(response.headers) };
278
295
  }
279
296
  async chat(model, messages, options = {}) {
@@ -294,6 +311,11 @@ export class OmniRouteClient {
294
311
  // (the combo may have routed anywhere); fall back to the requested id.
295
312
  const answered = typeof payload.model === 'string' && payload.model.trim() !== '' ? payload.model : model;
296
313
  const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls.map((call) => this.asToolCall(call)).filter((call) => call !== null) : undefined;
314
+ this.recordCompletion(response.headers, usage ? {
315
+ inputTokens: this.number(usage.prompt_tokens),
316
+ outputTokens: this.number(usage.completion_tokens),
317
+ totalTokens: this.number(usage.total_tokens),
318
+ } : undefined);
297
319
  return {
298
320
  content: typeof message.content === 'string' ? message.content : '',
299
321
  model: answered,
@@ -309,6 +331,50 @@ export class OmniRouteClient {
309
331
  compression: this.compressionFrom(response.headers),
310
332
  };
311
333
  }
334
+ /**
335
+ * OmniRoute's cost-telemetry set for one completion, or undefined when the
336
+ * headers carry no token counts. A stream's initial headers hold zeros for
337
+ * every field the gateway could not know yet, and zeros are treated as
338
+ * absent so they never overwrite a count read elsewhere.
339
+ */
340
+ usageFromHeaders(headers) {
341
+ const inputTokens = this.headerNumber(headers, 'x-omniroute-tokens-in') ?? 0;
342
+ const outputTokens = this.headerNumber(headers, 'x-omniroute-tokens-out') ?? 0;
343
+ if (inputTokens <= 0 && outputTokens <= 0)
344
+ return undefined;
345
+ const costUsd = this.headerNumber(headers, 'x-omniroute-response-cost');
346
+ const latencyMs = this.headerNumber(headers, 'x-omniroute-latency-ms');
347
+ return {
348
+ inputTokens: Math.max(0, inputTokens),
349
+ outputTokens: Math.max(0, outputTokens),
350
+ costUsd: costUsd !== undefined && costUsd > 0 ? costUsd : undefined,
351
+ latencyMs: latencyMs !== undefined && latencyMs > 0 ? latencyMs : undefined,
352
+ };
353
+ }
354
+ /**
355
+ * Fold one completion into the session's usage. Sources, most authoritative
356
+ * first: the SSE metadata trailer (final values of a stream), the response
357
+ * headers (final on a non-streaming reply, zeros on a stream), then the
358
+ * `usage` object in the body. Token counts come from one source only, so a
359
+ * reply that reports itself twice is still counted once.
360
+ */
361
+ recordCompletion(headers, body, trailer) {
362
+ const usage = this.metrics.usage;
363
+ const measured = (trailer && this.usageFromHeaders(trailer))
364
+ ?? this.usageFromHeaders(headers)
365
+ ?? (body && (body.inputTokens > 0 || body.outputTokens > 0) ? { inputTokens: body.inputTokens, outputTokens: body.outputTokens } : undefined);
366
+ if (!measured)
367
+ return;
368
+ if (measured.inputTokens > 0)
369
+ usage.contextTokens = measured.inputTokens;
370
+ usage.tokensIn += measured.inputTokens;
371
+ usage.tokensOut += measured.outputTokens;
372
+ if (measured.costUsd !== undefined)
373
+ usage.costUsd += measured.costUsd;
374
+ if (measured.latencyMs !== undefined)
375
+ usage.latencyMs = measured.latencyMs;
376
+ usage.updatedAt = new Date().toISOString();
377
+ }
312
378
  compressionFrom(headers) {
313
379
  const input = this.headerNumber(headers, 'x-omniroute-input-tokens');
314
380
  const compressed = this.headerNumber(headers, 'x-omniroute-compressed-tokens');
@@ -981,9 +981,12 @@ export function TerminalInterface({ engine }) {
981
981
  const contentWidth = Math.max(20, convoWidth - 6);
982
982
  const terminalRows = rows;
983
983
  const metrics = engine.client.snapshotMetrics();
984
- const meter = contextMeter(metrics.compression.inputTokens, engine.state.activeModel, metrics.fallback.activeProvider);
984
+ // The prompt tokens of the last completion, as the gateway counted them,
985
+ // are the size of the context the next turn will carry.
986
+ const contextTokens = metrics.usage?.contextTokens ?? 0;
987
+ const meter = contextMeter(contextTokens, engine.state.activeModel, metrics.fallback.activeProvider);
985
988
  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)}%` : '';
989
+ const contextLabel = contextTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
987
990
  const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
988
991
  const phase = busy && currentTool ? phaseFor(currentTool) : (busy ? 'working' : 'ready');
989
992
  const elapsedMs = busy && runStartedAt.current !== null ? Math.max(0, now - runStartedAt.current) : 0;
@@ -1009,7 +1012,7 @@ export function TerminalInterface({ engine }) {
1009
1012
  const liveThinkView = liveThinkLines.slice(-Math.max(2, Math.floor(liveBudget / 2)));
1010
1013
  const liveAnswerView = liveAnswerLines.slice(-liveBudget);
1011
1014
  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 });
1015
+ 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
1016
  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
1017
  const expanded = expandedTool === card.id;
1015
1018
  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.84",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",