troxy-cli 1.29.44 → 1.29.45

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.44",
3
+ "version": "1.29.45",
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": {
@@ -309,12 +309,20 @@ async function scorePrompt($, e, next) {
309
309
  return next(e);
310
310
  }
311
311
 
312
+ // The model the session last asked for, per session, so a /model switch is seen.
313
+ const lastAsked = new Map();
314
+
312
315
  async function* routeStep($, e, next) {
313
316
  // Main loop only: a subagent keeps whatever model it was given.
314
317
  if (!decision || e.agentId) return yield* next(e);
318
+ // The user picked a different model since the last turn: their choice wins
319
+ // for this turn (the proxy does the same, route_reason 'user_switch').
320
+ const sid = await $.session.id();
321
+ const prev = lastAsked.get(sid);
322
+ lastAsked.set(sid, e.model);
323
+ if (prev && prev !== e.model) { decision = null; return yield* next(e); }
315
324
  const model = pickModel(decision.score, e.model, decision.tokens);
316
325
  if (model !== e.model) {
317
- const sid = await $.session.id();
318
326
  const file = modRouteFile($.plugin.root, sid);
319
327
  const key = `${sid}:${e.model}>${model}`;
320
328
  if (file && lastNote !== key) {
@@ -388,4 +396,4 @@ export function register(on) {
388
396
  }
389
397
 
390
398
  // Exported for tests only - not part of the mod's runtime contract.
391
- export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings };
399
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep };
@@ -142,3 +142,36 @@ test('fetchSavings: sends the saved key and the session id; null without a key o
142
142
  assert.equal(await fetchSavings(mk({ env: { HOME: '/h' }, config: {} })), null);
143
143
  assert.equal(await fetchSavings({ ...mk(), session: { id: async () => 'x' } }), null);
144
144
  });
145
+
146
+ // A fake $ just big enough for scorePrompt + routeStep: the scorer says "easy"
147
+ // (score 0.1) and the context is small, so the mod would route down to haiku.
148
+ function routingApi() {
149
+ const kv = new Map();
150
+ return {
151
+ state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
152
+ store: { get: async () => undefined },
153
+ session: { id: async () => 'sess-switch-test', usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
154
+ plugin: { root: '/tmp/not-installed' },
155
+ model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
156
+ ui: { log: () => {} },
157
+ clock: { now: async () => 0 },
158
+ fs: { write: async () => {} },
159
+ };
160
+ }
161
+
162
+ async function runTurn($, model) {
163
+ const { scorePrompt, routeStep } = _internal;
164
+ await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
165
+ let sent;
166
+ const gen = routeStep($, { model }, async function* (e) { sent = e.model; });
167
+ for await (const _ of gen) { /* drain */ }
168
+ return sent;
169
+ }
170
+
171
+ test('routeStep: a /model switch is honored for that turn, then routing resumes', async () => {
172
+ const $ = routingApi();
173
+ const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
174
+ assert.equal(await runTurn($, opus), haiku); // first turn: routed down as before
175
+ assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
176
+ assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
177
+ });