antigravity-claude-proxy 2.9.1 → 2.9.3
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 +3 -2
- package/public/js/components/dashboard/charts.js +34 -2
- package/public/js/components/dashboard.js +2 -0
- package/public/views/dashboard.html +4 -2
- package/src/cloudcode/request-builder.js +23 -4
- package/src/cloudcode/session-manager.js +72 -16
- package/src/cloudcode/sse-streamer.js +15 -1
- package/src/constants.js +12 -0
- 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/modules/usage-stats.js +47 -16
- package/src/server.js +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "antigravity-claude-proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.3",
|
|
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",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"test:sanitizer": "node tests/test-schema-sanitizer.cjs",
|
|
39
39
|
"test:strategies": "node tests/test-strategies.cjs",
|
|
40
40
|
"test:cache-control": "node tests/test-cache-control.cjs",
|
|
41
|
-
"test:billing-header": "node tests/test-billing-header.cjs"
|
|
41
|
+
"test:billing-header": "node tests/test-billing-header.cjs",
|
|
42
|
+
"test:stats": "node tests/test-usage-stats.js"
|
|
42
43
|
},
|
|
43
44
|
"keywords": [
|
|
44
45
|
"claude",
|
|
@@ -461,8 +461,18 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|
|
461
461
|
});
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
-
// Build datasets for families
|
|
464
|
+
// Build datasets for families (only include families with activity > 0)
|
|
465
|
+
const activeFamilies = new Set();
|
|
466
|
+
Object.entries(dataByFamily).forEach(([family, values]) => {
|
|
467
|
+
if (values.some((v) => v > 0)) {
|
|
468
|
+
activeFamilies.add(family);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
component.activeFamilies = activeFamilies;
|
|
472
|
+
|
|
465
473
|
component.selectedFamilies.forEach((family) => {
|
|
474
|
+
if (!activeFamilies.has(family)) return;
|
|
475
|
+
|
|
466
476
|
const color = window.DashboardFilters.getFamilyColor(family);
|
|
467
477
|
const familyKey =
|
|
468
478
|
"family" + family.charAt(0).toUpperCase() + family.slice(1);
|
|
@@ -501,10 +511,22 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|
|
501
511
|
});
|
|
502
512
|
});
|
|
503
513
|
|
|
504
|
-
//
|
|
514
|
+
// Track models with activity > 0 in this filtered time range
|
|
515
|
+
const activeModelKeys = new Set();
|
|
516
|
+
Object.entries(dataByModel).forEach(([key, values]) => {
|
|
517
|
+
if (values.some((v) => v > 0)) {
|
|
518
|
+
activeModelKeys.add(key);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
component.activeModelKeys = activeModelKeys;
|
|
522
|
+
|
|
523
|
+
// Build datasets for models (only include models with activity > 0 in this time range)
|
|
505
524
|
component.families.forEach((family) => {
|
|
506
525
|
(component.selectedModels[family] || []).forEach((model, modelIndex) => {
|
|
507
526
|
const key = `${family}:${model}`;
|
|
527
|
+
// Skip models with 0 calls in the selected period
|
|
528
|
+
if (!activeModelKeys.has(key)) return;
|
|
529
|
+
|
|
508
530
|
const color = window.DashboardFilters.getModelColor(family, modelIndex);
|
|
509
531
|
datasets.push(
|
|
510
532
|
window.DashboardCharts.createDataset(
|
|
@@ -518,6 +540,13 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|
|
518
540
|
});
|
|
519
541
|
}
|
|
520
542
|
|
|
543
|
+
if (datasets.length === 0) {
|
|
544
|
+
if (window.UILogger) window.UILogger.debug("No active datasets for trend chart (all zero in time range)");
|
|
545
|
+
component.hasFilteredTrendData = false;
|
|
546
|
+
_trendChartUpdateLock = false;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
521
550
|
try {
|
|
522
551
|
const newChart = new Chart(canvas, {
|
|
523
552
|
type: "line",
|
|
@@ -543,6 +572,9 @@ window.DashboardCharts.updateTrendChart = function (component) {
|
|
|
543
572
|
borderWidth: 1,
|
|
544
573
|
padding: 10,
|
|
545
574
|
displayColors: true,
|
|
575
|
+
filter: function (tooltipItem) {
|
|
576
|
+
return (tooltipItem.parsed?.y || 0) > 0;
|
|
577
|
+
},
|
|
546
578
|
callbacks: {
|
|
547
579
|
label: function (context) {
|
|
548
580
|
return context.dataset.label + ": " + context.parsed.y;
|
|
@@ -451,7 +451,8 @@
|
|
|
451
451
|
<!-- Family Mode Legend -->
|
|
452
452
|
<template x-if="displayMode === 'family'">
|
|
453
453
|
<template x-for="family in selectedFamilies" :key="family">
|
|
454
|
-
<div class="flex items-center gap-1.5 text-[10px] font-mono"
|
|
454
|
+
<div class="flex items-center gap-1.5 text-[10px] font-mono"
|
|
455
|
+
x-show="!activeFamilies || activeFamilies.has(family)">
|
|
455
456
|
<div class="w-2 h-2 rounded-full" :style="'background-color:' + getFamilyColor(family)"></div>
|
|
456
457
|
<span class="text-gray-400"
|
|
457
458
|
x-text="$store.global.t('family' + family.charAt(0).toUpperCase() + family.slice(1))"></span>
|
|
@@ -462,7 +463,8 @@
|
|
|
462
463
|
<template x-if="displayMode === 'model'">
|
|
463
464
|
<template x-for="family in families" :key="'legend-' + family">
|
|
464
465
|
<template x-for="(model, modelIndex) in (selectedModels[family] || [])" :key="family + ':' + model">
|
|
465
|
-
<div class="flex items-center gap-1.5 text-[10px] font-mono"
|
|
466
|
+
<div class="flex items-center gap-1.5 text-[10px] font-mono"
|
|
467
|
+
x-show="!activeModelKeys || activeModelKeys.has(family + ':' + model)">
|
|
466
468
|
<div class="w-2 h-2 rounded-full"
|
|
467
469
|
:style="'background-color:' + getModelColor(family, modelIndex)"></div>
|
|
468
470
|
<span class="text-gray-400" x-text="model"></span>
|
|
@@ -24,10 +24,23 @@ import { deriveSessionId } from './session-manager.js';
|
|
|
24
24
|
// Defaults cover agents commonly used with this proxy; extend or override with
|
|
25
25
|
// the ANTIGRAVITY_SCRUB_IDENTITY env var, formatted as a comma-separated list of
|
|
26
26
|
// "Term=>Replacement" pairs, e.g. "Nous Research=>the team,Hermes=>the assistant".
|
|
27
|
+
//
|
|
28
|
+
// Claude Code and Anthropic are listed explicitly: issue #375 identified
|
|
29
|
+
// "You are Claude Code, Anthropic's official CLI for Claude." as a 429 trigger
|
|
30
|
+
// alongside the billing header, but the merged fix (2f6afae) only stripped the
|
|
31
|
+
// header and left both names in place.
|
|
32
|
+
//
|
|
33
|
+
// Matching is case-sensitive on purpose, so lower-case header names
|
|
34
|
+
// (x-anthropic-beta, anthropic-version) and file names (CLAUDE.md) are left
|
|
35
|
+
// alone. The bare word "Claude" is deliberately NOT scrubbed: it is legitimate
|
|
36
|
+
// in model self-descriptions, and requests succeed with it present once the two
|
|
37
|
+
// names above are neutralized.
|
|
27
38
|
const DEFAULT_IDENTITY_SCRUB = [
|
|
28
39
|
['Nous Research', 'the assistant team'],
|
|
29
40
|
['Hermes Agent', 'the assistant'],
|
|
30
|
-
['Hermes', 'the assistant']
|
|
41
|
+
['Hermes', 'the assistant'],
|
|
42
|
+
['Claude Code', 'the assistant'],
|
|
43
|
+
['Anthropic', 'the vendor']
|
|
31
44
|
];
|
|
32
45
|
|
|
33
46
|
function parseIdentityScrubEnv(raw) {
|
|
@@ -42,16 +55,22 @@ function parseIdentityScrubEnv(raw) {
|
|
|
42
55
|
.filter((rule) => rule && rule[0].length > 0);
|
|
43
56
|
}
|
|
44
57
|
|
|
58
|
+
function escapeRegExp(text) {
|
|
59
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Terms match as whole words only, so identifiers that merely contain a term
|
|
63
|
+
// (convertAnthropicToGoogle, AnthropicRequest, HermesHandler) are left alone.
|
|
45
64
|
const IDENTITY_SCRUB_RULES = [
|
|
46
65
|
...DEFAULT_IDENTITY_SCRUB,
|
|
47
66
|
...parseIdentityScrubEnv(process.env.ANTIGRAVITY_SCRUB_IDENTITY)
|
|
48
|
-
];
|
|
67
|
+
].map(([term, replacement]) => [new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g'), replacement]);
|
|
49
68
|
|
|
50
69
|
function scrubClientIdentity(text) {
|
|
51
70
|
if (typeof text !== 'string') return text;
|
|
52
71
|
let out = text;
|
|
53
|
-
for (const [
|
|
54
|
-
out = out.
|
|
72
|
+
for (const [pattern, replacement] of IDENTITY_SCRUB_RULES) {
|
|
73
|
+
out = out.replace(pattern, () => replacement);
|
|
55
74
|
}
|
|
56
75
|
return out;
|
|
57
76
|
}
|
|
@@ -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
|
package/src/constants.js
CHANGED
|
@@ -290,6 +290,17 @@ export const MODEL_FALLBACK_MAP = {
|
|
|
290
290
|
'claude-sonnet-4-6': 'gemini-3-flash'
|
|
291
291
|
};
|
|
292
292
|
|
|
293
|
+
// Built-in model aliases mapped to canonical backend models
|
|
294
|
+
export const BUILTIN_MODEL_MAPPINGS = {
|
|
295
|
+
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6',
|
|
296
|
+
'claude-3-7-sonnet-20250219': 'claude-sonnet-4-6',
|
|
297
|
+
'claude-opus-5-5': 'claude-opus-4-6-thinking',
|
|
298
|
+
'claude-sonnet-5-5': 'claude-sonnet-4-6',
|
|
299
|
+
'claude-haiku-5-5': 'claude-sonnet-4-6',
|
|
300
|
+
'claude-opus-5': 'claude-opus-4-6-thinking',
|
|
301
|
+
'claude-sonnet-5': 'claude-sonnet-4-6'
|
|
302
|
+
};
|
|
303
|
+
|
|
293
304
|
// Default test models for each family (used by test suite)
|
|
294
305
|
export const TEST_MODELS = {
|
|
295
306
|
claude: 'claude-sonnet-4-6',
|
|
@@ -522,6 +533,7 @@ export default {
|
|
|
522
533
|
OAUTH_REDIRECT_URI,
|
|
523
534
|
STRATEGY_LABELS,
|
|
524
535
|
MODEL_FALLBACK_MAP,
|
|
536
|
+
BUILTIN_MODEL_MAPPINGS,
|
|
525
537
|
TEST_MODELS,
|
|
526
538
|
DEFAULT_PRESETS,
|
|
527
539
|
DEFAULT_SERVER_PRESETS,
|
|
@@ -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
|
|
@@ -10,11 +10,47 @@ const DATA_DIR = path.dirname(HISTORY_FILE);
|
|
|
10
10
|
const OLD_DATA_DIR = path.join(process.cwd(), 'data');
|
|
11
11
|
const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json');
|
|
12
12
|
|
|
13
|
+
// Legacy model aliases that should be migrated to canonical backend model names in history
|
|
14
|
+
export const LEGACY_MODEL_MIGRATIONS = {
|
|
15
|
+
'opus-5-5': 'opus-4-6-thinking',
|
|
16
|
+
'sonnet-5-5': 'sonnet-4-6',
|
|
17
|
+
'haiku-5-5': 'sonnet-4-6'
|
|
18
|
+
};
|
|
19
|
+
|
|
13
20
|
// In-memory storage
|
|
14
21
|
// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } }
|
|
15
22
|
let history = {};
|
|
16
23
|
let isDirty = false;
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Migrate legacy/phantom model names in history to canonical backend model names
|
|
27
|
+
* @param {object} data - History data
|
|
28
|
+
* @returns {boolean} Whether any migration took place
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeHistory(data) {
|
|
31
|
+
if (!data || typeof data !== 'object') return false;
|
|
32
|
+
let modified = false;
|
|
33
|
+
|
|
34
|
+
for (const [timestamp, hourData] of Object.entries(data)) {
|
|
35
|
+
if (!hourData || typeof hourData !== 'object') continue;
|
|
36
|
+
|
|
37
|
+
for (const [family, familyData] of Object.entries(hourData)) {
|
|
38
|
+
if (!familyData || typeof familyData !== 'object' || family.startsWith('_')) continue;
|
|
39
|
+
|
|
40
|
+
for (const [legacyName, targetName] of Object.entries(LEGACY_MODEL_MIGRATIONS)) {
|
|
41
|
+
if (familyData[legacyName] !== undefined) {
|
|
42
|
+
const count = familyData[legacyName];
|
|
43
|
+
delete familyData[legacyName];
|
|
44
|
+
familyData[targetName] = (familyData[targetName] || 0) + count;
|
|
45
|
+
modified = true;
|
|
46
|
+
logger.info(`[UsageStats] Migrated legacy model ${legacyName} -> ${targetName} (${count} calls at ${timestamp})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return modified;
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
/**
|
|
19
55
|
* Extract model family from model ID
|
|
20
56
|
* @param {string} modelId - The model identifier (e.g., "claude-opus-4-6-thinking")
|
|
@@ -62,6 +98,10 @@ function load() {
|
|
|
62
98
|
if (fs.existsSync(HISTORY_FILE)) {
|
|
63
99
|
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
|
64
100
|
history = JSON.parse(data);
|
|
101
|
+
if (normalizeHistory(history)) {
|
|
102
|
+
isDirty = true;
|
|
103
|
+
save();
|
|
104
|
+
}
|
|
65
105
|
}
|
|
66
106
|
} catch (err) {
|
|
67
107
|
logger.error('[UsageStats] Failed to load history:', err);
|
|
@@ -143,28 +183,17 @@ function setupMiddleware(app) {
|
|
|
143
183
|
load();
|
|
144
184
|
|
|
145
185
|
// Auto-save every minute
|
|
146
|
-
setInterval(() => {
|
|
186
|
+
const saveInterval = setInterval(() => {
|
|
147
187
|
save();
|
|
148
188
|
prune();
|
|
149
189
|
}, 60 * 1000);
|
|
190
|
+
if (saveInterval.unref) {
|
|
191
|
+
saveInterval.unref();
|
|
192
|
+
}
|
|
150
193
|
|
|
151
194
|
// Save on exit
|
|
152
195
|
process.on('SIGINT', () => { save(); process.exit(); });
|
|
153
196
|
process.on('SIGTERM', () => { save(); process.exit(); });
|
|
154
|
-
|
|
155
|
-
// Request interceptor
|
|
156
|
-
// Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints
|
|
157
|
-
const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions'];
|
|
158
|
-
|
|
159
|
-
app.use((req, res, next) => {
|
|
160
|
-
if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) {
|
|
161
|
-
const model = req.body?.model;
|
|
162
|
-
if (model) {
|
|
163
|
-
track(model);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
next();
|
|
167
|
-
});
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
/**
|
|
@@ -202,5 +231,7 @@ export default {
|
|
|
202
231
|
track,
|
|
203
232
|
getFamily,
|
|
204
233
|
getShortName,
|
|
205
|
-
getHistory
|
|
234
|
+
getHistory,
|
|
235
|
+
normalizeHistory,
|
|
236
|
+
LEGACY_MODEL_MIGRATIONS
|
|
206
237
|
};
|
package/src/server.js
CHANGED
|
@@ -15,9 +15,9 @@ import { config } from './config.js';
|
|
|
15
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
16
|
const __dirname = path.dirname(__filename);
|
|
17
17
|
import { forceRefresh } from './auth/token-extractor.js';
|
|
18
|
-
import { REQUEST_BODY_LIMIT } from './constants.js';
|
|
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';
|
|
@@ -217,13 +217,13 @@ app.post('/', (req, res) => {
|
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* Test endpoint - Clear
|
|
220
|
+
* Test endpoint - Clear all signature caches (in-memory + persisted store)
|
|
221
221
|
* Used for testing cold cache scenarios in cross-model tests
|
|
222
222
|
*/
|
|
223
223
|
app.post('/test/clear-signature-cache', (req, res) => {
|
|
224
|
-
|
|
225
|
-
logger.debug('[Test] Cleared
|
|
226
|
-
res.json({ success: true, message: '
|
|
224
|
+
clearAllSignatureCaches();
|
|
225
|
+
logger.debug('[Test] Cleared all signature caches and removed persisted store');
|
|
226
|
+
res.json({ success: true, message: 'All signature caches cleared' });
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
/**
|
|
@@ -730,6 +730,10 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
730
730
|
const targetModel = modelMapping[requestedModel].mapping;
|
|
731
731
|
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
|
|
732
732
|
requestedModel = targetModel;
|
|
733
|
+
} else if (BUILTIN_MODEL_MAPPINGS[requestedModel]) {
|
|
734
|
+
const targetModel = BUILTIN_MODEL_MAPPINGS[requestedModel];
|
|
735
|
+
logger.info(`[Server] Built-in mapping model ${requestedModel} -> ${targetModel}`);
|
|
736
|
+
requestedModel = targetModel;
|
|
733
737
|
}
|
|
734
738
|
|
|
735
739
|
const modelId = requestedModel;
|
|
@@ -812,6 +816,7 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
812
816
|
const firstResult = await generator.next();
|
|
813
817
|
|
|
814
818
|
// If we get here, the stream started successfully.
|
|
819
|
+
usageStats.track(modelId);
|
|
815
820
|
res.status(200);
|
|
816
821
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
817
822
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -863,6 +868,7 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
863
868
|
} else {
|
|
864
869
|
// Handle non-streaming response
|
|
865
870
|
const response = await sendMessage(request, accountManager, FALLBACK_ENABLED);
|
|
871
|
+
usageStats.track(response?.model || modelId);
|
|
866
872
|
res.json(response);
|
|
867
873
|
}
|
|
868
874
|
|