@agentprojectcontext/apx 1.78.0 → 1.80.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.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -6,7 +6,82 @@ import { randomUUID } from "node:crypto";
6
6
  const API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
7
7
 
8
8
  function getKey(config) {
9
- return config.api_key || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
9
+ return getKeys(config)[0] || "";
10
+ }
11
+
12
+ /**
13
+ * Every key we may use, in order.
14
+ *
15
+ * WHY THIS IS A LIST. Gemini's free tier meters per KEY per MODEL per DAY, and
16
+ * the good models are metered tightly — 20 requests a day on gemini-3.5-flash.
17
+ * One key runs out mid-morning and the whole chain collapses to whatever is
18
+ * last, which in practice was a free OpenRouter router that answers with its
19
+ * raw chain of thought. Several keys against the same tier multiply the day's
20
+ * capacity by the number of keys, with no change in behaviour until one is
21
+ * exhausted.
22
+ *
23
+ * `api_key` stays the primary so nothing about the existing single-key config
24
+ * changes; `api_keys` is additive.
25
+ */
26
+ export function getKeys(config = {}) {
27
+ const raw = [
28
+ config.api_key,
29
+ ...(Array.isArray(config.api_keys) ? config.api_keys : []),
30
+ process.env.GEMINI_API_KEY,
31
+ process.env.GOOGLE_API_KEY,
32
+ ];
33
+ const seen = new Set();
34
+ const out = [];
35
+ for (const k of raw) {
36
+ const key = String(k || "").trim();
37
+ if (!key || seen.has(key)) continue;
38
+ seen.add(key);
39
+ out.push(key);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Keys known to be out of quota for a given model, until the daily reset.
46
+ *
47
+ * In memory on purpose: quotas reset on Google's clock, not ours, and a
48
+ * persisted "this key is dead" file would outlive the reset and permanently
49
+ * shrink the pool. Losing this on restart costs one wasted request per key.
50
+ */
51
+ const exhausted = new Map(); // `${model}\u0000${key}` -> epoch ms when it expires
52
+
53
+ function cooldownKey(model, key) {
54
+ return `${model}\u0000${key}`;
55
+ }
56
+
57
+ function isExhausted(model, key, now = Date.now()) {
58
+ const until = exhausted.get(cooldownKey(model, key));
59
+ if (!until) return false;
60
+ if (until <= now) {
61
+ exhausted.delete(cooldownKey(model, key));
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /** Park a key until the next UTC midnight, which is when Google's RPD resets. */
68
+ function markExhausted(model, key, now = Date.now()) {
69
+ const reset = new Date(now);
70
+ reset.setUTCHours(24, 0, 0, 0);
71
+ exhausted.set(cooldownKey(model, key), reset.getTime());
72
+ }
73
+
74
+ /** Did this response mean "this key has no quota left", as opposed to a real error? */
75
+ function isQuotaError(status, json) {
76
+ if (status === 429) return true;
77
+ const msg = String(json?.error?.message || "");
78
+ const reason = String(json?.error?.status || "");
79
+ return reason === "RESOURCE_EXHAUSTED" || /quota|rate limit/i.test(msg);
80
+ }
81
+
82
+ /** Test-only: forget which keys are parked. */
83
+ export function _resetKeyCooldowns() {
84
+ exhausted.clear();
10
85
  }
11
86
 
12
87
  // Convert OpenAI-style tool schemas (`{ type: "function", function: { name,
@@ -27,59 +102,211 @@ function toGeminiTools(toolSchemas) {
27
102
  ];
28
103
  }
29
104
 
30
- // Map our message history into Gemini's `contents` array. Tool results land
31
- // as `role: "function"` parts with a `functionResponse`. Function calls
32
- // emitted by the model in earlier turns become `functionCall` parts under
33
- // `role: "model"`.
34
- function toGeminiContents(messages) {
105
+ function signatureOf(part) {
106
+ return part?.thoughtSignature || part?.thought_signature || null;
107
+ }
108
+
109
+ function callSignatureOf(tc) {
110
+ return tc?._thoughtSignature || tc?.thought_signature || null;
111
+ }
112
+
113
+ function isFunctionCallPart(p) {
114
+ return !!(p?.functionCall || p?.function_call);
115
+ }
116
+
117
+ // A raw part is replayable if it still holds content. Empty objects would be
118
+ // rejected by the API, and we never invent parts we didn't receive.
119
+ function isUsablePart(p) {
120
+ return !!p && typeof p === "object" && Object.keys(p).length > 0;
121
+ }
122
+
123
+ function callArgsOf(tc) {
124
+ const raw =
125
+ typeof tc.function?.arguments === "string"
126
+ ? safeParseJson(tc.function.arguments)
127
+ : tc.function?.arguments || tc.arguments || {};
128
+ return raw && typeof raw === "object" ? raw : {};
129
+ }
130
+
131
+ // Which models stamp a thoughtSignature on their own turns — and therefore
132
+ // demand it back on every functionCall part we replay. Declarative on purpose:
133
+ // covering a new family is one glob in this list, never a new branch in the
134
+ // code below.
135
+ //
136
+ // Patterns are shell-style globs matched against the bare model id (no
137
+ // provider prefix): `*` is any run of characters, `?` a single one.
138
+ // Verified live, one tool call per model — all return the signature:
139
+ // gemini-2.5-flash, gemini-3-flash-preview, gemini-3.1-flash-lite,
140
+ // gemini-3.6-flash. (gemini-2.0-* is retired by Google and never signed.)
141
+ //
142
+ // The 4.x/5.x entries are a forward default: the mechanism is a family trait,
143
+ // not a per-version quirk, so a new release is covered the day it ships.
144
+ // Anything still unlisted — a tuned endpoint, a family we didn't predict — is
145
+ // picked up at runtime by the evidence check in signaturesRequired().
146
+ export const THOUGHT_SIGNATURE_MODELS = [
147
+ "gemini-2.5*",
148
+ "gemini-3*",
149
+ "gemini-4*",
150
+ "gemini-5*",
151
+ ];
152
+
153
+ function globToRegExp(pattern) {
154
+ const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&");
155
+ return new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "i");
156
+ }
157
+
158
+ // Per-install override, no code change required:
159
+ // engines.gemini.thought_signature_models: ["gemini-3*", "my-tuned-model*"]
160
+ // A configured list REPLACES the built-in default — it is the whole answer to
161
+ // "which models use this mechanism", so an install can also opt out entirely
162
+ // with an explicit empty list.
163
+ export function modelUsesThoughtSignatures(model, config = {}) {
164
+ const configured = config?.thought_signature_models;
165
+ const patterns = Array.isArray(configured) ? configured : THOUGHT_SIGNATURE_MODELS;
166
+ const id = String(model || "");
167
+ return patterns.some((p) => globToRegExp(p).test(id));
168
+ }
169
+
170
+ function historyHasSignature(messages) {
171
+ for (const m of messages) {
172
+ if (Array.isArray(m?._geminiRawParts) && m._geminiRawParts.some(signatureOf)) return true;
173
+ if (Array.isArray(m?.tool_calls) && m.tool_calls.some(callSignatureOf)) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ function signaturesRequired(model, messages, config) {
179
+ if (modelUsesThoughtSignatures(model, config)) return true;
180
+ // Evidence beats the list: a model nobody declared, but which signed
181
+ // something earlier in THIS conversation, gets the same treatment.
182
+ return historyHasSignature(messages);
183
+ }
184
+
185
+ // Map our message history into Gemini's `contents` array. Tool results land as
186
+ // `functionResponse` parts on a `role: "user"` turn. Function calls emitted by
187
+ // the model in earlier turns become `functionCall` parts under `role: "model"`.
188
+ //
189
+ // Thinking-model history fidelity: when APX stores a Gemini-originated
190
+ // assistant turn it attaches `_geminiRawParts` — the verbatim `parts` array
191
+ // from the API response. We replay those raw parts here instead of
192
+ // reconstructing from `tool_calls`, which would lose the thought parts (and
193
+ // their thoughtSignature) that the signing families require in every
194
+ // subsequent turn. Falling back to reconstruction handles messages that came
195
+ // from a non-Gemini engine or were serialised before this field was added.
196
+ function toGeminiContents(messages, { model = "", config = {} } = {}) {
197
+ const requireSignatures = signaturesRequired(model, messages, config);
35
198
  const out = [];
199
+ // tool_call ids whose functionCall part we could not replay with a valid
200
+ // signature and had to narrate as text instead. Their tool results must be
201
+ // degraded to text too: Gemini rejects a functionResponse that answers a
202
+ // call it can no longer see in the history.
203
+ const degraded = new Set();
204
+
36
205
  for (const m of messages) {
37
206
  if (m.role === "tool") {
38
- out.push({
39
- role: "function",
40
- parts: [
41
- {
42
- functionResponse: {
43
- name: m.name || m.tool_name || "tool",
44
- response: { content: m.content },
45
- },
46
- },
47
- ],
48
- });
207
+ const name = m.name || m.tool_name || "tool";
208
+ const id = m.tool_call_id || m.id;
209
+ if (id && degraded.has(id)) {
210
+ // The call this answers was dropped, so it cannot be a functionResponse
211
+ // (Gemini rejects a response to a call it cannot see). Carry the result
212
+ // as an observation on the user side. Phrased as a plain report, never
213
+ // as call syntax — anything that looks like a callable format in the
214
+ // history gets imitated instead of executed.
215
+ out.push({
216
+ role: "user",
217
+ parts: [{ text: `Resultado de ${name}: ${asText(m.content)}` }],
218
+ });
219
+ continue;
220
+ }
221
+ // Tool results ride under role "user", NOT "function": the newer models
222
+ // (3.6/3.7) reject `role: "function"` outright —
223
+ // 400 Role 'function' is not supported. Please use a valid role: …
224
+ // — while every version accepts a user turn carrying functionResponse
225
+ // parts. Parallel results merge into a single turn, mirroring the one
226
+ // model turn that emitted the calls.
227
+ const part = { functionResponse: { name, response: { content: m.content } } };
228
+ const prev = out[out.length - 1];
229
+ if (prev && prev.role === "user" && prev.parts.every((p) => p.functionResponse)) {
230
+ prev.parts.push(part);
231
+ } else {
232
+ out.push({ role: "user", parts: [part] });
233
+ }
49
234
  continue;
50
235
  }
236
+
51
237
  if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
52
- out.push({
53
- role: "model",
54
- parts: m.tool_calls.map((tc) => {
55
- const part = {
56
- functionCall: {
57
- name: tc.function?.name || tc.name,
58
- args:
59
- typeof tc.function?.arguments === "string"
60
- ? safeParseJson(tc.function.arguments)
61
- : tc.function?.arguments || tc.arguments || {},
62
- },
63
- };
64
- // Gemini 3.x thinking models require us to echo back the
65
- // thoughtSignature that came attached to the original functionCall
66
- // part, or the API rejects the next turn with 400. We captured it
67
- // in the response parser; replay it verbatim when present.
68
- const sig = tc._thoughtSignature || tc.thought_signature;
69
- if (sig) part.thoughtSignature = sig;
70
- return part;
71
- }),
72
- });
238
+ // Preferred path: replay the raw parts as Gemini returned them. This
239
+ // preserves thought parts and their thoughtSignatures verbatim — the
240
+ // signature belongs to the PART, and rebuilding loses whichever part
241
+ // was carrying it.
242
+ const raw = Array.isArray(m._geminiRawParts) ? m._geminiRawParts.filter(isUsablePart) : null;
243
+ if (
244
+ raw &&
245
+ raw.length > 0 &&
246
+ raw.some(isFunctionCallPart) &&
247
+ (!requireSignatures || raw.some(signatureOf))
248
+ ) {
249
+ out.push({ role: "model", parts: raw });
250
+ continue;
251
+ }
252
+
253
+ // Fallback: rebuild from tool_calls. Used for calls that never came from
254
+ // a Gemini response pseudo-tool calls parsed out of plain text, or a
255
+ // turn inherited from another engine after a retry-chain model switch.
256
+ //
257
+ // The signature is per TURN, not per call: on parallel calls Gemini
258
+ // stamps only the first part and accepts the siblings bare. So one
259
+ // signature anywhere in the turn is enough to send it as real calls; a
260
+ // turn with none at all is what earns the 400.
261
+ const turnHasSignature = m.tool_calls.some(callSignatureOf);
262
+ const parts = [];
263
+ const text = typeof m.content === "string" ? m.content.trim() : "";
264
+ if (text) parts.push({ text });
265
+ for (const tc of m.tool_calls) {
266
+ const name = tc.function?.name || tc.name;
267
+ const sig = callSignatureOf(tc);
268
+ if (requireSignatures && !turnHasSignature) {
269
+ // Nothing to replay: sending this as a functionCall is a guaranteed
270
+ // 400. DROP the call from the model turn — never transcribe it into
271
+ // text. A model turn that reads "[tool call: run_shell] {...}" is a
272
+ // worked example of writing calls as prose, and the model copies it:
273
+ // it stops emitting functionCall parts, the loop sees no tool_calls,
274
+ // and the transcript is delivered to the user as the final answer.
275
+ // The call itself is not what the model needs to continue — the
276
+ // RESULT is, and that still arrives (see the tool branch above).
277
+ if (tc.id) degraded.add(tc.id);
278
+ continue;
279
+ }
280
+ const part = { functionCall: { name, args: callArgsOf(tc) } };
281
+ if (sig) part.thoughtSignature = sig;
282
+ parts.push(part);
283
+ }
284
+ // Every call was dropped and the turn said nothing else: emit no turn at
285
+ // all rather than an empty model message.
286
+ if (parts.length === 0) continue;
287
+ out.push({ role: "model", parts });
73
288
  continue;
74
289
  }
75
- out.push({
76
- role: m.role === "assistant" ? "model" : "user",
77
- parts: [{ text: typeof m.content === "string" ? m.content : JSON.stringify(m.content) }],
78
- });
290
+
291
+ // A plain turn. A user turn may carry images (Telegram photos, etc.);
292
+ // Gemini takes them as inlineData parts beside the text. Non-multimodal
293
+ // engines ignore the field entirely, so carrying it costs them nothing.
294
+ const parts = [{ text: asText(m.content) }];
295
+ if (m.role !== "assistant" && Array.isArray(m.images)) {
296
+ for (const img of m.images) {
297
+ if (!img?.data || !img?.mime) continue;
298
+ parts.push({ inlineData: { mimeType: img.mime, data: img.data } });
299
+ }
300
+ }
301
+ out.push({ role: m.role === "assistant" ? "model" : "user", parts });
79
302
  }
80
303
  return out;
81
304
  }
82
305
 
306
+ function asText(v) {
307
+ return typeof v === "string" ? v : JSON.stringify(v);
308
+ }
309
+
83
310
  function safeParseJson(s) {
84
311
  try { return JSON.parse(s); } catch { return {}; }
85
312
  }
@@ -108,12 +335,12 @@ export default {
108
335
  config = {},
109
336
  signal,
110
337
  }) {
111
- const key = getKey(config);
112
- if (!key) throw new Error("gemini: no api_key (set GEMINI_API_KEY or engines.gemini.api_key)");
338
+ const keys = getKeys(config);
339
+ if (!keys.length) throw new Error("gemini: no api_key (set GEMINI_API_KEY or engines.gemini.api_key)");
113
340
  if (!model) throw new Error("gemini: model required");
114
341
 
115
342
  const body = {
116
- contents: toGeminiContents(messages),
343
+ contents: toGeminiContents(messages, { model, config }),
117
344
  generationConfig: { temperature, maxOutputTokens: maxTokens },
118
345
  };
119
346
  if (system) body.systemInstruction = { parts: [{ text: system }] };
@@ -130,26 +357,74 @@ export default {
130
357
  }
131
358
  }
132
359
 
133
- const url = `${API_BASE}/${encodeURIComponent(model)}:generateContent?key=${key}`;
134
- const res = await fetch(url, {
135
- method: "POST",
136
- headers: { "content-type": "application/json" },
137
- body: JSON.stringify(body),
138
- signal,
139
- });
140
- const json = await res.json();
141
- if (!res.ok) {
360
+ // Try each key in turn, skipping any already known to be out of quota for
361
+ // THIS model today. A key exhausted on gemini-3.5-flash usually still has
362
+ // its full allowance on the -lite tiers, so the cooldown is per model.
363
+ const fresh = keys.filter((k) => !isExhausted(model, k));
364
+ // All parked? Try them anyway rather than failing without asking — the
365
+ // cooldown is a guess about Google's clock, and being wrong should cost a
366
+ // request, not the turn.
367
+ const order = fresh.length ? fresh : keys;
368
+
369
+ let json;
370
+ let lastError = null;
371
+ for (let i = 0; i < order.length; i++) {
372
+ const key = order[i];
373
+ const url = `${API_BASE}/${encodeURIComponent(model)}:generateContent?key=${key}`;
374
+ const res = await fetch(url, {
375
+ method: "POST",
376
+ headers: { "content-type": "application/json" },
377
+ body: JSON.stringify(body),
378
+ signal,
379
+ });
380
+ const payload = await res.json();
381
+
382
+ if (res.ok) { json = payload; break; }
383
+
384
+ if (isQuotaError(res.status, payload)) {
385
+ markExhausted(model, key);
386
+ lastError = new Error(
387
+ `gemini ${res.status}: ${payload?.error?.message || "quota exhausted"}`
388
+ );
389
+ continue; // next key
390
+ }
391
+
392
+ // A real error — a bad request, a missing model, a revoked key. Rotating
393
+ // would just repeat it against every key and turn one clear failure into
394
+ // N slow ones.
142
395
  throw new Error(
143
- `gemini ${res.status}: ${json?.error?.message || JSON.stringify(json)}`
396
+ `gemini ${res.status}: ${payload?.error?.message || JSON.stringify(payload)}`
144
397
  );
145
398
  }
146
399
 
400
+ if (!json) {
401
+ throw lastError || new Error("gemini: every key is out of quota for this model");
402
+ }
403
+
147
404
  const parts = json.candidates?.[0]?.content?.parts || [];
148
- const text = parts.map((p) => p.text || "").join("");
405
+
406
+ // Thinking models (gemini-2.5-flash, gemini-2.5-pro, etc.) emit thought
407
+ // parts (p.thought === true) that must NOT be shown to the user — they are
408
+ // internal reasoning. Only non-thought text parts form the visible reply.
409
+ const text = parts.filter((p) => !p.thought).map((p) => p.text || "").join("");
410
+
149
411
  // Extract function calls and translate them into the OpenAI-shaped
150
412
  // tool_calls the run-agent loop expects.
413
+ //
414
+ // KEY FIX: On thinking models the thoughtSignature lives on the THOUGHT
415
+ // text part (p.thought === true), NOT on the functionCall part. We track
416
+ // the most recently seen signature as we walk the parts array, so the
417
+ // functionCall that follows a thought part picks it up correctly.
418
+ // Signatures that appear directly on a functionCall part are also captured
419
+ // (some non-thinking Gemini variants may do this).
151
420
  const toolCalls = [];
421
+ let pendingThoughtSig = null;
152
422
  for (const p of parts) {
423
+ // Collect thoughtSignature from wherever Gemini puts it: thought parts
424
+ // or (less commonly) directly on the functionCall part.
425
+ const partSig = p.thoughtSignature || p.thought_signature;
426
+ if (partSig) pendingThoughtSig = partSig;
427
+
153
428
  const fc = p.functionCall || p.function_call;
154
429
  if (fc?.name) {
155
430
  const tc = {
@@ -160,13 +435,13 @@ export default {
160
435
  arguments: typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args || {}),
161
436
  },
162
437
  };
163
- // Thinking models (Gemini 3.x) attach a thoughtSignature to the part
164
- // alongside the functionCall. We must replay it on the next request
165
- // or the API 400s. Carry it on the tool_call so the next call to
166
- // toGeminiContents() can put it back. Underscore prefix marks it as
167
- // adapter-private metadata other engines should ignore.
168
- const sig = p.thoughtSignature || p.thought_signature;
169
- if (sig) tc._thoughtSignature = sig;
438
+ // Attach the signature we collected above. On the next request
439
+ // toGeminiContents() puts it back on the part so Gemini 2.5+ doesn't
440
+ // reject the turn with 400 "missing thought_signature".
441
+ if (pendingThoughtSig) {
442
+ tc._thoughtSignature = pendingThoughtSig;
443
+ pendingThoughtSig = null; // consumed; reset for potential next call
444
+ }
170
445
  toolCalls.push(tc);
171
446
  }
172
447
  }
@@ -174,6 +449,11 @@ export default {
174
449
  return {
175
450
  text,
176
451
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
452
+ // Raw parts stored so toGeminiContents() can replay the model turn
453
+ // faithfully (thought parts + functionCalls) on the next request.
454
+ // This is the most robust way to satisfy Gemini's thought-signature
455
+ // requirement without any reconstruction logic.
456
+ _geminiRawParts: parts.length > 0 ? parts : undefined,
177
457
  finish_reason: json.candidates?.[0]?.finishReason || null,
178
458
  usage: {
179
459
  input_tokens: json.usageMetadata?.promptTokenCount || 0,
@@ -1,6 +1,19 @@
1
1
  // Shared OpenAI-compatible chat adapter (OpenAI, Groq, OpenRouter, …).
2
2
  import { pingUrl } from "./_health.js";
3
3
 
4
+ // Adapters stash provider-private metadata on tool_calls under an underscore
5
+ // prefix (e.g. gemini's `_thoughtSignature`). Strict OpenAI-shaped APIs reject
6
+ // unknown properties, and a retry-chain switch can hand us a turn produced by
7
+ // another engine — so scrub those keys on the way out.
8
+ function stripPrivateFields(tc) {
9
+ if (!tc || typeof tc !== "object") return tc;
10
+ const out = {};
11
+ for (const [k, v] of Object.entries(tc)) {
12
+ if (!k.startsWith("_")) out[k] = v;
13
+ }
14
+ return out;
15
+ }
16
+
4
17
  export function createOpenAiCompatibleEngine({
5
18
  id,
6
19
  defaultBaseUrl,
@@ -84,7 +97,7 @@ export function createOpenAiCompatibleEngine({
84
97
  role: m.role,
85
98
  content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
86
99
  };
87
- if (m.tool_calls) entry.tool_calls = m.tool_calls;
100
+ if (m.tool_calls) entry.tool_calls = m.tool_calls.map(stripPrivateFields);
88
101
  if (m.tool_call_id) entry.tool_call_id = m.tool_call_id;
89
102
  // Some adapters expect `name` on tool messages; we map from tool_name
90
103
  // (what run-agent.js writes) to be safe.
@@ -127,7 +140,13 @@ export function createOpenAiCompatibleEngine({
127
140
  }
128
141
 
129
142
  const choice = json.choices?.[0];
130
- const text = choice?.message?.content || "";
143
+ // Reasoning models on OpenRouter/Groq return their chain of thought in a
144
+ // SEPARATE field (`reasoning`, or `reasoning_content` on some routers).
145
+ // It is not an answer and must never reach a channel — fold it into the
146
+ // <think> form APX already knows how to strip per channel.
147
+ const reasoning = choice?.message?.reasoning || choice?.message?.reasoning_content || "";
148
+ const answer = choice?.message?.content || "";
149
+ const text = reasoning ? `<think>${reasoning}</think>${answer}` : answer;
131
150
  const toolCalls = choice?.message?.tool_calls;
132
151
 
133
152
  return {