@agentprojectcontext/apx 1.78.0 → 1.79.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 +30 -5
- 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 +70 -1
- package/src/core/channels/telegram/ask-callbacks.js +35 -0
- package/src/core/channels/telegram/dispatch.js +3 -0
- package/src/core/channels/telegram/reply.js +30 -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 +322 -60
- 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 +4 -0
- 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,193 @@ 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
|
+
out.push({
|
|
211
|
+
role: "user",
|
|
212
|
+
parts: [{ text: `[tool result: ${name}] ${asText(m.content)}` }],
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Tool results ride under role "user", NOT "function": the newer models
|
|
217
|
+
// (3.6/3.7) reject `role: "function"` outright —
|
|
218
|
+
// 400 Role 'function' is not supported. Please use a valid role: …
|
|
219
|
+
// — while every version accepts a user turn carrying functionResponse
|
|
220
|
+
// parts. Parallel results merge into a single turn, mirroring the one
|
|
221
|
+
// model turn that emitted the calls.
|
|
222
|
+
const part = { functionResponse: { name, response: { content: m.content } } };
|
|
223
|
+
const prev = out[out.length - 1];
|
|
224
|
+
if (prev && prev.role === "user" && prev.parts.every((p) => p.functionResponse)) {
|
|
225
|
+
prev.parts.push(part);
|
|
226
|
+
} else {
|
|
227
|
+
out.push({ role: "user", parts: [part] });
|
|
228
|
+
}
|
|
49
229
|
continue;
|
|
50
230
|
}
|
|
231
|
+
|
|
51
232
|
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
|
-
|
|
233
|
+
// Preferred path: replay the raw parts as Gemini returned them. This
|
|
234
|
+
// preserves thought parts and their thoughtSignatures verbatim — the
|
|
235
|
+
// signature belongs to the PART, and rebuilding loses whichever part
|
|
236
|
+
// was carrying it.
|
|
237
|
+
const raw = Array.isArray(m._geminiRawParts) ? m._geminiRawParts.filter(isUsablePart) : null;
|
|
238
|
+
if (
|
|
239
|
+
raw &&
|
|
240
|
+
raw.length > 0 &&
|
|
241
|
+
raw.some(isFunctionCallPart) &&
|
|
242
|
+
(!requireSignatures || raw.some(signatureOf))
|
|
243
|
+
) {
|
|
244
|
+
out.push({ role: "model", parts: raw });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fallback: rebuild from tool_calls. Used for calls that never came from
|
|
249
|
+
// a Gemini response — pseudo-tool calls parsed out of plain text, or a
|
|
250
|
+
// turn inherited from another engine after a retry-chain model switch.
|
|
251
|
+
//
|
|
252
|
+
// The signature is per TURN, not per call: on parallel calls Gemini
|
|
253
|
+
// stamps only the first part and accepts the siblings bare. So one
|
|
254
|
+
// signature anywhere in the turn is enough to send it as real calls; a
|
|
255
|
+
// turn with none at all is what earns the 400.
|
|
256
|
+
const turnHasSignature = m.tool_calls.some(callSignatureOf);
|
|
257
|
+
const parts = [];
|
|
258
|
+
const text = typeof m.content === "string" ? m.content.trim() : "";
|
|
259
|
+
if (text) parts.push({ text });
|
|
260
|
+
for (const tc of m.tool_calls) {
|
|
261
|
+
const name = tc.function?.name || tc.name;
|
|
262
|
+
const sig = callSignatureOf(tc);
|
|
263
|
+
if (requireSignatures && !turnHasSignature) {
|
|
264
|
+
// Nothing to replay: sending this as a functionCall is a guaranteed
|
|
265
|
+
// 400. Narrate it instead so the model keeps the context without the
|
|
266
|
+
// API rejecting the turn.
|
|
267
|
+
if (tc.id) degraded.add(tc.id);
|
|
268
|
+
parts.push({ text: `[tool call: ${name}] ${JSON.stringify(callArgsOf(tc))}` });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const part = { functionCall: { name, args: callArgsOf(tc) } };
|
|
272
|
+
if (sig) part.thoughtSignature = sig;
|
|
273
|
+
parts.push(part);
|
|
274
|
+
}
|
|
275
|
+
if (parts.length === 0) parts.push({ text: "" });
|
|
276
|
+
out.push({ role: "model", parts });
|
|
73
277
|
continue;
|
|
74
278
|
}
|
|
279
|
+
|
|
75
280
|
out.push({
|
|
76
281
|
role: m.role === "assistant" ? "model" : "user",
|
|
77
|
-
parts: [{ text:
|
|
282
|
+
parts: [{ text: asText(m.content) }],
|
|
78
283
|
});
|
|
79
284
|
}
|
|
80
285
|
return out;
|
|
81
286
|
}
|
|
82
287
|
|
|
288
|
+
function asText(v) {
|
|
289
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
290
|
+
}
|
|
291
|
+
|
|
83
292
|
function safeParseJson(s) {
|
|
84
293
|
try { return JSON.parse(s); } catch { return {}; }
|
|
85
294
|
}
|
|
@@ -108,12 +317,12 @@ export default {
|
|
|
108
317
|
config = {},
|
|
109
318
|
signal,
|
|
110
319
|
}) {
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
320
|
+
const keys = getKeys(config);
|
|
321
|
+
if (!keys.length) throw new Error("gemini: no api_key (set GEMINI_API_KEY or engines.gemini.api_key)");
|
|
113
322
|
if (!model) throw new Error("gemini: model required");
|
|
114
323
|
|
|
115
324
|
const body = {
|
|
116
|
-
contents: toGeminiContents(messages),
|
|
325
|
+
contents: toGeminiContents(messages, { model, config }),
|
|
117
326
|
generationConfig: { temperature, maxOutputTokens: maxTokens },
|
|
118
327
|
};
|
|
119
328
|
if (system) body.systemInstruction = { parts: [{ text: system }] };
|
|
@@ -130,26 +339,74 @@ export default {
|
|
|
130
339
|
}
|
|
131
340
|
}
|
|
132
341
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
342
|
+
// Try each key in turn, skipping any already known to be out of quota for
|
|
343
|
+
// THIS model today. A key exhausted on gemini-3.5-flash usually still has
|
|
344
|
+
// its full allowance on the -lite tiers, so the cooldown is per model.
|
|
345
|
+
const fresh = keys.filter((k) => !isExhausted(model, k));
|
|
346
|
+
// All parked? Try them anyway rather than failing without asking — the
|
|
347
|
+
// cooldown is a guess about Google's clock, and being wrong should cost a
|
|
348
|
+
// request, not the turn.
|
|
349
|
+
const order = fresh.length ? fresh : keys;
|
|
350
|
+
|
|
351
|
+
let json;
|
|
352
|
+
let lastError = null;
|
|
353
|
+
for (let i = 0; i < order.length; i++) {
|
|
354
|
+
const key = order[i];
|
|
355
|
+
const url = `${API_BASE}/${encodeURIComponent(model)}:generateContent?key=${key}`;
|
|
356
|
+
const res = await fetch(url, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "content-type": "application/json" },
|
|
359
|
+
body: JSON.stringify(body),
|
|
360
|
+
signal,
|
|
361
|
+
});
|
|
362
|
+
const payload = await res.json();
|
|
363
|
+
|
|
364
|
+
if (res.ok) { json = payload; break; }
|
|
365
|
+
|
|
366
|
+
if (isQuotaError(res.status, payload)) {
|
|
367
|
+
markExhausted(model, key);
|
|
368
|
+
lastError = new Error(
|
|
369
|
+
`gemini ${res.status}: ${payload?.error?.message || "quota exhausted"}`
|
|
370
|
+
);
|
|
371
|
+
continue; // next key
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A real error — a bad request, a missing model, a revoked key. Rotating
|
|
375
|
+
// would just repeat it against every key and turn one clear failure into
|
|
376
|
+
// N slow ones.
|
|
142
377
|
throw new Error(
|
|
143
|
-
`gemini ${res.status}: ${
|
|
378
|
+
`gemini ${res.status}: ${payload?.error?.message || JSON.stringify(payload)}`
|
|
144
379
|
);
|
|
145
380
|
}
|
|
146
381
|
|
|
382
|
+
if (!json) {
|
|
383
|
+
throw lastError || new Error("gemini: every key is out of quota for this model");
|
|
384
|
+
}
|
|
385
|
+
|
|
147
386
|
const parts = json.candidates?.[0]?.content?.parts || [];
|
|
148
|
-
|
|
387
|
+
|
|
388
|
+
// Thinking models (gemini-2.5-flash, gemini-2.5-pro, etc.) emit thought
|
|
389
|
+
// parts (p.thought === true) that must NOT be shown to the user — they are
|
|
390
|
+
// internal reasoning. Only non-thought text parts form the visible reply.
|
|
391
|
+
const text = parts.filter((p) => !p.thought).map((p) => p.text || "").join("");
|
|
392
|
+
|
|
149
393
|
// Extract function calls and translate them into the OpenAI-shaped
|
|
150
394
|
// tool_calls the run-agent loop expects.
|
|
395
|
+
//
|
|
396
|
+
// KEY FIX: On thinking models the thoughtSignature lives on the THOUGHT
|
|
397
|
+
// text part (p.thought === true), NOT on the functionCall part. We track
|
|
398
|
+
// the most recently seen signature as we walk the parts array, so the
|
|
399
|
+
// functionCall that follows a thought part picks it up correctly.
|
|
400
|
+
// Signatures that appear directly on a functionCall part are also captured
|
|
401
|
+
// (some non-thinking Gemini variants may do this).
|
|
151
402
|
const toolCalls = [];
|
|
403
|
+
let pendingThoughtSig = null;
|
|
152
404
|
for (const p of parts) {
|
|
405
|
+
// Collect thoughtSignature from wherever Gemini puts it: thought parts
|
|
406
|
+
// or (less commonly) directly on the functionCall part.
|
|
407
|
+
const partSig = p.thoughtSignature || p.thought_signature;
|
|
408
|
+
if (partSig) pendingThoughtSig = partSig;
|
|
409
|
+
|
|
153
410
|
const fc = p.functionCall || p.function_call;
|
|
154
411
|
if (fc?.name) {
|
|
155
412
|
const tc = {
|
|
@@ -160,13 +417,13 @@ export default {
|
|
|
160
417
|
arguments: typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args || {}),
|
|
161
418
|
},
|
|
162
419
|
};
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
420
|
+
// Attach the signature we collected above. On the next request
|
|
421
|
+
// toGeminiContents() puts it back on the part so Gemini 2.5+ doesn't
|
|
422
|
+
// reject the turn with 400 "missing thought_signature".
|
|
423
|
+
if (pendingThoughtSig) {
|
|
424
|
+
tc._thoughtSignature = pendingThoughtSig;
|
|
425
|
+
pendingThoughtSig = null; // consumed; reset for potential next call
|
|
426
|
+
}
|
|
170
427
|
toolCalls.push(tc);
|
|
171
428
|
}
|
|
172
429
|
}
|
|
@@ -174,6 +431,11 @@ export default {
|
|
|
174
431
|
return {
|
|
175
432
|
text,
|
|
176
433
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
434
|
+
// Raw parts stored so toGeminiContents() can replay the model turn
|
|
435
|
+
// faithfully (thought parts + functionCalls) on the next request.
|
|
436
|
+
// This is the most robust way to satisfy Gemini's thought-signature
|
|
437
|
+
// requirement without any reconstruction logic.
|
|
438
|
+
_geminiRawParts: parts.length > 0 ? parts : undefined,
|
|
177
439
|
finish_reason: json.candidates?.[0]?.finishReason || null,
|
|
178
440
|
usage: {
|
|
179
441
|
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 {
|