@tokenbuddy/tokenbuddy 1.0.31 → 1.0.33
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/src/buyer-store.d.ts +26 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +61 -8
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/daemon.d.ts +24 -0
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +1259 -9
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts +12 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -1
- package/dist/src/doctor-diagnostics.js +71 -2
- package/dist/src/doctor-diagnostics.js.map +1 -1
- package/dist/src/prewarm-cache.js +4 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/provider-routing-config.d.ts +91 -0
- package/dist/src/provider-routing-config.d.ts.map +1 -0
- package/dist/src/provider-routing-config.js +292 -0
- package/dist/src/provider-routing-config.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +113 -8
- package/src/daemon.ts +1389 -16
- package/src/doctor-diagnostics.ts +100 -1
- package/src/prewarm-cache.ts +5 -1
- package/src/provider-routing-config.ts +410 -0
- package/static/ui/assets/index-0MVXD7bH.css +1 -0
- package/static/ui/assets/index-Mt3BZFuP.js +266 -0
- package/static/ui/assets/index-Mt3BZFuP.js.map +1 -0
- package/static/ui/icons/apple-touch-icon.png +0 -0
- package/static/ui/icons/tokenbuddy-192.png +0 -0
- package/static/ui/icons/tokenbuddy-512.png +0 -0
- package/static/ui/icons/tokenbuddy.svg +6 -0
- package/static/ui/index.html +3 -2
- package/static/ui/tool-logos/cc-switch.png +0 -0
- package/static/ui/tool-logos/claude.svg +1 -0
- package/static/ui/tool-logos/cline.svg +1 -0
- package/static/ui/tool-logos/codex.svg +1 -0
- package/static/ui/tool-logos/cursor.svg +1 -0
- package/static/ui/tool-logos/dirac.ico +18 -0
- package/static/ui/tool-logos/factory.svg +4 -0
- package/static/ui/tool-logos/fast-agent.svg +6 -0
- package/static/ui/tool-logos/glm.svg +1 -0
- package/static/ui/tool-logos/goose.svg +1 -0
- package/static/ui/tool-logos/hermes.svg +1 -0
- package/static/ui/tool-logos/kilocode.svg +1 -0
- package/static/ui/tool-logos/opencode.svg +1 -0
- package/static/ui/tool-logos/pi.svg +28 -0
- package/static/ui/tool-logos/qwen-code.png +0 -0
- package/tests/cli-routing.test.ts +43 -0
- package/tests/control-plane-ui-endpoints.test.ts +776 -0
- package/tests/daemon-classify.test.ts +5 -1
- package/tests/e2e.test.ts +5 -0
- package/tests/prewarm-cache.test.ts +15 -0
- package/tests/provider-routing-config.test.ts +150 -0
- package/tests/tokenbuddy.test.ts +27 -0
- package/static/ui/assets/index-Bzbrp7Qe.css +0 -1
- package/static/ui/assets/index-DEDEl8o2.js +0 -236
- package/static/ui/assets/index-DEDEl8o2.js.map +0 -1
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readDoctorClawtipWallet,
|
|
19
19
|
type DoctorClawtipWalletSummary,
|
|
20
20
|
} from "./doctor-clawtip-wallet.js";
|
|
21
|
+
import type { PublicManualProviderConfig } from "./provider-routing-config.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* `tb doctor` 输出里 provider 行的形状。
|
|
@@ -55,6 +56,8 @@ export interface DoctorDiagnostics {
|
|
|
55
56
|
access: DoctorAccessSummary;
|
|
56
57
|
/** Clawtip 钱包信息 */
|
|
57
58
|
clawtipWallet: DoctorClawtipWalletSummary;
|
|
59
|
+
/** buyer 本地 Manual provider 状态 */
|
|
60
|
+
manualProviders: DoctorManualProvidersSummary;
|
|
58
61
|
/** 模型目录汇总 */
|
|
59
62
|
models: DoctorModelsSummary;
|
|
60
63
|
/** provider 安装 / 配置状态 */
|
|
@@ -108,12 +111,18 @@ interface DoctorModelsResponse {
|
|
|
108
111
|
sellers?: DoctorSellerEntry[];
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
interface DoctorManualProvidersResponse {
|
|
115
|
+
providers: PublicManualProviderConfig[];
|
|
116
|
+
}
|
|
117
|
+
|
|
111
118
|
/**
|
|
112
119
|
* `tb doctor` 模型表的单行:按 model id 聚合 seller 的折扣 / 价格区间。
|
|
113
120
|
*/
|
|
114
121
|
export interface DoctorModelSummaryEntry {
|
|
115
122
|
id: string;
|
|
116
123
|
sellerCount: number;
|
|
124
|
+
localProviderCount: number;
|
|
125
|
+
sourceRange: string;
|
|
117
126
|
discountMin?: number;
|
|
118
127
|
discountMax?: number;
|
|
119
128
|
discountRange: string;
|
|
@@ -128,6 +137,7 @@ interface DoctorFetchResults {
|
|
|
128
137
|
healthResult: RemoteJsonResult<Record<string, unknown>>;
|
|
129
138
|
sellersResult: RemoteJsonResult<DoctorSellerResponse>;
|
|
130
139
|
modelsResult: RemoteJsonResult<DoctorModelsResponse>;
|
|
140
|
+
manualProvidersResult: RemoteJsonResult<DoctorManualProvidersResponse>;
|
|
131
141
|
proxyModelsResult: RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>;
|
|
132
142
|
registryResult: RemoteJsonResult<DoctorRegistryDocument>;
|
|
133
143
|
}
|
|
@@ -136,6 +146,7 @@ interface DoctorFetchPromises {
|
|
|
136
146
|
healthResult: Promise<RemoteJsonResult<Record<string, unknown>>>;
|
|
137
147
|
sellersResult: Promise<RemoteJsonResult<DoctorSellerResponse>>;
|
|
138
148
|
modelsResult: Promise<RemoteJsonResult<DoctorModelsResponse>>;
|
|
149
|
+
manualProvidersResult: Promise<RemoteJsonResult<DoctorManualProvidersResponse>>;
|
|
139
150
|
proxyModelsResult: Promise<RemoteJsonResult<{ object?: string; data?: Array<{ id?: string }> }>>;
|
|
140
151
|
registryResult: Promise<RemoteJsonResult<DoctorRegistryDocument>>;
|
|
141
152
|
}
|
|
@@ -184,6 +195,14 @@ type DoctorSellersSummary = {
|
|
|
184
195
|
error?: string;
|
|
185
196
|
};
|
|
186
197
|
|
|
198
|
+
export type DoctorManualProvidersSummary = {
|
|
199
|
+
available: boolean;
|
|
200
|
+
count: number;
|
|
201
|
+
configuredKeyCount: number;
|
|
202
|
+
providers: PublicManualProviderConfig[];
|
|
203
|
+
error?: string;
|
|
204
|
+
};
|
|
205
|
+
|
|
187
206
|
/**
|
|
188
207
|
* `tb doctor` 模型目录汇总:原始 `data` + 聚合后的 `grouped`(按 model id 分组)。
|
|
189
208
|
*/
|
|
@@ -363,6 +382,14 @@ function formatPriceRange(minMicros?: number, maxMicros?: number): string {
|
|
|
363
382
|
return `${formatUsdPer1m(minMicros)}~${formatUsdPer1m(maxMicros)}`;
|
|
364
383
|
}
|
|
365
384
|
|
|
385
|
+
function formatModelSourceRange(marketplaceCount: number, localProviderCount: number): string {
|
|
386
|
+
const parts = [
|
|
387
|
+
marketplaceCount > 0 ? `${marketplaceCount} marketplace` : undefined,
|
|
388
|
+
localProviderCount > 0 ? `${localProviderCount} local` : undefined,
|
|
389
|
+
].filter(Boolean);
|
|
390
|
+
return parts.length > 0 ? parts.join(" + ") : "-";
|
|
391
|
+
}
|
|
392
|
+
|
|
366
393
|
function modelsHaveExplicitPriceData(models: ModelCatalogEntry[]): boolean {
|
|
367
394
|
return models.some((model) =>
|
|
368
395
|
typeof model.inputPriceMicrosPer1m === "number" ||
|
|
@@ -376,6 +403,7 @@ function buildDoctorModelSummaryEntries(
|
|
|
376
403
|
): DoctorModelSummaryEntry[] {
|
|
377
404
|
const grouped = new Map<string, {
|
|
378
405
|
sellerIds: Set<string>;
|
|
406
|
+
localProviderIds: Set<string>;
|
|
379
407
|
discounts: number[];
|
|
380
408
|
inputPrices: number[];
|
|
381
409
|
outputPrices: number[];
|
|
@@ -389,12 +417,16 @@ function buildDoctorModelSummaryEntries(
|
|
|
389
417
|
for (const model of models) {
|
|
390
418
|
const entry = grouped.get(model.id) || {
|
|
391
419
|
sellerIds: new Set<string>(),
|
|
420
|
+
localProviderIds: new Set<string>(),
|
|
392
421
|
discounts: [],
|
|
393
422
|
inputPrices: [],
|
|
394
423
|
outputPrices: [],
|
|
395
424
|
};
|
|
396
425
|
if (!entry.sellerIds.has(model.sellerId)) {
|
|
397
426
|
entry.sellerIds.add(model.sellerId);
|
|
427
|
+
if (model.paymentMethods.includes("provider_key")) {
|
|
428
|
+
entry.localProviderIds.add(model.sellerId);
|
|
429
|
+
}
|
|
398
430
|
const discount = discountBySellerId.get(model.sellerId);
|
|
399
431
|
if (typeof discount === "number") {
|
|
400
432
|
entry.discounts.push(discount);
|
|
@@ -411,6 +443,7 @@ function buildDoctorModelSummaryEntries(
|
|
|
411
443
|
|
|
412
444
|
return Array.from(grouped.entries())
|
|
413
445
|
.map(([id, entry]) => {
|
|
446
|
+
const marketplaceCount = Math.max(0, entry.sellerIds.size - entry.localProviderIds.size);
|
|
414
447
|
const discountMin = entry.discounts.length > 0 ? Math.min(...entry.discounts) : undefined;
|
|
415
448
|
const discountMax = entry.discounts.length > 0 ? Math.max(...entry.discounts) : undefined;
|
|
416
449
|
const inputPriceMinMicrosPer1m = entry.inputPrices.length > 0 ? Math.min(...entry.inputPrices) : undefined;
|
|
@@ -428,6 +461,8 @@ function buildDoctorModelSummaryEntries(
|
|
|
428
461
|
return {
|
|
429
462
|
id,
|
|
430
463
|
sellerCount: entry.sellerIds.size,
|
|
464
|
+
localProviderCount: entry.localProviderIds.size,
|
|
465
|
+
sourceRange: formatModelSourceRange(marketplaceCount, entry.localProviderIds.size),
|
|
431
466
|
discountMin,
|
|
432
467
|
discountMax,
|
|
433
468
|
discountRange,
|
|
@@ -457,6 +492,7 @@ function startDoctorFetches(
|
|
|
457
492
|
healthResult: Promise.resolve({ url: `${controlBaseUrl}/health`, available: false, error: unavailableMessage }),
|
|
458
493
|
sellersResult: Promise.resolve({ url: `${controlBaseUrl}/sellers`, available: false, error: unavailableMessage }),
|
|
459
494
|
modelsResult: Promise.resolve({ url: `${controlBaseUrl}/models`, available: false, error: unavailableMessage }),
|
|
495
|
+
manualProvidersResult: Promise.resolve({ url: `${controlBaseUrl}/routing/manual-providers`, available: false, error: unavailableMessage }),
|
|
460
496
|
proxyModelsResult: Promise.resolve({ url: `${openAiBaseUrl}/models`, available: false, error: unavailableMessage }),
|
|
461
497
|
registryResult: Promise.resolve({ url: sellerRegistryUrl || "", available: false, error: unavailableMessage }),
|
|
462
498
|
};
|
|
@@ -466,6 +502,7 @@ function startDoctorFetches(
|
|
|
466
502
|
healthResult: fetchJsonDocument<Record<string, unknown>>(`${controlBaseUrl}/health`),
|
|
467
503
|
sellersResult: fetchJsonDocument<DoctorSellerResponse>(`${controlBaseUrl}/sellers`),
|
|
468
504
|
modelsResult: fetchJsonDocument<DoctorModelsResponse>(`${controlBaseUrl}/models`),
|
|
505
|
+
manualProvidersResult: fetchJsonDocument<DoctorManualProvidersResponse>(`${controlBaseUrl}/routing/manual-providers`),
|
|
469
506
|
proxyModelsResult: fetchJsonDocument<{ object?: string; data?: Array<{ id?: string }> }>(`${openAiBaseUrl}/models`),
|
|
470
507
|
registryResult: sellerRegistryUrl
|
|
471
508
|
? fetchJsonDocument<DoctorRegistryDocument>(sellerRegistryUrl)
|
|
@@ -478,12 +515,14 @@ async function resolveDoctorFetches(fetches: DoctorFetchPromises): Promise<Docto
|
|
|
478
515
|
healthResult,
|
|
479
516
|
sellersResult,
|
|
480
517
|
modelsResult,
|
|
518
|
+
manualProvidersResult,
|
|
481
519
|
proxyModelsResult,
|
|
482
520
|
registryResult,
|
|
483
521
|
] = await Promise.all([
|
|
484
522
|
fetches.healthResult,
|
|
485
523
|
fetches.sellersResult,
|
|
486
524
|
fetches.modelsResult,
|
|
525
|
+
fetches.manualProvidersResult,
|
|
487
526
|
fetches.proxyModelsResult,
|
|
488
527
|
fetches.registryResult,
|
|
489
528
|
]);
|
|
@@ -491,6 +530,7 @@ async function resolveDoctorFetches(fetches: DoctorFetchPromises): Promise<Docto
|
|
|
491
530
|
healthResult,
|
|
492
531
|
sellersResult,
|
|
493
532
|
modelsResult,
|
|
533
|
+
manualProvidersResult,
|
|
494
534
|
proxyModelsResult,
|
|
495
535
|
registryResult,
|
|
496
536
|
};
|
|
@@ -521,6 +561,19 @@ function buildDoctorSellerSummary(
|
|
|
521
561
|
};
|
|
522
562
|
}
|
|
523
563
|
|
|
564
|
+
function buildDoctorManualProvidersSummary(
|
|
565
|
+
manualProvidersResult: RemoteJsonResult<DoctorManualProvidersResponse>
|
|
566
|
+
): DoctorManualProvidersSummary {
|
|
567
|
+
const providers = manualProvidersResult.data?.providers || [];
|
|
568
|
+
return {
|
|
569
|
+
available: manualProvidersResult.available,
|
|
570
|
+
count: providers.length,
|
|
571
|
+
configuredKeyCount: providers.filter((provider) => provider.keyRef?.configured).length,
|
|
572
|
+
providers,
|
|
573
|
+
error: manualProvidersResult.error,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
524
577
|
function buildDoctorAccessSummary(
|
|
525
578
|
controlPort: number,
|
|
526
579
|
proxyPort: number,
|
|
@@ -674,6 +727,42 @@ function printDoctorSellers(sellers: DoctorSellersSummary, writeLine: (line: str
|
|
|
674
727
|
}
|
|
675
728
|
}
|
|
676
729
|
|
|
730
|
+
function printDoctorManualProviders(
|
|
731
|
+
manualProviders: DoctorManualProvidersSummary,
|
|
732
|
+
writeLine: (line: string) => void
|
|
733
|
+
): void {
|
|
734
|
+
writeLine("Manual provider check complete.");
|
|
735
|
+
if (!manualProviders.available) {
|
|
736
|
+
writeLine(`❌ Manual providers unavailable: ${manualProviders.error || "unknown error"}`);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
writeLine(`Manual providers: ${manualProviders.count}`);
|
|
740
|
+
writeLine(`Configured keys: ${manualProviders.configuredKeyCount}/${manualProviders.count}`);
|
|
741
|
+
for (const provider of manualProviders.providers) {
|
|
742
|
+
const icon = provider.enabled && provider.keyRef?.configured
|
|
743
|
+
? "✅"
|
|
744
|
+
: provider.enabled
|
|
745
|
+
? "⚠️"
|
|
746
|
+
: "🔘";
|
|
747
|
+
const keyRef = provider.keyRef
|
|
748
|
+
? `${provider.keyRef.kind}:${provider.keyRef.name} ${provider.keyRef.configured ? "configured" : "missing"}`
|
|
749
|
+
: "missing";
|
|
750
|
+
writeLine(`${icon} ${provider.name} (${provider.id}) [${provider.enabled ? "enabled" : "disabled"}]`);
|
|
751
|
+
writeLine(` Key: ${keyRef}`);
|
|
752
|
+
writeLine(` Models: ${provider.models.length}`);
|
|
753
|
+
writeLine(` Protocols: ${formatList(provider.supportedProtocols)}`);
|
|
754
|
+
if (provider.lastAccess) {
|
|
755
|
+
writeLine(` Last access: ${provider.lastAccess}`);
|
|
756
|
+
}
|
|
757
|
+
if (provider.status) {
|
|
758
|
+
writeLine(` Status: ${provider.status}`);
|
|
759
|
+
}
|
|
760
|
+
if (provider.errorMessage) {
|
|
761
|
+
writeLine(` Error: ${provider.errorMessage}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
677
766
|
/**
|
|
678
767
|
* 打印模型目录汇总(写到 `writeLine`,默认 stdout)。
|
|
679
768
|
*
|
|
@@ -693,7 +782,7 @@ export function printDoctorModelsSummary(
|
|
|
693
782
|
writeLine(`Seller offers: ${models.count}`);
|
|
694
783
|
|
|
695
784
|
const table = new Table({
|
|
696
|
-
head: ["Model ID", "Seller Count", "Discount Range", "Price Range"],
|
|
785
|
+
head: ["Model ID", "Seller Count", "Sources", "Discount Range", "Price Range"],
|
|
697
786
|
style: {
|
|
698
787
|
head: []
|
|
699
788
|
}
|
|
@@ -702,6 +791,7 @@ export function printDoctorModelsSummary(
|
|
|
702
791
|
table.push([
|
|
703
792
|
entry.id,
|
|
704
793
|
String(entry.sellerCount),
|
|
794
|
+
entry.sourceRange,
|
|
705
795
|
entry.discountRange,
|
|
706
796
|
entry.priceRange
|
|
707
797
|
]);
|
|
@@ -794,6 +884,7 @@ export async function collectDoctorDiagnostics(options: DoctorCollectOptions): P
|
|
|
794
884
|
results.modelsResult.data?.sellers || [],
|
|
795
885
|
);
|
|
796
886
|
const models = buildDoctorModelsSummary(results.modelsResult, sellers.sellers);
|
|
887
|
+
const manualProviders = buildDoctorManualProvidersSummary(results.manualProvidersResult);
|
|
797
888
|
|
|
798
889
|
return {
|
|
799
890
|
access: buildDoctorAccessSummary(
|
|
@@ -809,6 +900,7 @@ export async function collectDoctorDiagnostics(options: DoctorCollectOptions): P
|
|
|
809
900
|
},
|
|
810
901
|
),
|
|
811
902
|
clawtipWallet: readDoctorClawtipWallet(),
|
|
903
|
+
manualProviders,
|
|
812
904
|
models,
|
|
813
905
|
providers: options.providers,
|
|
814
906
|
sellers,
|
|
@@ -882,6 +974,12 @@ export async function renderDoctorDiagnosticsProgressively(options: DoctorRender
|
|
|
882
974
|
);
|
|
883
975
|
printDoctorAccess(access, writeLine);
|
|
884
976
|
|
|
977
|
+
writeLine("\n--- Manual Providers ---");
|
|
978
|
+
writeLine("Reading local custom provider status...");
|
|
979
|
+
const manualProvidersResult = await fetches.manualProvidersResult;
|
|
980
|
+
const manualProviders = buildDoctorManualProvidersSummary(manualProvidersResult);
|
|
981
|
+
printDoctorManualProviders(manualProviders, writeLine);
|
|
982
|
+
|
|
885
983
|
writeLine("\n--- Sellers ---");
|
|
886
984
|
writeLine("Refreshing seller registry...");
|
|
887
985
|
const [sellersResult, registryResult] = await Promise.all([
|
|
@@ -920,6 +1018,7 @@ export async function renderDoctorDiagnosticsProgressively(options: DoctorRender
|
|
|
920
1018
|
return {
|
|
921
1019
|
access,
|
|
922
1020
|
clawtipWallet,
|
|
1021
|
+
manualProviders,
|
|
923
1022
|
models,
|
|
924
1023
|
providers: options.providers,
|
|
925
1024
|
sellers: finalSellers,
|
package/src/prewarm-cache.ts
CHANGED
|
@@ -456,7 +456,7 @@ function toCandidate(input: PrewarmCandidateInput): PrewarmCandidate {
|
|
|
456
456
|
healthProbeLatencyMs: finiteNonNegative(input.healthProbeLatencyMs),
|
|
457
457
|
ttftMs: finiteNonNegative(input.ttftMs),
|
|
458
458
|
avgInferenceMs: finiteNonNegative(input.avgInferenceMs),
|
|
459
|
-
avgTokensPerSecond:
|
|
459
|
+
avgTokensPerSecond: finitePositive(input.avgTokensPerSecond),
|
|
460
460
|
upstreamStatus: input.upstreamStatus,
|
|
461
461
|
upstreamErrorClass: input.upstreamErrorClass,
|
|
462
462
|
capacityBlockedUntil: finiteNonNegative(input.capacityBlockedUntil)
|
|
@@ -467,6 +467,10 @@ function finiteNonNegative(value: number | undefined): number | undefined {
|
|
|
467
467
|
return Number.isFinite(value) ? Math.max(0, value as number) : undefined;
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
function finitePositive(value: number | undefined): number | undefined {
|
|
471
|
+
return Number.isFinite(value) && (value as number) > 0 ? value : undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
470
474
|
function clampScore(score: number): number {
|
|
471
475
|
if (!Number.isFinite(score)) {
|
|
472
476
|
return 50;
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type { SellerRoutingScorer } from "./seller-routing-strategy.js";
|
|
2
|
+
|
|
3
|
+
export const PROVIDER_MODE_CONFIG_KEY = "provider-mode";
|
|
4
|
+
export const MANUAL_PROVIDER_CONFIG_KEY = "manual-providers";
|
|
5
|
+
export const AUTO_PROVIDER_CONFIG_KEY = "auto-provider";
|
|
6
|
+
export const MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY = "manual-provider-observations";
|
|
7
|
+
|
|
8
|
+
export type ProviderMode = "manual" | "auto";
|
|
9
|
+
export type ManualProviderKind = "openai-compatible";
|
|
10
|
+
export type ProviderProtocol = "chat_completions" | "responses" | "messages";
|
|
11
|
+
export type AutoProviderRange = "recommended" | "custom";
|
|
12
|
+
export type ManualProviderRoutingPolicy = "fallback" | "locked";
|
|
13
|
+
|
|
14
|
+
export interface ProviderModeConfig {
|
|
15
|
+
mode: ProviderMode;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ManualProviderConfig {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
kind: ManualProviderKind;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
apiKeyEnv?: string;
|
|
25
|
+
secretRef?: string;
|
|
26
|
+
models: string[];
|
|
27
|
+
supportedProtocols: ProviderProtocol[];
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
notes?: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ManualProvidersConfig {
|
|
35
|
+
version: 1;
|
|
36
|
+
providers: ManualProviderConfig[];
|
|
37
|
+
routing: ManualProviderRoutingConfig;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ManualProviderRoutingConfig {
|
|
42
|
+
policy: ManualProviderRoutingPolicy;
|
|
43
|
+
lockedProviderId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AutoProviderConfig {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
range: AutoProviderRange;
|
|
49
|
+
scorer: SellerRoutingScorer;
|
|
50
|
+
modelIds: string[];
|
|
51
|
+
sellerIds: string[];
|
|
52
|
+
maxConcurrentProviders: 10;
|
|
53
|
+
updatedAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PublicManualProviderConfig extends Omit<ManualProviderConfig, "apiKeyEnv" | "secretRef"> {
|
|
57
|
+
keyRef?: {
|
|
58
|
+
kind: "env" | "secret";
|
|
59
|
+
name: string;
|
|
60
|
+
configured: boolean;
|
|
61
|
+
};
|
|
62
|
+
current?: boolean;
|
|
63
|
+
lastAccess?: string;
|
|
64
|
+
status?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
|
65
|
+
errorClass?: string;
|
|
66
|
+
errorMessage?: string;
|
|
67
|
+
ttftMs?: number;
|
|
68
|
+
avgTokensPerSecond?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ManualProviderObservation {
|
|
72
|
+
providerId: string;
|
|
73
|
+
current: boolean;
|
|
74
|
+
lastAccess: string;
|
|
75
|
+
status: "healthy" | "degraded" | "unhealthy" | "unknown";
|
|
76
|
+
errorClass?: string;
|
|
77
|
+
errorMessage?: string;
|
|
78
|
+
ttftMs?: number;
|
|
79
|
+
avgTokensPerSecond?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ManualProviderObservationsConfig {
|
|
83
|
+
version: 1;
|
|
84
|
+
observations: ManualProviderObservation[];
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const VALID_PROTOCOLS = new Set<ProviderProtocol>(["chat_completions", "responses", "messages"]);
|
|
89
|
+
const VALID_SCORERS = new Set<SellerRoutingScorer>(["balanced", "speed", "discount"]);
|
|
90
|
+
|
|
91
|
+
export function defaultProviderModeConfig(now = new Date().toISOString()): ProviderModeConfig {
|
|
92
|
+
return {
|
|
93
|
+
mode: "manual",
|
|
94
|
+
updatedAt: now
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeProviderModeConfig(value: unknown, now = new Date().toISOString()): ProviderModeConfig {
|
|
99
|
+
if (!isRecord(value)) {
|
|
100
|
+
return defaultProviderModeConfig(now);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
mode: readProviderMode(value.mode),
|
|
104
|
+
updatedAt: readOptionalString(value.updatedAt) ?? now
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeManualProvidersConfig(value: unknown, now = new Date().toISOString()): ManualProvidersConfig {
|
|
109
|
+
if (!isRecord(value)) {
|
|
110
|
+
return {
|
|
111
|
+
version: 1,
|
|
112
|
+
providers: [],
|
|
113
|
+
routing: defaultManualProviderRoutingConfig(),
|
|
114
|
+
updatedAt: now
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const providersValue = Array.isArray(value.providers) ? value.providers : [];
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
const providers = providersValue.map((providerValue) => {
|
|
120
|
+
const provider = normalizeManualProviderConfig(providerValue, { now });
|
|
121
|
+
if (seen.has(provider.id)) {
|
|
122
|
+
throw new Error(`manual provider id is duplicated: ${provider.id}`);
|
|
123
|
+
}
|
|
124
|
+
seen.add(provider.id);
|
|
125
|
+
return provider;
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
version: 1,
|
|
129
|
+
providers,
|
|
130
|
+
routing: normalizeManualProviderRoutingConfig(value.routing),
|
|
131
|
+
updatedAt: readOptionalString(value.updatedAt) ?? now
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function defaultManualProviderRoutingConfig(): ManualProviderRoutingConfig {
|
|
136
|
+
return {
|
|
137
|
+
policy: "fallback"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function normalizeManualProviderRoutingConfig(value: unknown): ManualProviderRoutingConfig {
|
|
142
|
+
if (!isRecord(value)) {
|
|
143
|
+
return defaultManualProviderRoutingConfig();
|
|
144
|
+
}
|
|
145
|
+
const policy = readManualProviderRoutingPolicy(value.policy);
|
|
146
|
+
const lockedProviderId = readOptionalString(value.lockedProviderId);
|
|
147
|
+
if (policy === "locked" && !lockedProviderId) {
|
|
148
|
+
throw new Error("manual provider locked routing requires lockedProviderId");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
policy,
|
|
152
|
+
lockedProviderId: policy === "locked" ? readProviderId(lockedProviderId) : undefined
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeManualProviderConfig(
|
|
157
|
+
value: unknown,
|
|
158
|
+
options: { now?: string; id?: string; existingIds?: ReadonlySet<string> } = {}
|
|
159
|
+
): ManualProviderConfig {
|
|
160
|
+
if (!isRecord(value)) {
|
|
161
|
+
throw new Error("manual provider config must be an object");
|
|
162
|
+
}
|
|
163
|
+
if ("apiKey" in value) {
|
|
164
|
+
throw new Error("manual provider config must not contain a raw apiKey; use apiKeyEnv or secretRef");
|
|
165
|
+
}
|
|
166
|
+
const now = options.now ?? new Date().toISOString();
|
|
167
|
+
const id = readProviderId(options.id ?? value.id);
|
|
168
|
+
if (options.existingIds?.has(id)) {
|
|
169
|
+
throw new Error(`manual provider id is duplicated: ${id}`);
|
|
170
|
+
}
|
|
171
|
+
const apiKeyEnv = readOptionalString(value.apiKeyEnv);
|
|
172
|
+
const secretRef = readOptionalString(value.secretRef);
|
|
173
|
+
if (!apiKeyEnv && !secretRef) {
|
|
174
|
+
throw new Error("manual provider requires apiKeyEnv or secretRef");
|
|
175
|
+
}
|
|
176
|
+
if (apiKeyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) {
|
|
177
|
+
throw new Error("manual provider apiKeyEnv must be a valid environment variable name");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
name: readRequiredString(value.name, "manual provider name"),
|
|
183
|
+
kind: readManualProviderKind(value.kind),
|
|
184
|
+
baseUrl: normalizeProviderBaseUrl(value.baseUrl),
|
|
185
|
+
apiKeyEnv,
|
|
186
|
+
secretRef,
|
|
187
|
+
models: readNonEmptyStringList(value.models, "manual provider models"),
|
|
188
|
+
supportedProtocols: readProtocols(value.supportedProtocols),
|
|
189
|
+
enabled: value.enabled === undefined ? true : value.enabled === true,
|
|
190
|
+
notes: readOptionalString(value.notes),
|
|
191
|
+
createdAt: readOptionalString(value.createdAt) ?? now,
|
|
192
|
+
updatedAt: now
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function normalizeAutoProviderConfig(value: unknown, now = new Date().toISOString()): AutoProviderConfig {
|
|
197
|
+
if (!isRecord(value)) {
|
|
198
|
+
return defaultAutoProviderConfig(now);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
enabled: value.enabled === true,
|
|
202
|
+
range: readAutoProviderRange(value.range),
|
|
203
|
+
scorer: readScorer(value.scorer),
|
|
204
|
+
modelIds: readStringList(value.modelIds),
|
|
205
|
+
sellerIds: readStringList(value.sellerIds),
|
|
206
|
+
maxConcurrentProviders: 10,
|
|
207
|
+
updatedAt: readOptionalString(value.updatedAt) ?? now
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function defaultAutoProviderConfig(now = new Date().toISOString()): AutoProviderConfig {
|
|
212
|
+
return {
|
|
213
|
+
enabled: false,
|
|
214
|
+
range: "recommended",
|
|
215
|
+
scorer: "balanced",
|
|
216
|
+
modelIds: [],
|
|
217
|
+
sellerIds: [],
|
|
218
|
+
maxConcurrentProviders: 10,
|
|
219
|
+
updatedAt: now
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function normalizeManualProviderObservationsConfig(
|
|
224
|
+
value: unknown,
|
|
225
|
+
now = new Date().toISOString()
|
|
226
|
+
): ManualProviderObservationsConfig {
|
|
227
|
+
if (!isRecord(value)) {
|
|
228
|
+
return {
|
|
229
|
+
version: 1,
|
|
230
|
+
observations: [],
|
|
231
|
+
updatedAt: now
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const observationsValue = Array.isArray(value.observations) ? value.observations : [];
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
const observations = observationsValue
|
|
237
|
+
.filter(isRecord)
|
|
238
|
+
.map((entry) => normalizeManualProviderObservation(entry, now))
|
|
239
|
+
.filter((entry) => {
|
|
240
|
+
if (seen.has(entry.providerId)) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
seen.add(entry.providerId);
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
version: 1,
|
|
248
|
+
observations,
|
|
249
|
+
updatedAt: readOptionalString(value.updatedAt) ?? now
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function publicManualProviderConfig(
|
|
254
|
+
provider: ManualProviderConfig,
|
|
255
|
+
observation?: ManualProviderObservation,
|
|
256
|
+
env: NodeJS.ProcessEnv = process.env
|
|
257
|
+
): PublicManualProviderConfig {
|
|
258
|
+
const keyRef = provider.apiKeyEnv
|
|
259
|
+
? { kind: "env" as const, name: provider.apiKeyEnv, configured: Boolean(env[provider.apiKeyEnv]) }
|
|
260
|
+
: provider.secretRef
|
|
261
|
+
? { kind: "secret" as const, name: provider.secretRef, configured: true }
|
|
262
|
+
: undefined;
|
|
263
|
+
const { apiKeyEnv: _apiKeyEnv, secretRef: _secretRef, ...publicProvider } = provider;
|
|
264
|
+
return {
|
|
265
|
+
...publicProvider,
|
|
266
|
+
keyRef,
|
|
267
|
+
current: observation?.current,
|
|
268
|
+
lastAccess: observation?.lastAccess,
|
|
269
|
+
status: observation?.status,
|
|
270
|
+
errorClass: observation?.errorClass,
|
|
271
|
+
errorMessage: observation?.errorMessage,
|
|
272
|
+
ttftMs: observation?.ttftMs,
|
|
273
|
+
avgTokensPerSecond: observation?.avgTokensPerSecond
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeManualProviderObservation(value: Record<string, unknown>, now: string): ManualProviderObservation {
|
|
278
|
+
return {
|
|
279
|
+
providerId: readRequiredString(value.providerId, "manual provider observation providerId"),
|
|
280
|
+
current: value.current === true,
|
|
281
|
+
lastAccess: readOptionalString(value.lastAccess) ?? now,
|
|
282
|
+
status: readObservationStatus(value.status),
|
|
283
|
+
errorClass: readOptionalString(value.errorClass),
|
|
284
|
+
errorMessage: readOptionalString(value.errorMessage),
|
|
285
|
+
ttftMs: readOptionalNumber(value.ttftMs),
|
|
286
|
+
avgTokensPerSecond: readOptionalNumber(value.avgTokensPerSecond)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readObservationStatus(value: unknown): ManualProviderObservation["status"] {
|
|
291
|
+
if (value === "healthy" || value === "degraded" || value === "unhealthy" || value === "unknown") {
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
return "unknown";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readProviderMode(value: unknown): ProviderMode {
|
|
298
|
+
if (value === "manual" || value === "auto") {
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
throw new Error("provider mode must be manual or auto");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readManualProviderRoutingPolicy(value: unknown): ManualProviderRoutingPolicy {
|
|
305
|
+
if (value === undefined || value === null || value === "" || value === "fallback") {
|
|
306
|
+
return "fallback";
|
|
307
|
+
}
|
|
308
|
+
if (value === "locked") {
|
|
309
|
+
return "locked";
|
|
310
|
+
}
|
|
311
|
+
throw new Error("manual provider routing policy must be fallback or locked");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readManualProviderKind(value: unknown): ManualProviderKind {
|
|
315
|
+
if (value === undefined || value === null || value === "" || value === "openai-compatible") {
|
|
316
|
+
return "openai-compatible";
|
|
317
|
+
}
|
|
318
|
+
throw new Error("manual provider kind must be openai-compatible");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readAutoProviderRange(value: unknown): AutoProviderRange {
|
|
322
|
+
if (value === undefined || value === null || value === "" || value === "recommended") {
|
|
323
|
+
return "recommended";
|
|
324
|
+
}
|
|
325
|
+
if (value === "custom") {
|
|
326
|
+
return "custom";
|
|
327
|
+
}
|
|
328
|
+
throw new Error("auto provider range must be recommended or custom");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readScorer(value: unknown): SellerRoutingScorer {
|
|
332
|
+
if (value === undefined || value === null || value === "") {
|
|
333
|
+
return "balanced";
|
|
334
|
+
}
|
|
335
|
+
if (typeof value === "string" && VALID_SCORERS.has(value as SellerRoutingScorer)) {
|
|
336
|
+
return value as SellerRoutingScorer;
|
|
337
|
+
}
|
|
338
|
+
throw new Error("auto provider scorer must be balanced, speed, or discount");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readProtocols(value: unknown): ProviderProtocol[] {
|
|
342
|
+
const protocols = readStringList(value);
|
|
343
|
+
if (protocols.length === 0) {
|
|
344
|
+
return ["chat_completions"];
|
|
345
|
+
}
|
|
346
|
+
const invalid = protocols.find((protocol) => !VALID_PROTOCOLS.has(protocol as ProviderProtocol));
|
|
347
|
+
if (invalid) {
|
|
348
|
+
throw new Error(`manual provider protocol is invalid: ${invalid}`);
|
|
349
|
+
}
|
|
350
|
+
return protocols as ProviderProtocol[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function normalizeProviderBaseUrl(value: unknown): string {
|
|
354
|
+
const raw = readRequiredString(value, "manual provider baseUrl");
|
|
355
|
+
let parsed: URL;
|
|
356
|
+
try {
|
|
357
|
+
parsed = new URL(raw);
|
|
358
|
+
} catch {
|
|
359
|
+
throw new Error("manual provider baseUrl must be a valid URL");
|
|
360
|
+
}
|
|
361
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
362
|
+
throw new Error("manual provider baseUrl must use http or https");
|
|
363
|
+
}
|
|
364
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readProviderId(value: unknown): string {
|
|
368
|
+
const id = readRequiredString(value, "manual provider id");
|
|
369
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/.test(id)) {
|
|
370
|
+
throw new Error("manual provider id must be 2-64 characters of letters, numbers, underscore, or dash");
|
|
371
|
+
}
|
|
372
|
+
return id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function readRequiredString(value: unknown, label: string): string {
|
|
376
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
377
|
+
throw new Error(`${label} is required`);
|
|
378
|
+
}
|
|
379
|
+
return value.trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readOptionalString(value: unknown): string | undefined {
|
|
383
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function readOptionalNumber(value: unknown): number | undefined {
|
|
387
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readNonEmptyStringList(value: unknown, label: string): string[] {
|
|
391
|
+
const entries = readStringList(value);
|
|
392
|
+
if (entries.length === 0) {
|
|
393
|
+
throw new Error(`${label} must contain at least one value`);
|
|
394
|
+
}
|
|
395
|
+
return entries;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function readStringList(value: unknown): string[] {
|
|
399
|
+
if (!Array.isArray(value)) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
return value
|
|
403
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
404
|
+
.map((entry) => entry.trim())
|
|
405
|
+
.filter((entry, index, all) => entry.length > 0 && all.indexOf(entry) === index);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
409
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
410
|
+
}
|