llm-switcher 1.4.0 → 1.6.0
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/CHANGELOG.md +79 -0
- package/README.md +42 -3
- package/README.vi.md +42 -3
- package/claudesession.mjs +196 -0
- package/codexsession.mjs +141 -0
- package/idlecall.mjs +67 -0
- package/idlecompact.mjs +284 -0
- package/package.json +1 -1
- package/proxy.mjs +178 -1
- package/switch.mjs +54 -0
- package/tests/idlecompact.test.mjs +328 -0
- package/tests/update.test.mjs +92 -9
- package/ui.html +95 -0
- package/update.mjs +29 -8
package/idlecompact.mjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Idle-triggered compaction.
|
|
2
|
+
//
|
|
3
|
+
// A conversation that pauses long enough loses the provider's prompt cache. The next request pays
|
|
4
|
+
// full price for a prefix the provider no longer holds, and it pays it again on the turn after
|
|
5
|
+
// that if nothing changes. How long a provider keeps its cache is its own business and differs
|
|
6
|
+
// per provider: Anthropic 5 minutes by default and 1 hour when the request asks for it, OpenAI 30
|
|
7
|
+
// minutes, Gemini and DeepSeek publish no lifetime at all. So the time is a setting, not a
|
|
8
|
+
// constant, and the default is the longest of those that is documented rather than the shortest,
|
|
9
|
+
// because compacting a cache that is still warm throws history away and pays the cache write
|
|
10
|
+
// again for nothing.
|
|
11
|
+
//
|
|
12
|
+
// What this does with the result is the part that matters, and it depends on the client:
|
|
13
|
+
//
|
|
14
|
+
// - The request that is on its way out is shortened, so this turn costs the tokens of the
|
|
15
|
+
// summary instead of the tokens of the history. That saving is real whichever client it is.
|
|
16
|
+
// - For Claude Code the session file is also written, so the next resume starts from the
|
|
17
|
+
// summary instead of from the history. Claude Code reads that file to build every request, so
|
|
18
|
+
// the shortening lasts rather than lasting one turn.
|
|
19
|
+
//
|
|
20
|
+
// Codex needs neither half of that from here. Its compaction is a protocol item, not a file: a
|
|
21
|
+
// compaction_trigger in, one compaction output item out, and Codex stores it in its own history.
|
|
22
|
+
// The right place to serve that is the provider gateway, which can see the item, rather than a
|
|
23
|
+
// client-side gateway that would have to rewrite a thread file to reach the same state.
|
|
24
|
+
//
|
|
25
|
+
// Not every turn of a long session is compacted, and that is deliberate. Compacting on every turn
|
|
26
|
+
// would change the prefix every turn, so no turn would ever hit the cache, and the conversation
|
|
27
|
+
// would be summarized over and over. Compacting once, when a long pause says the cache is gone,
|
|
28
|
+
// keeps the summary stable for the turns that follow and spends one extra model call.
|
|
29
|
+
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
export const IDLE_COMPACT_KEY = 'idleCompact';
|
|
36
|
+
|
|
37
|
+
// Defaults. The idle time is the one setting worth being wrong about: too short and a warm cache
|
|
38
|
+
// is thrown away, too long and the history is paid for at full price on a turn that had no cache
|
|
39
|
+
// to lose anyway.
|
|
40
|
+
const DEFAULTS = {
|
|
41
|
+
enabled: false,
|
|
42
|
+
// Codex is off by default while Claude Code is the default on: a Codex compaction writes into
|
|
43
|
+
// Codex's own SQLite store, and that is opt-in per install on purpose.
|
|
44
|
+
codex: false,
|
|
45
|
+
model: '',
|
|
46
|
+
idleMinutes: 15,
|
|
47
|
+
minBytes: 64 * 1024,
|
|
48
|
+
keepRecent: 6,
|
|
49
|
+
summaryMaxChars: 24000,
|
|
50
|
+
sessionLookbackHours: 72,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function idleCompactPolicy(cfg) {
|
|
54
|
+
const raw = cfg?.[IDLE_COMPACT_KEY];
|
|
55
|
+
const p = { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) };
|
|
56
|
+
p.enabled = p.enabled === true;
|
|
57
|
+
p.codex = p.codex === true;
|
|
58
|
+
p.model = typeof p.model === 'string' ? p.model.trim() : '';
|
|
59
|
+
p.idleMinutes = Number.isFinite(+p.idleMinutes) && +p.idleMinutes > 0 ? +p.idleMinutes : DEFAULTS.idleMinutes;
|
|
60
|
+
p.minBytes = Number.isFinite(+p.minBytes) && +p.minBytes > 0 ? +p.minBytes : DEFAULTS.minBytes;
|
|
61
|
+
p.keepRecent = Number.isFinite(+p.keepRecent) && +p.keepRecent > 0 ? Math.floor(+p.keepRecent) : DEFAULTS.keepRecent;
|
|
62
|
+
p.summaryMaxChars = Number.isFinite(+p.summaryMaxChars) && +p.summaryMaxChars > 0
|
|
63
|
+
? Math.floor(+p.summaryMaxChars) : DEFAULTS.summaryMaxChars;
|
|
64
|
+
p.sessionLookbackHours = Number.isFinite(+p.sessionLookbackHours) && +p.sessionLookbackHours > 0
|
|
65
|
+
? +p.sessionLookbackHours : DEFAULTS.sessionLookbackHours;
|
|
66
|
+
p.idleMs = p.idleMinutes * 60000;
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- when a conversation was last here ----
|
|
71
|
+
|
|
72
|
+
// A gateway restart forgets every conversation, which costs one compaction and nothing else: the
|
|
73
|
+
// next request is treated as new, which is the safe direction to be wrong in.
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
|
|
76
|
+
function prune(now) {
|
|
77
|
+
const cutoff = now - DEFAULTS.sessionLookbackHours * 3600000;
|
|
78
|
+
for (const [k, t] of seen) if (t < cutoff) seen.delete(k);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Records that this conversation has just been answered. */
|
|
82
|
+
export function noteConversation(key, at = Date.now()) {
|
|
83
|
+
if (!key) return;
|
|
84
|
+
if (seen.size > 20000) prune(at);
|
|
85
|
+
seen.set(key, at);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Milliseconds since this conversation last asked for something, or 0 when it is not known. */
|
|
89
|
+
export function idleFor(key, now = Date.now()) {
|
|
90
|
+
if (!key) return 0;
|
|
91
|
+
const t = seen.get(key);
|
|
92
|
+
if (!t) return 0;
|
|
93
|
+
return now - t;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- which conversation this is ----
|
|
97
|
+
|
|
98
|
+
// Claude Code names its session in a header and again inside metadata.user_id. Codex names it in
|
|
99
|
+
// prompt_cache_key. A client that names nothing is keyed on the opening request instead, which is
|
|
100
|
+
// the one message a compaction does not remove, so the key survives the compaction itself.
|
|
101
|
+
export function conversationKey(_clientFormat, req, ir) {
|
|
102
|
+
const header = req?.headers?.['x-claude-code-session-id'];
|
|
103
|
+
if (header) return `h:${header}`;
|
|
104
|
+
const pck = ir?.promptCacheKey;
|
|
105
|
+
if (pck) return `p:${pck}`;
|
|
106
|
+
const uid = ir?.metadataUserId;
|
|
107
|
+
if (typeof uid === 'string') {
|
|
108
|
+
const m = /_session_([0-9a-f-]{8,})/i.exec(uid);
|
|
109
|
+
if (m) return `u:${m[1]}`;
|
|
110
|
+
if (/^[0-9a-f-]{36}$/i.test(uid)) return `u:${uid}`;
|
|
111
|
+
}
|
|
112
|
+
const first = firstUserText(ir);
|
|
113
|
+
if (first) return `c:${crypto.createHash('sha256').update(first).digest('hex').slice(0, 16)}`;
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The opening request is read from the IR rather than from the raw body, because by this point the
|
|
118
|
+
// body has been parsed once already and the IR is what the rest of the gateway works with.
|
|
119
|
+
function firstUserText(ir) {
|
|
120
|
+
for (const m of ir?.messages || []) {
|
|
121
|
+
if (m.role !== 'user' || m.role === 'tool') continue;
|
|
122
|
+
const t = textOf(m);
|
|
123
|
+
if (t) return t.slice(0, 2000);
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function textOf(msg) {
|
|
129
|
+
if (!msg) return '';
|
|
130
|
+
const c = msg.content;
|
|
131
|
+
if (typeof c === 'string') return c;
|
|
132
|
+
if (!Array.isArray(c)) return '';
|
|
133
|
+
let out = '';
|
|
134
|
+
for (const part of c) {
|
|
135
|
+
if (typeof part === 'string') { out += part; continue; }
|
|
136
|
+
if (!part || typeof part !== 'object') continue;
|
|
137
|
+
if (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text') out += part.text || '';
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- the history, shortened ----
|
|
143
|
+
|
|
144
|
+
// The middle keeps its words and loses its tool calls and their results, because those are most
|
|
145
|
+
// of the weight of a session and none of what a summary is for. The opening request and the last
|
|
146
|
+
// few messages are kept as they are: the opening request is what the session is for, and the last
|
|
147
|
+
// few messages are what the next turn reasons over.
|
|
148
|
+
export function compactIR(ir, policy) {
|
|
149
|
+
const msgs = ir.messages || [];
|
|
150
|
+
if (msgs.length < policy.keepRecent + 2) return null;
|
|
151
|
+
|
|
152
|
+
const head = msgs.findIndex(m => m.role === 'user' && m.role !== 'tool');
|
|
153
|
+
if (head < 0) return null;
|
|
154
|
+
let tail = msgs.length - policy.keepRecent;
|
|
155
|
+
if (tail <= head + 1) return null;
|
|
156
|
+
// A tool result whose call was cut is a request the provider refuses, so the tail never starts
|
|
157
|
+
// on one.
|
|
158
|
+
while (tail < msgs.length && isToolResult(msgs[tail])) tail++;
|
|
159
|
+
if (tail <= head + 1) return null;
|
|
160
|
+
|
|
161
|
+
const kept = [];
|
|
162
|
+
kept.push(...msgs.slice(0, head));
|
|
163
|
+
kept.push(msgs[head]);
|
|
164
|
+
const middle = [];
|
|
165
|
+
for (let i = head + 1; i < tail; i++) {
|
|
166
|
+
const m = msgs[i];
|
|
167
|
+
if (m.role === 'tool' || m.role === 'assistant' && m.toolCalls) continue;
|
|
168
|
+
const t = textOf(m).trim();
|
|
169
|
+
if (!t) continue;
|
|
170
|
+
middle.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: trimText(t, policy.userChars ?? 3000) });
|
|
171
|
+
}
|
|
172
|
+
kept.push(...middle);
|
|
173
|
+
kept.push(...msgs.slice(tail));
|
|
174
|
+
// Where a summary belongs: right after the opening request, before the text that stands in for
|
|
175
|
+
// the middle. That is where Claude Code puts its own, and a reader meets it as the first thing
|
|
176
|
+
// after being told what it is.
|
|
177
|
+
return {
|
|
178
|
+
messages: kept,
|
|
179
|
+
insertAt: head + 1,
|
|
180
|
+
middle: middle.length,
|
|
181
|
+
dropped: (tail - head - 1) - middle.length,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isToolResult(msg) {
|
|
186
|
+
if (!msg) return false;
|
|
187
|
+
if (msg.role === 'tool') return true;
|
|
188
|
+
return Array.isArray(msg.content) && msg.content.some(p => p && (p.type === 'tool_result'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function trimText(t, max) {
|
|
192
|
+
if (t.length <= max) return t;
|
|
193
|
+
const cut = t.slice(0, max);
|
|
194
|
+
const nl = cut.lastIndexOf('\n');
|
|
195
|
+
return (nl > max / 2 ? cut.slice(0, nl) : cut) + `\n[... ${t.length - max} more characters cut here ...]`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- the summary request ----
|
|
199
|
+
|
|
200
|
+
// The instruction names what a continuation needs, because "summarize this" produces something
|
|
201
|
+
// useless to the agent that has to keep working: the decisions and their reasons, the files that
|
|
202
|
+
// changed, the errors and their fixes, and what is still open.
|
|
203
|
+
export const SUMMARY_INSTRUCTION = 'Summarize this conversation so it can replace the history. ' +
|
|
204
|
+
'Keep: what was asked, the decisions made and why, the files and commands that changed things, ' +
|
|
205
|
+
'the errors and how they were resolved, and what is still pending. Drop: full tool output, ' +
|
|
206
|
+
'intermediate reasoning, and anything already superseded. Write plain prose a continuing agent ' +
|
|
207
|
+
'can act on without the original transcript.';
|
|
208
|
+
|
|
209
|
+
// The history handed to the summarizer is text only and already cut down, so the summarizer is not
|
|
210
|
+
// asked to read a megabyte of tool output to produce a paragraph.
|
|
211
|
+
export function summaryMessages(ir, policy) {
|
|
212
|
+
const compacted = compactIR(ir, policy) || { messages: (ir.messages || []).slice(-policy.keepRecent) };
|
|
213
|
+
const msgs = [{ role: 'user', content: [{ type: 'text', text: SUMMARY_INSTRUCTION }] }];
|
|
214
|
+
for (const m of compacted.messages) {
|
|
215
|
+
const t = textOf(m).trim();
|
|
216
|
+
if (t) msgs.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: [{ type: 'text', text: trimText(t, policy.userChars ?? 3000) }] });
|
|
217
|
+
}
|
|
218
|
+
return msgs;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What a session file records: the words of the summary, cut to the budget. */
|
|
222
|
+
export function clampSummary(text, policy) {
|
|
223
|
+
const s = String(text || '').trim();
|
|
224
|
+
if (s.length <= policy.summaryMaxChars) return s;
|
|
225
|
+
const cut = s.slice(0, policy.summaryMaxChars);
|
|
226
|
+
const nl = cut.lastIndexOf('\n\n');
|
|
227
|
+
if (nl > policy.summaryMaxChars / 2) return cut.slice(0, nl);
|
|
228
|
+
const dot = cut.lastIndexOf('. ');
|
|
229
|
+
if (dot > policy.summaryMaxChars / 2) return cut.slice(0, dot + 1);
|
|
230
|
+
return cut;
|
|
231
|
+
}
|
|
232
|
+
// ---- the Responses shape, where the history lives in the request ----
|
|
233
|
+
|
|
234
|
+
// Codex sends its history as an input array and reaches a Responses provider as the bytes it
|
|
235
|
+
// wrote, so its history is shortened here rather than through a renderer. Same rule as the
|
|
236
|
+
// intermediate form: the opening request and the recent turns stay as they are, the middle keeps
|
|
237
|
+
// its words and loses its calls and their output.
|
|
238
|
+
export function compactResponsesInput(input, policy) {
|
|
239
|
+
if (!Array.isArray(input) || input.length < policy.keepRecent + 2) return input;
|
|
240
|
+
const head = input.findIndex(isResponseMessage);
|
|
241
|
+
if (head < 0) return input;
|
|
242
|
+
let tail = input.length - policy.keepRecent;
|
|
243
|
+
if (tail <= head + 1) return input;
|
|
244
|
+
while (tail < input.length && isResponseResult(input[tail])) tail++;
|
|
245
|
+
if (tail <= head + 1) return input;
|
|
246
|
+
|
|
247
|
+
const kept = [];
|
|
248
|
+
kept.push(...input.slice(0, head));
|
|
249
|
+
kept.push(input[head]);
|
|
250
|
+
for (let i = head + 1; i < tail; i++) {
|
|
251
|
+
// A call, a result and a reasoning block are the weight of the session and none of the thread.
|
|
252
|
+
const t = responseText(input[i]);
|
|
253
|
+
if (!t) continue;
|
|
254
|
+
kept.push({
|
|
255
|
+
type: 'message',
|
|
256
|
+
role: input[i]?.role === 'assistant' ? 'assistant' : 'user',
|
|
257
|
+
content: [{ type: 'input_text', text: trimText(t, policy.userChars ?? 3000) }],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
kept.push(...input.slice(tail));
|
|
261
|
+
return kept;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isResponseMessage(item) {
|
|
265
|
+
return item && item.type === 'message' && item.role !== 'developer' && item.role !== 'system';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isResponseResult(item) {
|
|
269
|
+
return !!item && (item.type === 'function_call_output' || item.type === 'custom_tool_call_output'
|
|
270
|
+
|| item.type === 'local_shell_call_output');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function responseText(item) {
|
|
274
|
+
if (!isResponseMessage(item)) return '';
|
|
275
|
+
const c = item.content;
|
|
276
|
+
if (typeof c === 'string') return c;
|
|
277
|
+
if (!Array.isArray(c)) return '';
|
|
278
|
+
let out = '';
|
|
279
|
+
for (const b of c) {
|
|
280
|
+
if (b && typeof b === 'object' && typeof b.text === 'string' &&
|
|
281
|
+
['input_text', 'output_text', 'summary_text', 'text'].includes(b.type)) out += b.text;
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
11
11
|
import {
|
|
12
|
-
OUT_FORMATS, parseToIR, emitUpstreamBody, createUpstreamNormalizer, createCollector,
|
|
12
|
+
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES,
|
|
14
14
|
toGeminiSchema, isAntigravityModel,
|
|
15
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
@@ -32,6 +32,12 @@ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog
|
|
|
32
32
|
import { checkForUpdate } from './version.mjs';
|
|
33
33
|
import { applyUpdate } from './update.mjs';
|
|
34
34
|
import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
|
|
35
|
+
import {
|
|
36
|
+
idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR, compactResponsesInput, IDLE_COMPACT_KEY,
|
|
37
|
+
} from './idlecompact.mjs';
|
|
38
|
+
import { askSummary } from './idlecall.mjs';
|
|
39
|
+
import { claudeSessionId, findSessionFile, writeCompaction } from './claudesession.mjs';
|
|
40
|
+
import { writeCodexCompaction } from './codexsession.mjs';
|
|
35
41
|
|
|
36
42
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
43
|
const uiHtmlPath = path.join(__dirname, 'ui.html');
|
|
@@ -743,6 +749,124 @@ function cleanSchemaDeep(obj) {
|
|
|
743
749
|
// 1. Strip disallowed keywords ('encrypted', '$schema', 'cache_control')
|
|
744
750
|
// 2. Fix invalid schema values where a property has a string value "object" instead of a valid schema object
|
|
745
751
|
// 3. For ag/* targets (Gemini behind 9Router): rewrite to the strict Schema subset
|
|
752
|
+
// ---- idle compaction ----
|
|
753
|
+
|
|
754
|
+
// Claude Code only. Codex is left alone on purpose: its compaction is a protocol item, which the
|
|
755
|
+
// provider gateway answers, and the answer is stored by Codex in its own history. Shortening a
|
|
756
|
+
// Codex request here would replace that with a summary no client ever records, which costs the
|
|
757
|
+
// tokens of the summary and saves only one turn. The Antigravity CLI has no session file of its
|
|
758
|
+
// own to write, so there would be nothing to make the shortening last.
|
|
759
|
+
async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer) {
|
|
760
|
+
const policy = idleCompactPolicy(loadConfig());
|
|
761
|
+
const key = conversationKey(clientFormat, req, ir);
|
|
762
|
+
if (!key) return null;
|
|
763
|
+
const wants = clientFormat === 'anthropic'
|
|
764
|
+
? policy.enabled
|
|
765
|
+
: clientFormat === 'responses' && policy.enabled && policy.codex;
|
|
766
|
+
if (wants && idleFor(key) > policy.idleMs && payloadBytes(payload) >= policy.minBytes) {
|
|
767
|
+
const r = compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat });
|
|
768
|
+
// Recorded on the way through rather than on the way out, so a request the provider then
|
|
769
|
+
// fails still counts as the conversation having been here. What is being measured is the
|
|
770
|
+
// client's pause, not the provider's answer.
|
|
771
|
+
noteConversation(key);
|
|
772
|
+
return r;
|
|
773
|
+
}
|
|
774
|
+
noteConversation(key);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Measured from the parsed body rather than from Content-Length, which is absent on a chunked
|
|
779
|
+
// request and smaller than the body once it has been decoded.
|
|
780
|
+
function payloadBytes(payload) {
|
|
781
|
+
try { return JSON.stringify(payload).length; } catch { return 0; }
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function compactNow({ req, payload, ir, profile, profileKey, policy, key, bodyBuffer, clientFormat }) {
|
|
785
|
+
const compacted = compactIR(ir, policy);
|
|
786
|
+
if (!compacted) return null;
|
|
787
|
+
|
|
788
|
+
const before = JSON.stringify(ir.messages || []).length;
|
|
789
|
+
const mapped = mapModel(ir.model || payload.model || '', profile, 'anthropic');
|
|
790
|
+
const outFormat = resolveOutFormat(profile, mapped);
|
|
791
|
+
const build = (prof, model, ir2) => buildUpstreamRequest(prof, outFormat, ir2, model, req);
|
|
792
|
+
|
|
793
|
+
// A summary is only made when the reader named a model for it, and never with the conversation's
|
|
794
|
+
// own: that would spend the expensive model of an official account to save the tokens of the
|
|
795
|
+
// turn, which is a bad trade and, on a plan with limits, worse. With no model named there is
|
|
796
|
+
// still a saving, because the request leaves shortened, and without a summary there is nothing to
|
|
797
|
+
// write to the session file -- so a model call here would cost tokens and buy nothing.
|
|
798
|
+
const summary = policy.model
|
|
799
|
+
? askSummary({ ir, policy, profile, model: policy.model, build })
|
|
800
|
+
: Promise.resolve('');
|
|
801
|
+
|
|
802
|
+
// The request goes out shortened now, before the summary exists. The summary belongs in the
|
|
803
|
+
// next resume, not in this answer, so this turn carries the text that stands in for the middle
|
|
804
|
+
// and nothing else; a turn that also carried the summary would pay for it twice.
|
|
805
|
+
// Written back into the client's own body. Claude Code goes through the renderer this gateway
|
|
806
|
+
// already uses, because it is translated on the way out. Codex is not: a Responses client
|
|
807
|
+
// reaches a Responses provider as the bytes it sent, so its history is shortened where it
|
|
808
|
+
// lives, in the request itself.
|
|
809
|
+
const field = clientFormat === 'responses' ? 'input' : 'messages';
|
|
810
|
+
const rendered = clientFormat === 'responses'
|
|
811
|
+
? compactResponsesInput(payload.input, policy)
|
|
812
|
+
: irToAnthropicBody({ ...ir, messages: compacted.messages }, ir.model || payload.model).messages;
|
|
813
|
+
const next = { ...payload, [field]: rendered };
|
|
814
|
+
const nextBuf = Buffer.from(JSON.stringify(next), 'utf8');
|
|
815
|
+
|
|
816
|
+
console.log(
|
|
817
|
+
`[llm-switcher] idle compact ${key.slice(0, 12)} [${profileKey}]: ${before} -> ${JSON.stringify(rendered).length} bytes, ` +
|
|
818
|
+
`${compacted.middle} messages kept as text, ${compacted.dropped} dropped; summary call in flight`);
|
|
819
|
+
|
|
820
|
+
// The summary is not waited for. It serves the next resume, not this answer, and holding the
|
|
821
|
+
// client's turn open for a call nobody reads would make the saving feel like a cost. The file is
|
|
822
|
+
// written when it lands, which is also later than the response and therefore never races the
|
|
823
|
+
// entries Claude Code is appending right now.
|
|
824
|
+
Promise.resolve(summary).then(async (s) => {
|
|
825
|
+
if (!s) {
|
|
826
|
+
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: the summary call produced nothing; the request went out shortened anyway`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// Found by session id rather than by working directory: nothing in the request says what the
|
|
830
|
+
// client's directory is, and a compaction written to the wrong project is one nothing reads.
|
|
831
|
+
// Codex and Claude Code keep their history in different places, so each is written its own
|
|
832
|
+
// way: Codex in its rollout file and its store, Claude Code in its session file.
|
|
833
|
+
let written;
|
|
834
|
+
if (clientFormat === 'responses') {
|
|
835
|
+
written = await writeCodexCompaction({ summary: s });
|
|
836
|
+
} else {
|
|
837
|
+
const sessionId = claudeSessionId(req, payload);
|
|
838
|
+
const file = sessionId ? findSessionFile(sessionId) : '';
|
|
839
|
+
written = file
|
|
840
|
+
? writeCompaction({
|
|
841
|
+
file, summary: s, sessionId,
|
|
842
|
+
preTokens: Math.round(before / 4), postTokens: Math.round(JSON.stringify(rendered).length / 4),
|
|
843
|
+
})
|
|
844
|
+
: null;
|
|
845
|
+
}
|
|
846
|
+
console.log(`[llm-switcher] idle compact ${key.slice(0, 12)}: summary ${written ? 'stored' : 'made, not stored'}`);
|
|
847
|
+
}).catch(() => {});
|
|
848
|
+
|
|
849
|
+
return { payload: next, bodyBuffer: nextBuf };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// The summary goes in as a user message right after the opening request, which is where Claude
|
|
853
|
+
// Code puts its own. A conversation handed a hole in it reads the hole as something it forgot
|
|
854
|
+
// unless something says otherwise.
|
|
855
|
+
function withSummary(msgs, insertAt, summary) {
|
|
856
|
+
const at = Math.min(Math.max(1, insertAt), msgs.length);
|
|
857
|
+
return [
|
|
858
|
+
...msgs.slice(0, at),
|
|
859
|
+
{ role: 'user', content: [{ type: 'text', text: summaryNotice(summary) }] },
|
|
860
|
+
...msgs.slice(at),
|
|
861
|
+
];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function summaryNotice(summary) {
|
|
865
|
+
return 'Earlier in this conversation the history was shortened because it had been idle long ' +
|
|
866
|
+
'enough that the provider no longer held its prompt cache. What follows is a summary of the ' +
|
|
867
|
+
'part that was replaced, then the more recent messages unchanged.\n\nSummary:\n' + summary;
|
|
868
|
+
}
|
|
869
|
+
|
|
746
870
|
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req) {
|
|
747
871
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, { thinkingMode: profile.thinkingMode });
|
|
748
872
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
@@ -839,6 +963,23 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
839
963
|
// Code Assist names streaming in the path (:streamGenerateContent), not in the body.
|
|
840
964
|
if (clientFormat === 'codeassist') ir.stream = Boolean(opts.codeAssistStream);
|
|
841
965
|
|
|
966
|
+
// Compaction rewrites the request itself, not just the intermediate form: when the client and
|
|
967
|
+
// the provider speak the same shape this gateway forwards the client's own bytes, so a change
|
|
968
|
+
// made only to the parsed form would shorten nothing that leaves the building.
|
|
969
|
+
const shrunk = await maybeCompactIdle(req, clientFormat, payload, ir, profile, profileKey, bodyBuffer);
|
|
970
|
+
if (shrunk) {
|
|
971
|
+
payload = shrunk.payload;
|
|
972
|
+
bodyBuffer = shrunk.bodyBuffer;
|
|
973
|
+
try {
|
|
974
|
+
ir = parseToIR(clientFormat, payload);
|
|
975
|
+
} catch {
|
|
976
|
+
// The compacted body was built from this one, so it parses. If it ever did not, the
|
|
977
|
+
// original is still what the caller meant to send.
|
|
978
|
+
payload = JSON.parse(bodyBuffer.toString('utf8'));
|
|
979
|
+
return sendClientError(res, clientFormat, 400, 'Cannot reparse the compacted request');
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
842
983
|
const reqStartTime = Date.now();
|
|
843
984
|
const requestPreview = previewOf(ir);
|
|
844
985
|
const requestedModel = ir.model || payload.model || '';
|
|
@@ -1575,6 +1716,42 @@ async function routeApi(req, res, method, pathname) {
|
|
|
1575
1716
|
return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
|
|
1576
1717
|
}
|
|
1577
1718
|
|
|
1719
|
+
// Idle compaction. The settings live under one key so the dashboard and `switch compact`
|
|
1720
|
+
// write the same place, and the same defaults, instead of two views that can disagree.
|
|
1721
|
+
if (method === 'GET' && pathname === '/api/idle-compact') {
|
|
1722
|
+
return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
|
|
1723
|
+
}
|
|
1724
|
+
if (method === 'POST' && pathname === '/api/idle-compact') {
|
|
1725
|
+
let patch;
|
|
1726
|
+
try {
|
|
1727
|
+
patch = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8'));
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
return sendJson(res, err.status || 400, { error: err.message });
|
|
1730
|
+
}
|
|
1731
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
1732
|
+
return sendJson(res, 400, { error: 'body must be an object' });
|
|
1733
|
+
}
|
|
1734
|
+
// Per key, not per type-group. A loose check lets "yes" through for a boolean, and the
|
|
1735
|
+
// policy then reads it as false: a typo would switch the feature off without saying so.
|
|
1736
|
+
const kinds = { enabled: 'boolean', model: 'string', idleMinutes: 'number', minBytes: 'number', keepRecent: 'number' };
|
|
1737
|
+
for (const [k, want] of Object.entries(kinds)) {
|
|
1738
|
+
if (!(k in patch)) continue;
|
|
1739
|
+
const v = patch[k];
|
|
1740
|
+
const ok = want === 'number'
|
|
1741
|
+
? (typeof v === 'number' && Number.isFinite(v))
|
|
1742
|
+
: typeof v === want;
|
|
1743
|
+
if (!ok) return sendJson(res, 400, { error: `${k} must be ${want === 'number' ? 'a number' : `a ${want}`}` });
|
|
1744
|
+
}
|
|
1745
|
+
const cfg = loadConfig() || {};
|
|
1746
|
+
cfg[IDLE_COMPACT_KEY] = { ...(cfg[IDLE_COMPACT_KEY] || {}), ...patch };
|
|
1747
|
+
try {
|
|
1748
|
+
saveConfig(cfg);
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
return sendJson(res, 500, { error: err.message });
|
|
1751
|
+
}
|
|
1752
|
+
return sendJson(res, 200, { idleCompact: idleCompactPolicy(loadConfig()) });
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1578
1755
|
if (method !== 'POST') {
|
|
1579
1756
|
req.resume();
|
|
1580
1757
|
return sendJson(res, 404, { error: `Not found: ${method} ${pathname}` });
|
package/switch.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getMigrationError, getMigrationCollision, getLastLoadError,
|
|
14
14
|
casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
|
|
15
15
|
} from './state.mjs';
|
|
16
|
+
import { idleCompactPolicy } from './idlecompact.mjs';
|
|
16
17
|
import { runProbe, runCheck } from './contract.mjs';
|
|
17
18
|
import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
|
|
18
19
|
import {
|
|
@@ -845,6 +846,57 @@ async function managePlugin(action = 'status') {
|
|
|
845
846
|
}
|
|
846
847
|
}
|
|
847
848
|
|
|
849
|
+
// Idle compaction: a conversation that comes back after a pause has lost the provider's prompt
|
|
850
|
+
// cache, so its history is paid for at full price. This turns that on and says what it will do.
|
|
851
|
+
async function manageCompact(action = 'status', arg = '') {
|
|
852
|
+
const cfg = loadConfig() || {};
|
|
853
|
+
const policy = idleCompactPolicy(cfg);
|
|
854
|
+
const key = 'idleCompact';
|
|
855
|
+
const set = (patch) => {
|
|
856
|
+
cfg[key] = { ...(cfg[key] || {}), ...patch };
|
|
857
|
+
saveConfig(cfg);
|
|
858
|
+
};
|
|
859
|
+
const act = (action || 'status').toLowerCase();
|
|
860
|
+
const show = () => {
|
|
861
|
+
const p = idleCompactPolicy(loadConfig() || {});
|
|
862
|
+
console.log('Idle compaction (Claude Code)');
|
|
863
|
+
console.log(` Enabled: ${p.enabled ? 'yes' : 'no'}`);
|
|
864
|
+
console.log(` Idle over: ${p.idleMinutes} minutes`);
|
|
865
|
+
console.log(` Context over: ${Math.round(p.minBytes / 1024)} KB`);
|
|
866
|
+
console.log(` Keep recent: ${p.keepRecent} messages`);
|
|
867
|
+
console.log(` Summary model: ${p.model || '(none set)'}`);
|
|
868
|
+
if (!p.enabled) console.log('\n Turn it on with: switch compact on');
|
|
869
|
+
else if (!p.model) console.log('\n No summary model: requests still go out shortened, and no model is called at all.');
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
if (act === 'on' || act === 'enable') {
|
|
873
|
+
set({ enabled: true });
|
|
874
|
+
console.log('[OK] Idle compaction is on.');
|
|
875
|
+
show();
|
|
876
|
+
} else if (act === 'off' || act === 'disable') {
|
|
877
|
+
set({ enabled: false });
|
|
878
|
+
console.log('[OK] Idle compaction is off.');
|
|
879
|
+
} else if (act === 'model') {
|
|
880
|
+
const m = String(arg || '').trim();
|
|
881
|
+
if (!m) { console.log(`Summary model is currently: ${policy.model || '(none set)'}`); return; }
|
|
882
|
+
if (m === 'none' || m === 'default') { set({ model: '' }); console.log('[OK] The conversation\'s own model will write the summary.'); return; }
|
|
883
|
+
set({ model: m });
|
|
884
|
+
console.log(`[OK] Summaries will be written by: ${m}`);
|
|
885
|
+
} else if (act === 'idle') {
|
|
886
|
+
const n = Number(arg);
|
|
887
|
+
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact idle <minutes>'); process.exit(1); }
|
|
888
|
+
set({ idleMinutes: Math.floor(n) });
|
|
889
|
+
console.log(`[OK] Compact a conversation idle over ${Math.floor(n)} minutes.`);
|
|
890
|
+
} else if (act === 'min') {
|
|
891
|
+
const n = Number(arg);
|
|
892
|
+
if (!Number.isFinite(n) || n <= 0) { console.error('Usage: switch compact min <KB>'); process.exit(1); }
|
|
893
|
+
set({ minBytes: Math.floor(n) * 1024 });
|
|
894
|
+
console.log(`[OK] Compact a conversation over ${Math.floor(n)} KB.`);
|
|
895
|
+
} else {
|
|
896
|
+
show();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
848
900
|
async function manageShim(action = 'status') {
|
|
849
901
|
const act = (action || 'status').toLowerCase();
|
|
850
902
|
|
|
@@ -1247,6 +1299,8 @@ if (cmd === 'off' || cmd === 'stop') {
|
|
|
1247
1299
|
await runDoctor();
|
|
1248
1300
|
} else if (cmd === 'service' || cmd === 'daemon') {
|
|
1249
1301
|
await manageService(subArg.toLowerCase() || 'status');
|
|
1302
|
+
} else if (cmd === 'compact') {
|
|
1303
|
+
await manageCompact(subArg.toLowerCase() || 'status', positionalArgs()[2] || '');
|
|
1250
1304
|
} else if (cmd === 'shim' || cmd === 'shims') {
|
|
1251
1305
|
await manageShim(subArg.toLowerCase() || 'status');
|
|
1252
1306
|
} else if (cmd === 'plugin' || cmd === 'plugins') {
|