@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.
@@ -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
- toolCalls.push({
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
- parts.push({ functionCall });
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
- const flag = (process.env.ROUTECODEX_HUB_SNAPSHOTS || '').trim();
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 output of chat.toolOutputs) {
142
- const response = cloneAsJsonValue(safeParseJson(output.content));
143
- const part = {
144
- functionResponse: {
145
- name: output.name || 'tool',
146
- id: output.tool_call_id,
147
- response
148
- }
149
- };
150
- contents.push({ role: 'user', parts: [part] });
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
- const SNAPSHOT_BASE = path.join(os.homedir(), '.routecodex', 'golden_samples');
6
- function mapEndpointToFolder(endpoint, hint) {
7
- if (hint)
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
- catch {
23
- // ignore fs errors
6
+ const normalized = value.trim().toLowerCase();
7
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
8
+ return true;
24
9
  }
25
- }
26
- function sanitize(value) {
27
- return value.replace(/[^\w.-]/g, '_');
10
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
11
+ return false;
12
+ }
13
+ return fallback;
28
14
  }
29
15
  export function shouldRecordSnapshots() {
30
- const flag = process.env.ROUTECODEX_HUB_SNAPSHOTS;
31
- if (flag && flag.trim() === '0') {
32
- return false;
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
@@ -7,4 +7,5 @@
7
7
  export * from './conversion/index.js';
8
8
  export * from './router/virtual-router/bootstrap.js';
9
9
  export * from './router/virtual-router/types.js';
10
+ export * from './telemetry/stats-center.js';
10
11
  export declare const VERSION = "0.4.0";
package/dist/index.js CHANGED
@@ -7,4 +7,5 @@
7
7
  export * from './conversion/index.js';
8
8
  export * from './router/virtual-router/bootstrap.js';
9
9
  export * from './router/virtual-router/types.js';
10
+ export * from './telemetry/stats-center.js';
10
11
  export const VERSION = '0.4.0';
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonstudio/llms",
3
- "version": "0.6.203",
3
+ "version": "0.6.215",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",