omniharness-cli 0.1.84 → 0.1.86
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/cli.js +6 -1
- package/dist/config/omniRoute.js +21 -5
- package/dist/ui/debounce.js +18 -0
- package/dist/ui/modelWindows.js +51 -5
- package/dist/ui/resizeDebounce.js +52 -0
- package/dist/ui/terminalInterface.js +22 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ownVersion, runUpdate } from './update.js';
|
|
|
7
7
|
import { readActiveCombo } from './config/settings.js';
|
|
8
8
|
import { OmniRouteClient } from './config/omniRoute.js';
|
|
9
9
|
import { doctor, helpText, models } from './doctor.js';
|
|
10
|
+
import { debounceResizeEvents } from './ui/resizeDebounce.js';
|
|
10
11
|
// A crash anywhere below would otherwise surface as a raw Node stack trace, or
|
|
11
12
|
// as an unhandled rejection that terminates the process without saying why.
|
|
12
13
|
// A CLI should fail with a sentence.
|
|
@@ -81,8 +82,12 @@ else {
|
|
|
81
82
|
// The cost is that quitting no longer restores the pre-launch screen. For
|
|
82
83
|
// a tool whose output you are meant to read back, that is the right trade.
|
|
83
84
|
//
|
|
85
|
+
// Coalesce resize delivery before Ink's own internal listener ever sees
|
|
86
|
+
// it — see resizeDebounce.ts. Only meaningful for a real terminal; a
|
|
87
|
+
// non-TTY stdout never emits 'resize' and patching it would be inert.
|
|
88
|
+
const stdout = process.stdout.isTTY ? debounceResizeEvents(process.stdout, 80) : process.stdout;
|
|
84
89
|
// The app owns Ctrl+C so idle quits but an in-flight run is cancelled first.
|
|
85
|
-
const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { exitOnCtrlC: false });
|
|
90
|
+
const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { stdout, exitOnCtrlC: false });
|
|
86
91
|
await waitUntilExit();
|
|
87
92
|
})();
|
|
88
93
|
}
|
package/dist/config/omniRoute.js
CHANGED
|
@@ -145,17 +145,27 @@ export class OmniRouteClient {
|
|
|
145
145
|
}
|
|
146
146
|
/** List every model id the gateway exposes, including `auto/*` virtual combos and individual providers. */
|
|
147
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) {
|
|
148
156
|
const response = await this.requestWithRetry('/v1/models', { method: 'GET', signal });
|
|
149
157
|
const payload = await response.json();
|
|
150
158
|
const data = this.isRecord(payload) && Array.isArray(payload.data) ? payload.data : [];
|
|
151
|
-
const
|
|
159
|
+
const entries = [];
|
|
152
160
|
for (const entry of data) {
|
|
153
|
-
if (this.isRecord(entry)
|
|
154
|
-
|
|
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 });
|
|
155
165
|
}
|
|
156
|
-
if (
|
|
166
|
+
if (entries.length === 0)
|
|
157
167
|
throw new OmniRouteError(response.status, 'invalid models response');
|
|
158
|
-
return
|
|
168
|
+
return entries;
|
|
159
169
|
}
|
|
160
170
|
/** Retry transient responses for idempotent metadata reads without touching chat/tool requests. */
|
|
161
171
|
async requestWithRetry(path, init) {
|
|
@@ -431,6 +441,9 @@ export class OmniRouteClient {
|
|
|
431
441
|
const provider = headers.get('x-omniroute-provider') ?? decision.provider;
|
|
432
442
|
if (provider)
|
|
433
443
|
fallback.activeProvider = provider;
|
|
444
|
+
const model = headers.get('x-omniroute-model');
|
|
445
|
+
if (model && model.trim() !== '')
|
|
446
|
+
fallback.model = model.trim();
|
|
434
447
|
if (decision.strategy)
|
|
435
448
|
fallback.strategy = decision.strategy;
|
|
436
449
|
if (decision.latencyMs !== undefined)
|
|
@@ -485,6 +498,9 @@ export class OmniRouteClient {
|
|
|
485
498
|
number(value) {
|
|
486
499
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
487
500
|
}
|
|
501
|
+
positive(value) {
|
|
502
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
503
|
+
}
|
|
488
504
|
safeParse(text) {
|
|
489
505
|
try {
|
|
490
506
|
return JSON.parse(text);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function debounce(fn, delayMs) {
|
|
2
|
+
let timer;
|
|
3
|
+
const debounced = ((...args) => {
|
|
4
|
+
if (timer)
|
|
5
|
+
clearTimeout(timer);
|
|
6
|
+
timer = setTimeout(() => {
|
|
7
|
+
timer = undefined;
|
|
8
|
+
fn(...args);
|
|
9
|
+
}, delayMs);
|
|
10
|
+
});
|
|
11
|
+
debounced.cancel = () => {
|
|
12
|
+
if (timer)
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
timer = undefined;
|
|
15
|
+
};
|
|
16
|
+
return debounced;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=debounce.js.map
|
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';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { debounce } from './debounce.js';
|
|
2
|
+
/**
|
|
3
|
+
* Coalesce `resize` event delivery on a TTY stream so every listener — Ink's
|
|
4
|
+
* own internal one included — sees one event per burst instead of one per raw
|
|
5
|
+
* OS-level tick.
|
|
6
|
+
*
|
|
7
|
+
* Ink attaches its own `resize` listener directly to whatever stream is
|
|
8
|
+
* handed to `render()`, and on every single tick it recalculates its Yoga
|
|
9
|
+
* layout and writes a fresh frame — independent of anything application state
|
|
10
|
+
* does. That is not reachable by debouncing a `useState` call in a component:
|
|
11
|
+
* the redraw happens inside Ink regardless.
|
|
12
|
+
*
|
|
13
|
+
* A maximise or restore on Windows Terminal does not deliver one resize
|
|
14
|
+
* event. It animates through the transition and fires a burst of
|
|
15
|
+
* intermediate sizes a few milliseconds apart. Reacting to each one means
|
|
16
|
+
* Ink redraws against a size that is already stale by the time the escape
|
|
17
|
+
* sequences reach the terminal, and the frames land on top of one another
|
|
18
|
+
* instead of replacing one another — duplicated prompt boxes, fragments of an
|
|
19
|
+
* earlier frame still on screen once the window settles.
|
|
20
|
+
*
|
|
21
|
+
* The only place to fix that is before Ink's own listener ever sees the
|
|
22
|
+
* event — so this patches `.on` / `.off` for `resize` specifically, on the
|
|
23
|
+
* same stream object Ink and application code both subscribe to. `write`,
|
|
24
|
+
* `columns`, `rows`, `isTTY` and every other event pass through untouched.
|
|
25
|
+
*/
|
|
26
|
+
export function debounceResizeEvents(stream, delayMs) {
|
|
27
|
+
const realOn = stream.on.bind(stream);
|
|
28
|
+
const realOff = stream.off.bind(stream);
|
|
29
|
+
// Keyed on the caller's own listener, so .off(listener) still finds and
|
|
30
|
+
// cancels the right debounced wrapper — the caller never sees the wrapper.
|
|
31
|
+
const wrapped = new WeakMap();
|
|
32
|
+
stream.on = ((event, listener) => {
|
|
33
|
+
if (event !== 'resize')
|
|
34
|
+
return realOn(event, listener);
|
|
35
|
+
const debounced = debounce(listener, delayMs);
|
|
36
|
+
wrapped.set(listener, debounced);
|
|
37
|
+
return realOn(event, debounced);
|
|
38
|
+
});
|
|
39
|
+
stream.off = ((event, listener) => {
|
|
40
|
+
if (event !== 'resize')
|
|
41
|
+
return realOff(event, listener);
|
|
42
|
+
const debounced = wrapped.get(listener);
|
|
43
|
+
if (debounced) {
|
|
44
|
+
debounced.cancel();
|
|
45
|
+
wrapped.delete(listener);
|
|
46
|
+
return realOff(event, debounced);
|
|
47
|
+
}
|
|
48
|
+
return realOff(event, listener);
|
|
49
|
+
});
|
|
50
|
+
return stream;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=resizeDebounce.js.map
|
|
@@ -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,9 +313,20 @@ 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(() => {
|
|
323
|
+
// The real fix for a resize storm (maximise/restore firing a burst of
|
|
324
|
+
// intermediate sizes) lives one layer down, in resizeDebounce.ts: it
|
|
325
|
+
// coalesces delivery of the raw 'resize' event on the stream itself,
|
|
326
|
+
// before Ink's own internal listener — which redraws unconditionally on
|
|
327
|
+
// every tick, independent of any component state — ever sees it. This
|
|
328
|
+
// handler can stay a plain listener because of that; by the time it
|
|
329
|
+
// fires, the event has already been coalesced upstream.
|
|
316
330
|
const onResize = () => {
|
|
317
331
|
setWidth(widthOf(stdout));
|
|
318
332
|
setRows(rowsOf(stdout));
|
|
@@ -419,7 +433,7 @@ export function TerminalInterface({ engine }) {
|
|
|
419
433
|
if (kittyTimer)
|
|
420
434
|
clearTimeout(kittyTimer);
|
|
421
435
|
stdin?.off('data', onProbe);
|
|
422
|
-
stdout.off('resize', onResize);
|
|
436
|
+
stdout.off('resize', onResize); // resizeDebounce cancels the coalesced timer on .off itself
|
|
423
437
|
const pendingApproval = approvalResolve.current;
|
|
424
438
|
approvalResolve.current = null;
|
|
425
439
|
pendingApproval?.({ approved: false });
|
|
@@ -434,7 +448,9 @@ export function TerminalInterface({ engine }) {
|
|
|
434
448
|
const loadPicker = async () => {
|
|
435
449
|
setPickerError(undefined);
|
|
436
450
|
try {
|
|
437
|
-
const [accountCombos,
|
|
451
|
+
const [accountCombos, catalog] = await Promise.all([engine.client.listCombos(), engine.client.listCatalog()]);
|
|
452
|
+
setWindows(windowIndex(catalog));
|
|
453
|
+
const modelIds = catalog.map((entry) => entry.id);
|
|
438
454
|
const items = [];
|
|
439
455
|
for (const combo of accountCombos) {
|
|
440
456
|
if (combo.name.trim() !== '' && !items.some((item) => item.id === combo.name)) {
|
|
@@ -984,7 +1000,9 @@ export function TerminalInterface({ engine }) {
|
|
|
984
1000
|
// The prompt tokens of the last completion, as the gateway counted them,
|
|
985
1001
|
// are the size of the context the next turn will carry.
|
|
986
1002
|
const contextTokens = metrics.usage?.contextTokens ?? 0;
|
|
987
|
-
|
|
1003
|
+
// Sized to the model that answered, when the gateway named one: an `auto/*`
|
|
1004
|
+
// engine or a combo can land anywhere, and the window is that model's.
|
|
1005
|
+
const meter = contextMeter(contextTokens, metrics.fallback.model ?? engine.state.activeModel, metrics.fallback.activeProvider, windows);
|
|
988
1006
|
const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
|
|
989
1007
|
const contextLabel = contextTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
|
|
990
1008
|
const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
|