clauderipple 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -8,6 +8,7 @@ import { SseParser } from "./sse.js";
|
|
|
8
8
|
import { fetchWithRetry } from "../retry.js";
|
|
9
9
|
import { looksLikeAuth } from "../openai/index.js";
|
|
10
10
|
import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
|
|
11
|
+
import { refusesReplayedReasoning } from "../../reasoning.js";
|
|
11
12
|
import { providerExhaustion } from "../../health.js";
|
|
12
13
|
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
13
14
|
import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
|
|
@@ -963,6 +964,27 @@ export class ChatGptAdapter {
|
|
|
963
964
|
res.end();
|
|
964
965
|
return { status: upstream.status, bytes, ...(usage ? { usage } : {}), note: `codex passthrough account=${owner ? owner.slice(0, 8) : "caller"}` };
|
|
965
966
|
}
|
|
967
|
+
/**
|
|
968
|
+
* The options one turn is translated with. The conversation is the storage (reasoning.ts), so what
|
|
969
|
+
* to replay is read out of the request itself; `carried` is filled in as the input is built and the
|
|
970
|
+
* outcome lands in the request log. `replay: false` is the retry after the backend refused that
|
|
971
|
+
* carrier, and it puts back exactly the request this adapter sent before any of this existed.
|
|
972
|
+
*/
|
|
973
|
+
responsesOptions(carried, model, effort, replay = true) {
|
|
974
|
+
return {
|
|
975
|
+
model,
|
|
976
|
+
effort: effort ?? this.cfg.defaultEffort ?? "high",
|
|
977
|
+
identity: this.cfg.identity ?? true,
|
|
978
|
+
...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
|
|
979
|
+
replay,
|
|
980
|
+
// The envelope names this provider and this model, and the request builder refuses anything
|
|
981
|
+
// else: reasoning outlives the turn that made it, and a conversation moved to another model
|
|
982
|
+
// must not carry the old one's sealed state with it (reasoning.ts).
|
|
983
|
+
env: { p: this.name, m: model },
|
|
984
|
+
carried,
|
|
985
|
+
log: (line) => this.log.info(`chatgpt ${this.name}: ${line} (${model})`),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
966
988
|
/** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
|
|
967
989
|
async handle(req, res, path, json, model, effort) {
|
|
968
990
|
if (path.startsWith("/v1/messages/count_tokens")) {
|
|
@@ -975,13 +997,9 @@ export class ChatGptAdapter {
|
|
|
975
997
|
const serverTools = serverToolNames(json.tools);
|
|
976
998
|
if (serverTools.size > 0)
|
|
977
999
|
this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
identity: this.cfg.identity ?? true,
|
|
982
|
-
...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
|
|
983
|
-
});
|
|
984
|
-
const body = JSON.stringify(upstreamReq);
|
|
1000
|
+
const carried = { replayed: 0, undecodable: 0 };
|
|
1001
|
+
let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort));
|
|
1002
|
+
let body = JSON.stringify(upstreamReq);
|
|
985
1003
|
const cacheKey = upstreamReq.prompt_cache_key;
|
|
986
1004
|
// Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
|
|
987
1005
|
const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(cacheKey) ?? 0);
|
|
@@ -991,7 +1009,7 @@ export class ChatGptAdapter {
|
|
|
991
1009
|
// One account answers the turn. Which one is decided here, and a refusal before any byte has
|
|
992
1010
|
// reached the client moves the same turn to the next account, so the client is answered on its
|
|
993
1011
|
// first ask. The conversation stays on the account that answered: moving it costs the cache.
|
|
994
|
-
const
|
|
1012
|
+
const ask = () => this.sendToAnAccount({
|
|
995
1013
|
conversation: cacheKey,
|
|
996
1014
|
path: "/codex/responses",
|
|
997
1015
|
body,
|
|
@@ -1014,6 +1032,18 @@ export class ChatGptAdapter {
|
|
|
1014
1032
|
};
|
|
1015
1033
|
},
|
|
1016
1034
|
});
|
|
1035
|
+
let sent = await ask();
|
|
1036
|
+
// A 4xx that names reasoning is the backend refusing the sealed reasoning we replayed — the blob
|
|
1037
|
+
// was minted by the model that is no longer answering, or by another account. The summary is
|
|
1038
|
+
// worth nothing without it, so the turn is asked once without: replaying is an optimisation and
|
|
1039
|
+
// must never be the reason a user's turn fails (reasoning.ts).
|
|
1040
|
+
if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
|
|
1041
|
+
this.log.warn(`chatgpt ${this.name}: upstream ${sent.status} refused ${carried.replayed} replayed reasoning item(s) for ${model}; asking again without them: ${sent.text.slice(0, 200)}`);
|
|
1042
|
+
upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false));
|
|
1043
|
+
body = JSON.stringify(upstreamReq);
|
|
1044
|
+
carried.replayed = 0;
|
|
1045
|
+
sent = await ask();
|
|
1046
|
+
}
|
|
1017
1047
|
if (sent.kind !== "ok") {
|
|
1018
1048
|
res.off("close", onClose);
|
|
1019
1049
|
if (sent.kind === "aborted")
|
|
@@ -1061,7 +1091,7 @@ export class ChatGptAdapter {
|
|
|
1061
1091
|
if (this.cfg.debugDump === "all")
|
|
1062
1092
|
this.dump(upstream.status, json, upstreamReq, "");
|
|
1063
1093
|
const wantStream = json.stream === true;
|
|
1064
|
-
const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json));
|
|
1094
|
+
const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json), this.name);
|
|
1065
1095
|
const parser = new SseParser();
|
|
1066
1096
|
let bytes = 0;
|
|
1067
1097
|
let ping = null;
|
|
@@ -1179,8 +1209,8 @@ export class ChatGptAdapter {
|
|
|
1179
1209
|
exhaustion,
|
|
1180
1210
|
bytes,
|
|
1181
1211
|
note: failure
|
|
1182
|
-
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
1183
|
-
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
|
|
1212
|
+
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} replayed=${carried.replayed} reasoning=${mapper.reasoningCount})`
|
|
1213
|
+
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason} replayed=${carried.replayed} reasoning=${mapper.reasoningCount}${upstreamReq.service_tier ? " fast" : ""}`,
|
|
1184
1214
|
usage: {
|
|
1185
1215
|
input: u.input_tokens,
|
|
1186
1216
|
cached: u.cache_read_input_tokens,
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
// Prompt-cache rule: the Responses `input` we build must be a byte-stable prefix of the
|
|
4
4
|
// next turn's `input`. Claude Code resends the whole history every turn, so the mapping
|
|
5
5
|
// has to be deterministic and must not inject anything that varies (timestamps, salts,
|
|
6
|
-
// re-signed reasoning).
|
|
7
|
-
//
|
|
6
|
+
// re-signed reasoning). A reasoning item that came back from the backend is the exception
|
|
7
|
+
// that proves the rule: it is put back exactly as it arrived, so the bytes are the
|
|
8
|
+
// backend's own, and a turn that carried one maps the same way every time. Thinking
|
|
9
|
+
// blocks with no stored item still have nothing to put back and are dropped as before.
|
|
10
|
+
// `prompt_cache_key` is derived from the conversation's first user message — or,
|
|
8
11
|
// for a request that is not a conversation at all, from its system prompt (see `conversationKey`).
|
|
9
12
|
import crypto from "node:crypto";
|
|
10
13
|
import { identityLine } from "../../identity.js";
|
|
14
|
+
import { encodeEnvelope, envelopeFor, isOurSignature, replayableReasoning } from "../../reasoning.js";
|
|
11
15
|
function blockText(c) {
|
|
12
16
|
if (typeof c === "string")
|
|
13
17
|
return c;
|
|
@@ -235,6 +239,7 @@ export function toResponsesRequest(req, opts) {
|
|
|
235
239
|
// whose call_id has no function_call in the same input ("No tool call found…", measured 2026-09-13).
|
|
236
240
|
// Such results are sent as plain user text instead.
|
|
237
241
|
const knownCalls = new Set();
|
|
242
|
+
let lastCallId;
|
|
238
243
|
for (const m of req.messages) {
|
|
239
244
|
const role = m.role === "assistant" ? "assistant" : "user";
|
|
240
245
|
if (typeof m.content === "string") {
|
|
@@ -251,6 +256,32 @@ export function toResponsesRequest(req, opts) {
|
|
|
251
256
|
pending = [];
|
|
252
257
|
};
|
|
253
258
|
for (const b of m.content) {
|
|
259
|
+
// The reasoning this turn came back with, put back exactly where it was: the CLI replays the
|
|
260
|
+
// block in front of the function calls it belongs to, so iterating in order reproduces the
|
|
261
|
+
// original positions, parallel calls included. `lastCallId` is the call the carrier arrived
|
|
262
|
+
// after — the response path addresses the next turn's summary to it (see `onArrival`) — and is
|
|
263
|
+
// undefined for a lead-in item, which is where such a summary belongs.
|
|
264
|
+
const decoded = envelopeFor(b, "responses", opts.env, (reason) => {
|
|
265
|
+
(opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
|
|
266
|
+
if (opts.replay !== false)
|
|
267
|
+
opts.log?.(`dropped a reasoning carrier: ${reason}`);
|
|
268
|
+
});
|
|
269
|
+
// A carrier for the other wire is not ours to touch and falls through to the drop below.
|
|
270
|
+
if (decoded) {
|
|
271
|
+
if (opts.replay !== false) {
|
|
272
|
+
const item = replayableReasoning(decoded.r);
|
|
273
|
+
if (item) {
|
|
274
|
+
flush();
|
|
275
|
+
input.push(item);
|
|
276
|
+
(opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).replayed++;
|
|
277
|
+
opts.onArrival?.(item, lastCallId);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
(opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
254
285
|
switch (b.type) {
|
|
255
286
|
case "text": {
|
|
256
287
|
const t = b.text;
|
|
@@ -272,6 +303,7 @@ export function toResponsesRequest(req, opts) {
|
|
|
272
303
|
flush();
|
|
273
304
|
const tu = b;
|
|
274
305
|
knownCalls.add(tu.id);
|
|
306
|
+
lastCallId = tu.id;
|
|
275
307
|
input.push({ type: "function_call", call_id: tu.id, name: toolNameForResponses(tu.name), arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input ?? {}) });
|
|
276
308
|
break;
|
|
277
309
|
}
|
|
@@ -293,7 +325,8 @@ export function toResponsesRequest(req, opts) {
|
|
|
293
325
|
break;
|
|
294
326
|
}
|
|
295
327
|
default:
|
|
296
|
-
// thinking
|
|
328
|
+
// thinking, an unmarked redacted_thinking, anything unknown: no stored item to put back,
|
|
329
|
+
// so dropped as before (see header comment)
|
|
297
330
|
break;
|
|
298
331
|
}
|
|
299
332
|
}
|
|
@@ -327,6 +360,11 @@ export function toResponsesRequest(req, opts) {
|
|
|
327
360
|
prompt_cache_key: conversationId(req),
|
|
328
361
|
client_metadata: { session_id: conversationId(req), thread_id: conversationId(req), turn_id: crypto.randomUUID(), "x-codex-window-id": `${conversationId(req)}:0` },
|
|
329
362
|
};
|
|
363
|
+
// On every turn, as Codex asks. With `store: false` the backend keeps nothing, and without
|
|
364
|
+
// `include` it hands back no sealed item either — so a conversation that waited until it already
|
|
365
|
+
// carried reasoning before asking would never carry any. Only the retry after a refusal leaves it off.
|
|
366
|
+
if (opts.replay !== false)
|
|
367
|
+
out.include = ["reasoning.encrypted_content"];
|
|
330
368
|
if (tools.length > 0) {
|
|
331
369
|
out.tools = tools;
|
|
332
370
|
out.parallel_tool_calls = !(tc?.disable_parallel_tool_use ?? false);
|
|
@@ -348,6 +386,7 @@ export function estimateTokens(req) {
|
|
|
348
386
|
export class StreamMapper {
|
|
349
387
|
started = false;
|
|
350
388
|
blockIndex = -1;
|
|
389
|
+
/** `signed` marks a thinking block that already carries its signature — the envelope we wrote. */
|
|
351
390
|
open = null;
|
|
352
391
|
sawToolCall = false;
|
|
353
392
|
finished = false;
|
|
@@ -360,14 +399,30 @@ export class StreamMapper {
|
|
|
360
399
|
/** Accumulated content for non-streaming responses. */
|
|
361
400
|
content = [];
|
|
362
401
|
stopReason = "end_turn";
|
|
402
|
+
/**
|
|
403
|
+
* How many reasoning items this response carried whole — the ones the client will replay. The
|
|
404
|
+
* `reasoning=` figure the request log shows next to `replayed=`.
|
|
405
|
+
*/
|
|
406
|
+
reasoningCount = 0;
|
|
407
|
+
/** The reasoning item being assembled: its identity so far and the call it followed. */
|
|
408
|
+
pendingReasoning = null;
|
|
409
|
+
/** The most recent function_call item added, which is what a following reasoning item describes. */
|
|
410
|
+
lastCallId;
|
|
363
411
|
/** Input-token figure announced in message_start (the real one only arrives with response.completed). */
|
|
364
412
|
startInput;
|
|
365
413
|
/** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
|
|
366
414
|
toolNames;
|
|
367
|
-
|
|
415
|
+
/**
|
|
416
|
+
* The provider name that goes in each reasoning envelope (see reasoning.ts). A blob sealed here is
|
|
417
|
+
* only sent back here: reasoning outlives the request that made it, and a conversation moved to
|
|
418
|
+
* another provider must not carry this one's sealed state with it.
|
|
419
|
+
*/
|
|
420
|
+
provider;
|
|
421
|
+
constructor(model, startInput = 0, toolNames = new Map(), provider = "") {
|
|
368
422
|
this.model = model;
|
|
369
423
|
this.startInput = startInput;
|
|
370
424
|
this.toolNames = toolNames;
|
|
425
|
+
this.provider = provider;
|
|
371
426
|
this.messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
372
427
|
}
|
|
373
428
|
get isFinished() {
|
|
@@ -393,11 +448,17 @@ export class StreamMapper {
|
|
|
393
448
|
},
|
|
394
449
|
];
|
|
395
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* A thinking block must carry a signature even when there is none to give — an unsigned one is
|
|
453
|
+
* refused the moment the session moves to a Claude model (`thinking.ts`) — so a block that was
|
|
454
|
+
* opened without one gets an empty `signature_delta` as it closes. A block that already carries
|
|
455
|
+
* the envelope is left alone: a second, empty delta would replace it on the client.
|
|
456
|
+
*/
|
|
396
457
|
closeBlock() {
|
|
397
458
|
if (!this.open)
|
|
398
459
|
return [];
|
|
399
460
|
const evs = [];
|
|
400
|
-
if (this.open.kind === "thinking")
|
|
461
|
+
if (this.open.kind === "thinking" && !this.open.signed)
|
|
401
462
|
evs.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
|
|
402
463
|
evs.push({ event: "content_block_stop", data: { type: "content_block_stop", index: this.blockIndex } });
|
|
403
464
|
this.open = null;
|
|
@@ -410,6 +471,56 @@ export class StreamMapper {
|
|
|
410
471
|
evs.push({ event: "content_block_start", data: { type: "content_block_start", index: this.blockIndex, content_block: block } });
|
|
411
472
|
return evs;
|
|
412
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* Finish the reasoning item being assembled and hand the client the block that carries it: a
|
|
476
|
+
* thinking block whose text is the summary (empty when the backend sent none — measured
|
|
477
|
+
* 2026-10-14: Claude Code keeps and replays such a block, signature and all) and whose signature
|
|
478
|
+
* is the envelope around the sealed reasoning.
|
|
479
|
+
*
|
|
480
|
+
* An item with nothing sealed is only worth its summary, and then only if it has one: there is
|
|
481
|
+
* nothing to replay, and an empty thinking block is refused by Anthropic the moment the session
|
|
482
|
+
* switches to a Claude model (`thinking.ts`).
|
|
483
|
+
*/
|
|
484
|
+
sealReasoning() {
|
|
485
|
+
const pending = this.pendingReasoning;
|
|
486
|
+
if (!pending)
|
|
487
|
+
return [];
|
|
488
|
+
this.pendingReasoning = null;
|
|
489
|
+
const text = pending.text.trim() || (typeof pending.summary === "string" ? pending.summary.trim() : "");
|
|
490
|
+
const events = [];
|
|
491
|
+
if (!pending.encrypted) {
|
|
492
|
+
if (text) {
|
|
493
|
+
events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
494
|
+
events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
|
|
495
|
+
events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
|
|
496
|
+
events.push(...this.closeBlock());
|
|
497
|
+
this.content.push({ type: "thinking", thinking: text, signature: "" });
|
|
498
|
+
}
|
|
499
|
+
return events;
|
|
500
|
+
}
|
|
501
|
+
const item = { type: "reasoning", encrypted_content: pending.encrypted };
|
|
502
|
+
if (pending.id)
|
|
503
|
+
item.id = pending.id;
|
|
504
|
+
if (Array.isArray(pending.summary))
|
|
505
|
+
item.summary = pending.summary;
|
|
506
|
+
const signature = encodeEnvelope(this.envelopeFor(item));
|
|
507
|
+
events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
508
|
+
if (text)
|
|
509
|
+
events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
|
|
510
|
+
// The signature must arrive on this very block: it is the carrier, and the client keeps the block
|
|
511
|
+
// whole or not at all. It goes last, after the text, which is the order Anthropic's own blocks use.
|
|
512
|
+
events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature } } });
|
|
513
|
+
if (this.open)
|
|
514
|
+
this.open.signed = true;
|
|
515
|
+
events.push(...this.closeBlock());
|
|
516
|
+
this.content.push({ type: "thinking", thinking: text, signature });
|
|
517
|
+
this.reasoningCount++;
|
|
518
|
+
return events;
|
|
519
|
+
}
|
|
520
|
+
/** Where this item's envelope says it came from — this adapter's own provider and model. */
|
|
521
|
+
envelopeFor(item) {
|
|
522
|
+
return { w: "responses", p: this.provider, m: this.model, r: item };
|
|
523
|
+
}
|
|
413
524
|
/** Map one upstream event. Returns Anthropic events to emit (possibly none). */
|
|
414
525
|
feed(ev) {
|
|
415
526
|
const type = ev.type;
|
|
@@ -426,12 +537,23 @@ export class StreamMapper {
|
|
|
426
537
|
// The model echoes the mangled name; Claude Code only recognises the original.
|
|
427
538
|
const called = item.name ?? "tool";
|
|
428
539
|
const name = this.toolNames.get(called) ?? called;
|
|
540
|
+
this.lastCallId = id;
|
|
429
541
|
this.content.push({ type: "tool_use", id, name, input: {}, _args: "" });
|
|
430
542
|
out.push(...this.openBlock("tool", { type: "tool_use", id, name, input: {} }, item.id));
|
|
431
543
|
}
|
|
432
544
|
else if (item.type === "reasoning") {
|
|
433
|
-
|
|
434
|
-
|
|
545
|
+
// The item's reasoning is held until it is complete and handed over whole (see
|
|
546
|
+
// `sealReasoning`): one thinking block whose signature carries the envelope. The summary,
|
|
547
|
+
// when the backend sends one, is that block's text — and the summary deltas below fill it
|
|
548
|
+
// in. Passing the summary through `openBlock` here as well would split one item across two
|
|
549
|
+
// blocks, so this opens nothing.
|
|
550
|
+
this.pendingReasoning = {
|
|
551
|
+
...(item.id ? { id: item.id } : {}),
|
|
552
|
+
...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
|
|
553
|
+
...(item.summary !== undefined ? { summary: item.summary } : {}),
|
|
554
|
+
text: "",
|
|
555
|
+
callId: this.lastCallId,
|
|
556
|
+
};
|
|
435
557
|
}
|
|
436
558
|
else if (item.type === "message") {
|
|
437
559
|
this.content.push({ type: "text", text: "" });
|
|
@@ -454,6 +576,13 @@ export class StreamMapper {
|
|
|
454
576
|
case "response.reasoning_summary_text.delta":
|
|
455
577
|
case "response.reasoning_text.delta": {
|
|
456
578
|
const delta = String(ev.delta ?? "");
|
|
579
|
+
// The summary is the text of the reasoning item's block, and that block also carries the
|
|
580
|
+
// envelope — both are written when the item is done, so the block is not opened here. One
|
|
581
|
+
// item is one block; streaming the text into a block of its own would hand the client two.
|
|
582
|
+
if (this.pendingReasoning) {
|
|
583
|
+
this.pendingReasoning.text += delta;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
457
586
|
if (!this.open || this.open.kind !== "thinking") {
|
|
458
587
|
this.content.push({ type: "thinking", thinking: "", signature: "" });
|
|
459
588
|
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
@@ -488,7 +617,18 @@ export class StreamMapper {
|
|
|
488
617
|
break;
|
|
489
618
|
}
|
|
490
619
|
case "response.output_item.done": {
|
|
620
|
+
const done = ev.item;
|
|
621
|
+
// The backend may put `encrypted_content` only on the completed item, so the seal is taken
|
|
622
|
+
// here as well as from `response.output_item.added`, and the item is only handed over now
|
|
623
|
+
// anyway — a half-assembled reasoning block must not reach the client.
|
|
624
|
+
if (done?.type === "reasoning" && this.pendingReasoning) {
|
|
625
|
+
if (typeof done.encrypted_content === "string" && done.encrypted_content)
|
|
626
|
+
this.pendingReasoning.encrypted = done.encrypted_content;
|
|
627
|
+
if (done.summary !== undefined)
|
|
628
|
+
this.pendingReasoning.summary = done.summary;
|
|
629
|
+
}
|
|
491
630
|
out.push(...this.closeBlock());
|
|
631
|
+
out.push(...this.sealReasoning());
|
|
492
632
|
break;
|
|
493
633
|
}
|
|
494
634
|
case "response.completed":
|
|
@@ -25,6 +25,8 @@ import { ThoughtSignatureStore } from "./thoughtSignatures.js";
|
|
|
25
25
|
import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
|
|
26
26
|
import { GoogleAccountPool } from "./accounts.js";
|
|
27
27
|
import { fetchAvailableModels, staticAntigravityModels } from "./antigravity.js";
|
|
28
|
+
import { GoogleUsage } from "./usage.js";
|
|
29
|
+
import { ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE, isAntigravityPromptRefusal } from "./prompt-refusal.js";
|
|
28
30
|
const PING_MS = 15_000;
|
|
29
31
|
/** How long the CCA model list is reused before asking again. */
|
|
30
32
|
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
@@ -113,6 +115,8 @@ export class GoogleAdapter {
|
|
|
113
115
|
cfg;
|
|
114
116
|
log;
|
|
115
117
|
accounts;
|
|
118
|
+
/** Per-account quota, for the status bar's limits row (`GET /api/limits`). */
|
|
119
|
+
usage;
|
|
116
120
|
/** Cooldowns and conversation stickiness, shared with the proxy's other credential pools. */
|
|
117
121
|
pool;
|
|
118
122
|
lastInputByKey = new Map();
|
|
@@ -130,6 +134,7 @@ export class GoogleAdapter {
|
|
|
130
134
|
this.log = log;
|
|
131
135
|
this.pool = pool;
|
|
132
136
|
this.accounts = new GoogleAccountPool({ home, log, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
137
|
+
this.usage = new GoogleUsage(this.accounts, { warn: (message) => log.warn(message), ...(fetchImpl ? { fetch: fetchImpl } : {}) });
|
|
133
138
|
}
|
|
134
139
|
describeAuth() {
|
|
135
140
|
const all = this.accounts.summaries();
|
|
@@ -161,6 +166,13 @@ export class GoogleAdapter {
|
|
|
161
166
|
};
|
|
162
167
|
});
|
|
163
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Each account's quota groups by owner id, for the status bar's limits row. Never a token; a
|
|
171
|
+
* failed lookup keeps that account's last good groups.
|
|
172
|
+
*/
|
|
173
|
+
usageSnapshot() {
|
|
174
|
+
return this.usage.snapshot();
|
|
175
|
+
}
|
|
164
176
|
/**
|
|
165
177
|
* Ask Google again whether an account still needs its verification page (dashboard action). The
|
|
166
178
|
* mark otherwise clears only when the account next answers a turn, so a person who had just
|
|
@@ -338,6 +350,8 @@ export class GoogleAdapter {
|
|
|
338
350
|
const sent = await this.sendToAnAccount({
|
|
339
351
|
conversation: key,
|
|
340
352
|
signal: controller.signal,
|
|
353
|
+
system: upstreamRequest.systemInstruction,
|
|
354
|
+
model,
|
|
341
355
|
build: (credential) => {
|
|
342
356
|
const built = transport.build(upstreamRequest, {
|
|
343
357
|
model,
|
|
@@ -352,6 +366,11 @@ export class GoogleAdapter {
|
|
|
352
366
|
res.off("close", onClose);
|
|
353
367
|
if (sent.kind === "aborted")
|
|
354
368
|
return { status: 0, bytes: 0, note: "client closed" };
|
|
369
|
+
if (sent.kind === "prompt-refused") {
|
|
370
|
+
const err = anthropicError(400, "invalid_request_error", ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE);
|
|
371
|
+
return { kind: "prompt-incompatible", status: 400, bytes: 0, errorBody: err.body,
|
|
372
|
+
note: "upstream 429: suspected Antigravity SDK prompt refusal with remaining quota", refusal: "prompt-incompatible" };
|
|
373
|
+
}
|
|
355
374
|
if (sent.kind === "unreachable") {
|
|
356
375
|
const err = anthropicError(502, "api_error", `Google provider unreachable: ${sent.error.message}`);
|
|
357
376
|
if (!res.headersSent)
|
|
@@ -557,6 +576,13 @@ export class GoogleAdapter {
|
|
|
557
576
|
const safeText = redactErrorText(text, secrets);
|
|
558
577
|
const status = upstream.status;
|
|
559
578
|
this.log.warn(`google ${this.name}: account ${credential.ownerId.slice(0, 8)} answered ${status}: ${safeText.slice(0, 400)}`);
|
|
579
|
+
// The generic 429 is also real exhaustion. Only fresh quota evidence for this
|
|
580
|
+
// account/model permits a request-specific refusal; otherwise keep ordinary
|
|
581
|
+
// cooldown and account rotation. The proxy can still try a route fallback.
|
|
582
|
+
if (isAntigravityPromptRefusal(spec.system, status, text, upstream.headers.get("retry-after")) &&
|
|
583
|
+
this.usage.hasRemainingQuota(credential, spec.model)) {
|
|
584
|
+
return { kind: "prompt-refused" };
|
|
585
|
+
}
|
|
560
586
|
// Not a rest and not a refresh: the account works again the moment its person finishes
|
|
561
587
|
// Google's page, so it stays in rotation and the next turn finds out.
|
|
562
588
|
const verifyUrl = status === 403 ? googleValidationUrl(text) : undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const SDK_PARAGRAPH = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
2
|
+
const GENERIC_EXHAUSTED = "Resource has been exhausted (e.g. check quota).";
|
|
3
|
+
export const ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE = `Google Antigravity rejected this Claude Agent SDK system prompt (upstream 429: ${GENERIC_EXHAUSTED}). ` +
|
|
4
|
+
"This request appears incompatible with Antigravity; retrying or waiting on this account is unlikely to fix it. " +
|
|
5
|
+
"Choose another provider for this Claude Code request.";
|
|
6
|
+
/**
|
|
7
|
+
* Measured 2026-10-16: the same Gemini 3.8 Flash / Claude Opus 5.5 request got 429
|
|
8
|
+
* with this standalone SDK paragraph and 200 without it. This is response-based
|
|
9
|
+
* classification, never a prompt rewrite or a prediction before Google answers.
|
|
10
|
+
* Keep any explicit quota/reset details on the ordinary rate-limit path. This shape
|
|
11
|
+
* alone also occurs on real exhaustion; callers must require fresh quota evidence.
|
|
12
|
+
*/
|
|
13
|
+
export function isAntigravityPromptRefusal(system, status, text, retryAfter) {
|
|
14
|
+
if (status !== 429 || retryAfter !== null)
|
|
15
|
+
return false;
|
|
16
|
+
if (!system?.parts.some(part => part.text?.split(/\r?\n\s*\r?\n/).some(paragraph => paragraph.trim() === SDK_PARAGRAPH)))
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const error = JSON.parse(text)?.error;
|
|
20
|
+
if (!error || typeof error !== "object" || Array.isArray(error))
|
|
21
|
+
return false;
|
|
22
|
+
const e = error;
|
|
23
|
+
return e.code === 429 && e.status === "RESOURCE_EXHAUSTED" && e.message === GENERIC_EXHAUSTED &&
|
|
24
|
+
(e.details === undefined || (Array.isArray(e.details) && e.details.length === 0));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|