@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.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +46 -6
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +123 -6
- package/src/core/channels/telegram/ask-callbacks.js +121 -6
- package/src/core/channels/telegram/dispatch.js +21 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +32 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +343 -63
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +10 -1
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- 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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
112
|
-
if (!
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
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}: ${
|
|
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
|
-
|
|
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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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 {
|