troxy-cli 1.29.46 → 1.29.48
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.48",
|
|
4
4
|
"description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
|
|
5
5
|
"homepage": "https://troxy.io",
|
|
6
6
|
"bugs": {
|
package/src/daemon.js
CHANGED
|
@@ -105,7 +105,7 @@ export function startInterceptor({
|
|
|
105
105
|
routeResolver: (host, method, reqPath) => troxyRouteFor(providers, host, method, reqPath),
|
|
106
106
|
// Fresh from disk on every request, not cached at startup - a
|
|
107
107
|
// `troxy rotate-key` takes effect immediately, no daemon restart needed.
|
|
108
|
-
getTroxyKey: () => loadConfig()?.apiKey || null,
|
|
108
|
+
getTroxyKey: () => loadConfig()?.apiKey?.trim() || null,
|
|
109
109
|
getCerts: () => certs,
|
|
110
110
|
log: _log,
|
|
111
111
|
});
|
package/src/interceptor.js
CHANGED
|
@@ -104,7 +104,7 @@ export function detectSurface(incomingHeaders) {
|
|
|
104
104
|
const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
|
|
105
105
|
const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
|
|
106
106
|
// Written by the troxy-guard mod when it reroutes a prompt to a cheaper model:
|
|
107
|
-
// {
|
|
107
|
+
// { arm, from?, to?, ts } per session. Forwarded so the proxy can credit the saving to
|
|
108
108
|
// the model the user originally asked for (it only ever sees the rerouted one).
|
|
109
109
|
const _DEFAULT_MOD_ROUTE_DIR = path.join(os.homedir(), '.troxy', 'mod-route');
|
|
110
110
|
const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
@@ -112,12 +112,15 @@ const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
|
112
112
|
function _readModRoute(dir, sessionId) {
|
|
113
113
|
try {
|
|
114
114
|
const file = sessionArmPath(dir, sessionId);
|
|
115
|
-
if (!file) return null;
|
|
115
|
+
if (!file) return { arm: null, from: null, to: null };
|
|
116
116
|
const n = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// arm is per-session state and never expires; from/to is only valid if fresh.
|
|
118
|
+
const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
|
|
119
|
+
const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
|
|
120
|
+
const routed = isFresh && typeof n.from === 'string' && typeof n.to === 'string';
|
|
121
|
+
return { arm, from: routed ? n.from : null, to: routed ? n.to : null };
|
|
119
122
|
} catch {
|
|
120
|
-
return null;
|
|
123
|
+
return { arm: null, from: null, to: null };
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -172,10 +175,12 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
|
|
|
172
175
|
: _readFreshSandboxState(sandboxStateDir);
|
|
173
176
|
if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
|
|
174
177
|
const modRoute = _readModRoute(modRouteDir, incomingHeaders['x-claude-code-session-id']);
|
|
175
|
-
if (modRoute) {
|
|
178
|
+
if (modRoute && modRoute.from) {
|
|
176
179
|
headers['x-troxy-mod-routed-from'] = modRoute.from;
|
|
177
180
|
headers['x-troxy-mod-routed-to'] = modRoute.to;
|
|
178
181
|
}
|
|
182
|
+
// The mod is this session's one router: the proxy must not route it, and logs the arm.
|
|
183
|
+
if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
|
|
179
184
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
180
185
|
return headers;
|
|
181
186
|
}
|
|
@@ -279,13 +284,15 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
|
|
|
279
284
|
resolve(result);
|
|
280
285
|
};
|
|
281
286
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
let upstreamReq;
|
|
288
|
+
try {
|
|
289
|
+
upstreamReq = _clientForProtocol(route.upstream).request({
|
|
290
|
+
method: req.method,
|
|
291
|
+
hostname: url.hostname,
|
|
292
|
+
port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
293
|
+
path: url.pathname + search,
|
|
294
|
+
headers: buildTroxyForwardHeaders(req.headers, troxyKey),
|
|
295
|
+
}, (upstreamRes) => {
|
|
289
296
|
if (settled) return; // already failed open on a timeout; drop this late arrival
|
|
290
297
|
settled = true;
|
|
291
298
|
clearTimeout(connectTimer);
|
|
@@ -328,6 +335,11 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
|
|
|
328
335
|
|
|
329
336
|
upstreamReq.on('error', () => settle(false));
|
|
330
337
|
upstreamReq.end(body);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Invalid header value (e.g. a trailing newline in the key) throws synchronously: fail open.
|
|
340
|
+
log('troxy request rejected before sending: %s', err.message);
|
|
341
|
+
settle(false);
|
|
342
|
+
}
|
|
331
343
|
});
|
|
332
344
|
}
|
|
333
345
|
|
|
@@ -148,11 +148,13 @@ function bar(fraction) {
|
|
|
148
148
|
// [██████░░░░] This session: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate
|
|
149
149
|
function savedLine(money, rateLimits = []) {
|
|
150
150
|
const paid = money && money.cost_paid_usd, kept = money && money.dollars_saved_usd;
|
|
151
|
-
if (!(paid > 0)
|
|
151
|
+
if (!(paid > 0)) return undefined;
|
|
152
|
+
const windows = rateLimits.filter(w => WINDOW_LABEL[w.kind]);
|
|
153
|
+
const hasMovement = windows.some(w => Math.round(w.percentUsed) >= 1);
|
|
154
|
+
if (!(kept > 0) && !hasMovement) return undefined;
|
|
152
155
|
const scale = (paid + kept) / paid;
|
|
153
156
|
const parts = [`This session: your bar lasts ${scale.toFixed(scale < 1.1 ? 2 : 1)}x longer`];
|
|
154
|
-
|
|
155
|
-
if (windows.some(w => Math.round(w.percentUsed) >= 1)) {
|
|
157
|
+
if (hasMovement) {
|
|
156
158
|
for (const w of windows) {
|
|
157
159
|
const used = Math.round(w.percentUsed);
|
|
158
160
|
parts.push(`${WINDOW_LABEL[w.kind]}: ${used < 1 ? 'not moved yet' : `${used} instead of ${Math.round(used * scale)}`}`);
|
|
@@ -173,8 +175,8 @@ function sessionLine(t) {
|
|
|
173
175
|
// by the server. undefined until something was saved.
|
|
174
176
|
function savingsText(s) {
|
|
175
177
|
const c = (s && s.compressed_tokens) || 0, t = (s && s.trimmed_tokens) || 0;
|
|
176
|
-
if (!(c + t > 0)) return undefined;
|
|
177
178
|
const k = n => n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
179
|
+
// Always show, even if 0, so the band is transparent: 0 means no trimming/compression on this turn
|
|
178
180
|
return `${k(c + t)} tokens saved by compression and trimming`;
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -209,7 +211,7 @@ function statusText({ version, paused, totals, rateLimits = [], compactOff = fal
|
|
|
209
211
|
: 'This session: nothing moved to a cheaper model yet');
|
|
210
212
|
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
211
213
|
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
212
|
-
lines.push(
|
|
214
|
+
lines.push('The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.');
|
|
213
215
|
lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
|
|
214
216
|
: `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
|
|
215
217
|
lines.push('All sessions: dash.troxy.io');
|
|
@@ -323,30 +325,49 @@ const lastAsked = new Map();
|
|
|
323
325
|
// The model each session last ran on and when: its cache is warm for about an hour.
|
|
324
326
|
const lastSent = new Map();
|
|
325
327
|
const WARM_MS = 55 * 60 * 1000;
|
|
328
|
+
// Sessions whose next step is a cold moment because the conversation was just compacted.
|
|
329
|
+
const coldNext = new Set();
|
|
330
|
+
// One router per session, measured: a holdout session is never routed (the proxy
|
|
331
|
+
// doesn't route mod sessions either), so treated vs holdout is a clean A/B.
|
|
332
|
+
const ARM = { plugin: 'troxy-guard', key: 'arm' };
|
|
333
|
+
const MOD_HOLDOUT_FRACTION = 0.1;
|
|
334
|
+
|
|
335
|
+
// Which model a step runs on. Decide only at a cold moment (first step, 55+ min
|
|
336
|
+
// idle, after a compaction): the cache is cold anyway, so any model is free to
|
|
337
|
+
// pick, at any context size. In between, hold the warm model: no switch, no
|
|
338
|
+
// cache rewrite. The user's own /model always wins and becomes the held model.
|
|
339
|
+
function stepModel({ arm, requested, switched, cold, score, warm }) {
|
|
340
|
+
if (arm !== 'treated' || switched) return requested;
|
|
341
|
+
if (cold) return pickModel(score, requested, 0, null);
|
|
342
|
+
const w = tierOf(warm), rq = tierOf(requested);
|
|
343
|
+
return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
344
|
+
}
|
|
326
345
|
|
|
327
346
|
async function* routeStep($, e, next) {
|
|
328
347
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
329
348
|
if (e.agentId) return yield* next(e);
|
|
330
349
|
const sid = await $.session.id();
|
|
331
350
|
const now = await $.clock.now();
|
|
332
|
-
|
|
333
|
-
|
|
351
|
+
let { value: arm } = await $.state.get(ARM);
|
|
352
|
+
if (!arm) { arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated'; await $.state.set(ARM, arm); }
|
|
334
353
|
const prev = lastAsked.get(sid);
|
|
335
354
|
lastAsked.set(sid, e.model);
|
|
336
|
-
if (prev && prev !== e.model) decision = null;
|
|
337
|
-
if (!decision) { lastSent.set(sid, { model: e.model, at: now }); return yield* next(e); }
|
|
338
355
|
const ls = lastSent.get(sid);
|
|
339
|
-
const model =
|
|
356
|
+
const model = (await $.store.get('paused')) ? e.model : stepModel({
|
|
357
|
+
arm, requested: e.model, switched: Boolean(prev && prev !== e.model),
|
|
358
|
+
cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
|
|
359
|
+
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
360
|
+
});
|
|
361
|
+
coldNext.delete(sid);
|
|
340
362
|
lastSent.set(sid, { model, at: now });
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
if (turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
|
|
363
|
+
// The daemon tags every call with the arm, plus from/to when the model changed.
|
|
364
|
+
const file = modRouteFile($.plugin.root, sid);
|
|
365
|
+
const key = `${sid}:${arm}:${e.model}>${model}`;
|
|
366
|
+
if (file && lastNote !== key) {
|
|
367
|
+
lastNote = key;
|
|
368
|
+
await $.fs.write(file, JSON.stringify(model === e.model ? { arm, ts: now } : { arm, from: e.model, to: model, ts: now }));
|
|
349
369
|
}
|
|
370
|
+
if (model !== e.model && turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
|
|
350
371
|
return yield* next(model === e.model ? e : { ...e, model });
|
|
351
372
|
}
|
|
352
373
|
|
|
@@ -379,6 +400,11 @@ export function register(on) {
|
|
|
379
400
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
380
401
|
// it just means the requested model is used as-is.
|
|
381
402
|
on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
|
|
403
|
+
on('session.compact', async ($, e, next) => {
|
|
404
|
+
const r = await next(e);
|
|
405
|
+
if (!e.agentId) coldNext.add(await $.session.id()); // compacted: the next step is a cold moment
|
|
406
|
+
return r;
|
|
407
|
+
});
|
|
382
408
|
on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
|
|
383
409
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
384
410
|
const { value: line } = await $.state.get(LINE); // subscribes the band to the line
|
|
@@ -412,4 +438,4 @@ export function register(on) {
|
|
|
412
438
|
}
|
|
413
439
|
|
|
414
440
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
415
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep };
|
|
441
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type BarLine = string | null
|
|
2
|
+
export type ModArm = 'treated' | 'holdout' | null
|
|
2
3
|
export type SessionSaved = { paid: number; full: number; meter: Record<string, number> }
|
|
3
4
|
|
|
4
5
|
declare module 'claude-code' {
|
|
5
6
|
interface PluginState {
|
|
6
|
-
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine }
|
|
7
|
+
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine; arm: ModArm }
|
|
7
8
|
}
|
|
8
9
|
}
|
|
@@ -149,6 +149,20 @@ describe('buildTroxyForwardHeaders', () => {
|
|
|
149
149
|
note(dir, { from: 'a', to: 'b', ts: Date.now() });
|
|
150
150
|
assert.equal(build(dir, '../etc/passwd')['x-troxy-mod-routed-from'], undefined);
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it('tags the arm on every call, with or without a reroute', () => {
|
|
154
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
|
|
155
|
+
note(dir, { arm: 'holdout', ts: Date.now() });
|
|
156
|
+
let h = build(dir);
|
|
157
|
+
assert.equal(h['x-troxy-mod-arm'], 'holdout');
|
|
158
|
+
assert.equal(h['x-troxy-mod-routed-from'], undefined);
|
|
159
|
+
note(dir, { arm: 'treated', from: 'claude-opus-5-5', to: 'claude-haiku-4-5-20251001', ts: Date.now() });
|
|
160
|
+
h = build(dir);
|
|
161
|
+
assert.equal(h['x-troxy-mod-arm'], 'treated');
|
|
162
|
+
assert.equal(h['x-troxy-mod-routed-to'], 'claude-haiku-4-5-20251001');
|
|
163
|
+
note(dir, { arm: 'bogus', ts: Date.now() });
|
|
164
|
+
assert.equal(build(dir)['x-troxy-mod-arm'], undefined);
|
|
165
|
+
});
|
|
152
166
|
});
|
|
153
167
|
|
|
154
168
|
it('omits x-troxy-tool-sandbox header when no state file exists', () => {
|
|
@@ -702,3 +716,58 @@ describe('buildTroxyForwardHeaders session arm', () => {
|
|
|
702
716
|
assert.equal('x-troxy-tool-sandbox' in headers, false);
|
|
703
717
|
});
|
|
704
718
|
});
|
|
719
|
+
|
|
720
|
+
describe('interceptor fails open on invalid header values', () => {
|
|
721
|
+
const openServers = [];
|
|
722
|
+
after(async () => {
|
|
723
|
+
openServers.forEach(s => s.close());
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('handles API key with trailing newline gracefully without crashing', async () => {
|
|
727
|
+
// Simulates daemon.js config loading a key with a trailing newline,
|
|
728
|
+
// before the trim fix. The interceptor must fail open, not crash.
|
|
729
|
+
const targetServer = net.createServer((socket) => {
|
|
730
|
+
socket.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK');
|
|
731
|
+
socket.end();
|
|
732
|
+
});
|
|
733
|
+
await new Promise(r => targetServer.listen(0, '127.0.0.1', r));
|
|
734
|
+
openServers.push(targetServer);
|
|
735
|
+
const targetPort = targetServer.address().port;
|
|
736
|
+
|
|
737
|
+
const interceptor = createInterceptor({
|
|
738
|
+
interceptHosts: ['api.anthropic.com'],
|
|
739
|
+
routeResolver: (host, method, reqPath) => {
|
|
740
|
+
if (host === 'api.anthropic.com') {
|
|
741
|
+
return { method, path: '/v1/messages', upstream: `http://127.0.0.1:${targetPort}` };
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
},
|
|
745
|
+
// Return a key with trailing newline - simulates the bug before daemon.js trim
|
|
746
|
+
getTroxyKey: () => 'txy-test-key\n',
|
|
747
|
+
getCerts: () => null, // No TLS for this test, it goes straight to HTTP upstream
|
|
748
|
+
log: () => {},
|
|
749
|
+
});
|
|
750
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
751
|
+
openServers.push(interceptor);
|
|
752
|
+
|
|
753
|
+
// Make a request through the proxy to the anthropic host
|
|
754
|
+
// The invalid header should cause a catch in _relayToTroxy, which should
|
|
755
|
+
// settle(false) to fail open, not crash the server.
|
|
756
|
+
const response = await new Promise((resolve, reject) => {
|
|
757
|
+
const req = net.createConnection(
|
|
758
|
+
{ host: '127.0.0.1', port: interceptor.address().port },
|
|
759
|
+
() => {
|
|
760
|
+
req.write('GET / HTTP/1.1\r\nHost: api.anthropic.com\r\nConnection: close\r\n\r\n');
|
|
761
|
+
}
|
|
762
|
+
);
|
|
763
|
+
let body = '';
|
|
764
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
765
|
+
req.on('end', () => resolve(body));
|
|
766
|
+
req.on('error', reject);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Verify we got a response (either direct relay or error response),
|
|
770
|
+
// and the server didn't crash
|
|
771
|
+
assert.ok(response.length > 0, 'should get a response, not crash');
|
|
772
|
+
});
|
|
773
|
+
});
|
|
@@ -74,7 +74,8 @@ test('savedLine: how much longer the bar lasts, then the meter or the dollars',
|
|
|
74
74
|
'[███░░░░░░░] This session: your bar lasts 3.8x longer · $4.53 saved of $6.17 at list price · estimate');
|
|
75
75
|
assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, []),
|
|
76
76
|
'[█████████░] This session: your bar lasts 1.09x longer · $8.00 saved of $100.00 at list price · estimate');
|
|
77
|
-
assert.equal(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 0 }, meter),
|
|
77
|
+
assert.equal(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 0 }, meter),
|
|
78
|
+
'[██████████] This session: your bar lasts 1.00x longer · 5h: 19 instead of 19 · 7d: 12 instead of 12 · estimate');
|
|
78
79
|
assert.equal(savedLine(null, meter), undefined);
|
|
79
80
|
});
|
|
80
81
|
|
|
@@ -98,7 +99,7 @@ test('statusText: version, routing, this session, plan usage, where the account
|
|
|
98
99
|
'Troxy 1.29.42 · routing is on\n'
|
|
99
100
|
+ 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
|
|
100
101
|
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
101
|
-
+ '
|
|
102
|
+
+ 'The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.\n'
|
|
102
103
|
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
103
104
|
+ 'All sessions: dash.troxy.io\n'
|
|
104
105
|
+ 'Type /troxy off to pause routing.');
|
|
@@ -119,12 +120,12 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
|
|
|
119
120
|
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
test('savingsText: shows compression + trimming together,
|
|
123
|
+
test('savingsText: shows compression + trimming together, 0 when nothing was saved', () => {
|
|
123
124
|
const { savingsText } = _internal;
|
|
124
125
|
assert.equal(savingsText({ compressed_tokens: 5200, trimmed_tokens: 40100 }), '45k tokens saved by compression and trimming');
|
|
125
126
|
assert.equal(savingsText({ compressed_tokens: 300, trimmed_tokens: 0 }), '300 tokens saved by compression and trimming');
|
|
126
|
-
assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }),
|
|
127
|
-
assert.equal(savingsText(null),
|
|
127
|
+
assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), '0 tokens saved by compression and trimming');
|
|
128
|
+
assert.equal(savingsText(null), '0 tokens saved by compression and trimming');
|
|
128
129
|
});
|
|
129
130
|
|
|
130
131
|
test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
|
|
@@ -146,7 +147,7 @@ test('fetchSavings: sends the saved key and the session id; null without a key o
|
|
|
146
147
|
// A fake $ just big enough for scorePrompt + routeStep: the scorer says "easy"
|
|
147
148
|
// (score 0.1) and the context is small, so the mod would route down to haiku.
|
|
148
149
|
function routingApi() {
|
|
149
|
-
const kv = new Map();
|
|
150
|
+
const kv = new Map([['arm', 'treated']]);
|
|
150
151
|
const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
|
|
151
152
|
return {
|
|
152
153
|
state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
|
|
@@ -169,12 +170,23 @@ async function runTurn($, model) {
|
|
|
169
170
|
return sent;
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
test('routeStep: a /model switch
|
|
173
|
+
test('routeStep: a /model switch wins and becomes the held model', async () => {
|
|
173
174
|
const $ = routingApi();
|
|
174
175
|
const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
|
|
175
|
-
assert.equal(await runTurn($, opus), haiku); // first turn: routed down
|
|
176
|
+
assert.equal(await runTurn($, opus), haiku); // first turn is cold: routed down
|
|
176
177
|
assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
|
|
177
|
-
assert.equal(await runTurn($, sonnet),
|
|
178
|
+
assert.equal(await runTurn($, sonnet), sonnet); // warm on sonnet: held, no switch
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('stepModel: decide only at cold moments, hold in between, holdout never routes', () => {
|
|
182
|
+
const { stepModel, MODELS: M } = _internal;
|
|
183
|
+
const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.haiku };
|
|
184
|
+
assert.equal(stepModel({ ...base, cold: true, score: 0.5 }), M.sonnet); // cold: pick by score, any size
|
|
185
|
+
assert.equal(stepModel({ ...base, cold: false, score: 0.9 }), M.haiku); // warm: hold, even for a hard prompt
|
|
186
|
+
assert.equal(stepModel({ ...base, cold: false, score: null }), M.haiku); // warm, no score: hold
|
|
187
|
+
assert.equal(stepModel({ ...base, cold: true, score: null }), M.opus); // cold, no score: as asked
|
|
188
|
+
assert.equal(stepModel({ ...base, arm: 'holdout', cold: true, score: 0.1 }), M.opus);
|
|
189
|
+
assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
|
|
178
190
|
});
|
|
179
191
|
|
|
180
192
|
test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
|