@plexor-dev/claude-code-plugin 0.1.0-beta.16 → 0.1.0-beta.17
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/hooks/intercept.js +49 -7
- package/hooks/track-response.js +261 -49
- package/lib/server-sync.js +237 -0
- package/lib/session.js +13 -2
- package/package.json +1 -1
package/hooks/intercept.js
CHANGED
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a unique request ID for tracking.
|
|
19
|
+
* Format: pass_<timestamp>_<random> for passthrough requests
|
|
20
|
+
* This ensures every request can be tracked, even passthroughs.
|
|
21
|
+
* Issue #701: Missing request_id caused response tracking to fail.
|
|
22
|
+
*/
|
|
23
|
+
function generateRequestId(prefix = 'pass') {
|
|
24
|
+
const timestamp = Date.now().toString(36);
|
|
25
|
+
const random = crypto.randomBytes(4).toString('hex');
|
|
26
|
+
return `${prefix}_${timestamp}_${random}`;
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
// Try to load lib modules, fall back to inline implementations
|
|
17
30
|
let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
|
|
@@ -162,6 +175,8 @@ async function main() {
|
|
|
162
175
|
const baseUrl = process.env.ANTHROPIC_BASE_URL || '';
|
|
163
176
|
if (baseUrl.includes('plexor')) {
|
|
164
177
|
logger.debug('Plexor proxy detected via ANTHROPIC_BASE_URL, passing through');
|
|
178
|
+
// Note: Don't add _plexor metadata here - the proxy will add its own
|
|
179
|
+
// This passthrough is transparent to avoid double-processing
|
|
165
180
|
return output(request);
|
|
166
181
|
}
|
|
167
182
|
|
|
@@ -175,6 +190,7 @@ async function main() {
|
|
|
175
190
|
...request,
|
|
176
191
|
plexor_cwd: process.cwd(),
|
|
177
192
|
_plexor: {
|
|
193
|
+
request_id: generateRequestId('slash'), // Issue #701: Add request_id for tracking
|
|
178
194
|
source: 'passthrough_slash_command',
|
|
179
195
|
reason: 'slash_command_detected',
|
|
180
196
|
cwd: process.cwd(),
|
|
@@ -192,6 +208,7 @@ async function main() {
|
|
|
192
208
|
...request,
|
|
193
209
|
plexor_cwd: process.cwd(),
|
|
194
210
|
_plexor: {
|
|
211
|
+
request_id: generateRequestId('cli'), // Issue #701: Add request_id for tracking
|
|
195
212
|
source: 'passthrough_cli',
|
|
196
213
|
reason: 'cli_tool_execution_detected',
|
|
197
214
|
cwd: process.cwd(),
|
|
@@ -209,6 +226,7 @@ async function main() {
|
|
|
209
226
|
...request,
|
|
210
227
|
plexor_cwd: process.cwd(),
|
|
211
228
|
_plexor: {
|
|
229
|
+
request_id: generateRequestId('agent'), // Issue #701: Add request_id for tracking
|
|
212
230
|
source: 'passthrough_agentic',
|
|
213
231
|
reason: 'tool_use_detected',
|
|
214
232
|
cwd: process.cwd(),
|
|
@@ -221,12 +239,30 @@ async function main() {
|
|
|
221
239
|
|
|
222
240
|
if (!settings.enabled) {
|
|
223
241
|
logger.debug('Plexor disabled, passing through');
|
|
224
|
-
|
|
242
|
+
session.recordPassthrough();
|
|
243
|
+
return output({
|
|
244
|
+
...request,
|
|
245
|
+
_plexor: {
|
|
246
|
+
request_id: generateRequestId('disabled'), // Issue #701: Add request_id for tracking
|
|
247
|
+
source: 'passthrough_disabled',
|
|
248
|
+
reason: 'plexor_disabled',
|
|
249
|
+
latency_ms: Date.now() - startTime
|
|
250
|
+
}
|
|
251
|
+
});
|
|
225
252
|
}
|
|
226
253
|
|
|
227
254
|
if (!settings.apiKey) {
|
|
228
255
|
logger.info('Not authenticated. Run /plexor-login to enable optimization.');
|
|
229
|
-
|
|
256
|
+
session.recordPassthrough();
|
|
257
|
+
return output({
|
|
258
|
+
...request,
|
|
259
|
+
_plexor: {
|
|
260
|
+
request_id: generateRequestId('noauth'), // Issue #701: Add request_id for tracking
|
|
261
|
+
source: 'passthrough_no_auth',
|
|
262
|
+
reason: 'not_authenticated',
|
|
263
|
+
latency_ms: Date.now() - startTime
|
|
264
|
+
}
|
|
265
|
+
});
|
|
230
266
|
}
|
|
231
267
|
|
|
232
268
|
const client = new PlexorClient({
|
|
@@ -248,6 +284,7 @@ async function main() {
|
|
|
248
284
|
return output({
|
|
249
285
|
...request,
|
|
250
286
|
_plexor: {
|
|
287
|
+
request_id: generateRequestId('cache'), // Issue #701: Add request_id for tracking
|
|
251
288
|
source: 'local_cache',
|
|
252
289
|
latency_ms: Date.now() - startTime
|
|
253
290
|
}
|
|
@@ -312,11 +349,15 @@ async function main() {
|
|
|
312
349
|
logger.error(`[Plexor] Error: ${error.message}`);
|
|
313
350
|
logger.debug(error.stack);
|
|
314
351
|
|
|
352
|
+
const errorRequestId = generateRequestId('error'); // Issue #701: Add request_id for tracking
|
|
353
|
+
|
|
315
354
|
// Use already-parsed request if available, otherwise pass through raw
|
|
316
355
|
if (request) {
|
|
356
|
+
session.recordPassthrough();
|
|
317
357
|
return output({
|
|
318
358
|
...request,
|
|
319
359
|
_plexor: {
|
|
360
|
+
request_id: errorRequestId,
|
|
320
361
|
error: error.message,
|
|
321
362
|
source: 'passthrough_error'
|
|
322
363
|
}
|
|
@@ -325,9 +366,11 @@ async function main() {
|
|
|
325
366
|
// Try to parse the input we already read
|
|
326
367
|
try {
|
|
327
368
|
const req = JSON.parse(input);
|
|
369
|
+
session.recordPassthrough();
|
|
328
370
|
return output({
|
|
329
371
|
...req,
|
|
330
372
|
_plexor: {
|
|
373
|
+
request_id: errorRequestId,
|
|
331
374
|
error: error.message,
|
|
332
375
|
source: 'passthrough_error'
|
|
333
376
|
}
|
|
@@ -476,11 +519,10 @@ function isAgenticRequest(request) {
|
|
|
476
519
|
}
|
|
477
520
|
}
|
|
478
521
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
522
|
+
// NOTE: Removed multi-turn check (Issue #701)
|
|
523
|
+
// Multi-turn conversations WITHOUT tool use are NOT agentic
|
|
524
|
+
// The old check caused normal conversations to be marked as agentic
|
|
525
|
+
// after just 2 assistant messages, leading to infinite loops
|
|
484
526
|
|
|
485
527
|
return false;
|
|
486
528
|
}
|
package/hooks/track-response.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* This script runs after the LLM response is received.
|
|
7
7
|
* It tracks response metrics for analytics and updates session stats.
|
|
8
8
|
*
|
|
9
|
+
* Issue #701 Fix: Now tracks ALL responses including passthroughs.
|
|
10
|
+
* Previously, responses without request_id would exit early without tracking,
|
|
11
|
+
* leading to "0 tokens" display and potential context issues.
|
|
12
|
+
*
|
|
9
13
|
* Input: JSON object with response content, tokens used, etc.
|
|
10
14
|
* Output: Passthrough (no modifications)
|
|
11
15
|
*/
|
|
@@ -13,17 +17,22 @@
|
|
|
13
17
|
const path = require('path');
|
|
14
18
|
|
|
15
19
|
// Use lib modules
|
|
16
|
-
let ConfigManager, SessionManager, LocalCache, Logger,
|
|
20
|
+
let ConfigManager, SessionManager, LocalCache, Logger, ServerSync;
|
|
17
21
|
try {
|
|
18
22
|
ConfigManager = require('../lib/config');
|
|
19
23
|
SessionManager = require('../lib/session');
|
|
20
24
|
LocalCache = require('../lib/cache');
|
|
21
25
|
Logger = require('../lib/logger');
|
|
22
|
-
|
|
26
|
+
// Issue #701: Phase 2 - Server sync for persistent session state
|
|
27
|
+
const serverSyncModule = require('../lib/server-sync');
|
|
28
|
+
ServerSync = serverSyncModule.getServerSync;
|
|
23
29
|
} catch {
|
|
24
30
|
// Fallback inline implementations if lib not found
|
|
25
31
|
const fs = require('fs');
|
|
26
32
|
const CONFIG_PATH = path.join(process.env.HOME || '', '.plexor', 'config.json');
|
|
33
|
+
const SESSION_PATH = path.join(process.env.HOME || '', '.plexor', 'session.json');
|
|
34
|
+
const PLEXOR_DIR = path.join(process.env.HOME || '', '.plexor');
|
|
35
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
27
36
|
|
|
28
37
|
ConfigManager = class {
|
|
29
38
|
async load() {
|
|
@@ -40,9 +49,84 @@ try {
|
|
|
40
49
|
}
|
|
41
50
|
};
|
|
42
51
|
|
|
52
|
+
// Inline SessionManager for fallback (Issue #701: proper tracking)
|
|
43
53
|
SessionManager = class {
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
constructor() {
|
|
55
|
+
this.sessionPath = SESSION_PATH;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
load() {
|
|
59
|
+
try {
|
|
60
|
+
const data = fs.readFileSync(this.sessionPath, 'utf8');
|
|
61
|
+
const session = JSON.parse(data);
|
|
62
|
+
if (Date.now() - session.last_activity > SESSION_TIMEOUT_MS) {
|
|
63
|
+
return this.createNew();
|
|
64
|
+
}
|
|
65
|
+
return session;
|
|
66
|
+
} catch {
|
|
67
|
+
return this.createNew();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
createNew() {
|
|
72
|
+
const session = {
|
|
73
|
+
session_id: `session_${Date.now()}`,
|
|
74
|
+
started_at: new Date().toISOString(),
|
|
75
|
+
last_activity: Date.now(),
|
|
76
|
+
requests: 0, optimizations: 0, cache_hits: 0, passthroughs: 0,
|
|
77
|
+
original_tokens: 0, optimized_tokens: 0, tokens_saved: 0,
|
|
78
|
+
output_tokens: 0, // Issue #701: Track output tokens
|
|
79
|
+
baseline_cost: 0, actual_cost: 0, cost_saved: 0
|
|
80
|
+
};
|
|
81
|
+
this.save(session);
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
save(session) {
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(PLEXOR_DIR)) {
|
|
88
|
+
fs.mkdirSync(PLEXOR_DIR, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
session.last_activity = Date.now();
|
|
91
|
+
fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
recordOptimization(result) {
|
|
96
|
+
const s = this.load();
|
|
97
|
+
s.requests++;
|
|
98
|
+
s.optimizations++;
|
|
99
|
+
s.original_tokens += result.original_tokens || 0;
|
|
100
|
+
s.optimized_tokens += result.optimized_tokens || 0;
|
|
101
|
+
s.tokens_saved += result.tokens_saved || 0;
|
|
102
|
+
s.output_tokens += result.output_tokens || 0; // Issue #701
|
|
103
|
+
s.baseline_cost += result.baseline_cost || 0;
|
|
104
|
+
s.actual_cost += result.estimated_cost || result.actual_cost || 0;
|
|
105
|
+
s.cost_saved += (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0);
|
|
106
|
+
this.save(s);
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
recordCacheHit() {
|
|
111
|
+
const s = this.load();
|
|
112
|
+
s.requests++;
|
|
113
|
+
s.cache_hits++;
|
|
114
|
+
this.save(s);
|
|
115
|
+
return s;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
recordPassthrough(outputTokens = 0) {
|
|
119
|
+
const s = this.load();
|
|
120
|
+
s.requests++;
|
|
121
|
+
s.passthroughs = (s.passthroughs || 0) + 1;
|
|
122
|
+
s.output_tokens += outputTokens; // Issue #701: Track output even for passthroughs
|
|
123
|
+
this.save(s);
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getStats() {
|
|
128
|
+
return this.load();
|
|
129
|
+
}
|
|
46
130
|
};
|
|
47
131
|
|
|
48
132
|
LocalCache = class {
|
|
@@ -50,10 +134,28 @@ try {
|
|
|
50
134
|
};
|
|
51
135
|
|
|
52
136
|
Logger = class {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
137
|
+
constructor(name) { this.name = name; }
|
|
138
|
+
info(msg, data) {
|
|
139
|
+
if (data) {
|
|
140
|
+
console.error(`[${this.name}] ${msg}`, JSON.stringify(data));
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`[${this.name}] ${msg}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
error(msg) { console.error(`[${this.name}] [ERROR] ${msg}`); }
|
|
146
|
+
debug(msg) {
|
|
147
|
+
if (process.env.PLEXOR_DEBUG) {
|
|
148
|
+
console.error(`[${this.name}] [DEBUG] ${msg}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
56
151
|
};
|
|
152
|
+
|
|
153
|
+
// Issue #701: Fallback ServerSync that does nothing
|
|
154
|
+
ServerSync = () => ({
|
|
155
|
+
syncSession: async () => ({ synced: false, message: 'Server sync not available' }),
|
|
156
|
+
scheduleSync: () => {},
|
|
157
|
+
needsSync: () => false
|
|
158
|
+
});
|
|
57
159
|
}
|
|
58
160
|
|
|
59
161
|
const logger = new Logger('track-response');
|
|
@@ -61,69 +163,185 @@ const config = new ConfigManager();
|
|
|
61
163
|
const cache = new LocalCache();
|
|
62
164
|
const session = new SessionManager();
|
|
63
165
|
|
|
166
|
+
// Issue #701: Phase 2 - Initialize server sync (lazy, initialized on first use)
|
|
167
|
+
let serverSync = null;
|
|
168
|
+
|
|
169
|
+
async function getServerSync() {
|
|
170
|
+
if (serverSync) return serverSync;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const settings = await config.load();
|
|
174
|
+
if (settings.apiKey && settings.enabled) {
|
|
175
|
+
serverSync = ServerSync({
|
|
176
|
+
apiKey: settings.apiKey,
|
|
177
|
+
baseUrl: settings.apiUrl || 'https://api.plexor.dev',
|
|
178
|
+
enabled: settings.serverSyncEnabled !== false
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
serverSync = ServerSync({ enabled: false });
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
serverSync = ServerSync({ enabled: false });
|
|
185
|
+
}
|
|
186
|
+
return serverSync;
|
|
187
|
+
}
|
|
188
|
+
|
|
64
189
|
async function main() {
|
|
190
|
+
let response;
|
|
191
|
+
let input;
|
|
192
|
+
|
|
65
193
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
194
|
+
input = await readStdin();
|
|
195
|
+
response = JSON.parse(input);
|
|
68
196
|
|
|
69
|
-
|
|
197
|
+
// Calculate output tokens for ALL responses (Issue #701)
|
|
198
|
+
const outputTokens = estimateOutputTokens(response);
|
|
70
199
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
200
|
+
// Get Plexor metadata if present
|
|
201
|
+
const plexorMeta = response._plexor;
|
|
202
|
+
|
|
203
|
+
// Issue #701: Track ALL responses, not just when enabled
|
|
204
|
+
// This ensures session stats are always accurate
|
|
205
|
+
if (plexorMeta) {
|
|
206
|
+
await trackResponseWithMetadata(plexorMeta, outputTokens);
|
|
207
|
+
} else {
|
|
208
|
+
// No Plexor metadata - still track the output tokens
|
|
209
|
+
session.recordPassthrough(outputTokens);
|
|
210
|
+
logger.debug('Response tracked without Plexor metadata');
|
|
74
211
|
}
|
|
75
212
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
session.
|
|
213
|
+
// Issue #701: Phase 2 - Schedule server sync (non-blocking)
|
|
214
|
+
// This syncs local session stats to the server for persistence
|
|
215
|
+
try {
|
|
216
|
+
const sync = await getServerSync();
|
|
217
|
+
const localStats = session.getStats();
|
|
218
|
+
sync.scheduleSync(localStats);
|
|
219
|
+
} catch (syncError) {
|
|
220
|
+
// Server sync failures should not affect response passthrough
|
|
221
|
+
logger.debug(`Server sync scheduling failed: ${syncError.message}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Pass through unchanged
|
|
225
|
+
return output(response);
|
|
226
|
+
|
|
227
|
+
} catch (error) {
|
|
228
|
+
logger.error(`Tracking error: ${error.message}`);
|
|
229
|
+
|
|
230
|
+
// On any error, try to pass through the response unchanged
|
|
231
|
+
if (response) {
|
|
81
232
|
return output(response);
|
|
233
|
+
} else if (input) {
|
|
234
|
+
try {
|
|
235
|
+
return output(JSON.parse(input));
|
|
236
|
+
} catch {
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
process.exit(1);
|
|
82
241
|
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
83
244
|
|
|
84
|
-
|
|
85
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Track response using Plexor metadata.
|
|
247
|
+
* Issue #701: Handles ALL source types, not just plexor_api.
|
|
248
|
+
*/
|
|
249
|
+
async function trackResponseWithMetadata(plexorMeta, outputTokens) {
|
|
250
|
+
const source = plexorMeta.source || 'unknown';
|
|
251
|
+
const requestId = plexorMeta.request_id;
|
|
86
252
|
|
|
87
|
-
|
|
88
|
-
const outputTokens = estimateTokens(response.content || '');
|
|
253
|
+
logger.debug(`Tracking response: source=${source}, request_id=${requestId}`);
|
|
89
254
|
|
|
90
|
-
|
|
91
|
-
|
|
255
|
+
switch (source) {
|
|
256
|
+
case 'plexor_api':
|
|
257
|
+
// Full optimization was applied
|
|
92
258
|
session.recordOptimization({
|
|
93
259
|
original_tokens: plexorMeta.original_tokens || 0,
|
|
94
260
|
optimized_tokens: plexorMeta.optimized_tokens || 0,
|
|
95
261
|
tokens_saved: plexorMeta.tokens_saved || 0,
|
|
96
262
|
baseline_cost: plexorMeta.baseline_cost || 0,
|
|
97
|
-
estimated_cost: plexorMeta.estimated_cost || 0
|
|
263
|
+
estimated_cost: plexorMeta.estimated_cost || 0,
|
|
264
|
+
output_tokens: outputTokens
|
|
98
265
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
request_id: plexorMeta.request_id,
|
|
266
|
+
logger.info('[Plexor] Optimized response tracked', {
|
|
267
|
+
request_id: requestId,
|
|
102
268
|
input_tokens: plexorMeta.optimized_tokens,
|
|
103
269
|
output_tokens: outputTokens,
|
|
270
|
+
savings_percent: plexorMeta.savings_percent,
|
|
104
271
|
provider: plexorMeta.recommended_provider
|
|
105
272
|
});
|
|
106
|
-
|
|
273
|
+
break;
|
|
274
|
+
|
|
275
|
+
case 'local_cache':
|
|
107
276
|
session.recordCacheHit();
|
|
108
|
-
logger.info('[Plexor] Cache hit recorded');
|
|
109
|
-
|
|
110
|
-
session.recordPassthrough();
|
|
111
|
-
}
|
|
277
|
+
logger.info('[Plexor] Cache hit recorded', { request_id: requestId });
|
|
278
|
+
break;
|
|
112
279
|
|
|
113
|
-
|
|
114
|
-
|
|
280
|
+
case 'passthrough_agentic':
|
|
281
|
+
case 'passthrough_slash_command':
|
|
282
|
+
case 'passthrough_cli':
|
|
283
|
+
case 'passthrough_disabled':
|
|
284
|
+
case 'passthrough_no_auth':
|
|
285
|
+
case 'passthrough_error':
|
|
286
|
+
case 'passthrough':
|
|
287
|
+
// Passthrough - still track output tokens
|
|
288
|
+
session.recordPassthrough(outputTokens);
|
|
289
|
+
logger.debug(`Passthrough recorded: ${source}`, {
|
|
290
|
+
request_id: requestId,
|
|
291
|
+
reason: plexorMeta.reason,
|
|
292
|
+
output_tokens: outputTokens
|
|
293
|
+
});
|
|
294
|
+
break;
|
|
115
295
|
|
|
116
|
-
|
|
117
|
-
|
|
296
|
+
default:
|
|
297
|
+
// Unknown source - treat as passthrough
|
|
298
|
+
session.recordPassthrough(outputTokens);
|
|
299
|
+
logger.debug(`Unknown source tracked as passthrough: ${source}`, {
|
|
300
|
+
request_id: requestId,
|
|
301
|
+
output_tokens: outputTokens
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
118
305
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Estimate output tokens from response content.
|
|
308
|
+
* Handles both string and structured content formats.
|
|
309
|
+
*/
|
|
310
|
+
function estimateOutputTokens(response) {
|
|
311
|
+
if (!response) return 0;
|
|
312
|
+
|
|
313
|
+
// Try to get content from various response formats
|
|
314
|
+
let text = '';
|
|
315
|
+
|
|
316
|
+
// Anthropic format: content array with text blocks
|
|
317
|
+
if (Array.isArray(response.content)) {
|
|
318
|
+
for (const block of response.content) {
|
|
319
|
+
if (block.type === 'text' && block.text) {
|
|
320
|
+
text += block.text;
|
|
321
|
+
} else if (block.type === 'tool_use') {
|
|
322
|
+
// Tool use blocks contribute to token count
|
|
323
|
+
text += JSON.stringify(block.input || {});
|
|
324
|
+
}
|
|
125
325
|
}
|
|
126
326
|
}
|
|
327
|
+
// Simple string content
|
|
328
|
+
else if (typeof response.content === 'string') {
|
|
329
|
+
text = response.content;
|
|
330
|
+
}
|
|
331
|
+
// OpenAI format: choices array
|
|
332
|
+
else if (response.choices && response.choices[0]) {
|
|
333
|
+
const choice = response.choices[0];
|
|
334
|
+
if (choice.message && choice.message.content) {
|
|
335
|
+
text = choice.message.content;
|
|
336
|
+
} else if (choice.text) {
|
|
337
|
+
text = choice.text;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!text) return 0;
|
|
342
|
+
|
|
343
|
+
// Approximate: ~4 characters per token (rough estimate)
|
|
344
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
127
345
|
}
|
|
128
346
|
|
|
129
347
|
async function readStdin() {
|
|
@@ -152,12 +370,6 @@ function output(data) {
|
|
|
152
370
|
process.exit(0);
|
|
153
371
|
}
|
|
154
372
|
|
|
155
|
-
function estimateTokens(text) {
|
|
156
|
-
if (!text) return 0;
|
|
157
|
-
// Approximate: ~4 characters per token
|
|
158
|
-
return Math.max(1, Math.ceil(text.length / 4));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
373
|
main().catch((error) => {
|
|
162
374
|
console.error(`[Plexor] Fatal error: ${error.message}`);
|
|
163
375
|
process.exit(1);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plexor Server Sync Module
|
|
3
|
+
*
|
|
4
|
+
* Issue #701: Syncs local plugin session state to the Plexor server.
|
|
5
|
+
* This ensures accurate token tracking even for passthrough requests.
|
|
6
|
+
*
|
|
7
|
+
* Phase 2 of the hypervisor architecture implementation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const http = require('http');
|
|
12
|
+
|
|
13
|
+
class ServerSync {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.apiKey = options.apiKey;
|
|
16
|
+
this.baseUrl = options.baseUrl || 'https://api.plexor.dev';
|
|
17
|
+
this.timeout = options.timeout || 5000;
|
|
18
|
+
this.enabled = options.enabled !== false;
|
|
19
|
+
this.lastSyncTime = null;
|
|
20
|
+
this.syncInterval = options.syncInterval || 60000; // 1 minute default
|
|
21
|
+
this.pendingSync = null;
|
|
22
|
+
this.serverSessionId = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sync local session stats to the server.
|
|
27
|
+
* Issue #701: Ensures server has accurate metrics even for passthroughs.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} localSession - Local session stats from SessionManager
|
|
30
|
+
* @returns {Promise<Object>} Server response with synced metrics
|
|
31
|
+
*/
|
|
32
|
+
async syncSession(localSession) {
|
|
33
|
+
if (!this.enabled || !this.apiKey) {
|
|
34
|
+
return { synced: false, message: 'Server sync disabled or no API key' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const syncData = {
|
|
39
|
+
session_id: this.serverSessionId,
|
|
40
|
+
requests: localSession.requests || 0,
|
|
41
|
+
optimizations: localSession.optimizations || 0,
|
|
42
|
+
cache_hits: localSession.cache_hits || 0,
|
|
43
|
+
passthroughs: localSession.passthroughs || 0,
|
|
44
|
+
original_tokens: localSession.original_tokens || 0,
|
|
45
|
+
optimized_tokens: localSession.optimized_tokens || 0,
|
|
46
|
+
tokens_saved: localSession.tokens_saved || 0,
|
|
47
|
+
output_tokens: localSession.output_tokens || 0,
|
|
48
|
+
baseline_cost: localSession.baseline_cost || 0,
|
|
49
|
+
actual_cost: localSession.actual_cost || 0,
|
|
50
|
+
cost_saved: localSession.cost_saved || 0
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const response = await this._postToServer('/v1/plugin/session/sync', syncData);
|
|
54
|
+
|
|
55
|
+
if (response.synced) {
|
|
56
|
+
this.serverSessionId = response.server_session_id;
|
|
57
|
+
this.lastSyncTime = Date.now();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return response;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Don't throw - server sync failures shouldn't break the plugin
|
|
63
|
+
return {
|
|
64
|
+
synced: false,
|
|
65
|
+
message: `Server sync failed: ${error.message}`,
|
|
66
|
+
error: error.message
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get server-side session state.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} sessionId - Session ID to query
|
|
75
|
+
* @returns {Promise<Object>} Server session state
|
|
76
|
+
*/
|
|
77
|
+
async getServerSession(sessionId) {
|
|
78
|
+
if (!this.enabled || !this.apiKey) {
|
|
79
|
+
return { found: false, message: 'Server sync disabled or no API key' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return await this._getFromServer(`/v1/plugin/session/${sessionId}`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
found: false,
|
|
87
|
+
message: `Failed to get server session: ${error.message}`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Schedule periodic sync (debounced).
|
|
94
|
+
*
|
|
95
|
+
* @param {Object} localSession - Local session stats
|
|
96
|
+
*/
|
|
97
|
+
scheduleSync(localSession) {
|
|
98
|
+
// Debounce: don't sync more than once per interval
|
|
99
|
+
if (this.pendingSync) {
|
|
100
|
+
clearTimeout(this.pendingSync);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.pendingSync = setTimeout(async () => {
|
|
104
|
+
await this.syncSession(localSession);
|
|
105
|
+
this.pendingSync = null;
|
|
106
|
+
}, 1000); // Sync 1 second after last request
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* POST data to the Plexor server.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} path - API path
|
|
113
|
+
* @param {Object} data - Data to send
|
|
114
|
+
* @returns {Promise<Object>} Parsed JSON response
|
|
115
|
+
*/
|
|
116
|
+
_postToServer(path, data) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const url = new URL(path, this.baseUrl);
|
|
119
|
+
const isHttps = url.protocol === 'https:';
|
|
120
|
+
const client = isHttps ? https : http;
|
|
121
|
+
|
|
122
|
+
const options = {
|
|
123
|
+
hostname: url.hostname,
|
|
124
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
125
|
+
path: url.pathname,
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
130
|
+
'X-Plexor-Plugin-Version': '0.1.0-beta.5',
|
|
131
|
+
'X-Plexor-Issue': '701'
|
|
132
|
+
},
|
|
133
|
+
timeout: this.timeout
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const req = client.request(options, (res) => {
|
|
137
|
+
let body = '';
|
|
138
|
+
res.on('data', chunk => body += chunk);
|
|
139
|
+
res.on('end', () => {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(body);
|
|
142
|
+
resolve(parsed);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
req.on('error', reject);
|
|
150
|
+
req.on('timeout', () => {
|
|
151
|
+
req.destroy();
|
|
152
|
+
reject(new Error('Request timeout'));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
req.write(JSON.stringify(data));
|
|
156
|
+
req.end();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* GET data from the Plexor server.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} path - API path
|
|
164
|
+
* @returns {Promise<Object>} Parsed JSON response
|
|
165
|
+
*/
|
|
166
|
+
_getFromServer(path) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const url = new URL(path, this.baseUrl);
|
|
169
|
+
const isHttps = url.protocol === 'https:';
|
|
170
|
+
const client = isHttps ? https : http;
|
|
171
|
+
|
|
172
|
+
const options = {
|
|
173
|
+
hostname: url.hostname,
|
|
174
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
175
|
+
path: url.pathname,
|
|
176
|
+
method: 'GET',
|
|
177
|
+
headers: {
|
|
178
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
179
|
+
'X-Plexor-Plugin-Version': '0.1.0-beta.5'
|
|
180
|
+
},
|
|
181
|
+
timeout: this.timeout
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const req = client.request(options, (res) => {
|
|
185
|
+
let body = '';
|
|
186
|
+
res.on('data', chunk => body += chunk);
|
|
187
|
+
res.on('end', () => {
|
|
188
|
+
try {
|
|
189
|
+
resolve(JSON.parse(body));
|
|
190
|
+
} catch (e) {
|
|
191
|
+
reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
req.on('error', reject);
|
|
197
|
+
req.on('timeout', () => {
|
|
198
|
+
req.destroy();
|
|
199
|
+
reject(new Error('Request timeout'));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
req.end();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if sync is needed based on time elapsed.
|
|
208
|
+
*/
|
|
209
|
+
needsSync() {
|
|
210
|
+
if (!this.lastSyncTime) return true;
|
|
211
|
+
return Date.now() - this.lastSyncTime > this.syncInterval;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Singleton instance
|
|
216
|
+
let _serverSync = null;
|
|
217
|
+
|
|
218
|
+
function getServerSync(options = {}) {
|
|
219
|
+
if (!_serverSync) {
|
|
220
|
+
_serverSync = new ServerSync(options);
|
|
221
|
+
} else if (options.apiKey && options.apiKey !== _serverSync.apiKey) {
|
|
222
|
+
// Update API key if changed
|
|
223
|
+
_serverSync.apiKey = options.apiKey;
|
|
224
|
+
_serverSync.baseUrl = options.baseUrl || _serverSync.baseUrl;
|
|
225
|
+
}
|
|
226
|
+
return _serverSync;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resetServerSync() {
|
|
230
|
+
_serverSync = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
ServerSync,
|
|
235
|
+
getServerSync,
|
|
236
|
+
resetServerSync
|
|
237
|
+
};
|
package/lib/session.js
CHANGED
|
@@ -38,9 +38,11 @@ class SessionManager {
|
|
|
38
38
|
requests: 0,
|
|
39
39
|
optimizations: 0,
|
|
40
40
|
cache_hits: 0,
|
|
41
|
+
passthroughs: 0, // Issue #701: Track passthroughs
|
|
41
42
|
original_tokens: 0,
|
|
42
43
|
optimized_tokens: 0,
|
|
43
44
|
tokens_saved: 0,
|
|
45
|
+
output_tokens: 0, // Issue #701: Track output tokens
|
|
44
46
|
baseline_cost: 0,
|
|
45
47
|
actual_cost: 0,
|
|
46
48
|
cost_saved: 0
|
|
@@ -71,6 +73,7 @@ class SessionManager {
|
|
|
71
73
|
session.original_tokens += stats.original_tokens || 0;
|
|
72
74
|
session.optimized_tokens += stats.optimized_tokens || 0;
|
|
73
75
|
session.tokens_saved += stats.tokens_saved || 0;
|
|
76
|
+
session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0); // Issue #701
|
|
74
77
|
session.baseline_cost += stats.baseline_cost || 0;
|
|
75
78
|
session.actual_cost += stats.actual_cost || 0;
|
|
76
79
|
session.cost_saved += stats.cost_saved || 0;
|
|
@@ -80,6 +83,12 @@ class SessionManager {
|
|
|
80
83
|
session.cache_hits++;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
// Issue #701: Track passthroughs and their output tokens
|
|
87
|
+
if (stats.passthrough) {
|
|
88
|
+
session.passthroughs = (session.passthroughs || 0) + 1;
|
|
89
|
+
session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
this.save(session);
|
|
84
93
|
return session;
|
|
85
94
|
}
|
|
@@ -90,6 +99,7 @@ class SessionManager {
|
|
|
90
99
|
original_tokens: result.original_tokens || 0,
|
|
91
100
|
optimized_tokens: result.optimized_tokens || 0,
|
|
92
101
|
tokens_saved: result.tokens_saved || 0,
|
|
102
|
+
output_tokens: result.output_tokens || 0, // Issue #701: Track output tokens
|
|
93
103
|
baseline_cost: result.baseline_cost || 0,
|
|
94
104
|
actual_cost: result.estimated_cost || result.actual_cost || 0,
|
|
95
105
|
cost_saved: (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0)
|
|
@@ -100,8 +110,9 @@ class SessionManager {
|
|
|
100
110
|
return this.recordRequest({ cache_hit: true });
|
|
101
111
|
}
|
|
102
112
|
|
|
103
|
-
recordPassthrough() {
|
|
104
|
-
|
|
113
|
+
recordPassthrough(outputTokens = 0) {
|
|
114
|
+
// Issue #701: Track passthroughs and their output tokens
|
|
115
|
+
return this.recordRequest({ passthrough: true, output_tokens: outputTokens });
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
getStats() {
|
package/package.json
CHANGED