antigravity-claude-proxy 2.9.2 → 3.0.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/README.md +1 -1
- package/package.json +5 -5
- package/src/cloudcode/session-manager.js +72 -16
- package/src/cloudcode/sse-streamer.js +15 -1
- package/src/format/response-converter.js +2 -1
- package/src/format/signature-cache.js +262 -42
- package/src/format/thinking-utils.js +15 -0
- package/src/server.js +13 -7
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ A proxy server that exposes an **Anthropic-compatible API** backed by **Antigrav
|
|
|
45
45
|
|
|
46
46
|
## Prerequisites
|
|
47
47
|
|
|
48
|
-
- **Node.js**
|
|
48
|
+
- **Node.js** 22.19.0 or later
|
|
49
49
|
- **Antigravity** installed (for single-account mode) OR Google account(s) for multi-account mode
|
|
50
50
|
|
|
51
51
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "antigravity-claude-proxy",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -59,14 +59,14 @@
|
|
|
59
59
|
"url": "https://github.com/badri-s2001/antigravity-claude-proxy/issues"
|
|
60
60
|
},
|
|
61
61
|
"engines": {
|
|
62
|
-
"node": ">=
|
|
62
|
+
"node": ">=22.19.0"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"async-mutex": "^0.5.0",
|
|
66
|
-
"better-sqlite3": "^
|
|
66
|
+
"better-sqlite3": "^13.0.3",
|
|
67
67
|
"cors": "^2.8.5",
|
|
68
|
-
"express": "^
|
|
69
|
-
"undici": "^
|
|
68
|
+
"express": "^5.3.0",
|
|
69
|
+
"undici": "^8.12.4"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@tailwindcss/forms": "^0.5.7",
|
|
@@ -1,36 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session Management for Cloud Code
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Session IDs give the Cloud Code backend a cache-affinity namespace.
|
|
5
|
+
* A per-conversation ID (stable hash of the first user message) keeps every
|
|
6
|
+
* conversation in its own namespace so interleaved conversations on the same
|
|
7
|
+
* account stop evicting each other's implicit-cache KV blocks. Requests
|
|
8
|
+
* without any user text fall back to a per-account process-lifetime ID.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import crypto from 'crypto';
|
|
10
12
|
|
|
11
13
|
|
|
12
|
-
// Runtime storage for session IDs (per account)
|
|
13
|
-
// This mimics the behavior of the binary which generates a session ID at startup
|
|
14
|
-
// and keeps it for the process lifetime.
|
|
14
|
+
// Runtime fallback storage for session IDs (per account)
|
|
15
15
|
// Key: accountEmail, Value: sessionId
|
|
16
16
|
const runtimeSessionStore = new Map();
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
* Extract the first meaningful user text from an Anthropic request.
|
|
20
|
+
* Returns null when no user message carries text.
|
|
21
|
+
*/
|
|
22
|
+
function extractFirstUserText(anthropicRequest) {
|
|
23
|
+
const messages = anthropicRequest?.messages;
|
|
24
|
+
if (!Array.isArray(messages)) return null;
|
|
25
|
+
for (const msg of messages) {
|
|
26
|
+
if (!msg || msg.role !== 'user') continue;
|
|
27
|
+
const content = msg.content;
|
|
28
|
+
let text = null;
|
|
29
|
+
if (typeof content === 'string') {
|
|
30
|
+
text = content;
|
|
31
|
+
} else if (Array.isArray(content)) {
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const block of content) {
|
|
34
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
35
|
+
parts.push(block.text);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (parts.length > 0) text = parts.join('\n');
|
|
39
|
+
}
|
|
40
|
+
if (text && text.trim().length > 0) {
|
|
41
|
+
// Hash the full text, never a prefix: clients such as Claude Code
|
|
42
|
+
// open the first user message with a long shared preamble
|
|
43
|
+
// (<system-reminder>, CLAUDE.md), so a truncated prefix maps
|
|
44
|
+
// every conversation in a project to the same session ID.
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive a deterministic per-conversation session ID shaped like the binary's
|
|
53
|
+
* `uuid + Date.now()` format. The same conversation (same first user text,
|
|
54
|
+
* same account) always maps to the same ID across turns and proxy restarts.
|
|
55
|
+
*/
|
|
56
|
+
function deriveConversationSessionId(anthropicRequest, accountEmail) {
|
|
57
|
+
const firstUserText = extractFirstUserText(anthropicRequest);
|
|
58
|
+
if (!firstUserText) return null;
|
|
59
|
+
const digest = crypto
|
|
60
|
+
.createHash('sha256')
|
|
61
|
+
.update(`${accountEmail || 'anonymous'}\u0000${firstUserText}`)
|
|
62
|
+
.digest('hex');
|
|
63
|
+
const uuid =
|
|
64
|
+
`${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-` +
|
|
65
|
+
`${digest.slice(16, 20)}-${digest.slice(20, 32)}`;
|
|
66
|
+
const tail = BigInt(`0x${digest.slice(32, 45)}`).toString();
|
|
67
|
+
return `${uuid}${tail}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get or create a session ID for a request.
|
|
24
72
|
*
|
|
25
|
-
*
|
|
26
|
-
* -
|
|
27
|
-
*
|
|
73
|
+
* Priority:
|
|
74
|
+
* 1. Per-conversation deterministic ID from the first user message —
|
|
75
|
+
* restores cache continuity per conversation and isolates concurrent
|
|
76
|
+
* conversations from each other's cache pressure.
|
|
77
|
+
* 2. Per-account process-lifetime ID when the request carries no user
|
|
78
|
+
* text at all (previous behavior).
|
|
28
79
|
*
|
|
29
|
-
* @param {Object} anthropicRequest - The Anthropic-format request
|
|
30
|
-
* @param {string} accountEmail - The account email to scope
|
|
80
|
+
* @param {Object} anthropicRequest - The Anthropic-format request
|
|
81
|
+
* @param {string} accountEmail - The account email to scope fallback IDs
|
|
31
82
|
* @returns {string} A stable session ID string matching binary format
|
|
32
83
|
*/
|
|
33
84
|
export function deriveSessionId(anthropicRequest, accountEmail) {
|
|
85
|
+
const conversationId = deriveConversationSessionId(anthropicRequest, accountEmail);
|
|
86
|
+
if (conversationId) {
|
|
87
|
+
return conversationId;
|
|
88
|
+
}
|
|
89
|
+
|
|
34
90
|
if (!accountEmail) {
|
|
35
91
|
// Fallback for requests without an account (should differ every time)
|
|
36
92
|
return generateBinaryStyleId();
|
|
@@ -55,7 +55,11 @@ export async function* streamSSEResponse(response, originalModel) {
|
|
|
55
55
|
const usage = innerResponse.usageMetadata;
|
|
56
56
|
if (usage) {
|
|
57
57
|
inputTokens = usage.promptTokenCount || inputTokens;
|
|
58
|
-
|
|
58
|
+
// Anthropic semantics: output_tokens covers ALL generated
|
|
59
|
+
// tokens, thinking included (thoughtsTokenCount is billed as
|
|
60
|
+
// output by the backend). candidatesTokenCount alone silently
|
|
61
|
+
// undercounts thinking-heavy responses.
|
|
62
|
+
outputTokens = ((usage.candidatesTokenCount || 0) + (usage.thoughtsTokenCount || 0)) || outputTokens;
|
|
59
63
|
cacheReadTokens = usage.cachedContentTokenCount || cacheReadTokens;
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -279,10 +283,20 @@ export async function* streamSSEResponse(response, originalModel) {
|
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
// Emit message_delta and message_stop
|
|
286
|
+
//
|
|
287
|
+
// Usage reporting: Google streams usageMetadata progressively; the
|
|
288
|
+
// early chunks carry an interim promptTokenCount and no
|
|
289
|
+
// cachedContentTokenCount yet. message_start therefore latches an
|
|
290
|
+
// inflated input_tokens (the nearly-full prompt minus zero cache) whenever
|
|
291
|
+
// the request is served mostly from cache. The final usageMetadata is
|
|
292
|
+
// authoritative, so correct input_tokens here: clients that take the last
|
|
293
|
+
// non-zero value (or read message_delta.usage.input_tokens) then see the
|
|
294
|
+
// true uncached input instead of a phantom near-100%-of-prompt figure.
|
|
282
295
|
yield {
|
|
283
296
|
type: 'message_delta',
|
|
284
297
|
delta: { stop_reason: stopReason || 'end_turn', stop_sequence: null },
|
|
285
298
|
usage: {
|
|
299
|
+
input_tokens: Math.max(0, inputTokens - cacheReadTokens),
|
|
286
300
|
output_tokens: outputTokens,
|
|
287
301
|
cache_read_input_tokens: cacheReadTokens,
|
|
288
302
|
cache_creation_input_tokens: 0
|
|
@@ -112,7 +112,8 @@ export function convertGoogleToAnthropic(googleResponse, model) {
|
|
|
112
112
|
stop_sequence: null,
|
|
113
113
|
usage: {
|
|
114
114
|
input_tokens: promptTokens - cachedTokens,
|
|
115
|
-
|
|
115
|
+
// Thinking tokens are generated (and billed) output — include them.
|
|
116
|
+
output_tokens: (usageMetadata.candidatesTokenCount || 0) + (usageMetadata.thoughtsTokenCount || 0),
|
|
116
117
|
cache_read_input_tokens: cachedTokens,
|
|
117
118
|
cache_creation_input_tokens: 0
|
|
118
119
|
}
|
|
@@ -1,82 +1,270 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signature Cache
|
|
3
|
-
* In-memory cache for Gemini thoughtSignatures
|
|
2
|
+
* Signature Cache — persistent, LRU-bounded
|
|
4
3
|
*
|
|
5
|
-
* Gemini models require thoughtSignature on tool calls
|
|
6
|
-
*
|
|
7
|
-
* so they can be restored in subsequent requests.
|
|
4
|
+
* Gemini models require thoughtSignature on tool calls and thinking blocks,
|
|
5
|
+
* but Anthropic-protocol clients strip non-standard fields. This cache stores
|
|
6
|
+
* signatures by tool_use_id so they can be restored in subsequent requests.
|
|
7
|
+
*
|
|
8
|
+
* Signatures have no fixed time-based expiry — they stay valid for the lifetime
|
|
9
|
+
* of active conversations, and dropping them mid-conversation silently rewrites
|
|
10
|
+
* conversation history on replay, which busts the upstream implicit-cache prefix
|
|
11
|
+
* and drops thinking blocks. The store is persisted to disk and reloaded at boot,
|
|
12
|
+
* bounded by an LRU cap so memory and on-disk size stay bounded over months of use.
|
|
8
13
|
*
|
|
9
14
|
* Also caches thinking block signatures with model family for cross-model
|
|
10
15
|
* compatibility checking.
|
|
16
|
+
*
|
|
17
|
+
* Persistence: JSON file, atomic tmp+rename writes, mode 0600, debounced
|
|
18
|
+
* async writes (plus a periodic safety flush and synchronous flush on SIGTERM/SIGINT/
|
|
19
|
+
* exit). All persistence failures fail open: the in-memory cache keeps
|
|
20
|
+
* working and the proxy never errors because of store I/O.
|
|
11
21
|
*/
|
|
12
22
|
|
|
13
|
-
import
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { MIN_SIGNATURE_LENGTH } from '../constants.js';
|
|
27
|
+
|
|
28
|
+
const toolSignatureCache = new Map(); // toolUseId -> signature (insertion-ordered LRU)
|
|
29
|
+
const thinkingSignatureCache = new Map(); // signature -> modelFamily (insertion-ordered LRU)
|
|
30
|
+
|
|
31
|
+
const STORE_VERSION = 1;
|
|
32
|
+
const DEFAULT_MAX_TOOL_SIGNATURES = 20_000;
|
|
33
|
+
const DEFAULT_MAX_THINKING_SIGNATURES = 10_000;
|
|
34
|
+
const FLUSH_DEBOUNCE_MS = 5000;
|
|
35
|
+
const PERIODIC_FLUSH_MS = 30_000;
|
|
36
|
+
const MAX_SILENT_STORE_ERRORS = 20;
|
|
37
|
+
|
|
38
|
+
function parsePositiveIntEnv(name, fallback) {
|
|
39
|
+
const raw = process.env[name];
|
|
40
|
+
if (!raw) return fallback;
|
|
41
|
+
const n = Number.parseInt(raw, 10);
|
|
42
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const maxToolSignatures = parsePositiveIntEnv('ANTIGRAVITY_SIGNATURE_CACHE_MAX_TOOL', DEFAULT_MAX_TOOL_SIGNATURES);
|
|
46
|
+
const maxThinkingSignatures = parsePositiveIntEnv('ANTIGRAVITY_SIGNATURE_CACHE_MAX_THINK', DEFAULT_MAX_THINKING_SIGNATURES);
|
|
47
|
+
|
|
48
|
+
function lruSet(map, key, value, maxEntries) {
|
|
49
|
+
if (map.has(key)) {
|
|
50
|
+
map.delete(key);
|
|
51
|
+
}
|
|
52
|
+
map.set(key, value);
|
|
53
|
+
while (map.size > maxEntries) {
|
|
54
|
+
const oldestKey = map.keys().next().value;
|
|
55
|
+
if (oldestKey === undefined) break;
|
|
56
|
+
map.delete(oldestKey);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function lruGet(map, key) {
|
|
61
|
+
if (!map.has(key)) return null;
|
|
62
|
+
const value = map.get(key);
|
|
63
|
+
map.delete(key);
|
|
64
|
+
map.set(key, value);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveStorePath() {
|
|
69
|
+
const fromEnv = process.env.ANTIGRAVITY_SIGNATURE_STORE;
|
|
70
|
+
if (fromEnv && fromEnv.trim()) return fromEnv;
|
|
71
|
+
return path.join(os.homedir(), '.config', 'antigravity-proxy', 'signature-store.json');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let storePath = resolveStorePath();
|
|
75
|
+
let flushTimer = null;
|
|
76
|
+
let periodicTimer = null;
|
|
77
|
+
let dirty = false;
|
|
78
|
+
let storeErrorCount = 0;
|
|
79
|
+
let writeInFlight = null;
|
|
80
|
+
let storeGeneration = 0;
|
|
14
81
|
|
|
15
|
-
|
|
16
|
-
|
|
82
|
+
function reportStoreError(context, error) {
|
|
83
|
+
if (storeErrorCount < MAX_SILENT_STORE_ERRORS) {
|
|
84
|
+
storeErrorCount++;
|
|
85
|
+
// Console only: this module is imported before the logger is configured.
|
|
86
|
+
console.warn(`[SignatureCache] ${context} failed: ${error?.message ?? error}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadFromDisk() {
|
|
91
|
+
try {
|
|
92
|
+
const raw = fs.readFileSync(storePath, 'utf8');
|
|
93
|
+
if (!raw.trim()) return;
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (parsed?.version !== STORE_VERSION) return;
|
|
96
|
+
let prunedOnLoad = false;
|
|
97
|
+
if (parsed.tool && typeof parsed.tool === 'object') {
|
|
98
|
+
const entries = Object.entries(parsed.tool);
|
|
99
|
+
if (entries.length > maxToolSignatures) prunedOnLoad = true;
|
|
100
|
+
for (const [id, sig] of entries) {
|
|
101
|
+
if (typeof id === 'string' && typeof sig === 'string') {
|
|
102
|
+
lruSet(toolSignatureCache, id, sig, maxToolSignatures);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (parsed.think && typeof parsed.think === 'object') {
|
|
107
|
+
const entries = Object.entries(parsed.think);
|
|
108
|
+
if (entries.length > maxThinkingSignatures) prunedOnLoad = true;
|
|
109
|
+
for (const [sig, family] of entries) {
|
|
110
|
+
if (typeof sig === 'string' && typeof family === 'string') {
|
|
111
|
+
lruSet(thinkingSignatureCache, sig, family, maxThinkingSignatures);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (prunedOnLoad) {
|
|
116
|
+
scheduleFlush();
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code !== 'ENOENT') {
|
|
120
|
+
reportStoreError('load', error);
|
|
121
|
+
}
|
|
122
|
+
// Corrupt or unreadable store: start empty rather than failing boot.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function serializeStore() {
|
|
127
|
+
return JSON.stringify({
|
|
128
|
+
version: STORE_VERSION,
|
|
129
|
+
tool: Object.fromEntries(toolSignatureCache),
|
|
130
|
+
think: Object.fromEntries(thinkingSignatureCache),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
17
133
|
|
|
18
134
|
/**
|
|
19
|
-
*
|
|
135
|
+
* Synchronous flush, used only on process exit/signals where async I/O
|
|
136
|
+
* would never complete. Bumps the store generation so any async write still
|
|
137
|
+
* in flight discards its (older) snapshot instead of renaming over this one.
|
|
138
|
+
*/
|
|
139
|
+
function writeStoreSync() {
|
|
140
|
+
if (!dirty) return;
|
|
141
|
+
storeGeneration++;
|
|
142
|
+
try {
|
|
143
|
+
const data = serializeStore();
|
|
144
|
+
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
145
|
+
const tmp = `${storePath}.tmp-sync-${process.pid}`;
|
|
146
|
+
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
147
|
+
fs.renameSync(tmp, storePath);
|
|
148
|
+
dirty = false;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
reportStoreError('flush', error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normal-operation flush. File I/O runs asynchronously so a store near the
|
|
156
|
+
* LRU cap (tens of MB) never blocks the event loop on disk writes; only the
|
|
157
|
+
* JSON serialization stays synchronous. At most one write is in flight.
|
|
158
|
+
*/
|
|
159
|
+
function writeStoreAsync() {
|
|
160
|
+
if (!dirty || writeInFlight) return;
|
|
161
|
+
let data;
|
|
162
|
+
try {
|
|
163
|
+
data = serializeStore();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
reportStoreError('flush', error);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
dirty = false;
|
|
169
|
+
const generation = storeGeneration;
|
|
170
|
+
const targetPath = storePath;
|
|
171
|
+
const tmp = `${targetPath}.tmp-${process.pid}`;
|
|
172
|
+
let failed = false;
|
|
173
|
+
writeInFlight = (async () => {
|
|
174
|
+
try {
|
|
175
|
+
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
176
|
+
await fs.promises.writeFile(tmp, data, { mode: 0o600 });
|
|
177
|
+
if (generation !== storeGeneration) {
|
|
178
|
+
// Cache was cleared or flushed synchronously meanwhile.
|
|
179
|
+
await fs.promises.rm(tmp, { force: true });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await fs.promises.rename(tmp, targetPath);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
failed = true;
|
|
185
|
+
dirty = true;
|
|
186
|
+
reportStoreError('flush', error);
|
|
187
|
+
}
|
|
188
|
+
})().finally(() => {
|
|
189
|
+
writeInFlight = null;
|
|
190
|
+
// Changes made during the write get flushed promptly; after a failed
|
|
191
|
+
// write, leave the retry to the periodic flush instead of spinning.
|
|
192
|
+
if (dirty && !failed) scheduleFlush();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function scheduleFlush() {
|
|
197
|
+
dirty = true;
|
|
198
|
+
if (flushTimer) return;
|
|
199
|
+
flushTimer = setTimeout(() => {
|
|
200
|
+
flushTimer = null;
|
|
201
|
+
writeStoreAsync();
|
|
202
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
203
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function removeStoreFile() {
|
|
207
|
+
try {
|
|
208
|
+
fs.rmSync(storePath, { force: true });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
reportStoreError('remove', error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
loadFromDisk();
|
|
215
|
+
|
|
216
|
+
periodicTimer = setInterval(() => writeStoreAsync(), PERIODIC_FLUSH_MS);
|
|
217
|
+
if (typeof periodicTimer.unref === 'function') periodicTimer.unref();
|
|
218
|
+
|
|
219
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'exit']) {
|
|
220
|
+
process.on(signal, () => writeStoreSync());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Store a signature for a tool_use_id. Bounded by an LRU cap (no fixed TTL):
|
|
225
|
+
* replayed signatures stay byte-stable for active conversations.
|
|
20
226
|
* @param {string} toolUseId - The tool use ID
|
|
21
227
|
* @param {string} signature - The thoughtSignature to cache
|
|
22
228
|
*/
|
|
23
229
|
export function cacheSignature(toolUseId, signature) {
|
|
24
230
|
if (!toolUseId || !signature) return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
231
|
+
const prev = toolSignatureCache.get(toolUseId);
|
|
232
|
+
lruSet(toolSignatureCache, toolUseId, signature, maxToolSignatures);
|
|
233
|
+
if (prev === signature) return;
|
|
234
|
+
scheduleFlush();
|
|
29
235
|
}
|
|
30
236
|
|
|
31
237
|
/**
|
|
32
|
-
* Get a cached signature for a tool_use_id
|
|
238
|
+
* Get a cached signature for a tool_use_id.
|
|
33
239
|
* @param {string} toolUseId - The tool use ID
|
|
34
|
-
* @returns {string|null} The cached signature or null if
|
|
240
|
+
* @returns {string|null} The cached signature or null if never seen
|
|
35
241
|
*/
|
|
36
242
|
export function getCachedSignature(toolUseId) {
|
|
37
243
|
if (!toolUseId) return null;
|
|
38
|
-
|
|
39
|
-
if (!entry) return null;
|
|
40
|
-
|
|
41
|
-
// Check TTL
|
|
42
|
-
if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
|
|
43
|
-
signatureCache.delete(toolUseId);
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return entry.signature;
|
|
244
|
+
return lruGet(toolSignatureCache, toolUseId);
|
|
48
245
|
}
|
|
49
246
|
|
|
50
247
|
/**
|
|
51
|
-
* Cache a thinking block signature with its model family
|
|
248
|
+
* Cache a thinking block signature with its model family. Bounded by an LRU cap.
|
|
52
249
|
* @param {string} signature - The thinking signature to cache
|
|
53
250
|
* @param {string} modelFamily - The model family ('claude' or 'gemini')
|
|
54
251
|
*/
|
|
55
252
|
export function cacheThinkingSignature(signature, modelFamily) {
|
|
56
253
|
if (!signature || signature.length < MIN_SIGNATURE_LENGTH) return;
|
|
57
|
-
thinkingSignatureCache.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
254
|
+
const prev = thinkingSignatureCache.get(signature);
|
|
255
|
+
lruSet(thinkingSignatureCache, signature, modelFamily, maxThinkingSignatures);
|
|
256
|
+
if (prev === modelFamily) return;
|
|
257
|
+
scheduleFlush();
|
|
61
258
|
}
|
|
62
259
|
|
|
63
260
|
/**
|
|
64
|
-
* Get the cached model family for a thinking signature
|
|
261
|
+
* Get the cached model family for a thinking signature.
|
|
65
262
|
* @param {string} signature - The signature to look up
|
|
66
|
-
* @returns {string|null} 'claude', 'gemini', or null if
|
|
263
|
+
* @returns {string|null} 'claude', 'gemini', or null if never seen
|
|
67
264
|
*/
|
|
68
265
|
export function getCachedSignatureFamily(signature) {
|
|
69
266
|
if (!signature) return null;
|
|
70
|
-
|
|
71
|
-
if (!entry) return null;
|
|
72
|
-
|
|
73
|
-
// Check TTL
|
|
74
|
-
if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
|
|
75
|
-
thinkingSignatureCache.delete(signature);
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return entry.modelFamily;
|
|
267
|
+
return lruGet(thinkingSignatureCache, signature);
|
|
80
268
|
}
|
|
81
269
|
|
|
82
270
|
/**
|
|
@@ -85,4 +273,36 @@ export function getCachedSignatureFamily(signature) {
|
|
|
85
273
|
*/
|
|
86
274
|
export function clearThinkingSignatureCache() {
|
|
87
275
|
thinkingSignatureCache.clear();
|
|
276
|
+
scheduleFlush();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Flush pending changes to disk now and wait for the write to finish.
|
|
281
|
+
* The debounced flush normally does this; exported for tests and callers
|
|
282
|
+
* that need the store on disk at a known point.
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
export async function flushSignatureStore() {
|
|
286
|
+
if (flushTimer) {
|
|
287
|
+
clearTimeout(flushTimer);
|
|
288
|
+
flushTimer = null;
|
|
289
|
+
}
|
|
290
|
+
if (writeInFlight) await writeInFlight;
|
|
291
|
+
writeStoreAsync();
|
|
292
|
+
if (writeInFlight) await writeInFlight;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Clear both signature caches and delete the persisted store.
|
|
297
|
+
*/
|
|
298
|
+
export function clearAllSignatureCaches() {
|
|
299
|
+
toolSignatureCache.clear();
|
|
300
|
+
thinkingSignatureCache.clear();
|
|
301
|
+
if (flushTimer) {
|
|
302
|
+
clearTimeout(flushTimer);
|
|
303
|
+
flushTimer = null;
|
|
304
|
+
}
|
|
305
|
+
dirty = false;
|
|
306
|
+
storeGeneration++; // any in-flight async write must not resurrect the store
|
|
307
|
+
removeStoreFile();
|
|
88
308
|
}
|
|
@@ -656,6 +656,21 @@ function stripInvalidThinkingBlocks(messages, targetFamily = null) {
|
|
|
656
656
|
* @returns {Array<Object>} Modified messages with synthetic messages injected
|
|
657
657
|
*/
|
|
658
658
|
export function closeToolLoopForThinking(messages, targetFamily = null) {
|
|
659
|
+
// Escape hatch (Gemini only): skip the synthetic-message injection.
|
|
660
|
+
// Google's backend accepts raw mid-tool-loop and interrupted-tool
|
|
661
|
+
// histories natively (verified 2026-09-03: identical answers with and
|
|
662
|
+
// without injection), so for Gemini targets the fabricated messages are
|
|
663
|
+
// pure downside — a fake "[Continue]" becomes the model's latest user
|
|
664
|
+
// instruction and the count-bearing text is unstable across requests.
|
|
665
|
+
// Claude targets keep the recovery: the Anthropic API hard-rejects
|
|
666
|
+
// unclosed tool_use ids, so the interrupted-tool synthesis stays needed.
|
|
667
|
+
// Set ANTIGRAVITY_DISABLE_THINKING_RECOVERY=1 to activate.
|
|
668
|
+
if (
|
|
669
|
+
process.env.ANTIGRAVITY_DISABLE_THINKING_RECOVERY === '1' &&
|
|
670
|
+
targetFamily === 'gemini'
|
|
671
|
+
) {
|
|
672
|
+
return messages;
|
|
673
|
+
}
|
|
659
674
|
const state = analyzeConversationState(messages);
|
|
660
675
|
|
|
661
676
|
// Handle neither tool loop nor interrupted tool
|
package/src/server.js
CHANGED
|
@@ -17,7 +17,7 @@ const __dirname = path.dirname(__filename);
|
|
|
17
17
|
import { forceRefresh } from './auth/token-extractor.js';
|
|
18
18
|
import { REQUEST_BODY_LIMIT, BUILTIN_MODEL_MAPPINGS } from './constants.js';
|
|
19
19
|
import { AccountManager } from './account-manager/index.js';
|
|
20
|
-
import {
|
|
20
|
+
import { clearAllSignatureCaches } from './format/signature-cache.js';
|
|
21
21
|
import { formatDuration } from './utils/helpers.js';
|
|
22
22
|
import { logger } from './utils/logger.js';
|
|
23
23
|
import usageStats from './modules/usage-stats.js';
|
|
@@ -78,6 +78,12 @@ async function ensureInitialized() {
|
|
|
78
78
|
// Middleware
|
|
79
79
|
app.use(cors());
|
|
80
80
|
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
|
81
|
+
// Express 5 leaves req.body undefined when no body was parsed (Express 4 set {}).
|
|
82
|
+
// Keep the old behavior so handlers can destructure req.body safely.
|
|
83
|
+
app.use((req, res, next) => {
|
|
84
|
+
if (req.body === undefined) req.body = {};
|
|
85
|
+
next();
|
|
86
|
+
});
|
|
81
87
|
|
|
82
88
|
// API Key authentication middleware for /v1/* endpoints
|
|
83
89
|
app.use('/v1', (req, res, next) => {
|
|
@@ -217,13 +223,13 @@ app.post('/', (req, res) => {
|
|
|
217
223
|
});
|
|
218
224
|
|
|
219
225
|
/**
|
|
220
|
-
* Test endpoint - Clear
|
|
226
|
+
* Test endpoint - Clear all signature caches (in-memory + persisted store)
|
|
221
227
|
* Used for testing cold cache scenarios in cross-model tests
|
|
222
228
|
*/
|
|
223
229
|
app.post('/test/clear-signature-cache', (req, res) => {
|
|
224
|
-
|
|
225
|
-
logger.debug('[Test] Cleared
|
|
226
|
-
res.json({ success: true, message: '
|
|
230
|
+
clearAllSignatureCaches();
|
|
231
|
+
logger.debug('[Test] Cleared all signature caches and removed persisted store');
|
|
232
|
+
res.json({ success: true, message: 'All signature caches cleared' });
|
|
227
233
|
});
|
|
228
234
|
|
|
229
235
|
/**
|
|
@@ -917,8 +923,8 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
917
923
|
*/
|
|
918
924
|
usageStats.setupRoutes(app);
|
|
919
925
|
|
|
920
|
-
app.use(
|
|
921
|
-
// Log 404s
|
|
926
|
+
app.use((req, res) => {
|
|
927
|
+
// Log 404s
|
|
922
928
|
if (logger.isDebugEnabled) {
|
|
923
929
|
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
|
|
924
930
|
}
|