llm-switcher 1.6.0 → 1.6.2

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/formats.mjs CHANGED
@@ -311,6 +311,8 @@ const OPENAI_CHAT_LEVELS = Object.freeze(['minimal', 'low', 'medium', 'high']);
311
311
  // Gemini's own ceiling for thinkingBudget. Above it the request is refused, not trimmed.
312
312
  const GEMINI_MAX_BUDGET = 32768;
313
313
 
314
+ export const ANTHROPIC_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];
315
+
314
316
  function effortRank(e) {
315
317
  const i = EFFORT_LEVELS.indexOf(String(e || '').toLowerCase());
316
318
  return i;
@@ -322,24 +324,46 @@ function effortRank(e) {
322
324
  * ladder names, and a caller reads it as thinking off.
323
325
  */
324
326
  function normalizeEffort(e) {
325
- const s = String(e || '').toLowerCase();
327
+ const s = String(e || '').toLowerCase().trim();
328
+ if (s === 'extra-low') return 'minimal';
326
329
  return effortRank(s) >= 0 ? s : 'medium';
327
330
  }
328
331
 
329
332
  /**
330
- * The level a target accepts: the one asked for, or the top of `allowed` when the target names
331
- * fewer levels than the ladder does. Never silently drops below what was asked for.
333
+ * Maps an effort level to the closest equivalent supported by the target.
334
+ * - Exact match if supported.
335
+ * - If target has fewer levels, chooses the closest matching depth (prefers floor for higher, ceil for lower).
332
336
  */
333
- function capEffort(e, allowed) {
337
+ export function mapEquivalentEffort(e, allowed) {
334
338
  const wanted = normalizeEffort(e);
335
- if (!Array.isArray(allowed) || !allowed.length || allowed.includes(wanted)) return wanted;
339
+ if (!Array.isArray(allowed) || !allowed.length) return wanted;
340
+ if (allowed.includes(wanted)) return wanted;
341
+
342
+ const wantedRank = effortRank(wanted);
336
343
  let best = allowed[0];
344
+ let bestDist = Math.abs(effortRank(best) - wantedRank);
345
+
337
346
  for (const a of allowed) {
338
- if (effortRank(a) >= 0 && effortRank(a) <= effortRank(wanted) && effortRank(a) > effortRank(best)) best = a;
347
+ const r = effortRank(a);
348
+ if (r < 0) continue;
349
+ const dist = Math.abs(r - wantedRank);
350
+ // Choose the level closest in rank. On a tie, prefer the higher level to preserve reasoning capacity.
351
+ if (dist < bestDist || (dist === bestDist && r > effortRank(best))) {
352
+ best = a;
353
+ bestDist = dist;
354
+ }
339
355
  }
340
356
  return best;
341
357
  }
342
358
 
359
+ /**
360
+ * The level a target accepts: the one asked for, or the closest equivalent in `allowed`
361
+ * when the target names fewer levels than the ladder does.
362
+ */
363
+ function capEffort(e, allowed) {
364
+ return mapEquivalentEffort(e, allowed);
365
+ }
366
+
343
367
  /**
344
368
  * The level a token budget stands for: the highest whose budget the number reaches. An exact
345
369
  * effortToEffort round trip returns the level that was asked for.
@@ -976,12 +1000,17 @@ function healToolPairs(messages) {
976
1000
  return out;
977
1001
  }
978
1002
 
979
- export const THINKING_MODES = ['auto', 'native', 'off'];
1003
+ const THINKING_MODES = ['auto', 'native', 'off'];
1004
+ const VALID_EFFORTS = ['auto', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
980
1005
 
981
1006
  function normThinkingMode(mode) {
982
1007
  return THINKING_MODES.includes(mode) ? mode : 'auto';
983
1008
  }
984
1009
 
1010
+ function normThinkingEffort(effort) {
1011
+ return typeof effort === 'string' && VALID_EFFORTS.includes(effort.toLowerCase()) ? effort.toLowerCase() : 'auto';
1012
+ }
1013
+
985
1014
  // IR -> OpenAI Chat Completions body.
986
1015
  //
987
1016
  // opts.thinkingMode (per profile):
@@ -1071,14 +1100,25 @@ function irToChatBody(ir, model, opts = {}) {
1071
1100
  body.response_format = { type: 'json_object' };
1072
1101
  }
1073
1102
 
1103
+ const effortOverride = normThinkingEffort(opts.thinkingEffort);
1074
1104
  if (wantsThinking && mode === 'native') {
1075
1105
  // A strict OpenAI upstream names four levels and refuses the rest, so `xhigh` becomes `high`
1076
1106
  // there and not `medium`: the request keeps as much of the depth as the target can take.
1077
- const asked = ir.thinking?.effort
1078
- || (ir.thinking?.type === 'adaptive' ? 'high' : (ir.thinking?.budget ? budgetToEffort(ir.thinking.budget) : 'medium'));
1079
- body.reasoning_effort = capEffort(asked, OPENAI_CHAT_LEVELS);
1107
+ const asked = effortOverride !== 'auto'
1108
+ ? effortOverride
1109
+ : (ir.thinking?.effort || (ir.thinking?.type === 'adaptive' ? 'high' : (ir.thinking?.budget ? budgetToEffort(ir.thinking.budget) : 'medium')));
1110
+ if (asked !== 'none') {
1111
+ body.reasoning_effort = capEffort(asked, OPENAI_CHAT_LEVELS);
1112
+ }
1080
1113
  } else if (wantsThinking) {
1081
- if (ir.thinking?.type === 'adaptive') {
1114
+ if (effortOverride !== 'auto') {
1115
+ if (effortOverride !== 'none') {
1116
+ const rawBudget = effortToBudget(effortOverride);
1117
+ const safeBudget = clampBudget(rawBudget);
1118
+ body.thinking = { type: 'enabled', budget_tokens: safeBudget };
1119
+ body.reasoning_effort = normalizeEffort(effortOverride);
1120
+ }
1121
+ } else if (ir.thinking?.type === 'adaptive') {
1082
1122
  body.thinking = { type: 'adaptive' };
1083
1123
  body.reasoning_effort = normalizeEffort(ir.thinking.effort || 'high');
1084
1124
  } else {
@@ -1122,7 +1162,7 @@ function contentToAnthropicBlocks(c) {
1122
1162
  }
1123
1163
 
1124
1164
  // IR -> Anthropic Messages body.
1125
- function irToAnthropicBody(ir, model) {
1165
+ function irToAnthropicBody(ir, model, opts = {}) {
1126
1166
  const messages = [];
1127
1167
 
1128
1168
  for (const m of healToolPairs(ir.messages)) {
@@ -1202,15 +1242,26 @@ function irToAnthropicBody(ir, model) {
1202
1242
  // level Anthropic names, so the depth survives the hop instead of becoming a number. A client
1203
1243
  // that sent budget_tokens has already said the same thing in Anthropic's own words, and its
1204
1244
  // budget stands: budget and effort together would be the same request twice.
1205
- if (ir.thinking && ir.thinking.type !== 'disabled' && ir.thinking.effort && !ir.thinking.budget) {
1206
- body.output_config = { ...(body.output_config || {}), effort: capEffort(ir.thinking.effort, EFFORT_LEVELS) };
1207
- }
1208
- if (ir.thinking && ir.thinking.type === 'adaptive') {
1209
- body.thinking = { type: 'adaptive' };
1210
- } else if (ir.thinking && ir.thinking.type !== 'disabled' && body.max_tokens > 1024) {
1211
- // Anthropic requires 1024 <= budget_tokens < max_tokens; drop thinking when max_tokens is too small.
1212
- const budget = clampBudget(ir.thinking.budget ?? (ir.thinking.effort ? effortToBudget(ir.thinking.effort) : 4096));
1213
- body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
1245
+ const effortOverride = normThinkingEffort(opts.thinkingEffort);
1246
+ if (effortOverride !== 'auto') {
1247
+ if (effortOverride !== 'none') {
1248
+ body.output_config = { ...(body.output_config || {}), effort: capEffort(effortOverride, EFFORT_LEVELS) };
1249
+ if (body.max_tokens > 1024) {
1250
+ const budget = clampBudget(effortToBudget(effortOverride));
1251
+ body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
1252
+ }
1253
+ }
1254
+ } else {
1255
+ if (ir.thinking && ir.thinking.type !== 'disabled' && ir.thinking.effort && !ir.thinking.budget) {
1256
+ body.output_config = { ...(body.output_config || {}), effort: capEffort(ir.thinking.effort, EFFORT_LEVELS) };
1257
+ }
1258
+ if (ir.thinking && ir.thinking.type === 'adaptive') {
1259
+ body.thinking = { type: 'adaptive' };
1260
+ } else if (ir.thinking && ir.thinking.type !== 'disabled' && body.max_tokens > 1024) {
1261
+ // Anthropic requires 1024 <= budget_tokens < max_tokens; drop thinking when max_tokens is too small.
1262
+ const budget = clampBudget(ir.thinking.budget ?? (ir.thinking.effort ? effortToBudget(ir.thinking.effort) : 4096));
1263
+ body.thinking = { type: 'enabled', budget_tokens: Math.min(budget, body.max_tokens - 1) };
1264
+ }
1214
1265
  }
1215
1266
  if (body.thinking) {
1216
1267
  // When thinking is on, Anthropic rejects temperature != 1, top_k, or top_p < 0.95.
@@ -1232,7 +1283,7 @@ function dataUrlToInlineData(url) {
1232
1283
  }
1233
1284
 
1234
1285
  // IR -> Vertex generateContent body.
1235
- function irToVertexBody(ir, model) {
1286
+ function irToVertexBody(ir, model, opts = {}) {
1236
1287
  const contents = [];
1237
1288
  const textPartsOf = (c) => {
1238
1289
  if (typeof c === 'string') return c ? [{ text: c }] : [];
@@ -1320,7 +1371,15 @@ function irToVertexBody(ir, model) {
1320
1371
  if (typeof ir.params.topK === 'number') gc.topK = ir.params.topK;
1321
1372
  if (typeof ir.params.maxTokens === 'number') gc.maxOutputTokens = ir.params.maxTokens;
1322
1373
  if (ir.params.stop.length) gc.stopSequences = ir.params.stop;
1323
- if (ir.thinking && ir.thinking.type !== 'disabled') {
1374
+ const effortOverride = normThinkingEffort(opts.thinkingEffort);
1375
+ if (effortOverride !== 'auto') {
1376
+ if (effortOverride !== 'none') {
1377
+ gc.thinkingConfig = {
1378
+ includeThoughts: true,
1379
+ thinkingBudget: Math.min(clampBudget(effortToBudget(effortOverride)), GEMINI_MAX_BUDGET)
1380
+ };
1381
+ }
1382
+ } else if (ir.thinking && ir.thinking.type !== 'disabled') {
1324
1383
  // includeThoughts: without this flag Gemini won't return thought parts -> the client loses thinking.
1325
1384
  gc.thinkingConfig = { includeThoughts: true };
1326
1385
  if (ir.thinking.type === 'enabled') {
@@ -1465,8 +1524,8 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
1465
1524
  let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
1466
1525
  if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
1467
1526
  switch (outFormat) {
1468
- case 'anthropic': return irToAnthropicBody(src, model);
1469
- case 'vertex': return irToVertexBody(src, model);
1527
+ case 'anthropic': return irToAnthropicBody(src, model, opts);
1528
+ case 'vertex': return irToVertexBody(src, model, opts);
1470
1529
  case 'openai-chat':
1471
1530
  default: return irToChatBody(src, model, opts);
1472
1531
  }
@@ -2529,6 +2588,7 @@ export {
2529
2588
  smartReasoning, smartText, smartToolCalls, smartUsage, smartFinish,
2530
2589
  firstChoice, smartDelta, sanitizeJsonSchema, toGeminiSchema, splitParts,
2531
2590
  budgetToEffort, effortToBudget, clampBudget, normalizeEffort, capEffort, parseArgs, stringifyArgs,
2591
+ THINKING_MODES, VALID_EFFORTS,
2532
2592
  anthropicToIR, responsesToIR, codeAssistToIR, parseToIR,
2533
2593
  healToolPairs, healAnthropicPayload, rememberToolSignature, lookupToolSignature, estimateTokens, irToChatBody, irToAnthropicBody, irToVertexBody, emitUpstreamBody,
2534
2594
  normalizeUpstream, createUpstreamNormalizer, createCollector,
package/idlecache.mjs ADDED
@@ -0,0 +1,159 @@
1
+ // Gateway-owned prefix cache. Client transcripts and databases are never opened here.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { conversationKey } from './idlecompact.mjs';
6
+
7
+ const digest = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
+ const MAX_RECORD_BYTES = 2 * 1024 * 1024;
9
+ const VERSION = 1;
10
+
11
+ export function policyFingerprint(policy) {
12
+ return digest(policy);
13
+ }
14
+
15
+ export function compactCacheKey(clientFormat, req, payload, ir, profile, profileKey, policy) {
16
+ const session = conversationKey(clientFormat, req, ir);
17
+ if (!session) return '';
18
+ // Credentials influence scope but only the final digest is stored on disk.
19
+ return digest({ clientFormat, session, profileKey, profile,
20
+ principal: [req?.headers?.authorization || '', req?.headers?.['x-api-key'] || '',
21
+ req?.headers?.['x-llm-switcher-token'] || ''],
22
+ context: { model: payload.model, system: payload.system, instructions: payload.instructions,
23
+ tools: payload.tools, toolChoice: payload.tool_choice }, policy });
24
+ }
25
+
26
+ function validRecord(r, key) {
27
+ if (!r || r.version !== VERSION || r.key !== key || !Number.isFinite(r.seenAt) || r.seenAt < 0) return false;
28
+ const allowed = ['version', 'key', 'seenAt', 'prefixCount', 'prefixHash', 'replacement',
29
+ 'replacementHash', 'generation', 'createdAt'];
30
+ if (Object.keys(r).some(field => !allowed.includes(field))) return false;
31
+ if (!('prefixCount' in r)) return Object.keys(r).every(field => ['version', 'key', 'seenAt'].includes(field));
32
+ return Number.isInteger(r.prefixCount) && r.prefixCount > 0 && r.prefixCount <= 100000 &&
33
+ /^[a-f0-9]{64}$/.test(r.prefixHash) && typeof r.generation === 'string' &&
34
+ /^[a-f0-9-]{36}$/.test(r.generation) && Array.isArray(r.replacement) &&
35
+ /^[a-f0-9]{64}$/.test(r.replacementHash) && digest(r.replacement) === r.replacementHash &&
36
+ r.replacement.length > 0 && r.replacement.length <= 100000 &&
37
+ r.replacement.every(item => item && typeof item === 'object' && !Array.isArray(item) &&
38
+ (typeof item.role === 'string' || typeof item.type === 'string')) &&
39
+ Number.isFinite(r.createdAt) && r.createdAt >= 0;
40
+ }
41
+
42
+ export class IdlePrefixCache {
43
+ constructor(stateDir, { now = () => Date.now(), onDiagnostic = () => {} } = {}) {
44
+ this.dir = path.join(stateDir, 'idle-compact');
45
+ this.now = now;
46
+ this.onDiagnostic = onDiagnostic;
47
+ }
48
+
49
+ diagnostic(code) {
50
+ try { this.onDiagnostic(code); } catch { /* diagnostics cannot fail a client turn */ }
51
+ }
52
+
53
+ file(key) {
54
+ if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('invalid cache key');
55
+ return path.join(this.dir, `${key}.json`);
56
+ }
57
+
58
+ load(key, policy) {
59
+ try {
60
+ const dirStat = fs.lstatSync(this.dir);
61
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error('invalid cache directory');
62
+ const file = this.file(key);
63
+ const stat = fs.lstatSync(file);
64
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_RECORD_BYTES) throw new Error('invalid cache file');
65
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
66
+ if (!validRecord(record, key)) throw new Error('invalid cache record');
67
+ if (this.now() - record.seenAt > policy.sessionLookbackHours * 3600000 ||
68
+ record.seenAt > this.now() + 60000 ||
69
+ ('createdAt' in record && (record.createdAt > this.now() + 60000 ||
70
+ this.now() - record.createdAt > policy.sessionLookbackHours * 3600000))) {
71
+ this.remove(key);
72
+ return null;
73
+ }
74
+ return record;
75
+ } catch (err) {
76
+ if (err.code !== 'ENOENT') { this.diagnostic('cache-read-failed'); this.remove(key); }
77
+ return null;
78
+ }
79
+ }
80
+
81
+ remove(key) {
82
+ try {
83
+ const stat = fs.lstatSync(this.dir);
84
+ if (stat.isDirectory() && !stat.isSymbolicLink()) fs.unlinkSync(this.file(key));
85
+ } catch { /* missing or unavailable is safe */ }
86
+ }
87
+
88
+ save(record, policy) {
89
+ let tmp;
90
+ try {
91
+ if (!validRecord(record, record.key)) throw new Error('invalid cache record');
92
+ const bytes = JSON.stringify(record);
93
+ if (Buffer.byteLength(bytes) > MAX_RECORD_BYTES) throw new Error('cache record too large');
94
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
95
+ const stat = fs.lstatSync(this.dir);
96
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid cache directory');
97
+ fs.chmodSync(this.dir, 0o700);
98
+ const file = this.file(record.key);
99
+ tmp = `${file}.${crypto.randomUUID()}.tmp`;
100
+ fs.writeFileSync(tmp, bytes, { flag: 'wx', mode: 0o600 });
101
+ fs.renameSync(tmp, file);
102
+ this.prune(policy);
103
+ return true;
104
+ } catch {
105
+ if (tmp) try { fs.unlinkSync(tmp); } catch { /* unavailable */ }
106
+ this.diagnostic('cache-write-failed');
107
+ return false;
108
+ }
109
+ }
110
+
111
+ prune(policy) {
112
+ // Expire records using the configured session lookback, without evicting live sessions.
113
+ try {
114
+ const stat = fs.lstatSync(this.dir);
115
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid cache directory');
116
+ for (const entry of fs.readdirSync(this.dir)) {
117
+ if (!/^[a-f0-9]{64}\.json$/.test(entry)) continue;
118
+ const key = entry.slice(0, -5);
119
+ this.load(key, policy);
120
+ }
121
+ } catch { this.diagnostic('cache-prune-failed'); }
122
+ }
123
+
124
+ matches(record, history) {
125
+ return Array.isArray(history) && history.length >= record.prefixCount &&
126
+ digest(history.slice(0, record.prefixCount)) === record.prefixHash;
127
+ }
128
+
129
+ /** Return the previous pause and a cached rewrite; exact original suffix is kept verbatim. */
130
+ visit(key, history, policy) {
131
+ if (!key || !Array.isArray(history)) return { idleMs: 0, history: null, record: null };
132
+ let record = this.load(key, policy);
133
+ const idleMs = record ? Math.max(0, this.now() - record.seenAt) : 0;
134
+ if (record?.prefixCount && !this.matches(record, history)) {
135
+ record = null;
136
+ }
137
+ const shortened = record?.prefixCount
138
+ ? [...record.replacement, ...history.slice(record.prefixCount)] : null;
139
+ const touched = { ...(record || { version: VERSION, key }), seenAt: this.now() };
140
+ if (!this.save(touched, policy)) return { idleMs: 0, history: null, record: null };
141
+ return { idleMs: record ? idleMs : 0, history: shortened, record: touched };
142
+ }
143
+
144
+ create(key, history, prefixCount, replacement, policy) {
145
+ const record = { version: VERSION, key, seenAt: this.now(), createdAt: this.now(),
146
+ prefixCount, prefixHash: digest(history.slice(0, prefixCount)), replacement,
147
+ replacementHash: digest(replacement),
148
+ generation: crypto.randomUUID() };
149
+ return this.save(record, policy) ? record : null;
150
+ }
151
+
152
+ /** A late summary can upgrade only the generation that asked for it. */
153
+ upgrade(key, generation, replacement, policy) {
154
+ const record = this.load(key, policy);
155
+ if (!record?.prefixCount || record.generation !== generation) return false;
156
+ return this.save({ ...record, replacement, replacementHash: digest(replacement),
157
+ generation: crypto.randomUUID() }, policy);
158
+ }
159
+ }
package/idlecall.mjs CHANGED
@@ -1,67 +1,59 @@
1
- // The summary call.
2
- //
3
- // One extra model call, made once, when a long conversation comes back after a pause. It is not
4
- // free and it is not fast, and both costs are the price of not paying for the history on every
5
- // turn of the new window.
6
- //
7
- // It is a separate call rather than a turn of the conversation because the answer serves two
8
- // readers with different needs: the person waiting for the answer to their own question, and the
9
- // agent that will read the summary next time. Folding them together would mean either the person
10
- // reads a summary instead of an answer, or the summary is a summary of a summary.
11
- //
12
- // The model it uses is a setting, and empty means the one this conversation is already talking
13
- // to. A cheaper model writes a cheaper summary and a worse one: the summary replaces the history,
14
- // so what it leaves out is gone. That is the reader's call to make, not this gateway's.
15
-
1
+ // Optional, independently routed summary call. Empty model means no model is called.
16
2
  import { clampSummary, summaryMessages } from './idlecompact.mjs';
17
3
 
18
- /** Asks for the summary. Returns the text, or '' when nothing usable came back. */
19
- export async function askSummary({ ir, policy, profile, mappedModel, model, build, fetchImpl = fetch, timeoutMs = 180000 }) {
20
- const msgs = summaryMessages(ir, policy);
21
- if (!msgs.length) return '';
22
-
23
- const ir2 = { ...ir, messages: msgs, stream: false, tools: [], toolChoice: null };
24
- const { url, headers, upBody } = build(profile, model, ir2);
25
- const ac = new AbortController();
26
- const timer = setTimeout(() => ac.abort(), timeoutMs);
4
+ export async function askSummary({ ir, policy, profile, model, build, fetchImpl = fetch,
5
+ timeoutMs = 30000, onDiagnostic = () => {} }) {
6
+ if (!policy?.model || !model) return '';
7
+ const report = (code, status) => { try { onDiagnostic({ code, ...(status ? { status } : {}) }); } catch {} };
8
+ let timer;
27
9
  try {
28
- const res = await fetchImpl(url, {
29
- method: 'POST',
30
- headers,
31
- body: JSON.stringify(upBody),
32
- signal: ac.signal,
10
+ const messages = summaryMessages(ir, policy);
11
+ if (messages.length < 2) { report('summary-empty-input'); return ''; }
12
+ const ir2 = { ...ir, messages, model, stream: false, tools: [], toolChoice: null };
13
+ // Build and serialization are inside the failure boundary too: a bad route cannot reject
14
+ // the promise before the caller installs its error handler.
15
+ const { url, headers, upBody } = build(profile, model, ir2);
16
+ const body = JSON.stringify(upBody);
17
+ const ac = new AbortController();
18
+ const boundedTimeout = Number.isFinite(timeoutMs) ? Math.max(1, Math.min(180000, timeoutMs)) : 30000;
19
+ timer = setTimeout(() => ac.abort(), boundedTimeout);
20
+ // Race an abort as well: test doubles and nonstandard fetch implementations may ignore it.
21
+ const timedOut = new Promise((_, reject) => {
22
+ ac.signal.addEventListener('abort', () => {
23
+ const error = new Error('summary timeout'); error.name = 'AbortError'; reject(error);
24
+ }, { once: true });
33
25
  });
34
- if (!res.ok) return '';
35
- const raw = await res.text();
36
- return clampSummary(answerText(raw, ir2), policy);
37
- } catch {
38
- // A summary that could not be produced is not a reason to fail the turn. The caller still
39
- // shortens the request, because a shortened history is worth having even with nothing
40
- // written to the session file.
26
+ const result = await Promise.race([(async () => {
27
+ const res = await fetchImpl(url, { method: 'POST', headers, body, signal: ac.signal });
28
+ if (!res.ok) { report('summary-http-failed', res.status); return ''; }
29
+ const text = clampSummary(answerText(await res.text()), policy);
30
+ if (!text) report('summary-empty-output');
31
+ return text;
32
+ })(), timedOut]);
33
+ return result;
34
+ } catch (error) {
35
+ report(error?.name === 'AbortError' ? 'summary-timeout' : 'summary-call-failed');
41
36
  return '';
42
37
  } finally {
43
38
  clearTimeout(timer);
44
39
  }
45
40
  }
46
41
 
47
- // The answer's text, from whichever shape came back.
48
- function answerText(raw, ir) {
42
+ function answerText(raw) {
49
43
  let doc;
50
44
  try { doc = JSON.parse(raw); } catch { return ''; }
51
- if (Array.isArray(doc?.content)) {
52
- return doc.content.filter(b => b?.type === 'text').map(b => b.text || '').join('\n');
53
- }
54
- if (doc?.choices?.[0]?.message?.content) return String(doc.choices[0].message.content);
55
- if (Array.isArray(doc?.output)) {
56
- let out = '';
57
- for (const item of doc.output) {
58
- if (typeof item?.text === 'string') out += item.text;
59
- for (const c of item?.content || []) if (c?.type === 'output_text' || c?.type === 'text') out += c.text || '';
60
- }
61
- return out;
62
- }
63
- if (Array.isArray(doc?.candidates)) {
64
- return doc.candidates.map(c => (c?.content?.parts || []).map(p => p.text || '').join('')).join('\n');
65
- }
45
+ // A tool response is not a summary, even if accompanied by some assistant text.
46
+ if (doc?.content?.some?.(part => part?.type === 'tool_use') ||
47
+ doc?.choices?.[0]?.message?.tool_calls?.length ||
48
+ doc?.output?.some?.(item => ['function_call', 'custom_tool_call', 'local_shell_call'].includes(item?.type)) ||
49
+ doc?.candidates?.some?.(candidate => candidate?.content?.parts?.some?.(part => part.functionCall))) return '';
50
+ if (Array.isArray(doc?.content)) return doc.content.filter(part => part?.type === 'text')
51
+ .map(part => typeof part.text === 'string' ? part.text : '').join('\n');
52
+ if (typeof doc?.choices?.[0]?.message?.content === 'string') return doc.choices[0].message.content;
53
+ if (Array.isArray(doc?.output)) return doc.output.flatMap(item =>
54
+ (Array.isArray(item?.content) ? item.content : []).filter(part => ['output_text', 'text'].includes(part?.type)))
55
+ .map(part => typeof part.text === 'string' ? part.text : '').join('\n');
56
+ if (Array.isArray(doc?.candidates)) return doc.candidates.map(candidate =>
57
+ (candidate?.content?.parts || []).map(part => typeof part.text === 'string' ? part.text : '').join('')).join('\n');
66
58
  return '';
67
- }
59
+ }