@jsonstudio/llms 0.6.203 → 0.6.215
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/dist/conversion/codecs/gemini-openai-codec.js +45 -3
- package/dist/conversion/hub/pipeline/hub-pipeline.js +2 -2
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +118 -11
- package/dist/conversion/shared/snapshot-utils.js +17 -47
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/router/virtual-router/engine.d.ts +1 -0
- package/dist/router/virtual-router/engine.js +16 -0
- package/dist/telemetry/stats-center.d.ts +73 -0
- package/dist/telemetry/stats-center.js +280 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { normalizeChatMessageContent } from '../shared/chat-output-normalizer.js
|
|
|
4
4
|
import { mapBridgeToolsToChat } from '../shared/tool-mapping.js';
|
|
5
5
|
import { prepareGeminiToolsForBridge } from '../shared/gemini-tool-utils.js';
|
|
6
6
|
import { registerResponsesReasoning, consumeResponsesReasoning, registerResponsesOutputTextMeta, consumeResponsesOutputTextMeta, consumeResponsesPayloadSnapshot, registerResponsesPayloadSnapshot, consumeResponsesPassthrough, registerResponsesPassthrough } from '../shared/responses-reasoning-registry.js';
|
|
7
|
+
const DUMMY_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
|
7
8
|
function isObject(v) {
|
|
8
9
|
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
9
10
|
}
|
|
@@ -54,6 +55,32 @@ function mapChatRoleToGemini(role) {
|
|
|
54
55
|
return 'tool';
|
|
55
56
|
return 'user';
|
|
56
57
|
}
|
|
58
|
+
function coerceThoughtSignature(value) {
|
|
59
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function extractThoughtSignatureFromToolCall(tc) {
|
|
65
|
+
if (!tc || typeof tc !== 'object') {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const direct = coerceThoughtSignature(tc.thought_signature ?? tc.thoughtSignature);
|
|
69
|
+
if (direct) {
|
|
70
|
+
return direct;
|
|
71
|
+
}
|
|
72
|
+
const extraContent = tc.extra_content ?? tc.extraContent;
|
|
73
|
+
if (extraContent && typeof extraContent === 'object') {
|
|
74
|
+
const googleNode = extraContent.google ?? extraContent.Google;
|
|
75
|
+
if (googleNode && typeof googleNode === 'object') {
|
|
76
|
+
const googleSig = coerceThoughtSignature(googleNode.thought_signature ?? googleNode.thoughtSignature);
|
|
77
|
+
if (googleSig) {
|
|
78
|
+
return googleSig;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
57
84
|
export function buildOpenAIChatFromGeminiRequest(payload) {
|
|
58
85
|
const messages = [];
|
|
59
86
|
// systemInstruction → Chat system 消息
|
|
@@ -195,11 +222,21 @@ export function buildOpenAIChatFromGeminiResponse(payload) {
|
|
|
195
222
|
else {
|
|
196
223
|
argsStr = safeJson(argsRaw);
|
|
197
224
|
}
|
|
198
|
-
|
|
225
|
+
const thoughtSignature = coerceThoughtSignature(pObj.thoughtSignature);
|
|
226
|
+
const toolCall = {
|
|
199
227
|
id,
|
|
200
228
|
type: 'function',
|
|
201
229
|
function: { name, arguments: argsStr }
|
|
202
|
-
}
|
|
230
|
+
};
|
|
231
|
+
if (thoughtSignature) {
|
|
232
|
+
toolCall.thought_signature = thoughtSignature;
|
|
233
|
+
toolCall.extra_content = {
|
|
234
|
+
google: {
|
|
235
|
+
thought_signature: thoughtSignature
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
toolCalls.push(toolCall);
|
|
203
240
|
continue;
|
|
204
241
|
}
|
|
205
242
|
}
|
|
@@ -398,7 +435,12 @@ export function buildGeminiFromOpenAIChat(chatResp) {
|
|
|
398
435
|
const id = typeof tc.id === 'string' ? String(tc.id) : undefined;
|
|
399
436
|
if (id)
|
|
400
437
|
functionCall.id = id;
|
|
401
|
-
|
|
438
|
+
const thoughtSignature = extractThoughtSignatureFromToolCall(tc) ?? DUMMY_THOUGHT_SIGNATURE;
|
|
439
|
+
const partEntry = { functionCall };
|
|
440
|
+
if (thoughtSignature) {
|
|
441
|
+
partEntry.thoughtSignature = thoughtSignature;
|
|
442
|
+
}
|
|
443
|
+
parts.push(partEntry);
|
|
402
444
|
}
|
|
403
445
|
const candidate = {
|
|
404
446
|
content: {
|
|
@@ -11,6 +11,7 @@ import { GeminiSemanticMapper } from '../semantic-mappers/gemini-mapper.js';
|
|
|
11
11
|
import { ChatFormatAdapter } from '../format-adapters/chat-format-adapter.js';
|
|
12
12
|
import { ChatSemanticMapper } from '../semantic-mappers/chat-mapper.js';
|
|
13
13
|
import { createSnapshotRecorder } from '../snapshot-recorder.js';
|
|
14
|
+
import { shouldRecordSnapshots } from '../../shared/snapshot-utils.js';
|
|
14
15
|
import { runReqInboundStage1FormatParse } from './stages/req_inbound/req_inbound_stage1_format_parse/index.js';
|
|
15
16
|
import { runReqInboundStage2SemanticMap } from './stages/req_inbound/req_inbound_stage2_semantic_map/index.js';
|
|
16
17
|
import { runChatContextCapture, captureResponsesContextSnapshot } from './stages/req_inbound/req_inbound_stage3_context_capture/index.js';
|
|
@@ -348,8 +349,7 @@ export class HubPipeline {
|
|
|
348
349
|
return adapterContext;
|
|
349
350
|
}
|
|
350
351
|
maybeCreateStageRecorder(context, endpoint) {
|
|
351
|
-
|
|
352
|
-
if (flag === '0') {
|
|
352
|
+
if (!shouldRecordSnapshots()) {
|
|
353
353
|
return undefined;
|
|
354
354
|
}
|
|
355
355
|
const effectiveEndpoint = endpoint || context.entryEndpoint || '/v1/chat/completions';
|
|
@@ -17,6 +17,31 @@ const GENERATION_CONFIG_KEYS = [
|
|
|
17
17
|
];
|
|
18
18
|
const PASSTHROUGH_METADATA_PREFIX = 'rcc_passthrough_';
|
|
19
19
|
const PASSTHROUGH_PARAMETERS = ['tool_choice'];
|
|
20
|
+
const DUMMY_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
|
21
|
+
function coerceThoughtSignature(value) {
|
|
22
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
23
|
+
return value.trim();
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function extractThoughtSignatureFromToolCall(tc) {
|
|
28
|
+
if (!tc || typeof tc !== 'object') {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const node = tc;
|
|
32
|
+
const direct = coerceThoughtSignature(node.thought_signature ?? node.thoughtSignature);
|
|
33
|
+
if (direct) {
|
|
34
|
+
return direct;
|
|
35
|
+
}
|
|
36
|
+
const extra = node.extra_content ?? node.extraContent;
|
|
37
|
+
if (extra && typeof extra === 'object') {
|
|
38
|
+
const googleNode = extra.google ?? extra.Google;
|
|
39
|
+
if (googleNode && typeof googleNode === 'object') {
|
|
40
|
+
return coerceThoughtSignature(googleNode.thought_signature ?? googleNode.thoughtSignature);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
20
45
|
function normalizeToolOutputs(messages, missing) {
|
|
21
46
|
const outputs = [];
|
|
22
47
|
messages.forEach((msg, index) => {
|
|
@@ -35,6 +60,37 @@ function normalizeToolOutputs(messages, missing) {
|
|
|
35
60
|
});
|
|
36
61
|
return outputs.length ? outputs : undefined;
|
|
37
62
|
}
|
|
63
|
+
function synthesizeToolOutputsFromMessages(messages) {
|
|
64
|
+
if (!Array.isArray(messages)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const outputs = [];
|
|
68
|
+
for (const message of messages) {
|
|
69
|
+
if (!message || typeof message !== 'object')
|
|
70
|
+
continue;
|
|
71
|
+
if (message.role !== 'assistant')
|
|
72
|
+
continue;
|
|
73
|
+
const toolCalls = Array.isArray(message.tool_calls)
|
|
74
|
+
? message.tool_calls
|
|
75
|
+
: [];
|
|
76
|
+
for (const call of toolCalls) {
|
|
77
|
+
const callId = typeof call.id === 'string' ? call.id : undefined;
|
|
78
|
+
if (!callId) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const existing = outputs.find((entry) => entry.tool_call_id === callId);
|
|
82
|
+
if (existing) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
outputs.push({
|
|
86
|
+
tool_call_id: callId,
|
|
87
|
+
content: '',
|
|
88
|
+
name: (call.function && call.function.name) || undefined
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return outputs;
|
|
93
|
+
}
|
|
38
94
|
function normalizeToolContent(value) {
|
|
39
95
|
if (typeof value === 'string')
|
|
40
96
|
return value;
|
|
@@ -47,6 +103,30 @@ function normalizeToolContent(value) {
|
|
|
47
103
|
return String(value ?? '');
|
|
48
104
|
}
|
|
49
105
|
}
|
|
106
|
+
function convertToolMessageToOutput(message) {
|
|
107
|
+
const rawId = (message.tool_call_id ?? message.id);
|
|
108
|
+
const callId = typeof rawId === 'string' && rawId.trim().length ? rawId.trim() : undefined;
|
|
109
|
+
if (!callId) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
tool_call_id: callId,
|
|
114
|
+
content: normalizeToolContent(message.content),
|
|
115
|
+
name: typeof message.name === 'string' ? message.name : undefined
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function buildFunctionResponseEntry(output) {
|
|
119
|
+
const parsedPayload = safeParseJson(output.content);
|
|
120
|
+
const normalizedPayload = ensureFunctionResponsePayload(cloneAsJsonValue(parsedPayload));
|
|
121
|
+
const part = {
|
|
122
|
+
functionResponse: {
|
|
123
|
+
name: output.name || 'tool',
|
|
124
|
+
id: output.tool_call_id,
|
|
125
|
+
response: normalizedPayload
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return { role: 'user', parts: [part] };
|
|
129
|
+
}
|
|
50
130
|
function collectSystemSegments(systemInstruction) {
|
|
51
131
|
if (!systemInstruction)
|
|
52
132
|
return [];
|
|
@@ -93,13 +173,20 @@ function collectParameters(payload) {
|
|
|
93
173
|
}
|
|
94
174
|
function buildGeminiRequestFromChat(chat, metadata) {
|
|
95
175
|
const contents = [];
|
|
176
|
+
const emittedToolOutputs = new Set();
|
|
96
177
|
for (const message of chat.messages) {
|
|
97
178
|
if (!message || typeof message !== 'object')
|
|
98
179
|
continue;
|
|
99
180
|
if (message.role === 'system')
|
|
100
181
|
continue;
|
|
101
|
-
if (message.role === 'tool')
|
|
182
|
+
if (message.role === 'tool') {
|
|
183
|
+
const toolOutput = convertToolMessageToOutput(message);
|
|
184
|
+
if (toolOutput) {
|
|
185
|
+
contents.push(buildFunctionResponseEntry(toolOutput));
|
|
186
|
+
emittedToolOutputs.add(toolOutput.tool_call_id);
|
|
187
|
+
}
|
|
102
188
|
continue;
|
|
189
|
+
}
|
|
103
190
|
const entry = {
|
|
104
191
|
role: mapChatRoleToGemini(message.role),
|
|
105
192
|
parts: []
|
|
@@ -131,24 +218,36 @@ function buildGeminiRequestFromChat(chat, metadata) {
|
|
|
131
218
|
if (typeof tc.id === 'string') {
|
|
132
219
|
part.functionCall.id = tc.id;
|
|
133
220
|
}
|
|
221
|
+
const thoughtSignature = extractThoughtSignatureFromToolCall(tc) ?? DUMMY_THOUGHT_SIGNATURE;
|
|
222
|
+
if (thoughtSignature) {
|
|
223
|
+
part.thoughtSignature = thoughtSignature;
|
|
224
|
+
}
|
|
134
225
|
entry.parts.push(part);
|
|
135
226
|
}
|
|
136
227
|
if (entry.parts.length) {
|
|
137
228
|
contents.push(entry);
|
|
138
229
|
}
|
|
139
230
|
}
|
|
231
|
+
const toolOutputMap = new Map();
|
|
140
232
|
if (Array.isArray(chat.toolOutputs)) {
|
|
141
|
-
for (const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
233
|
+
for (const entry of chat.toolOutputs) {
|
|
234
|
+
if (entry && typeof entry.tool_call_id === 'string' && entry.tool_call_id.trim().length) {
|
|
235
|
+
toolOutputMap.set(entry.tool_call_id.trim(), entry);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (toolOutputMap.size === 0) {
|
|
240
|
+
const syntheticOutputs = synthesizeToolOutputsFromMessages(chat.messages);
|
|
241
|
+
for (const output of syntheticOutputs) {
|
|
242
|
+
toolOutputMap.set(output.tool_call_id, output);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const output of toolOutputMap.values()) {
|
|
246
|
+
if (emittedToolOutputs.has(output.tool_call_id)) {
|
|
247
|
+
continue;
|
|
151
248
|
}
|
|
249
|
+
contents.push(buildFunctionResponseEntry(output));
|
|
250
|
+
emittedToolOutputs.add(output.tool_call_id);
|
|
152
251
|
}
|
|
153
252
|
const request = {
|
|
154
253
|
model: chat.parameters?.model || 'models/gemini-pro',
|
|
@@ -235,6 +334,14 @@ function safeParseJson(value) {
|
|
|
235
334
|
return value;
|
|
236
335
|
}
|
|
237
336
|
}
|
|
337
|
+
function ensureFunctionResponsePayload(value) {
|
|
338
|
+
if (value && typeof value === 'object') {
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
result: value === undefined ? null : value
|
|
343
|
+
};
|
|
344
|
+
}
|
|
238
345
|
function cloneAsJsonValue(value) {
|
|
239
346
|
try {
|
|
240
347
|
return JSON.parse(JSON.stringify(value ?? null));
|
|
@@ -1,35 +1,25 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
1
|
import { writeSnapshotViaHooks } from './snapshot-hooks.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return hint;
|
|
9
|
-
const ep = String(endpoint || '').toLowerCase();
|
|
10
|
-
if (ep.includes('/responses'))
|
|
11
|
-
return 'openai-responses';
|
|
12
|
-
if (ep.includes('/messages'))
|
|
13
|
-
return 'anthropic-messages';
|
|
14
|
-
if (ep.includes('/gemini'))
|
|
15
|
-
return 'gemini-chat';
|
|
16
|
-
return 'openai-chat';
|
|
17
|
-
}
|
|
18
|
-
async function ensureDir(dir) {
|
|
19
|
-
try {
|
|
20
|
-
await fs.mkdir(dir, { recursive: true });
|
|
2
|
+
function resolveBoolFromEnv(value, fallback) {
|
|
3
|
+
if (!value) {
|
|
4
|
+
return fallback;
|
|
21
5
|
}
|
|
22
|
-
|
|
23
|
-
|
|
6
|
+
const normalized = value.trim().toLowerCase();
|
|
7
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
8
|
+
return true;
|
|
24
9
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
10
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return fallback;
|
|
28
14
|
}
|
|
29
15
|
export function shouldRecordSnapshots() {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
16
|
+
const hubFlag = process.env.ROUTECODEX_HUB_SNAPSHOTS;
|
|
17
|
+
if (hubFlag && hubFlag.trim().length) {
|
|
18
|
+
return resolveBoolFromEnv(hubFlag, true);
|
|
19
|
+
}
|
|
20
|
+
const sharedFlag = process.env.ROUTECODEX_SNAPSHOT ?? process.env.ROUTECODEX_SNAPSHOTS;
|
|
21
|
+
if (sharedFlag && sharedFlag.trim().length) {
|
|
22
|
+
return resolveBoolFromEnv(sharedFlag, true);
|
|
33
23
|
}
|
|
34
24
|
return true;
|
|
35
25
|
}
|
|
@@ -37,26 +27,6 @@ export async function recordSnapshot(options) {
|
|
|
37
27
|
if (!shouldRecordSnapshots())
|
|
38
28
|
return;
|
|
39
29
|
const endpoint = options.endpoint || '/v1/chat/completions';
|
|
40
|
-
const folder = mapEndpointToFolder(endpoint, options.folderHint);
|
|
41
|
-
const dir = path.join(SNAPSHOT_BASE, folder);
|
|
42
|
-
try {
|
|
43
|
-
await ensureDir(dir);
|
|
44
|
-
const safeStage = sanitize(options.stage);
|
|
45
|
-
const safeRequestId = sanitize(options.requestId);
|
|
46
|
-
const file = path.join(dir, `${safeRequestId}_${safeStage}.json`);
|
|
47
|
-
const payload = {
|
|
48
|
-
meta: {
|
|
49
|
-
stage: options.stage,
|
|
50
|
-
timestamp: Date.now(),
|
|
51
|
-
endpoint
|
|
52
|
-
},
|
|
53
|
-
body: options.data
|
|
54
|
-
};
|
|
55
|
-
await fs.writeFile(file, JSON.stringify(payload, null, 2), 'utf-8');
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
console.warn('[snapshot-utils] failed to write snapshot', error);
|
|
59
|
-
}
|
|
60
30
|
void writeSnapshotViaHooks({
|
|
61
31
|
endpoint,
|
|
62
32
|
stage: options.stage,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export declare class VirtualRouterEngine {
|
|
|
11
11
|
private routeStats;
|
|
12
12
|
private readonly debug;
|
|
13
13
|
private healthConfig;
|
|
14
|
+
private readonly statsCenter;
|
|
14
15
|
initialize(config: VirtualRouterConfig): void;
|
|
15
16
|
route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
|
|
16
17
|
target: TargetMetadata;
|
|
@@ -5,6 +5,7 @@ import { RoutingClassifier } from './classifier.js';
|
|
|
5
5
|
import { buildRoutingFeatures } from './features.js';
|
|
6
6
|
import { ContextAdvisor } from './context-advisor.js';
|
|
7
7
|
import { DEFAULT_MODEL_CONTEXT_TOKENS, DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
|
|
8
|
+
import { getStatsCenter } from '../../telemetry/stats-center.js';
|
|
8
9
|
export class VirtualRouterEngine {
|
|
9
10
|
routing = {};
|
|
10
11
|
providerRegistry = new ProviderRegistry();
|
|
@@ -16,6 +17,7 @@ export class VirtualRouterEngine {
|
|
|
16
17
|
routeStats = new Map();
|
|
17
18
|
debug = console; // thin hook; host may monkey-patch for colored logging
|
|
18
19
|
healthConfig = null;
|
|
20
|
+
statsCenter = getStatsCenter();
|
|
19
21
|
initialize(config) {
|
|
20
22
|
this.validateConfig(config);
|
|
21
23
|
this.routing = config.routing;
|
|
@@ -40,6 +42,20 @@ export class VirtualRouterEngine {
|
|
|
40
42
|
const target = this.providerRegistry.buildTarget(selection.providerKey);
|
|
41
43
|
this.healthManager.recordSuccess(selection.providerKey);
|
|
42
44
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
45
|
+
try {
|
|
46
|
+
this.statsCenter.recordVirtualRouterHit({
|
|
47
|
+
requestId: metadata.requestId,
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
|
|
50
|
+
routeName: selection.routeUsed,
|
|
51
|
+
pool: selection.routeUsed,
|
|
52
|
+
providerKey: selection.providerKey,
|
|
53
|
+
modelId: target.modelId || undefined
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// stats must never break routing
|
|
58
|
+
}
|
|
43
59
|
const hitReason = this.buildHitReason(selection.routeUsed, selection.providerKey, classification, features);
|
|
44
60
|
const formatted = this.formatVirtualRouterHit(selection.routeUsed, selection.providerKey, target.modelId || '', hitReason);
|
|
45
61
|
if (formatted) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface VirtualRouterHitEvent {
|
|
2
|
+
requestId: string;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
entryEndpoint: string;
|
|
5
|
+
routeName: string;
|
|
6
|
+
pool: string;
|
|
7
|
+
providerKey: string;
|
|
8
|
+
runtimeKey?: string;
|
|
9
|
+
providerType?: string;
|
|
10
|
+
modelId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderUsageEvent {
|
|
13
|
+
requestId: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
providerKey: string;
|
|
16
|
+
runtimeKey?: string;
|
|
17
|
+
providerType: string;
|
|
18
|
+
modelId?: string;
|
|
19
|
+
routeName?: string;
|
|
20
|
+
entryEndpoint?: string;
|
|
21
|
+
success: boolean;
|
|
22
|
+
latencyMs: number;
|
|
23
|
+
promptTokens?: number;
|
|
24
|
+
completionTokens?: number;
|
|
25
|
+
totalTokens?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface RouterStatsBucket {
|
|
28
|
+
requestCount: number;
|
|
29
|
+
poolHitCount: Record<string, number>;
|
|
30
|
+
routeHitCount: Record<string, number>;
|
|
31
|
+
providerHitCount: Record<string, number>;
|
|
32
|
+
}
|
|
33
|
+
export interface RouterStatsSnapshot {
|
|
34
|
+
global: RouterStatsBucket;
|
|
35
|
+
byEntryEndpoint: Record<string, RouterStatsBucket>;
|
|
36
|
+
}
|
|
37
|
+
export interface ProviderStatsBucket {
|
|
38
|
+
requestCount: number;
|
|
39
|
+
successCount: number;
|
|
40
|
+
errorCount: number;
|
|
41
|
+
latencySumMs: number;
|
|
42
|
+
minLatencyMs: number;
|
|
43
|
+
maxLatencyMs: number;
|
|
44
|
+
usage: {
|
|
45
|
+
promptTokens: number;
|
|
46
|
+
completionTokens: number;
|
|
47
|
+
totalTokens: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export interface ProviderStatsSnapshot {
|
|
51
|
+
global: ProviderStatsBucket;
|
|
52
|
+
byProviderKey: Record<string, ProviderStatsBucket>;
|
|
53
|
+
byRoute: Record<string, ProviderStatsBucket>;
|
|
54
|
+
byEntryEndpoint: Record<string, ProviderStatsBucket>;
|
|
55
|
+
}
|
|
56
|
+
export interface StatsSnapshot {
|
|
57
|
+
router: RouterStatsSnapshot;
|
|
58
|
+
providers: ProviderStatsSnapshot;
|
|
59
|
+
}
|
|
60
|
+
export interface StatsCenterOptions {
|
|
61
|
+
enable?: boolean;
|
|
62
|
+
autoPrintOnExit?: boolean;
|
|
63
|
+
persistPath?: string | null;
|
|
64
|
+
}
|
|
65
|
+
export interface StatsCenter {
|
|
66
|
+
recordVirtualRouterHit(ev: VirtualRouterHitEvent): void;
|
|
67
|
+
recordProviderUsage(ev: ProviderUsageEvent): void;
|
|
68
|
+
getSnapshot(): StatsSnapshot;
|
|
69
|
+
flushToDisk(): Promise<void>;
|
|
70
|
+
reset(): void;
|
|
71
|
+
}
|
|
72
|
+
export declare function initStatsCenter(options?: StatsCenterOptions): StatsCenter;
|
|
73
|
+
export declare function getStatsCenter(): StatsCenter;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
function createEmptyRouterBucket() {
|
|
5
|
+
return {
|
|
6
|
+
requestCount: 0,
|
|
7
|
+
poolHitCount: {},
|
|
8
|
+
routeHitCount: {},
|
|
9
|
+
providerHitCount: {}
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function createEmptyProviderBucket() {
|
|
13
|
+
return {
|
|
14
|
+
requestCount: 0,
|
|
15
|
+
successCount: 0,
|
|
16
|
+
errorCount: 0,
|
|
17
|
+
latencySumMs: 0,
|
|
18
|
+
minLatencyMs: Number.POSITIVE_INFINITY,
|
|
19
|
+
maxLatencyMs: 0,
|
|
20
|
+
usage: {
|
|
21
|
+
promptTokens: 0,
|
|
22
|
+
completionTokens: 0,
|
|
23
|
+
totalTokens: 0
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function createEmptySnapshot() {
|
|
28
|
+
return {
|
|
29
|
+
router: {
|
|
30
|
+
global: createEmptyRouterBucket(),
|
|
31
|
+
byEntryEndpoint: {}
|
|
32
|
+
},
|
|
33
|
+
providers: {
|
|
34
|
+
global: createEmptyProviderBucket(),
|
|
35
|
+
byProviderKey: {},
|
|
36
|
+
byRoute: {},
|
|
37
|
+
byEntryEndpoint: {}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
class NoopStatsCenter {
|
|
42
|
+
recordVirtualRouterHit() { }
|
|
43
|
+
recordProviderUsage() { }
|
|
44
|
+
getSnapshot() { return createEmptySnapshot(); }
|
|
45
|
+
async flushToDisk() { }
|
|
46
|
+
reset() { }
|
|
47
|
+
}
|
|
48
|
+
class DefaultStatsCenter {
|
|
49
|
+
snapshot = createEmptySnapshot();
|
|
50
|
+
dirty = false;
|
|
51
|
+
flushInFlight = false;
|
|
52
|
+
persistPath;
|
|
53
|
+
constructor(persistPath) {
|
|
54
|
+
if (persistPath === null) {
|
|
55
|
+
this.persistPath = null;
|
|
56
|
+
}
|
|
57
|
+
else if (typeof persistPath === 'string' && persistPath.trim().length) {
|
|
58
|
+
this.persistPath = persistPath.trim();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const base = path.join(os.homedir(), '.routecodex', 'stats');
|
|
62
|
+
this.persistPath = path.join(base, 'stats.json');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
recordVirtualRouterHit(ev) {
|
|
66
|
+
if (!ev || !ev.routeName || !ev.providerKey) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const snap = this.snapshot;
|
|
70
|
+
this.applyRouterHitToBucket(snap.router.global, ev);
|
|
71
|
+
const entryKey = ev.entryEndpoint || 'unknown';
|
|
72
|
+
if (!snap.router.byEntryEndpoint[entryKey]) {
|
|
73
|
+
snap.router.byEntryEndpoint[entryKey] = createEmptyRouterBucket();
|
|
74
|
+
}
|
|
75
|
+
this.applyRouterHitToBucket(snap.router.byEntryEndpoint[entryKey], ev);
|
|
76
|
+
this.dirty = true;
|
|
77
|
+
}
|
|
78
|
+
recordProviderUsage(ev) {
|
|
79
|
+
if (!ev || !ev.providerKey || !ev.providerType) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const snap = this.snapshot;
|
|
83
|
+
this.applyProviderUsageToBucket(snap.providers.global, ev);
|
|
84
|
+
const providerKey = ev.providerKey;
|
|
85
|
+
if (!snap.providers.byProviderKey[providerKey]) {
|
|
86
|
+
snap.providers.byProviderKey[providerKey] = createEmptyProviderBucket();
|
|
87
|
+
}
|
|
88
|
+
this.applyProviderUsageToBucket(snap.providers.byProviderKey[providerKey], ev);
|
|
89
|
+
const routeKey = ev.routeName || 'unknown';
|
|
90
|
+
if (!snap.providers.byRoute[routeKey]) {
|
|
91
|
+
snap.providers.byRoute[routeKey] = createEmptyProviderBucket();
|
|
92
|
+
}
|
|
93
|
+
this.applyProviderUsageToBucket(snap.providers.byRoute[routeKey], ev);
|
|
94
|
+
const entryKey = ev.entryEndpoint || 'unknown';
|
|
95
|
+
if (!snap.providers.byEntryEndpoint[entryKey]) {
|
|
96
|
+
snap.providers.byEntryEndpoint[entryKey] = createEmptyProviderBucket();
|
|
97
|
+
}
|
|
98
|
+
this.applyProviderUsageToBucket(snap.providers.byEntryEndpoint[entryKey], ev);
|
|
99
|
+
this.dirty = true;
|
|
100
|
+
}
|
|
101
|
+
getSnapshot() {
|
|
102
|
+
return this.snapshot;
|
|
103
|
+
}
|
|
104
|
+
reset() {
|
|
105
|
+
this.snapshot = createEmptySnapshot();
|
|
106
|
+
this.dirty = false;
|
|
107
|
+
}
|
|
108
|
+
async flushToDisk() {
|
|
109
|
+
if (!this.persistPath || !this.dirty || this.flushInFlight) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.flushInFlight = true;
|
|
113
|
+
try {
|
|
114
|
+
const dir = path.dirname(this.persistPath);
|
|
115
|
+
await fs.mkdir(dir, { recursive: true });
|
|
116
|
+
const payload = JSON.stringify(this.snapshot, null, 2);
|
|
117
|
+
await fs.writeFile(this.persistPath, payload, 'utf-8');
|
|
118
|
+
this.dirty = false;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// ignore persistence errors
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
this.flushInFlight = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
applyRouterHitToBucket(bucket, ev) {
|
|
128
|
+
bucket.requestCount += 1;
|
|
129
|
+
if (ev.pool) {
|
|
130
|
+
bucket.poolHitCount[ev.pool] = (bucket.poolHitCount[ev.pool] || 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
if (ev.routeName) {
|
|
133
|
+
bucket.routeHitCount[ev.routeName] = (bucket.routeHitCount[ev.routeName] || 0) + 1;
|
|
134
|
+
}
|
|
135
|
+
if (ev.providerKey) {
|
|
136
|
+
bucket.providerHitCount[ev.providerKey] = (bucket.providerHitCount[ev.providerKey] || 0) + 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
applyProviderUsageToBucket(bucket, ev) {
|
|
140
|
+
bucket.requestCount += 1;
|
|
141
|
+
if (ev.success) {
|
|
142
|
+
bucket.successCount += 1;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
bucket.errorCount += 1;
|
|
146
|
+
}
|
|
147
|
+
if (Number.isFinite(ev.latencyMs) && ev.latencyMs >= 0) {
|
|
148
|
+
bucket.latencySumMs += ev.latencyMs;
|
|
149
|
+
if (ev.latencyMs < bucket.minLatencyMs) {
|
|
150
|
+
bucket.minLatencyMs = ev.latencyMs;
|
|
151
|
+
}
|
|
152
|
+
if (ev.latencyMs > bucket.maxLatencyMs) {
|
|
153
|
+
bucket.maxLatencyMs = ev.latencyMs;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (typeof ev.promptTokens === 'number' && Number.isFinite(ev.promptTokens)) {
|
|
157
|
+
bucket.usage.promptTokens += Math.max(0, ev.promptTokens);
|
|
158
|
+
}
|
|
159
|
+
if (typeof ev.completionTokens === 'number' && Number.isFinite(ev.completionTokens)) {
|
|
160
|
+
bucket.usage.completionTokens += Math.max(0, ev.completionTokens);
|
|
161
|
+
}
|
|
162
|
+
if (typeof ev.totalTokens === 'number' && Number.isFinite(ev.totalTokens)) {
|
|
163
|
+
bucket.usage.totalTokens += Math.max(0, ev.totalTokens);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const derivedTotal = (typeof ev.promptTokens === 'number' ? Math.max(0, ev.promptTokens) : 0) +
|
|
167
|
+
(typeof ev.completionTokens === 'number' ? Math.max(0, ev.completionTokens) : 0);
|
|
168
|
+
bucket.usage.totalTokens += derivedTotal;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let instance = null;
|
|
173
|
+
function resolveEnableFlag(defaultValue) {
|
|
174
|
+
const raw = process.env.ROUTECODEX_STATS;
|
|
175
|
+
if (!raw)
|
|
176
|
+
return defaultValue;
|
|
177
|
+
const normalized = raw.trim().toLowerCase();
|
|
178
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized))
|
|
179
|
+
return true;
|
|
180
|
+
if (['0', 'false', 'no', 'off'].includes(normalized))
|
|
181
|
+
return false;
|
|
182
|
+
return defaultValue;
|
|
183
|
+
}
|
|
184
|
+
function printStatsToConsole(snapshot) {
|
|
185
|
+
const router = snapshot.router;
|
|
186
|
+
const providers = snapshot.providers;
|
|
187
|
+
const totalRequests = router.global.requestCount;
|
|
188
|
+
const poolEntries = Object.entries(router.global.poolHitCount);
|
|
189
|
+
const providerEntries = Object.entries(router.global.providerHitCount);
|
|
190
|
+
// Router summary
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.log('[stats] Virtual Router:');
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.log(` total requests: ${totalRequests}`);
|
|
195
|
+
if (poolEntries.length) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.log(' pools:');
|
|
198
|
+
for (const [pool, count] of poolEntries) {
|
|
199
|
+
const ratio = totalRequests > 0 ? (count / totalRequests) * 100 : 0;
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.log(` ${pool}: ${count} (${ratio.toFixed(2)}%)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (providerEntries.length) {
|
|
205
|
+
// eslint-disable-next-line no-console
|
|
206
|
+
console.log(' top providers:');
|
|
207
|
+
const sorted = providerEntries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
208
|
+
for (const [providerKey, count] of sorted) {
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.log(` ${providerKey}: ${count}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const globalProvider = providers.global;
|
|
214
|
+
const totalProviderRequests = globalProvider.requestCount;
|
|
215
|
+
const avgLatency = globalProvider.successCount > 0 ? globalProvider.latencySumMs / globalProvider.successCount : 0;
|
|
216
|
+
// Provider summary
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.log('\n[stats] Providers:');
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log(` total requests : ${totalProviderRequests} (success=${globalProvider.successCount}, error=${globalProvider.errorCount})`);
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.log(` avg latency : ${avgLatency.toFixed(1)} ms`);
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(` total tokens : prompt=${globalProvider.usage.promptTokens} completion=${globalProvider.usage.completionTokens} total=${globalProvider.usage.totalTokens}`);
|
|
225
|
+
}
|
|
226
|
+
export function initStatsCenter(options) {
|
|
227
|
+
if (instance) {
|
|
228
|
+
return instance;
|
|
229
|
+
}
|
|
230
|
+
const enabled = resolveEnableFlag(options?.enable ?? true);
|
|
231
|
+
if (!enabled) {
|
|
232
|
+
instance = new NoopStatsCenter();
|
|
233
|
+
return instance;
|
|
234
|
+
}
|
|
235
|
+
const center = new DefaultStatsCenter(options?.persistPath);
|
|
236
|
+
instance = center;
|
|
237
|
+
const autoPrint = options?.autoPrintOnExit !== false;
|
|
238
|
+
if (autoPrint && typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
239
|
+
const handler = async () => {
|
|
240
|
+
try {
|
|
241
|
+
await center.flushToDisk();
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// ignore
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const snapshot = center.getSnapshot();
|
|
248
|
+
printStatsToConsole(snapshot);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
process.once('beforeExit', handler);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
process.once('SIGINT', handler);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// ignore
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
process.once('SIGTERM', handler);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// ignore
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return instance;
|
|
274
|
+
}
|
|
275
|
+
export function getStatsCenter() {
|
|
276
|
+
if (!instance) {
|
|
277
|
+
return initStatsCenter();
|
|
278
|
+
}
|
|
279
|
+
return instance;
|
|
280
|
+
}
|