@plexor-dev/claude-code-plugin-staging 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -7
- package/commands/plexor-agent.js +84 -0
- package/commands/plexor-agent.md +36 -0
- package/commands/plexor-enabled.js +177 -18
- package/commands/plexor-enabled.md +31 -13
- package/commands/plexor-login.js +212 -43
- package/commands/plexor-login.md +4 -21
- package/commands/plexor-logout.js +72 -14
- package/commands/plexor-logout.md +2 -20
- package/commands/plexor-provider.js +62 -81
- package/commands/plexor-provider.md +23 -13
- package/commands/plexor-routing.js +77 -0
- package/commands/plexor-routing.md +37 -0
- package/commands/plexor-settings.js +161 -123
- package/commands/plexor-settings.md +38 -14
- package/commands/plexor-setup.js +253 -0
- package/commands/plexor-setup.md +16 -160
- package/commands/plexor-status.js +252 -24
- package/commands/plexor-status.md +1 -13
- package/commands/plexor-uninstall.js +319 -0
- package/commands/plexor-uninstall.md +12 -0
- package/hooks/intercept.js +211 -32
- package/hooks/track-response.js +302 -2
- package/lib/config-utils.js +314 -0
- package/lib/config.js +22 -3
- package/lib/constants.js +19 -1
- package/lib/logger.js +64 -5
- package/lib/settings-manager.js +233 -24
- package/lib/verify-route.js +84 -0
- package/package.json +6 -4
- package/scripts/postinstall.js +271 -44
- package/scripts/uninstall.js +194 -41
- package/commands/plexor-config.js +0 -170
- package/commands/plexor-config.md +0 -28
- package/commands/plexor-mode.js +0 -107
- package/commands/plexor-mode.md +0 -27
package/hooks/track-response.js
CHANGED
|
@@ -85,10 +85,10 @@ try {
|
|
|
85
85
|
save(session) {
|
|
86
86
|
try {
|
|
87
87
|
if (!fs.existsSync(PLEXOR_DIR)) {
|
|
88
|
-
fs.mkdirSync(PLEXOR_DIR, { recursive: true });
|
|
88
|
+
fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
|
|
89
89
|
}
|
|
90
90
|
session.last_activity = Date.now();
|
|
91
|
-
fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
|
|
91
|
+
fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
92
92
|
} catch {}
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -133,6 +133,17 @@ try {
|
|
|
133
133
|
async getMetadata() { return null; }
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
+
const uxMessagesEnabled = !/^(0|false|no|off)$/i.test(
|
|
137
|
+
String(process.env.PLEXOR_UX_MESSAGES ?? process.env.PLEXOR_UX_DEBUG_MESSAGES ?? 'true')
|
|
138
|
+
);
|
|
139
|
+
const CYAN = '\x1b[36m';
|
|
140
|
+
const RESET = '\x1b[0m';
|
|
141
|
+
const formatUx = (msg) => {
|
|
142
|
+
const text = String(msg || '').trim();
|
|
143
|
+
if (!text) return '[PLEXOR: message]';
|
|
144
|
+
return text.startsWith('[PLEXOR:') ? text : `[PLEXOR: ${text}]`;
|
|
145
|
+
};
|
|
146
|
+
|
|
136
147
|
Logger = class {
|
|
137
148
|
constructor(name) { this.name = name; }
|
|
138
149
|
info(msg, data) {
|
|
@@ -143,6 +154,10 @@ try {
|
|
|
143
154
|
}
|
|
144
155
|
}
|
|
145
156
|
error(msg) { console.error(`[${this.name}] [ERROR] ${msg}`); }
|
|
157
|
+
ux(msg) {
|
|
158
|
+
if (!uxMessagesEnabled) return;
|
|
159
|
+
console.error(`${CYAN}${formatUx(msg)}${RESET}`);
|
|
160
|
+
}
|
|
146
161
|
debug(msg) {
|
|
147
162
|
if (process.env.PLEXOR_DEBUG) {
|
|
148
163
|
console.error(`[${this.name}] [DEBUG] ${msg}`);
|
|
@@ -194,11 +209,17 @@ async function main() {
|
|
|
194
209
|
input = await readStdin();
|
|
195
210
|
response = JSON.parse(input);
|
|
196
211
|
|
|
212
|
+
// Normalize known Plexor recovery text into explicit [PLEXOR: ...] format
|
|
213
|
+
// and mirror high-signal UX hints to stderr in cyan.
|
|
214
|
+
response = normalizePlexorResponseMessages(response);
|
|
215
|
+
emitPlexorUxMessages(response);
|
|
216
|
+
|
|
197
217
|
// Calculate output tokens for ALL responses (Issue #701)
|
|
198
218
|
const outputTokens = estimateOutputTokens(response);
|
|
199
219
|
|
|
200
220
|
// Get Plexor metadata if present
|
|
201
221
|
const plexorMeta = response._plexor;
|
|
222
|
+
emitPlexorOutcomeSummary(response, plexorMeta, outputTokens);
|
|
202
223
|
|
|
203
224
|
// Issue #701: Track ALL responses, not just when enabled
|
|
204
225
|
// This ensures session stats are always accurate
|
|
@@ -374,3 +395,282 @@ main().catch((error) => {
|
|
|
374
395
|
console.error(`[Plexor] Fatal error: ${error.message}`);
|
|
375
396
|
process.exit(1);
|
|
376
397
|
});
|
|
398
|
+
|
|
399
|
+
function normalizePlexorText(text) {
|
|
400
|
+
if (typeof text !== 'string' || !text) {
|
|
401
|
+
return text;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let normalized = text;
|
|
405
|
+
|
|
406
|
+
if (
|
|
407
|
+
normalized.includes("I've been repeating the same operation without making progress.") &&
|
|
408
|
+
!normalized.includes("[PLEXOR: I've been repeating the same operation without making progress.]")
|
|
409
|
+
) {
|
|
410
|
+
normalized = normalized.replace(
|
|
411
|
+
"I've been repeating the same operation without making progress.",
|
|
412
|
+
"[PLEXOR: I've been repeating the same operation without making progress.]"
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
normalized = normalized.replace(
|
|
417
|
+
/\[The repeated operation has been blocked\.[^\]]*\]/g,
|
|
418
|
+
(match) => `[PLEXOR: ${match.slice(1, -1)}]`
|
|
419
|
+
);
|
|
420
|
+
normalized = normalized.replace(
|
|
421
|
+
/\[All tasks have been completed\. Session ending\.\]/g,
|
|
422
|
+
'[PLEXOR: All tasks have been completed. Session ending.]'
|
|
423
|
+
);
|
|
424
|
+
normalized = normalized.replace(
|
|
425
|
+
/\[Blocked destructive cleanup command while recovering from prior errors\.[^\]]*\]/g,
|
|
426
|
+
(match) => `[PLEXOR: ${match.slice(1, -1)}]`
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
return normalized;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizePlexorResponseMessages(response) {
|
|
433
|
+
if (!response || typeof response !== 'object') {
|
|
434
|
+
return response;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Anthropic format
|
|
438
|
+
if (Array.isArray(response.content)) {
|
|
439
|
+
response.content = response.content.map((block) => {
|
|
440
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') {
|
|
441
|
+
return block;
|
|
442
|
+
}
|
|
443
|
+
return { ...block, text: normalizePlexorText(block.text) };
|
|
444
|
+
});
|
|
445
|
+
return response;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// String content format
|
|
449
|
+
if (typeof response.content === 'string') {
|
|
450
|
+
response.content = normalizePlexorText(response.content);
|
|
451
|
+
return response;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// OpenAI choices format
|
|
455
|
+
if (Array.isArray(response.choices)) {
|
|
456
|
+
response.choices = response.choices.map((choice) => {
|
|
457
|
+
if (!choice || typeof choice !== 'object') return choice;
|
|
458
|
+
const updated = { ...choice };
|
|
459
|
+
if (typeof updated.text === 'string') {
|
|
460
|
+
updated.text = normalizePlexorText(updated.text);
|
|
461
|
+
}
|
|
462
|
+
if (updated.message && typeof updated.message.content === 'string') {
|
|
463
|
+
updated.message = {
|
|
464
|
+
...updated.message,
|
|
465
|
+
content: normalizePlexorText(updated.message.content)
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return updated;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return response;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function collectResponseText(response) {
|
|
476
|
+
const texts = [];
|
|
477
|
+
if (!response || typeof response !== 'object') {
|
|
478
|
+
return texts;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (Array.isArray(response.content)) {
|
|
482
|
+
for (const block of response.content) {
|
|
483
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
484
|
+
texts.push(block.text);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} else if (typeof response.content === 'string') {
|
|
488
|
+
texts.push(response.content);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (Array.isArray(response.choices)) {
|
|
492
|
+
for (const choice of response.choices) {
|
|
493
|
+
if (typeof choice?.text === 'string') {
|
|
494
|
+
texts.push(choice.text);
|
|
495
|
+
}
|
|
496
|
+
if (typeof choice?.message?.content === 'string') {
|
|
497
|
+
texts.push(choice.message.content);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return texts;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function emitPlexorUxMessages(response) {
|
|
506
|
+
if (!logger || typeof logger.ux !== 'function') {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const lines = collectResponseText(response).join('\n');
|
|
511
|
+
if (!lines) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const signals = new Set();
|
|
516
|
+
|
|
517
|
+
if (lines.includes("I've been repeating the same operation without making progress.")) {
|
|
518
|
+
signals.add("[PLEXOR: I've been repeating the same operation without making progress.]");
|
|
519
|
+
}
|
|
520
|
+
if (/repeated operation has been blocked/i.test(lines)) {
|
|
521
|
+
signals.add('[PLEXOR: The repeated operation has been blocked; switching approach.]');
|
|
522
|
+
}
|
|
523
|
+
if (/circuit breaker has fired/i.test(lines)) {
|
|
524
|
+
signals.add('[PLEXOR: Circuit breaker fired; recovery guidance active.]');
|
|
525
|
+
}
|
|
526
|
+
if (/blocked destructive cleanup command/i.test(lines)) {
|
|
527
|
+
signals.add('[PLEXOR: Blocked destructive cleanup command during recovery.]');
|
|
528
|
+
}
|
|
529
|
+
if (/all tasks have been completed\. session ending\./i.test(lines)) {
|
|
530
|
+
signals.add('[PLEXOR: All tasks completed; session ending.]');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const explicitPlexorMessages = lines.match(/\[PLEXOR:[^\]]+\]/g) || [];
|
|
534
|
+
for (const explicit of explicitPlexorMessages) {
|
|
535
|
+
signals.add(explicit);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const signal of signals) {
|
|
539
|
+
logger.ux(signal);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function toNumber(value) {
|
|
544
|
+
if (value === null || value === undefined || value === '') {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
548
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function toBoolean(value) {
|
|
552
|
+
if (typeof value === 'boolean') return value;
|
|
553
|
+
if (typeof value === 'string') {
|
|
554
|
+
const normalized = value.trim().toLowerCase();
|
|
555
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
556
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function formatUsd(value) {
|
|
562
|
+
if (value === null || value === undefined || !Number.isFinite(value)) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
return `$${value.toFixed(6)}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function extractGatewayOutcome(response, plexorMeta, outputTokens) {
|
|
569
|
+
const provider =
|
|
570
|
+
response?.plexor_provider_used ||
|
|
571
|
+
response?.plexor?.provider_used ||
|
|
572
|
+
plexorMeta?.recommended_provider ||
|
|
573
|
+
null;
|
|
574
|
+
const selectedModel =
|
|
575
|
+
response?.plexor_selected_model ||
|
|
576
|
+
response?.plexor?.selected_model ||
|
|
577
|
+
plexorMeta?.recommended_model ||
|
|
578
|
+
null;
|
|
579
|
+
const requestedModel = response?.model || null;
|
|
580
|
+
const routingSource = response?.plexor_routing_source || response?.plexor?.routing_source || null;
|
|
581
|
+
const authTier = response?.plexor_auth_tier || response?.plexor?.auth_tier || null;
|
|
582
|
+
const stopReason = response?.stop_reason || response?.choices?.[0]?.finish_reason || null;
|
|
583
|
+
const agentHalt = toBoolean(response?.plexor_agent_halt);
|
|
584
|
+
const fallbackUsed =
|
|
585
|
+
toBoolean(response?.plexor_fallback_used) ??
|
|
586
|
+
toBoolean(response?.fallback_used) ??
|
|
587
|
+
toBoolean(response?.plexor?.fallback_used);
|
|
588
|
+
const costUsd =
|
|
589
|
+
toNumber(response?.plexor_cost_usd) ??
|
|
590
|
+
toNumber(response?.cost_usd) ??
|
|
591
|
+
toNumber(plexorMeta?.estimated_cost) ??
|
|
592
|
+
null;
|
|
593
|
+
const savingsUsd =
|
|
594
|
+
toNumber(response?.plexor_savings_usd) ??
|
|
595
|
+
toNumber(response?.savings_usd) ??
|
|
596
|
+
null;
|
|
597
|
+
const inputTokens =
|
|
598
|
+
toNumber(response?.usage?.input_tokens) ??
|
|
599
|
+
toNumber(response?.usage?.prompt_tokens) ??
|
|
600
|
+
toNumber(plexorMeta?.optimized_tokens) ??
|
|
601
|
+
null;
|
|
602
|
+
const emittedOutputTokens =
|
|
603
|
+
toNumber(response?.usage?.output_tokens) ??
|
|
604
|
+
toNumber(response?.usage?.completion_tokens) ??
|
|
605
|
+
toNumber(outputTokens);
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
provider,
|
|
609
|
+
selectedModel,
|
|
610
|
+
requestedModel,
|
|
611
|
+
routingSource,
|
|
612
|
+
authTier,
|
|
613
|
+
stopReason,
|
|
614
|
+
agentHalt,
|
|
615
|
+
fallbackUsed,
|
|
616
|
+
costUsd,
|
|
617
|
+
savingsUsd,
|
|
618
|
+
inputTokens,
|
|
619
|
+
emittedOutputTokens,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function emitPlexorOutcomeSummary(response, plexorMeta, outputTokens) {
|
|
624
|
+
if (!logger || typeof logger.ux !== 'function') {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const outcome = extractGatewayOutcome(response, plexorMeta, outputTokens);
|
|
629
|
+
const messages = [];
|
|
630
|
+
|
|
631
|
+
if (outcome.provider || outcome.selectedModel || outcome.requestedModel) {
|
|
632
|
+
const parts = [];
|
|
633
|
+
if (outcome.provider) parts.push(`provider=${outcome.provider}`);
|
|
634
|
+
if (outcome.selectedModel) parts.push(`selected_model=${outcome.selectedModel}`);
|
|
635
|
+
if (!outcome.selectedModel && outcome.requestedModel) {
|
|
636
|
+
parts.push(`requested_model=${outcome.requestedModel}`);
|
|
637
|
+
}
|
|
638
|
+
if (outcome.routingSource) parts.push(`routing=${outcome.routingSource}`);
|
|
639
|
+
if (outcome.authTier) parts.push(`auth_tier=${outcome.authTier}`);
|
|
640
|
+
messages.push(`Routing summary: ${parts.join(' ')}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (outcome.stopReason || outcome.agentHalt !== null || outcome.fallbackUsed !== null) {
|
|
644
|
+
const parts = [];
|
|
645
|
+
if (outcome.stopReason) parts.push(`stop_reason=${outcome.stopReason}`);
|
|
646
|
+
if (outcome.agentHalt === true) parts.push('agent_halt=auto_continue');
|
|
647
|
+
if (outcome.fallbackUsed !== null) parts.push(`fallback_used=${outcome.fallbackUsed}`);
|
|
648
|
+
if (parts.length > 0) {
|
|
649
|
+
messages.push(`Execution status: ${parts.join(' ')}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
outcome.costUsd !== null ||
|
|
655
|
+
outcome.savingsUsd !== null ||
|
|
656
|
+
outcome.inputTokens !== null ||
|
|
657
|
+
outcome.emittedOutputTokens !== null
|
|
658
|
+
) {
|
|
659
|
+
const parts = [];
|
|
660
|
+
const costStr = formatUsd(outcome.costUsd);
|
|
661
|
+
const savingsStr = formatUsd(outcome.savingsUsd);
|
|
662
|
+
if (costStr) parts.push(`cost=${costStr}`);
|
|
663
|
+
if (savingsStr) parts.push(`savings=${savingsStr}`);
|
|
664
|
+
if (outcome.inputTokens !== null) parts.push(`input_tokens=${Math.round(outcome.inputTokens)}`);
|
|
665
|
+
if (outcome.emittedOutputTokens !== null) {
|
|
666
|
+
parts.push(`output_tokens=${Math.round(outcome.emittedOutputTokens)}`);
|
|
667
|
+
}
|
|
668
|
+
if (parts.length > 0) {
|
|
669
|
+
messages.push(`Usage summary: ${parts.join(' ')}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const msg of messages) {
|
|
674
|
+
logger.ux(msg);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared config utilities for Plexor slash commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const { PLEXOR_DIR, CONFIG_PATH } = require('./constants');
|
|
10
|
+
|
|
11
|
+
const DISABLED_HINT_VALUES = new Set(['', 'auto', 'none', 'off']);
|
|
12
|
+
const VALID_ORCHESTRATION_MODES = new Set(['supervised', 'autonomous', 'danger-full-auto']);
|
|
13
|
+
const VALID_ROUTING_MODES = new Set(['eco', 'balanced', 'quality', 'passthrough', 'cost']);
|
|
14
|
+
const MANAGED_HEADER_KEYS = new Set([
|
|
15
|
+
'x-force-provider',
|
|
16
|
+
'x-force-model',
|
|
17
|
+
'x-allow-providers',
|
|
18
|
+
'x-deny-providers',
|
|
19
|
+
'x-allow-models',
|
|
20
|
+
'x-deny-models',
|
|
21
|
+
'x-plexor-mode',
|
|
22
|
+
'x-plexor-orchestration-mode'
|
|
23
|
+
]);
|
|
24
|
+
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
25
|
+
|
|
26
|
+
function normalizeForcedProvider(value) {
|
|
27
|
+
if (typeof value !== 'string') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const normalized = value.trim().toLowerCase();
|
|
31
|
+
if (!normalized || normalized === 'auto') {
|
|
32
|
+
return 'auto';
|
|
33
|
+
}
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeForcedModel(value) {
|
|
38
|
+
if (typeof value !== 'string') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const normalized = value.trim();
|
|
42
|
+
if (!normalized || DISABLED_HINT_VALUES.has(normalized.toLowerCase())) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeCsv(value) {
|
|
49
|
+
if (typeof value !== 'string') {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const tokens = value
|
|
53
|
+
.split(',')
|
|
54
|
+
.map((token) => token.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
if (!tokens.length) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return tokens.join(',');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseCustomHeaders(raw) {
|
|
63
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
const trimmedRaw = raw.trim();
|
|
67
|
+
|
|
68
|
+
// Backward compatibility: older plugin versions persisted ANTHROPIC_CUSTOM_HEADERS
|
|
69
|
+
// as a JSON object string. Parse that first so managed key replacement works.
|
|
70
|
+
if (trimmedRaw.startsWith('{')) {
|
|
71
|
+
try {
|
|
72
|
+
const legacy = JSON.parse(trimmedRaw);
|
|
73
|
+
if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, value] of Object.entries(legacy)) {
|
|
76
|
+
const normalizedKey = String(key || '').trim().toLowerCase();
|
|
77
|
+
const normalizedValue = String(value ?? '').trim();
|
|
78
|
+
if (!normalizedKey || !normalizedValue) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out[normalizedKey] = normalizedValue;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Fall through to line-based parser.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const parsed = {};
|
|
91
|
+
for (const line of raw.split('\n')) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const idx = trimmed.indexOf(':');
|
|
97
|
+
if (idx <= 0) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const key = trimmed.slice(0, idx).trim().toLowerCase();
|
|
101
|
+
const value = trimmed.slice(idx + 1).trim();
|
|
102
|
+
if (!key || !value) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
parsed[key] = value;
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function serializeCustomHeaders(headers) {
|
|
111
|
+
return Object.entries(headers)
|
|
112
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
113
|
+
.join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildManagedAnthropicHeaders(config) {
|
|
117
|
+
const settings = config?.settings || {};
|
|
118
|
+
const headers = {};
|
|
119
|
+
|
|
120
|
+
const modeRaw = String(settings.mode || '')
|
|
121
|
+
.trim()
|
|
122
|
+
.toLowerCase();
|
|
123
|
+
if (VALID_ROUTING_MODES.has(modeRaw)) {
|
|
124
|
+
headers['x-plexor-mode'] = modeRaw === 'cost' ? 'eco' : modeRaw;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const orchestrationRaw = String(
|
|
128
|
+
settings.orchestrationMode || settings.orchestration_mode || ''
|
|
129
|
+
)
|
|
130
|
+
.trim()
|
|
131
|
+
.toLowerCase();
|
|
132
|
+
if (VALID_ORCHESTRATION_MODES.has(orchestrationRaw)) {
|
|
133
|
+
headers['x-plexor-orchestration-mode'] = orchestrationRaw;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const forceProvider = normalizeForcedProvider(
|
|
137
|
+
settings.preferred_provider ?? settings.preferredProvider ?? 'auto'
|
|
138
|
+
);
|
|
139
|
+
if (forceProvider && forceProvider !== 'auto') {
|
|
140
|
+
headers['x-force-provider'] = forceProvider;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const forceModel = normalizeForcedModel(settings.preferred_model ?? settings.preferredModel);
|
|
144
|
+
if (forceModel) {
|
|
145
|
+
headers['x-force-model'] = forceModel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const allowProviders = normalizeCsv(
|
|
149
|
+
settings.provider_allowlist ?? settings.providerAllowlist ?? ''
|
|
150
|
+
);
|
|
151
|
+
if (allowProviders) {
|
|
152
|
+
headers['x-allow-providers'] = allowProviders;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const denyProviders = normalizeCsv(settings.provider_denylist ?? settings.providerDenylist ?? '');
|
|
156
|
+
if (denyProviders) {
|
|
157
|
+
headers['x-deny-providers'] = denyProviders;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const allowModels = normalizeCsv(settings.model_allowlist ?? settings.modelAllowlist ?? '');
|
|
161
|
+
if (allowModels) {
|
|
162
|
+
headers['x-allow-models'] = allowModels;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const denyModels = normalizeCsv(settings.model_denylist ?? settings.modelDenylist ?? '');
|
|
166
|
+
if (denyModels) {
|
|
167
|
+
headers['x-deny-models'] = denyModels;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return headers;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function syncClaudeCustomHeaders(config) {
|
|
174
|
+
try {
|
|
175
|
+
let settings = {};
|
|
176
|
+
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
177
|
+
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
|
178
|
+
settings = raw.trim() ? JSON.parse(raw) : {};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
|
|
182
|
+
settings = {};
|
|
183
|
+
}
|
|
184
|
+
settings.env = settings.env && typeof settings.env === 'object' ? settings.env : {};
|
|
185
|
+
|
|
186
|
+
const existing = parseCustomHeaders(settings.env.ANTHROPIC_CUSTOM_HEADERS);
|
|
187
|
+
for (const key of MANAGED_HEADER_KEYS) {
|
|
188
|
+
delete existing[key];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const managed = buildManagedAnthropicHeaders(config);
|
|
192
|
+
const merged = { ...existing, ...managed };
|
|
193
|
+
|
|
194
|
+
if (Object.keys(merged).length) {
|
|
195
|
+
settings.env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(merged);
|
|
196
|
+
} else {
|
|
197
|
+
delete settings.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
|
|
201
|
+
if (!fs.existsSync(claudeDir)) {
|
|
202
|
+
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
|
|
203
|
+
}
|
|
204
|
+
const tempPath = path.join(claudeDir, `.settings.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
205
|
+
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
206
|
+
fs.renameSync(tempPath, CLAUDE_SETTINGS_PATH);
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hasForcedHintConflict(config) {
|
|
214
|
+
const settings = config?.settings || {};
|
|
215
|
+
const provider = normalizeForcedProvider(
|
|
216
|
+
settings.preferred_provider ?? settings.preferredProvider ?? 'auto'
|
|
217
|
+
);
|
|
218
|
+
const model = normalizeForcedModel(settings.preferred_model ?? settings.preferredModel);
|
|
219
|
+
return Boolean(model) && provider !== 'auto';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateForcedHintConfig(config) {
|
|
223
|
+
if (!hasForcedHintConflict(config)) {
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
message:
|
|
229
|
+
'Invalid Plexor config: set only one force hint. Use preferred_provider OR preferred_model, not both.'
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function loadConfig() {
|
|
234
|
+
try {
|
|
235
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
236
|
+
return { version: 1, auth: {}, settings: {} };
|
|
237
|
+
}
|
|
238
|
+
const data = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
239
|
+
if (!data || data.trim() === '') {
|
|
240
|
+
return { version: 1, auth: {}, settings: {} };
|
|
241
|
+
}
|
|
242
|
+
const config = JSON.parse(data);
|
|
243
|
+
if (typeof config !== 'object' || config === null) {
|
|
244
|
+
return { version: 1, auth: {}, settings: {} };
|
|
245
|
+
}
|
|
246
|
+
return config;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof SyntaxError) {
|
|
249
|
+
try { fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.corrupted'); } catch { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
return { version: 1, auth: {}, settings: {} };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function saveConfig(config) {
|
|
256
|
+
try {
|
|
257
|
+
const validation = validateForcedHintConfig(config);
|
|
258
|
+
if (!validation.ok) {
|
|
259
|
+
console.error(`Error: ${validation.message}`);
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!fs.existsSync(PLEXOR_DIR)) {
|
|
264
|
+
fs.mkdirSync(PLEXOR_DIR, { recursive: true, mode: 0o700 });
|
|
265
|
+
}
|
|
266
|
+
const tempId = crypto.randomBytes(8).toString('hex');
|
|
267
|
+
const tempPath = path.join(PLEXOR_DIR, `.config.${tempId}.tmp`);
|
|
268
|
+
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
269
|
+
fs.renameSync(tempPath, CONFIG_PATH);
|
|
270
|
+
// Best-effort sync to Claude client headers so force hints are respected.
|
|
271
|
+
syncClaudeCustomHeaders(config);
|
|
272
|
+
return true;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
275
|
+
console.error('Error: Cannot write to ~/.plexor/config.json');
|
|
276
|
+
} else {
|
|
277
|
+
console.error('Failed to save config:', err.message);
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read a setting with env-var → config → default precedence.
|
|
285
|
+
* @param {object} config - Loaded config object
|
|
286
|
+
* @param {string} configKey - Primary key in config.settings (camelCase)
|
|
287
|
+
* @param {string|null} configKeyAlt - Alternative key (snake_case legacy)
|
|
288
|
+
* @param {string} envVar - Environment variable name
|
|
289
|
+
* @param {string[]} validValues - Accepted values
|
|
290
|
+
* @param {string} defaultValue - Fallback
|
|
291
|
+
* @returns {{ value: string, source: string }}
|
|
292
|
+
*/
|
|
293
|
+
function readSetting(config, configKey, configKeyAlt, envVar, validValues, defaultValue) {
|
|
294
|
+
const env = process.env[envVar];
|
|
295
|
+
if (env && validValues.includes(env.toLowerCase())) {
|
|
296
|
+
return { value: env.toLowerCase(), source: 'environment' };
|
|
297
|
+
}
|
|
298
|
+
const cfg = config.settings?.[configKey] || (configKeyAlt && config.settings?.[configKeyAlt]);
|
|
299
|
+
if (cfg && validValues.includes(cfg.toLowerCase())) {
|
|
300
|
+
return { value: cfg.toLowerCase(), source: 'config' };
|
|
301
|
+
}
|
|
302
|
+
return { value: defaultValue, source: 'default' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
loadConfig,
|
|
307
|
+
saveConfig,
|
|
308
|
+
readSetting,
|
|
309
|
+
hasForcedHintConflict,
|
|
310
|
+
validateForcedHintConfig,
|
|
311
|
+
parseCustomHeaders,
|
|
312
|
+
serializeCustomHeaders,
|
|
313
|
+
buildManagedAnthropicHeaders
|
|
314
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { CONFIG_PATH, PLEXOR_DIR, DEFAULT_API_URL, DEFAULT_TIMEOUT } = require('./constants');
|
|
8
|
+
const { validateForcedHintConfig } = require('./config-utils');
|
|
8
9
|
|
|
9
10
|
class ConfigManager {
|
|
10
11
|
constructor() {
|
|
@@ -22,7 +23,10 @@ class ConfigManager {
|
|
|
22
23
|
timeout: cfg.settings?.timeout || DEFAULT_TIMEOUT,
|
|
23
24
|
localCacheEnabled: cfg.settings?.localCacheEnabled ?? false,
|
|
24
25
|
mode: cfg.settings?.mode || 'balanced',
|
|
25
|
-
preferredProvider: cfg.settings?.preferred_provider || 'auto'
|
|
26
|
+
preferredProvider: cfg.settings?.preferred_provider || 'auto',
|
|
27
|
+
preferredModel: cfg.settings?.preferredModel || cfg.settings?.preferred_model || null,
|
|
28
|
+
orchestrationMode:
|
|
29
|
+
cfg.settings?.orchestrationMode || cfg.settings?.orchestration_mode || 'autonomous'
|
|
26
30
|
};
|
|
27
31
|
} catch {
|
|
28
32
|
return { enabled: false, apiKey: '', apiUrl: DEFAULT_API_URL };
|
|
@@ -52,11 +56,26 @@ class ConfigManager {
|
|
|
52
56
|
timeout: config.timeout ?? existing.settings?.timeout,
|
|
53
57
|
localCacheEnabled: config.localCacheEnabled ?? existing.settings?.localCacheEnabled,
|
|
54
58
|
mode: config.mode ?? existing.settings?.mode,
|
|
55
|
-
preferred_provider: config.preferredProvider ?? existing.settings?.preferred_provider
|
|
59
|
+
preferred_provider: config.preferredProvider ?? existing.settings?.preferred_provider,
|
|
60
|
+
preferred_model:
|
|
61
|
+
config.preferredModel ??
|
|
62
|
+
config.preferred_model ??
|
|
63
|
+
existing.settings?.preferredModel ??
|
|
64
|
+
existing.settings?.preferred_model,
|
|
65
|
+
orchestrationMode:
|
|
66
|
+
config.orchestrationMode ??
|
|
67
|
+
config.orchestration_mode ??
|
|
68
|
+
existing.settings?.orchestrationMode ??
|
|
69
|
+
existing.settings?.orchestration_mode
|
|
56
70
|
}
|
|
57
71
|
};
|
|
58
72
|
|
|
59
|
-
|
|
73
|
+
const validation = validateForcedHintConfig(updated);
|
|
74
|
+
if (!validation.ok) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(this.configPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
60
79
|
return true;
|
|
61
80
|
} catch {
|
|
62
81
|
return false;
|