troxy-cli 1.29.44 → 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.
|
|
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
|
|
103
|
-
|
|
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(`
|
|
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,19 +313,32 @@ 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
|
-
|
|
306
|
-
|
|
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
|
|
|
321
|
+
// The model the session last asked for, per session, so a /model switch is seen.
|
|
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;
|
|
326
|
+
|
|
312
327
|
async function* routeStep($, e, next) {
|
|
313
328
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
314
|
-
if (
|
|
315
|
-
const
|
|
329
|
+
if (e.agentId) return yield* next(e);
|
|
330
|
+
const sid = await $.session.id();
|
|
331
|
+
const now = await $.clock.now();
|
|
332
|
+
// The user picked a different model since the last turn: their choice wins
|
|
333
|
+
// for this turn (the proxy does the same, route_reason 'user_switch').
|
|
334
|
+
const prev = lastAsked.get(sid);
|
|
335
|
+
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
|
+
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 });
|
|
316
341
|
if (model !== e.model) {
|
|
317
|
-
const sid = await $.session.id();
|
|
318
342
|
const file = modRouteFile($.plugin.root, sid);
|
|
319
343
|
const key = `${sid}:${e.model}>${model}`;
|
|
320
344
|
if (file && lastNote !== key) {
|
|
@@ -388,4 +412,4 @@ export function register(on) {
|
|
|
388
412
|
}
|
|
389
413
|
|
|
390
414
|
// 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 };
|
|
415
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep };
|
|
@@ -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
|
-
+ '
|
|
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.');
|
|
@@ -142,3 +142,62 @@ 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
|
+
const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
|
|
151
|
+
return {
|
|
152
|
+
state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
|
|
153
|
+
store: { get: async () => undefined },
|
|
154
|
+
session: { id: async () => sid, usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
|
|
155
|
+
plugin: { root: '/tmp/not-installed' },
|
|
156
|
+
model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
|
|
157
|
+
ui: { log: () => {} },
|
|
158
|
+
clock: { now: async () => 0 },
|
|
159
|
+
fs: { write: async () => {} },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runTurn($, model) {
|
|
164
|
+
const { scorePrompt, routeStep } = _internal;
|
|
165
|
+
await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
|
|
166
|
+
let sent;
|
|
167
|
+
const gen = routeStep($, { model }, async function* (e) { sent = e.model; });
|
|
168
|
+
for await (const _ of gen) { /* drain */ }
|
|
169
|
+
return sent;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test('routeStep: a /model switch is honored for that turn, then routing resumes', async () => {
|
|
173
|
+
const $ = routingApi();
|
|
174
|
+
const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
|
|
175
|
+
assert.equal(await runTurn($, opus), haiku); // first turn: routed down as before
|
|
176
|
+
assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
|
|
177
|
+
assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
|
|
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
|
+
});
|