troxy-cli 1.29.45 → 1.29.46

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.45",
3
+ "version": "1.29.46",
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": {
@@ -91,16 +91,27 @@ function parseScore(text) {
91
91
  return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : null;
92
92
  }
93
93
 
94
- function pickModel(score, requested, contextTokens) {
94
+ function pickModel(score, requested, contextTokens, warm) {
95
95
  const rq = tierOf(requested);
96
96
  if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
97
+ // No score (scorer failed or echoed the prompt): stay on the warm model at any
98
+ // size, since a switch would re-write the cache for no reason.
99
+ if (score === null) {
100
+ const w = tierOf(warm);
101
+ return w !== null && RANK[w] <= RANK[rq] ? warm : requested;
102
+ }
97
103
  let tier = rq;
98
104
  for (const [limit, name] of BANDS) {
99
105
  if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
100
106
  }
101
107
  // The cache is per model: the first prompt on a new model re-writes the whole
102
- // conversation, so past the floor a switch costs more than one cheap turn saves.
103
- if (tier !== rq && contextTokens > ROUTE_CONTEXT_FLOOR_TOKENS) tier = rq;
108
+ // conversation, so past the floor stay on the model the cache is warm on
109
+ // (`warm`, what this session last ran), unless the prompt needs a stronger one.
110
+ if (contextTokens > ROUTE_CONTEXT_FLOOR_TOKENS) {
111
+ const w = tierOf(warm);
112
+ if (w === null || RANK[w] > RANK[rq]) tier = rq;
113
+ else if (RANK[tier] <= RANK[w]) tier = w;
114
+ }
104
115
  return tier === rq ? requested : MODELS[tier];
105
116
  }
106
117
 
@@ -198,7 +209,7 @@ function statusText({ version, paused, totals, rateLimits = [], compactOff = fal
198
209
  : 'This session: nothing moved to a cheaper model yet');
199
210
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
200
211
  if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
201
- lines.push(`Prompts with over ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context stay on your model (switching would rewrite the cache).`);
212
+ lines.push(`Past ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.`);
202
213
  lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
203
214
  : `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
204
215
  lines.push('All sessions: dash.troxy.io');
@@ -302,26 +313,31 @@ async function scorePrompt($, e, next) {
302
313
  );
303
314
  const score = r.isAnswered ? parseScore(r.text) : null;
304
315
  $.ui.log(`troxy scorer: ${r.isAnswered ? JSON.stringify(r.text) : r.reason} -> ${score}`, { to: 'debug' });
305
- if (score !== null) {
306
- const u = await $.session.usage();
307
- decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
308
- }
316
+ const u = await $.session.usage();
317
+ decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
309
318
  return next(e);
310
319
  }
311
320
 
312
321
  // The model the session last asked for, per session, so a /model switch is seen.
313
322
  const lastAsked = new Map();
323
+ // The model each session last ran on and when: its cache is warm for about an hour.
324
+ const lastSent = new Map();
325
+ const WARM_MS = 55 * 60 * 1000;
314
326
 
315
327
  async function* routeStep($, e, next) {
316
328
  // Main loop only: a subagent keeps whatever model it was given.
317
- if (!decision || e.agentId) return yield* next(e);
329
+ if (e.agentId) return yield* next(e);
330
+ const sid = await $.session.id();
331
+ const now = await $.clock.now();
318
332
  // The user picked a different model since the last turn: their choice wins
319
333
  // for this turn (the proxy does the same, route_reason 'user_switch').
320
- const sid = await $.session.id();
321
334
  const prev = lastAsked.get(sid);
322
335
  lastAsked.set(sid, e.model);
323
- if (prev && prev !== e.model) { decision = null; return yield* next(e); }
324
- const model = pickModel(decision.score, e.model, decision.tokens);
336
+ if (prev && prev !== e.model) decision = null;
337
+ if (!decision) { lastSent.set(sid, { model: e.model, at: now }); return yield* next(e); }
338
+ const ls = lastSent.get(sid);
339
+ const model = pickModel(decision.score, e.model, decision.tokens, ls && now - ls.at < WARM_MS ? ls.model : null);
340
+ lastSent.set(sid, { model, at: now });
325
341
  if (model !== e.model) {
326
342
  const file = modRouteFile($.plugin.root, sid);
327
343
  const key = `${sid}:${e.model}>${model}`;
@@ -98,7 +98,7 @@ test('statusText: version, routing, this session, plan usage, where the account
98
98
  'Troxy 1.29.42 · routing is on\n'
99
99
  + 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
100
100
  + 'Plan usage now: 5h 23% · 7d 77%\n'
101
- + 'Prompts with over 50k tokens of context stay on your model (switching would rewrite the cache).\n'
101
+ + 'Past 50k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.\n'
102
102
  + 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
103
103
  + 'All sessions: dash.troxy.io\n'
104
104
  + 'Type /troxy off to pause routing.');
@@ -147,10 +147,11 @@ test('fetchSavings: sends the saved key and the session id; null without a key o
147
147
  // (score 0.1) and the context is small, so the mod would route down to haiku.
148
148
  function routingApi() {
149
149
  const kv = new Map();
150
+ const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
150
151
  return {
151
152
  state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
152
153
  store: { get: async () => undefined },
153
- session: { id: async () => 'sess-switch-test', usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
154
+ session: { id: async () => sid, usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
154
155
  plugin: { root: '/tmp/not-installed' },
155
156
  model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
156
157
  ui: { log: () => {} },
@@ -175,3 +176,28 @@ test('routeStep: a /model switch is honored for that turn, then routing resumes'
175
176
  assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
176
177
  assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
177
178
  });
179
+
180
+ test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
181
+ const { pickModel, MODELS } = _internal;
182
+ const big = 80000;
183
+ assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.haiku); // easy: stay warm on haiku
184
+ assert.equal(pickModel(0.5, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // needs sonnet: step up
185
+ assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.opus), MODELS.opus); // warm on opus: no switch down
186
+ assert.equal(pickModel(0.1, MODELS.opus, big, null), MODELS.opus); // cold or unknown: as before
187
+ assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.haiku); // under the floor: route freely
188
+ });
189
+
190
+ test('routeStep: the session that crossed 50k on haiku stays on haiku (no cache rewrite)', async () => {
191
+ const $ = routingApi();
192
+ const { opus, haiku } = _internal.MODELS;
193
+ assert.equal(await runTurn($, opus), haiku);
194
+ $.session.usage = async () => ({ context: { tokens: 60000 }, rateLimits: [] });
195
+ assert.equal(await runTurn($, opus), haiku);
196
+ });
197
+
198
+ test('pickModel: no score never causes a switch away from the warm model past the floor', () => {
199
+ const { pickModel, MODELS } = _internal;
200
+ assert.equal(pickModel(null, MODELS.opus, 80000, MODELS.haiku), MODELS.haiku);
201
+ assert.equal(pickModel(null, MODELS.opus, 1000, MODELS.haiku), MODELS.haiku); // small context too
202
+ assert.equal(pickModel(null, MODELS.opus, 1000, null), MODELS.opus); // nothing warm: as asked
203
+ });