@tokenbuddy/tokenbuddy 1.0.30 → 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.
Files changed (57) hide show
  1. package/dist/src/buyer-store.d.ts +26 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +61 -8
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/daemon.d.ts +24 -0
  6. package/dist/src/daemon.d.ts.map +1 -1
  7. package/dist/src/daemon.js +1259 -9
  8. package/dist/src/daemon.js.map +1 -1
  9. package/dist/src/doctor-diagnostics.d.ts +12 -0
  10. package/dist/src/doctor-diagnostics.d.ts.map +1 -1
  11. package/dist/src/doctor-diagnostics.js +71 -2
  12. package/dist/src/doctor-diagnostics.js.map +1 -1
  13. package/dist/src/prewarm-cache.js +4 -1
  14. package/dist/src/prewarm-cache.js.map +1 -1
  15. package/dist/src/provider-routing-config.d.ts +91 -0
  16. package/dist/src/provider-routing-config.d.ts.map +1 -0
  17. package/dist/src/provider-routing-config.js +292 -0
  18. package/dist/src/provider-routing-config.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/buyer-store.ts +113 -8
  21. package/src/daemon.ts +1389 -16
  22. package/src/doctor-diagnostics.ts +100 -1
  23. package/src/prewarm-cache.ts +5 -1
  24. package/src/provider-routing-config.ts +410 -0
  25. package/static/ui/assets/index-0MVXD7bH.css +1 -0
  26. package/static/ui/assets/index-Mt3BZFuP.js +266 -0
  27. package/static/ui/assets/index-Mt3BZFuP.js.map +1 -0
  28. package/static/ui/icons/apple-touch-icon.png +0 -0
  29. package/static/ui/icons/tokenbuddy-192.png +0 -0
  30. package/static/ui/icons/tokenbuddy-512.png +0 -0
  31. package/static/ui/icons/tokenbuddy.svg +6 -0
  32. package/static/ui/index.html +3 -2
  33. package/static/ui/tool-logos/cc-switch.png +0 -0
  34. package/static/ui/tool-logos/claude.svg +1 -0
  35. package/static/ui/tool-logos/cline.svg +1 -0
  36. package/static/ui/tool-logos/codex.svg +1 -0
  37. package/static/ui/tool-logos/cursor.svg +1 -0
  38. package/static/ui/tool-logos/dirac.ico +18 -0
  39. package/static/ui/tool-logos/factory.svg +4 -0
  40. package/static/ui/tool-logos/fast-agent.svg +6 -0
  41. package/static/ui/tool-logos/glm.svg +1 -0
  42. package/static/ui/tool-logos/goose.svg +1 -0
  43. package/static/ui/tool-logos/hermes.svg +1 -0
  44. package/static/ui/tool-logos/kilocode.svg +1 -0
  45. package/static/ui/tool-logos/opencode.svg +1 -0
  46. package/static/ui/tool-logos/pi.svg +28 -0
  47. package/static/ui/tool-logos/qwen-code.png +0 -0
  48. package/tests/cli-routing.test.ts +43 -0
  49. package/tests/control-plane-ui-endpoints.test.ts +776 -0
  50. package/tests/daemon-classify.test.ts +5 -1
  51. package/tests/e2e.test.ts +5 -0
  52. package/tests/prewarm-cache.test.ts +15 -0
  53. package/tests/provider-routing-config.test.ts +150 -0
  54. package/tests/tokenbuddy.test.ts +27 -0
  55. package/static/ui/assets/index-Bzbrp7Qe.css +0 -1
  56. package/static/ui/assets/index-DEDEl8o2.js +0 -236
  57. package/static/ui/assets/index-DEDEl8o2.js.map +0 -1
package/src/daemon.ts CHANGED
@@ -63,6 +63,27 @@ import {
63
63
  ROUTING_CONFIG_KEY,
64
64
  type BuyerSellerRoutingConfig
65
65
  } from "./seller-routing-config.js";
66
+ import {
67
+ AUTO_PROVIDER_CONFIG_KEY,
68
+ MANUAL_PROVIDER_CONFIG_KEY,
69
+ MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY,
70
+ PROVIDER_MODE_CONFIG_KEY,
71
+ defaultAutoProviderConfig,
72
+ defaultProviderModeConfig,
73
+ normalizeAutoProviderConfig,
74
+ normalizeManualProviderObservationsConfig,
75
+ normalizeManualProviderConfig,
76
+ normalizeManualProvidersConfig,
77
+ normalizeProviderModeConfig,
78
+ publicManualProviderConfig,
79
+ type AutoProviderConfig,
80
+ type ManualProviderConfig,
81
+ type ManualProviderObservation,
82
+ type ManualProviderObservationsConfig,
83
+ type ManualProvidersConfig,
84
+ type ProviderMode,
85
+ type ProviderModeConfig
86
+ } from "./provider-routing-config.js";
66
87
  import {
67
88
  assertInitSetupSteps,
68
89
  buildCompletedInitSetupMarker,
@@ -86,6 +107,18 @@ const PROXY_JSON_BODY_LIMIT = "10mb";
86
107
  const SELLER_CAPACITY_BLOCK_MS = 2_000;
87
108
  const CLAWTIP_STATIC_ROUTE = "/static/clawtip";
88
109
  const CLAWTIP_RECHARGE_QR_FILE = "recharge.png";
110
+ const MANUAL_PROVIDER_SECRET_CONFIG_KEY = "manual-provider-secrets";
111
+
112
+ interface ManualProviderSecretsConfig {
113
+ version: 1;
114
+ secrets: Array<{
115
+ secretRef: string;
116
+ apiKey: string;
117
+ createdAt: string;
118
+ updatedAt: string;
119
+ }>;
120
+ updatedAt: string;
121
+ }
89
122
 
90
123
  function currentModuleDir(): string {
91
124
  if (typeof __dirname !== "undefined") {
@@ -263,6 +296,8 @@ export interface DaemonConfig {
263
296
 
264
297
  interface SellerRoute {
265
298
  seller: RegistrySeller;
299
+ transport: "tokenbuddy_seller" | "manual_provider";
300
+ manualProvider?: ManualProviderConfig;
266
301
  manifest: SellerManifest | null;
267
302
  protocol: string;
268
303
  modelId: string;
@@ -276,6 +311,7 @@ interface SellerRoute {
276
311
  interface UsageSummary {
277
312
  promptTokens: number;
278
313
  completionTokens: number;
314
+ cacheReadTokens: number;
279
315
  billedMicros: number;
280
316
  }
281
317
 
@@ -325,6 +361,19 @@ interface SellerSettlementSummary {
325
361
  reservedBalanceMicros?: number;
326
362
  spentMicros?: number;
327
363
  priceVersion?: string;
364
+ billingBreakdown?: BillingBreakdownSummary;
365
+ }
366
+
367
+ interface BillingBreakdownSummary {
368
+ inputPriceMicrosPer1m: number;
369
+ outputPriceMicrosPer1m: number;
370
+ cacheReadPriceMicrosPer1m: number;
371
+ inputCostMicros: number;
372
+ outputCostMicros: number;
373
+ cacheReadCostMicros: number;
374
+ originalUsdMicros: number;
375
+ billingMultiplier: number;
376
+ serviceTier?: string;
328
377
  }
329
378
 
330
379
  interface SellerAttemptRequestContext {
@@ -339,6 +388,12 @@ interface SellerBalanceSnapshot {
339
388
  availableMicros: number;
340
389
  }
341
390
 
391
+ interface PurchasePaymentSummary {
392
+ paymentAmount?: string;
393
+ paymentAmountMinor?: number;
394
+ paymentCurrency?: string;
395
+ }
396
+
342
397
  function numericHeaderField(value: unknown): number | undefined {
343
398
  if (typeof value === "number" && Number.isFinite(value)) {
344
399
  return value;
@@ -350,6 +405,77 @@ function numericHeaderField(value: unknown): number | undefined {
350
405
  return undefined;
351
406
  }
352
407
 
408
+ function nonNegativeIntegerField(value: unknown): number | undefined {
409
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
410
+ }
411
+
412
+ function nonNegativeFiniteField(value: unknown): number | undefined {
413
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
414
+ }
415
+
416
+ function usageRecord(value: unknown): Record<string, unknown> | undefined {
417
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
418
+ }
419
+
420
+ function safeBillingServiceTier(value: unknown): string | undefined {
421
+ if (typeof value !== "string") return undefined;
422
+ const trimmed = value.trim();
423
+ if (trimmed.length === 0 || trimmed.length > 64) return undefined;
424
+ return /^[A-Za-z0-9 _.-]+$/.test(trimmed) ? trimmed : undefined;
425
+ }
426
+
427
+ function billingBreakdownSummary(value: unknown): BillingBreakdownSummary | undefined {
428
+ const data = usageRecord(value);
429
+ if (!data) return undefined;
430
+ const inputPriceMicrosPer1m = nonNegativeIntegerField(data.inputPriceMicrosPer1m ?? data.input_price_micros_per_1m);
431
+ const outputPriceMicrosPer1m = nonNegativeIntegerField(data.outputPriceMicrosPer1m ?? data.output_price_micros_per_1m);
432
+ const cacheReadPriceMicrosPer1m = nonNegativeIntegerField(data.cacheReadPriceMicrosPer1m ?? data.cache_read_price_micros_per_1m);
433
+ const inputCostMicros = nonNegativeIntegerField(data.inputCostMicros ?? data.input_cost_micros);
434
+ const outputCostMicros = nonNegativeIntegerField(data.outputCostMicros ?? data.output_cost_micros);
435
+ const cacheReadCostMicros = nonNegativeIntegerField(data.cacheReadCostMicros ?? data.cache_read_cost_micros);
436
+ const originalUsdMicros = nonNegativeIntegerField(data.originalUsdMicros ?? data.original_usd_micros);
437
+ const billingMultiplier = nonNegativeFiniteField(data.billingMultiplier ?? data.billing_multiplier);
438
+ if (
439
+ inputPriceMicrosPer1m === undefined ||
440
+ outputPriceMicrosPer1m === undefined ||
441
+ cacheReadPriceMicrosPer1m === undefined ||
442
+ inputCostMicros === undefined ||
443
+ outputCostMicros === undefined ||
444
+ cacheReadCostMicros === undefined ||
445
+ originalUsdMicros === undefined ||
446
+ billingMultiplier === undefined
447
+ ) {
448
+ return undefined;
449
+ }
450
+ return {
451
+ inputPriceMicrosPer1m,
452
+ outputPriceMicrosPer1m,
453
+ cacheReadPriceMicrosPer1m,
454
+ inputCostMicros,
455
+ outputCostMicros,
456
+ cacheReadCostMicros,
457
+ originalUsdMicros,
458
+ billingMultiplier,
459
+ serviceTier: safeBillingServiceTier(data.serviceTier ?? data.service_tier)
460
+ };
461
+ }
462
+
463
+ function purchasePaymentSummaryFromQuote(value: unknown): PurchasePaymentSummary {
464
+ const quote = usageRecord(value);
465
+ if (!quote) return {};
466
+ const paymentAmount = typeof quote.paymentAmount === "string" && quote.paymentAmount.trim().length > 0
467
+ ? quote.paymentAmount.trim()
468
+ : undefined;
469
+ const paymentCurrency = typeof quote.paymentCurrency === "string" && quote.paymentCurrency.trim().length > 0
470
+ ? quote.paymentCurrency.trim().toUpperCase()
471
+ : undefined;
472
+ return {
473
+ paymentAmount,
474
+ paymentAmountMinor: nonNegativeIntegerField(quote.paymentAmountMinor),
475
+ paymentCurrency
476
+ };
477
+ }
478
+
353
479
  class SellerSettlementStreamExtractor {
354
480
  private pending = "";
355
481
  private settlement: SellerSettlementSummary | undefined;
@@ -422,7 +548,8 @@ function parseSellerSettlementObject(raw: string): SellerSettlementSummary | und
422
548
  ? parsed.priceVersion
423
549
  : typeof parsed.price_version === "string"
424
550
  ? parsed.price_version
425
- : undefined
551
+ : undefined,
552
+ billingBreakdown: billingBreakdownSummary(parsed.billingBreakdown ?? parsed.billing_breakdown)
426
553
  };
427
554
  } catch {
428
555
  return undefined;
@@ -483,6 +610,11 @@ function finiteNumber(value: unknown): number | undefined {
483
610
  return undefined;
484
611
  }
485
612
 
613
+ function finitePositiveNumber(value: unknown): number | undefined {
614
+ const number = finiteNumber(value);
615
+ return number !== undefined && number > 0 ? number : undefined;
616
+ }
617
+
486
618
  function readErrorCode(bodyText: string): string | undefined {
487
619
  try {
488
620
  const parsed = JSON.parse(bodyText) as { error?: { code?: unknown }; code?: unknown };
@@ -668,7 +800,7 @@ export class TokenbuddyDaemon {
668
800
  httpStatus: res.status,
669
801
  ttftMs: finiteNumber(latency?.ttftMs ?? latency?.ttft_ms),
670
802
  avgInferenceMs: finiteNumber(latency?.avgInferenceMs ?? latency?.avg_inference_ms),
671
- avgTokensPerSecond: finiteNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second),
803
+ avgTokensPerSecond: finitePositiveNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second),
672
804
  upstreamStatus: typeof upstream?.status === "string"
673
805
  ? upstream.status as "healthy" | "degraded" | "unhealthy" | "unknown"
674
806
  : undefined,
@@ -1049,6 +1181,187 @@ export class TokenbuddyDaemon {
1049
1181
  return this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir));
1050
1182
  }
1051
1183
 
1184
+ private providerPaymentState(): { paymentReady: boolean; paymentLabel?: string } {
1185
+ const payment = this.livePayments().find((entry) => entry.enabled && entry.method !== "mock");
1186
+ return {
1187
+ paymentReady: Boolean(payment),
1188
+ paymentLabel: payment ? paymentLabel(payment.method) : undefined
1189
+ };
1190
+ }
1191
+
1192
+ private currentProviderMode(): ProviderModeConfig {
1193
+ return normalizeProviderModeConfig(
1194
+ this.tokenStore.getDaemonRuntimeConfig<unknown>(PROVIDER_MODE_CONFIG_KEY)?.config
1195
+ );
1196
+ }
1197
+
1198
+ private saveProviderMode(mode: ProviderMode): ProviderModeConfig {
1199
+ const config = {
1200
+ ...defaultProviderModeConfig(),
1201
+ mode
1202
+ };
1203
+ this.tokenStore.saveDaemonRuntimeConfig(PROVIDER_MODE_CONFIG_KEY, config);
1204
+ return config;
1205
+ }
1206
+
1207
+ private currentManualProviders(): ManualProvidersConfig {
1208
+ return normalizeManualProvidersConfig(
1209
+ this.tokenStore.getDaemonRuntimeConfig<unknown>(MANUAL_PROVIDER_CONFIG_KEY)?.config
1210
+ );
1211
+ }
1212
+
1213
+ private saveManualProviders(config: ManualProvidersConfig): void {
1214
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_CONFIG_KEY, {
1215
+ ...config,
1216
+ updatedAt: new Date().toISOString()
1217
+ });
1218
+ }
1219
+
1220
+ private currentManualProviderSecrets(): ManualProviderSecretsConfig {
1221
+ const value = this.tokenStore.getDaemonRuntimeConfig<unknown>(MANUAL_PROVIDER_SECRET_CONFIG_KEY)?.config;
1222
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1223
+ return { version: 1, secrets: [], updatedAt: new Date().toISOString() };
1224
+ }
1225
+ const record = value as { secrets?: unknown[]; updatedAt?: unknown };
1226
+ const secrets = (Array.isArray(record.secrets) ? record.secrets : [])
1227
+ .filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)))
1228
+ .flatMap((entry) => {
1229
+ const secretRef = typeof entry.secretRef === "string" ? entry.secretRef.trim() : "";
1230
+ const apiKey = typeof entry.apiKey === "string" ? entry.apiKey : "";
1231
+ if (!secretRef || !apiKey) return [];
1232
+ return [{
1233
+ secretRef,
1234
+ apiKey,
1235
+ createdAt: typeof entry.createdAt === "string" ? entry.createdAt : new Date().toISOString(),
1236
+ updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString()
1237
+ }];
1238
+ });
1239
+ return {
1240
+ version: 1,
1241
+ secrets,
1242
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString()
1243
+ };
1244
+ }
1245
+
1246
+ private saveManualProviderSecret(secretRef: string, apiKey: string): void {
1247
+ const now = new Date().toISOString();
1248
+ const current = this.currentManualProviderSecrets();
1249
+ const existing = current.secrets.find((entry) => entry.secretRef === secretRef);
1250
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_SECRET_CONFIG_KEY, {
1251
+ version: 1,
1252
+ secrets: [
1253
+ ...current.secrets.filter((entry) => entry.secretRef !== secretRef),
1254
+ {
1255
+ secretRef,
1256
+ apiKey,
1257
+ createdAt: existing?.createdAt ?? now,
1258
+ updatedAt: now
1259
+ }
1260
+ ],
1261
+ updatedAt: now
1262
+ });
1263
+ }
1264
+
1265
+ private removeManualProviderSecret(secretRef: string | undefined): void {
1266
+ if (!secretRef) return;
1267
+ const current = this.currentManualProviderSecrets();
1268
+ const secrets = current.secrets.filter((entry) => entry.secretRef !== secretRef);
1269
+ if (secrets.length === current.secrets.length) return;
1270
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_SECRET_CONFIG_KEY, {
1271
+ version: 1,
1272
+ secrets,
1273
+ updatedAt: new Date().toISOString()
1274
+ });
1275
+ }
1276
+
1277
+ private currentManualProviderObservations(): ManualProviderObservationsConfig {
1278
+ return normalizeManualProviderObservationsConfig(
1279
+ this.tokenStore.getDaemonRuntimeConfig<unknown>(MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY)?.config
1280
+ );
1281
+ }
1282
+
1283
+ private recordManualProviderObservation(input: Omit<ManualProviderObservation, "lastAccess"> & { lastAccess?: string }): void {
1284
+ const now = input.lastAccess ?? new Date().toISOString();
1285
+ const current = this.currentManualProviderObservations();
1286
+ const observations = current.observations
1287
+ .filter((entry) => entry.providerId !== input.providerId)
1288
+ .map((entry) => ({
1289
+ ...entry,
1290
+ current: input.current ? false : entry.current
1291
+ }));
1292
+ observations.push({
1293
+ providerId: input.providerId,
1294
+ current: input.current,
1295
+ lastAccess: now,
1296
+ status: input.status,
1297
+ errorClass: input.errorClass,
1298
+ errorMessage: input.errorMessage,
1299
+ ttftMs: input.ttftMs,
1300
+ avgTokensPerSecond: input.avgTokensPerSecond
1301
+ });
1302
+ this.tokenStore.saveDaemonRuntimeConfig(MANUAL_PROVIDER_OBSERVATIONS_CONFIG_KEY, {
1303
+ version: 1,
1304
+ observations,
1305
+ updatedAt: now
1306
+ });
1307
+ }
1308
+
1309
+ private currentAutoProviderConfig(): AutoProviderConfig {
1310
+ return normalizeAutoProviderConfig(
1311
+ this.tokenStore.getDaemonRuntimeConfig<unknown>(AUTO_PROVIDER_CONFIG_KEY)?.config
1312
+ );
1313
+ }
1314
+
1315
+ private saveAutoProviderConfig(config: AutoProviderConfig): void {
1316
+ this.tokenStore.saveDaemonRuntimeConfig(AUTO_PROVIDER_CONFIG_KEY, {
1317
+ ...config,
1318
+ updatedAt: new Date().toISOString()
1319
+ });
1320
+ }
1321
+
1322
+ private applyAutoProviderRoutingConfig(config: AutoProviderConfig): BuyerSellerRoutingConfig {
1323
+ const routing: BuyerSellerRoutingConfig = config.range === "custom"
1324
+ ? {
1325
+ mode: "fixedSet",
1326
+ scorer: config.scorer,
1327
+ sellerIds: config.sellerIds
1328
+ }
1329
+ : {
1330
+ mode: "fullAuto",
1331
+ scorer: config.scorer
1332
+ };
1333
+ assertSellerRoutingConfig(routing);
1334
+ this.tokenStore.saveDaemonRuntimeConfig(ROUTING_CONFIG_KEY, routing);
1335
+ const current = this.refreshSellerRoutingConfig();
1336
+ if (config.modelIds.length > 0) {
1337
+ this.applyFocusSet(config.modelIds);
1338
+ }
1339
+ return current;
1340
+ }
1341
+
1342
+ private autoProviderCanRoute(config: AutoProviderConfig): boolean {
1343
+ return config.enabled && (config.range !== "custom" || config.sellerIds.length > 0);
1344
+ }
1345
+
1346
+ private providerModePayload(): Record<string, unknown> {
1347
+ const mode = this.currentProviderMode();
1348
+ const autoProvider = this.currentAutoProviderConfig();
1349
+ const payment = this.providerPaymentState();
1350
+ return {
1351
+ mode: mode.mode,
1352
+ updatedAt: mode.updatedAt,
1353
+ active: mode.mode,
1354
+ manualEnabled: mode.mode === "manual",
1355
+ autoEnabled: mode.mode === "auto" && this.autoProviderCanRoute(autoProvider) && payment.paymentReady,
1356
+ paymentReady: payment.paymentReady,
1357
+ paymentRequired: !payment.paymentReady,
1358
+ paymentLabel: payment.paymentLabel,
1359
+ locked: {
1360
+ auto: !payment.paymentReady
1361
+ }
1362
+ };
1363
+ }
1364
+
1052
1365
  private clientToolsSummary(): { clients: ClientToolStatus[]; summary: { configuredCount: number; detectedCount: number; totalCount: number; installCommand: string } } {
1053
1366
  const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
1054
1367
  const clients = [
@@ -1289,7 +1602,7 @@ export class TokenbuddyDaemon {
1289
1602
  this.sellerPool.recordRuntimeMetrics(route.seller.id, {
1290
1603
  ttftMs: finiteNumber(latency?.ttftMs ?? latency?.ttft_ms),
1291
1604
  avgInferenceMs: finiteNumber(latency?.avgInferenceMs ?? latency?.avg_inference_ms),
1292
- avgTokensPerSecond: finiteNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second)
1605
+ avgTokensPerSecond: finitePositiveNumber(latency?.avgTokensPerSecond ?? latency?.avg_tokens_per_second)
1293
1606
  });
1294
1607
  } catch (error: unknown) {
1295
1608
  logger.warn("pool.runtime_metrics.refresh_failed", "seller health refresh failed after inference", {
@@ -1393,6 +1706,91 @@ export class TokenbuddyDaemon {
1393
1706
  return payments.find((payment) => payment.isDefault)?.method || payments.find((payment) => payment.method === "mock")?.method;
1394
1707
  }
1395
1708
 
1709
+ private selectManualProviderRoutes(endpoint: string, modelId: string): { routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string } {
1710
+ const protocol = this.endpointProtocol(endpoint);
1711
+ if (!protocol) {
1712
+ throw new Error(`unsupported proxy endpoint: ${endpoint}`);
1713
+ }
1714
+ const config = this.currentManualProviders();
1715
+ let providers: ManualProviderConfig[];
1716
+ let reason: string;
1717
+ if (config.routing.policy === "locked") {
1718
+ const provider = config.providers.find((entry) => entry.id === config.routing.lockedProviderId);
1719
+ if (!provider) {
1720
+ throw new Error(`locked manual provider is not configured: ${config.routing.lockedProviderId}`);
1721
+ }
1722
+ if (!provider.enabled) {
1723
+ throw new Error(`locked manual provider is disabled: ${provider.id}`);
1724
+ }
1725
+ if (!provider.supportedProtocols.includes(protocol as ManualProviderConfig["supportedProtocols"][number])) {
1726
+ throw new Error(`locked manual provider ${provider.id} does not support ${endpoint}`);
1727
+ }
1728
+ if (!provider.models.includes(modelId)) {
1729
+ throw new Error(`locked manual provider ${provider.id} does not support model ${modelId}`);
1730
+ }
1731
+ providers = [provider];
1732
+ reason = `manual:locked:${provider.id}`;
1733
+ } else {
1734
+ providers = config.providers.filter((provider) => (
1735
+ provider.enabled &&
1736
+ provider.models.includes(modelId) &&
1737
+ provider.supportedProtocols.includes(protocol as ManualProviderConfig["supportedProtocols"][number])
1738
+ ));
1739
+ reason = `manual:fallback:routes_${providers.length}`;
1740
+ }
1741
+ if (providers.length === 0) {
1742
+ throw new Error(`no compatible manual provider for ${endpoint} model ${modelId}`);
1743
+ }
1744
+ const routes: SellerRoute[] = providers.map((provider) => ({
1745
+ seller: {
1746
+ id: provider.id,
1747
+ name: provider.name,
1748
+ url: provider.baseUrl,
1749
+ status: "active",
1750
+ supportedProtocols: provider.supportedProtocols,
1751
+ paymentMethods: ["provider_key"],
1752
+ models: provider.models
1753
+ },
1754
+ transport: "manual_provider",
1755
+ manualProvider: provider,
1756
+ manifest: null,
1757
+ protocol,
1758
+ modelId,
1759
+ paymentMethod: "provider_key",
1760
+ planSource: "registry_fallback",
1761
+ planReason: reason,
1762
+ planSellerCount: providers.length
1763
+ }));
1764
+ return {
1765
+ routes,
1766
+ paymentMethod: "provider_key",
1767
+ plan: {
1768
+ routes: [],
1769
+ source: "registry_fallback",
1770
+ sourceReason: "manual_provider_config",
1771
+ reason,
1772
+ mode: "fixedSet",
1773
+ scorer: "balanced",
1774
+ candidateCount: providers.length,
1775
+ diagnostics: {
1776
+ registryVisibleCount: providers.length,
1777
+ prewarmCandidateCount: 0,
1778
+ prewarmUsableCount: 0,
1779
+ prewarmMissingSellerIds: [],
1780
+ prewarmBlockedSellerIds: [],
1781
+ prewarmIncompatibleSellerIds: [],
1782
+ sourceCandidateCount: providers.length,
1783
+ blockedOpenCircuitCount: 0,
1784
+ blockedCapacityCount: 0,
1785
+ blockedLocalConcurrencyCount: 0,
1786
+ blockedSellerIds: [],
1787
+ incompatibleCount: 0,
1788
+ incompatibleSellerIds: []
1789
+ }
1790
+ }
1791
+ };
1792
+ }
1793
+
1396
1794
  private async selectSellerRoutes(endpoint: string, modelId: string, requestId?: string): Promise<{ routes: SellerRoute[]; plan: SellerRoutePlan; paymentMethod: string }> {
1397
1795
  const protocol = this.endpointProtocol(endpoint);
1398
1796
  if (!protocol) {
@@ -1458,6 +1856,7 @@ export class TokenbuddyDaemon {
1458
1856
 
1459
1857
  const routes: SellerRoute[] = planned.routes.map((route) => ({
1460
1858
  seller: route.seller,
1859
+ transport: "tokenbuddy_seller",
1461
1860
  manifest: null,
1462
1861
  protocol,
1463
1862
  modelId,
@@ -1713,6 +2112,9 @@ export class TokenbuddyDaemon {
1713
2112
  status: string;
1714
2113
  creditMicros: number;
1715
2114
  currency: string;
2115
+ paymentAmount?: string;
2116
+ paymentAmountMinor?: number;
2117
+ paymentCurrency?: string;
1716
2118
  durationMs: number;
1717
2119
  }): void {
1718
2120
  logger.info("purchase.ledger.recorded", "safe purchase ledger recorded", {
@@ -1724,6 +2126,9 @@ export class TokenbuddyDaemon {
1724
2126
  ledgerStatus: input.status,
1725
2127
  creditMicros: input.creditMicros,
1726
2128
  currency: input.currency,
2129
+ paymentAmount: input.paymentAmount,
2130
+ paymentAmountMinor: input.paymentAmountMinor,
2131
+ paymentCurrency: input.paymentCurrency,
1727
2132
  durationMs: input.durationMs
1728
2133
  });
1729
2134
  }
@@ -1752,6 +2157,32 @@ export class TokenbuddyDaemon {
1752
2157
  models: ModelCatalogEntry[];
1753
2158
  sellers: SellerCatalogEntry[];
1754
2159
  }> {
2160
+ const manualConfig = this.currentManualProviders();
2161
+ if (this.currentProviderMode().mode === "manual" && manualConfig.providers.some((provider) => provider.enabled)) {
2162
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
2163
+ const providers = manualConfig.providers.filter((provider) => provider.enabled);
2164
+ return {
2165
+ models: providers.flatMap((provider) => provider.models.map((modelId) => ({
2166
+ id: modelId,
2167
+ sellerId: provider.id,
2168
+ sellerName: provider.name,
2169
+ sellerUrl: provider.baseUrl,
2170
+ supportedProtocols: provider.supportedProtocols,
2171
+ paymentMethods: ["provider_key"]
2172
+ }))),
2173
+ sellers: providers.map((provider) => {
2174
+ const observation = observations.get(provider.id);
2175
+ return {
2176
+ id: provider.id,
2177
+ name: provider.name,
2178
+ url: provider.baseUrl,
2179
+ status: observation?.status ?? "active",
2180
+ ttftMs: observation?.ttftMs,
2181
+ avgTokensPerSecond: observation?.avgTokensPerSecond
2182
+ };
2183
+ })
2184
+ };
2185
+ }
1755
2186
  try {
1756
2187
  const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
1757
2188
  this.lastRegistrySnapshot = catalog.registry ?? sellerCatalogResultToRegistrySnapshot(catalog);
@@ -1786,7 +2217,7 @@ export class TokenbuddyDaemon {
1786
2217
  return {
1787
2218
  ...seller,
1788
2219
  ttftMs: runtime?.ttftMs ?? seller.ttftMs,
1789
- avgTokensPerSecond: runtime?.avgTokensPerSecond ?? seller.avgTokensPerSecond ?? 0
2220
+ avgTokensPerSecond: runtime?.avgTokensPerSecond ?? seller.avgTokensPerSecond
1790
2221
  };
1791
2222
  });
1792
2223
  }
@@ -1824,25 +2255,28 @@ export class TokenbuddyDaemon {
1824
2255
  const fallback: UsageSummary = {
1825
2256
  promptTokens: 0,
1826
2257
  completionTokens: 0,
2258
+ cacheReadTokens: 0,
1827
2259
  billedMicros: 0
1828
2260
  };
1829
2261
  if (!bodyText.trim()) {
1830
2262
  return fallback;
1831
2263
  }
1832
2264
  try {
1833
- const data = JSON.parse(bodyText) as {
1834
- usage?: {
1835
- prompt_tokens?: number;
1836
- completion_tokens?: number;
1837
- input_tokens?: number;
1838
- output_tokens?: number;
1839
- };
1840
- };
1841
- const promptTokens = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
1842
- const completionTokens = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
2265
+ const data = usageRecord(JSON.parse(bodyText));
2266
+ const usage = usageRecord(data?.usage) ?? usageRecord(usageRecord(data?.response)?.usage);
2267
+ const promptDetails = usageRecord(usage?.prompt_tokens_details);
2268
+ const inputDetails = usageRecord(usage?.input_tokens_details);
2269
+ const promptTokens = nonNegativeIntegerField(usage?.prompt_tokens) ?? nonNegativeIntegerField(usage?.input_tokens) ?? 0;
2270
+ const completionTokens = nonNegativeIntegerField(usage?.completion_tokens) ?? nonNegativeIntegerField(usage?.output_tokens) ?? 0;
2271
+ const cacheReadTokens = nonNegativeIntegerField(promptDetails?.cached_tokens)
2272
+ ?? nonNegativeIntegerField(inputDetails?.cached_tokens)
2273
+ ?? nonNegativeIntegerField(usage?.cache_read_input_tokens)
2274
+ ?? nonNegativeIntegerField(usage?.cache_read_tokens)
2275
+ ?? 0;
1843
2276
  return {
1844
2277
  promptTokens,
1845
2278
  completionTokens,
2279
+ cacheReadTokens,
1846
2280
  billedMicros: (promptTokens + completionTokens) * 4
1847
2281
  };
1848
2282
  } catch {
@@ -1891,6 +2325,7 @@ export class TokenbuddyDaemon {
1891
2325
  const sellerRequestId = settlement?.requestId && settlement.requestId !== requestId
1892
2326
  ? settlement.requestId
1893
2327
  : undefined;
2328
+ const billingBreakdown = settlement?.billingBreakdown;
1894
2329
  this.tokenStore.recordInferenceLedger({
1895
2330
  requestId,
1896
2331
  sellerKey: route.seller.id,
@@ -1899,11 +2334,21 @@ export class TokenbuddyDaemon {
1899
2334
  status: settlement ? "settled" : "estimated",
1900
2335
  promptTokens: usage.promptTokens,
1901
2336
  completionTokens: usage.completionTokens,
2337
+ cacheReadTokens: usage.cacheReadTokens,
1902
2338
  billedMicros: settledMicros ?? usage.billedMicros,
1903
2339
  estimatedMicros: usage.billedMicros,
1904
2340
  settledMicros,
1905
2341
  settledUsdMicros: settlement?.settledUsdMicros,
1906
2342
  priceVersion: settlement?.priceVersion,
2343
+ inputPriceMicrosPer1m: billingBreakdown?.inputPriceMicrosPer1m,
2344
+ outputPriceMicrosPer1m: billingBreakdown?.outputPriceMicrosPer1m,
2345
+ cacheReadPriceMicrosPer1m: billingBreakdown?.cacheReadPriceMicrosPer1m,
2346
+ inputCostMicros: billingBreakdown?.inputCostMicros,
2347
+ outputCostMicros: billingBreakdown?.outputCostMicros,
2348
+ cacheReadCostMicros: billingBreakdown?.cacheReadCostMicros,
2349
+ originalUsdMicros: billingBreakdown?.originalUsdMicros,
2350
+ billingMultiplier: billingBreakdown?.billingMultiplier,
2351
+ serviceTier: billingBreakdown?.serviceTier,
1907
2352
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
1908
2353
  balanceSource: settlement ? "seller_authoritative" : "estimated",
1909
2354
  prompt,
@@ -1928,6 +2373,7 @@ export class TokenbuddyDaemon {
1928
2373
  billedMicros: settledMicros ?? usage.billedMicros,
1929
2374
  promptTokens: usage.promptTokens,
1930
2375
  completionTokens: usage.completionTokens,
2376
+ cacheReadTokens: usage.cacheReadTokens,
1931
2377
  balanceSnapshotMicros: settlement?.remainingCreditMicros,
1932
2378
  balanceSource: settlement ? "seller_authoritative" : "estimated",
1933
2379
  sellerRequestId,
@@ -2240,6 +2686,7 @@ export class TokenbuddyDaemon {
2240
2686
  const currency = completeData.currency || createData.currency || "USD";
2241
2687
  const expiresAt = new Date(Date.now() + 86400 * 1000).toISOString();
2242
2688
  const ledgerStatus = completeData.status || "funded";
2689
+ const paymentSummary = purchasePaymentSummaryFromQuote(completeData.quote ?? createData.quote);
2243
2690
 
2244
2691
  this.tokenStore.saveToken(sellerKey, token, tokenClass, creditMicros, expiresAt);
2245
2692
  this.tokenStore.recordPurchaseLedger({
@@ -2250,6 +2697,7 @@ export class TokenbuddyDaemon {
2250
2697
  status: ledgerStatus,
2251
2698
  creditMicros,
2252
2699
  currency,
2700
+ ...paymentSummary,
2253
2701
  paymentReference: completeData.paymentReference || completeData.payment_reference,
2254
2702
  completedAt: new Date().toISOString()
2255
2703
  });
@@ -2262,6 +2710,7 @@ export class TokenbuddyDaemon {
2262
2710
  status: ledgerStatus,
2263
2711
  creditMicros,
2264
2712
  currency,
2713
+ ...paymentSummary,
2265
2714
  durationMs: Date.now() - startedAt
2266
2715
  });
2267
2716
  // v1.1: feed the credit tracker so the route-failover controller
@@ -2276,6 +2725,7 @@ export class TokenbuddyDaemon {
2276
2725
  tokenClass,
2277
2726
  creditMicros,
2278
2727
  currency,
2728
+ ...paymentSummary,
2279
2729
  ledgerStatus,
2280
2730
  completeStatus: completeRes.status,
2281
2731
  durationMs: Date.now() - startedAt
@@ -2436,6 +2886,366 @@ export class TokenbuddyDaemon {
2436
2886
  });
2437
2887
  }
2438
2888
 
2889
+ private manualProviderApiKey(provider: ManualProviderConfig): string {
2890
+ if (provider.apiKeyEnv) {
2891
+ const value = process.env[provider.apiKeyEnv];
2892
+ if (!value) {
2893
+ throw new Error(`manual provider key env is not configured: ${provider.apiKeyEnv}`);
2894
+ }
2895
+ return value;
2896
+ }
2897
+ if (provider.secretRef) {
2898
+ const secret = this.currentManualProviderSecrets().secrets.find((entry) => entry.secretRef === provider.secretRef);
2899
+ if (!secret) {
2900
+ throw new Error(`manual provider secret is not configured: ${provider.secretRef}`);
2901
+ }
2902
+ return secret.apiKey;
2903
+ }
2904
+ throw new Error(`manual provider key reference is not configured: ${provider.id}`);
2905
+ }
2906
+
2907
+ private manualProviderEndpointUrl(provider: ManualProviderConfig, endpoint: string): string {
2908
+ const baseUrl = provider.baseUrl.replace(/\/+$/, "");
2909
+ if (baseUrl.endsWith("/v1") && endpoint.startsWith("/v1/")) {
2910
+ return `${baseUrl}${endpoint.slice(3)}`;
2911
+ }
2912
+ return `${baseUrl}${endpoint}`;
2913
+ }
2914
+
2915
+ private manualProviderEndpointUrlFromBase(baseUrl: string, endpoint: string): string {
2916
+ const normalized = baseUrl.replace(/\/+$/, "");
2917
+ if (normalized.endsWith("/v1") && endpoint.startsWith("/v1/")) {
2918
+ return `${normalized}${endpoint.slice(3)}`;
2919
+ }
2920
+ return `${normalized}${endpoint}`;
2921
+ }
2922
+
2923
+ private async probeManualProviderModels(input: {
2924
+ baseUrl: string;
2925
+ apiKey: string;
2926
+ }): Promise<{ modelIds: string[]; elapsedMs: number }> {
2927
+ let parsed: URL;
2928
+ try {
2929
+ parsed = new URL(input.baseUrl);
2930
+ } catch {
2931
+ throw new Error("manual provider baseUrl must be a valid URL");
2932
+ }
2933
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2934
+ throw new Error("manual provider baseUrl must use http or https");
2935
+ }
2936
+ if (input.apiKey.trim().length < 6) {
2937
+ throw new Error("manual provider api key is too short");
2938
+ }
2939
+
2940
+ const startedAt = Date.now();
2941
+ const ac = new AbortController();
2942
+ const timer = setTimeout(() => ac.abort(new Error("manual provider model probe timeout")), this.config.warmupProbeTimeoutMs ?? 3000);
2943
+ try {
2944
+ const response = await fetch(this.manualProviderEndpointUrlFromBase(parsed.toString(), "/v1/models"), {
2945
+ method: "GET",
2946
+ headers: {
2947
+ "Authorization": `Bearer ${input.apiKey.trim()}`
2948
+ },
2949
+ signal: ac.signal
2950
+ });
2951
+ if (!response.ok) {
2952
+ if (response.status === 401 || response.status === 403) {
2953
+ throw new Error("manual provider authentication failed");
2954
+ }
2955
+ if (response.status === 404) {
2956
+ throw new Error("manual provider /v1/models endpoint was not found");
2957
+ }
2958
+ throw new Error(`manual provider model probe returned HTTP ${response.status}`);
2959
+ }
2960
+ const body = await response.json().catch(() => undefined) as unknown;
2961
+ const modelIds = parseOpenAiModelIds(body);
2962
+ if (modelIds.length === 0) {
2963
+ throw new Error("manual provider model list is empty");
2964
+ }
2965
+ return {
2966
+ modelIds,
2967
+ elapsedMs: Date.now() - startedAt
2968
+ };
2969
+ } finally {
2970
+ clearTimeout(timer);
2971
+ }
2972
+ }
2973
+
2974
+ private manualProviderErrorClass(status: number): string {
2975
+ if (status === 401 || status === 403) {
2976
+ return "auth_failed";
2977
+ }
2978
+ if (status === 429) {
2979
+ return "rate_limited";
2980
+ }
2981
+ if (status >= 500) {
2982
+ return "upstream_5xx";
2983
+ }
2984
+ if (status === 404) {
2985
+ return "model_or_endpoint_not_found";
2986
+ }
2987
+ return `http_${status}`;
2988
+ }
2989
+
2990
+ private shouldFailoverManualProvider(status: number): boolean {
2991
+ return status === 408 || status === 429 || status >= 500;
2992
+ }
2993
+
2994
+ private async fetchManualProviderRoute(
2995
+ route: SellerRoute,
2996
+ endpoint: string,
2997
+ requestId: string,
2998
+ idempotencyKey: string,
2999
+ routeIndex: number,
3000
+ attempt: number,
3001
+ body: Record<string, unknown>
3002
+ ): Promise<globalThis.Response> {
3003
+ const provider = route.manualProvider;
3004
+ if (!provider) {
3005
+ throw new Error("manual provider route missing provider config");
3006
+ }
3007
+ const deadlineMs = this.requestDeadlineMs();
3008
+ const requestAc = new AbortController();
3009
+ const requestTimer = setTimeout(() => requestAc.abort(new Error("buyer deadline exceeded")), deadlineMs);
3010
+ const attemptContext = sellerAttemptRequestContext(
3011
+ requestId,
3012
+ idempotencyKey,
3013
+ routeIndex,
3014
+ attempt,
3015
+ 0
3016
+ );
3017
+ try {
3018
+ return await fetch(this.manualProviderEndpointUrl(provider, endpoint), {
3019
+ method: "POST",
3020
+ headers: {
3021
+ "Content-Type": "application/json",
3022
+ "Authorization": `Bearer ${this.manualProviderApiKey(provider)}`,
3023
+ "X-Request-Id": attemptContext.requestId,
3024
+ "Idempotency-Key": attemptContext.idempotencyKey,
3025
+ "X-TokenBuddy-Deadline-Ms": String(deadlineMs)
3026
+ },
3027
+ body: JSON.stringify({
3028
+ ...body,
3029
+ requestId: attemptContext.requestId
3030
+ }),
3031
+ signal: requestAc.signal
3032
+ });
3033
+ } finally {
3034
+ clearTimeout(requestTimer);
3035
+ }
3036
+ }
3037
+
3038
+ private async forwardManualProviderRoute(input: {
3039
+ route: SellerRoute;
3040
+ endpoint: string;
3041
+ reqBody: Record<string, unknown>;
3042
+ res: Response;
3043
+ requestId: string;
3044
+ idempotencyKey: string;
3045
+ modelId: string;
3046
+ requestedModelId?: string;
3047
+ routeIndex: number;
3048
+ routes: SellerRoute[];
3049
+ plan: SellerRoutePlan;
3050
+ paymentMethod: string;
3051
+ startedAt: number;
3052
+ markFirstByte: () => void;
3053
+ }): Promise<boolean> {
3054
+ const { route, endpoint, reqBody, res, requestId, idempotencyKey, modelId, requestedModelId, routeIndex, routes, plan, paymentMethod, startedAt, markFirstByte } = input;
3055
+ const provider = route.manualProvider;
3056
+ if (!provider) {
3057
+ throw new Error("manual provider route missing provider config");
3058
+ }
3059
+ const sellerKey = route.seller.id;
3060
+ const attemptStartedAt = Date.now();
3061
+ const upstreamBody = this.applyResolvedModelToBody(endpoint, {
3062
+ ...reqBody,
3063
+ requestId
3064
+ }, modelId);
3065
+
3066
+ logger.info("manual_provider.request.started", "manual provider request started", {
3067
+ requestId,
3068
+ providerId: provider.id,
3069
+ sellerKey,
3070
+ model: modelId,
3071
+ requestedModel: requestedModelId,
3072
+ endpoint,
3073
+ stream: Boolean(reqBody.stream),
3074
+ routeIndex,
3075
+ backup: routeIndex > 0,
3076
+ routePlanReason: route.planReason,
3077
+ bodySummary: summarizeProxyBody(upstreamBody)
3078
+ });
3079
+
3080
+ let response: globalThis.Response;
3081
+ try {
3082
+ response = await this.fetchManualProviderRoute(route, endpoint, requestId, idempotencyKey, routeIndex, 0, upstreamBody);
3083
+ } catch (error: unknown) {
3084
+ const errorMessage = error instanceof Error ? error.message : String(error);
3085
+ this.recordManualProviderObservation({
3086
+ providerId: provider.id,
3087
+ current: false,
3088
+ status: "unhealthy",
3089
+ errorClass: "deadline",
3090
+ errorMessage
3091
+ });
3092
+ logger.warn("manual_provider.request.failed", "manual provider request failed before response", {
3093
+ requestId,
3094
+ providerId: provider.id,
3095
+ model: modelId,
3096
+ endpoint,
3097
+ errorMessage,
3098
+ durationMs: Date.now() - attemptStartedAt
3099
+ });
3100
+ if (routeIndex + 1 < routes.length) {
3101
+ return false;
3102
+ }
3103
+ throw error;
3104
+ }
3105
+
3106
+ if (!response.ok) {
3107
+ const errorBody = await response.text();
3108
+ const errorClass = this.manualProviderErrorClass(response.status);
3109
+ const canFailover = this.shouldFailoverManualProvider(response.status) && routeIndex + 1 < routes.length;
3110
+ this.recordManualProviderObservation({
3111
+ providerId: provider.id,
3112
+ current: false,
3113
+ status: this.shouldFailoverManualProvider(response.status) ? "degraded" : "unhealthy",
3114
+ errorClass,
3115
+ errorMessage: errorBody.slice(0, 512)
3116
+ });
3117
+ logger.warn("manual_provider.upstream_fetch.failed", "manual provider upstream fetch returned non-ok status", {
3118
+ requestId,
3119
+ providerId: provider.id,
3120
+ model: modelId,
3121
+ endpoint,
3122
+ status: response.status,
3123
+ errorClass,
3124
+ failover: canFailover,
3125
+ durationMs: Date.now() - attemptStartedAt
3126
+ });
3127
+ if (canFailover) {
3128
+ return false;
3129
+ }
3130
+ this.copyUpstreamHeaders(response, res);
3131
+ res.status(response.status);
3132
+ res.send(errorBody);
3133
+ return true;
3134
+ }
3135
+
3136
+ this.copyUpstreamHeaders(response, res);
3137
+ res.status(response.status);
3138
+ logger.info("manual_provider.upstream_fetch.succeeded", "manual provider upstream fetch succeeded", {
3139
+ requestId,
3140
+ providerId: provider.id,
3141
+ model: modelId,
3142
+ endpoint,
3143
+ status: response.status,
3144
+ stream: Boolean(reqBody.stream)
3145
+ });
3146
+
3147
+ const contentType = response.headers.get("content-type") || "";
3148
+ if (contentType.includes("text/event-stream") || Boolean(reqBody.stream)) {
3149
+ const reader = response.body?.getReader();
3150
+ if (!reader) {
3151
+ res.end();
3152
+ return true;
3153
+ }
3154
+ let bytes = 0;
3155
+ const decoder = new TextDecoder();
3156
+ while (true) {
3157
+ const { done, value } = await reader.read();
3158
+ if (done) {
3159
+ break;
3160
+ }
3161
+ bytes += value.byteLength;
3162
+ const chunk = decoder.decode(value, { stream: true });
3163
+ if (chunk.length > 0) {
3164
+ markFirstByte();
3165
+ res.write(chunk);
3166
+ }
3167
+ }
3168
+ const decoderTail = decoder.decode();
3169
+ if (decoderTail.length > 0) {
3170
+ markFirstByte();
3171
+ res.write(decoderTail);
3172
+ }
3173
+ res.end();
3174
+ const durationMs = Date.now() - startedAt;
3175
+ const ttftMs = durationMs > 0 ? Date.now() - attemptStartedAt : undefined;
3176
+ this.recordManualProviderObservation({
3177
+ providerId: provider.id,
3178
+ current: true,
3179
+ status: "healthy",
3180
+ ttftMs,
3181
+ avgTokensPerSecond: undefined
3182
+ });
3183
+ this.tokenStore.recordInferenceLedger({
3184
+ requestId,
3185
+ sellerKey,
3186
+ modelId,
3187
+ endpoint,
3188
+ status: "ok",
3189
+ promptTokens: 0,
3190
+ completionTokens: 0,
3191
+ cacheReadTokens: 0,
3192
+ billedMicros: Math.max(1, bytes),
3193
+ estimatedMicros: Math.max(1, bytes),
3194
+ priceVersion: `local-provider:${provider.id}`,
3195
+ balanceSource: "self_funded_provider",
3196
+ prompt: this.inferPromptForHash(reqBody),
3197
+ ttftMs,
3198
+ fallbackCount: routeIndex,
3199
+ routeReason: plan.reason,
3200
+ falloverChain: routes.slice(0, routeIndex + 1).map((entry) => entry.seller.id),
3201
+ upstreamStatus: "healthy",
3202
+ durationMs,
3203
+ paymentMethod
3204
+ });
3205
+ return true;
3206
+ }
3207
+
3208
+ const responseBody = await response.text();
3209
+ markFirstByte();
3210
+ res.send(responseBody);
3211
+ const usage = this.readUsage(responseBody);
3212
+ const durationMs = Date.now() - startedAt;
3213
+ const ttftMs = Date.now() - attemptStartedAt;
3214
+ const completionTokens = usage.completionTokens;
3215
+ const avgTokensPerSecond = durationMs > 0 && completionTokens > 0 ? completionTokens / (durationMs / 1000) : undefined;
3216
+ this.recordManualProviderObservation({
3217
+ providerId: provider.id,
3218
+ current: true,
3219
+ status: "healthy",
3220
+ ttftMs,
3221
+ avgTokensPerSecond
3222
+ });
3223
+ this.tokenStore.recordInferenceLedger({
3224
+ requestId,
3225
+ sellerKey,
3226
+ modelId,
3227
+ endpoint,
3228
+ status: "ok",
3229
+ promptTokens: usage.promptTokens,
3230
+ completionTokens: usage.completionTokens,
3231
+ cacheReadTokens: usage.cacheReadTokens,
3232
+ billedMicros: usage.billedMicros,
3233
+ estimatedMicros: usage.billedMicros,
3234
+ priceVersion: `local-provider:${provider.id}`,
3235
+ balanceSource: "self_funded_provider",
3236
+ prompt: this.inferPromptForHash(reqBody),
3237
+ response: responseBody,
3238
+ ttftMs,
3239
+ fallbackCount: routeIndex,
3240
+ routeReason: plan.reason,
3241
+ falloverChain: routes.slice(0, routeIndex + 1).map((entry) => entry.seller.id),
3242
+ upstreamStatus: "healthy",
3243
+ durationMs,
3244
+ paymentMethod
3245
+ });
3246
+ return true;
3247
+ }
3248
+
2439
3249
  private async forwardProxyRequest(endpoint: string, req: Request, res: Response): Promise<void> {
2440
3250
  const startedAt = Date.now();
2441
3251
  let firstByteAt: number | null = null;
@@ -2454,7 +3264,10 @@ export class TokenbuddyDaemon {
2454
3264
  }
2455
3265
 
2456
3266
  try {
2457
- const { routes, plan, paymentMethod } = await this.selectSellerRoutes(endpoint, modelId, requestId);
3267
+ const routeSelection = this.currentProviderMode().mode === "manual"
3268
+ ? this.selectManualProviderRoutes(endpoint, modelId)
3269
+ : await this.selectSellerRoutes(endpoint, modelId, requestId);
3270
+ const { routes, plan, paymentMethod } = routeSelection;
2458
3271
  const upstreamStatusFromHeaders = (h: Headers): string | undefined => {
2459
3272
  const raw = h.get("x-tokenbuddy-upstream-status") || h.get("x-upstream-status");
2460
3273
  if (!raw) return undefined;
@@ -2465,6 +3278,39 @@ export class TokenbuddyDaemon {
2465
3278
  for (let routeIndex = 0; routeIndex < routes.length; routeIndex += 1) {
2466
3279
  const route = routes[routeIndex];
2467
3280
  const sellerKey = route.seller.id;
3281
+ if (route.transport === "manual_provider") {
3282
+ const completed = await this.forwardManualProviderRoute({
3283
+ route,
3284
+ endpoint,
3285
+ reqBody: body as Record<string, unknown>,
3286
+ res,
3287
+ requestId,
3288
+ idempotencyKey,
3289
+ modelId,
3290
+ requestedModelId,
3291
+ routeIndex,
3292
+ routes,
3293
+ plan,
3294
+ paymentMethod,
3295
+ startedAt,
3296
+ markFirstByte
3297
+ });
3298
+ if (completed) {
3299
+ return;
3300
+ }
3301
+ lastError = new Error(`manual provider ${sellerKey} failed over`);
3302
+ logger.warn("manual_provider.route.failover", "manual provider route failed over", {
3303
+ requestId,
3304
+ providerId: sellerKey,
3305
+ model: modelId,
3306
+ endpoint,
3307
+ routeIndex,
3308
+ nextRouteIndex: routeIndex + 1,
3309
+ routesRemaining: routes.length - routeIndex,
3310
+ routesRemainingAfterCurrent: Math.max(0, routes.length - routeIndex - 1)
3311
+ });
3312
+ continue;
3313
+ }
2468
3314
  const lease = this.sellerConcurrencyLimiter.tryAcquire(sellerKey, { requestId, modelId, endpoint });
2469
3315
  if (!lease) {
2470
3316
  const routesRemaining = routes.length - routeIndex;
@@ -2777,7 +3623,7 @@ export class TokenbuddyDaemon {
2777
3623
  route,
2778
3624
  endpoint,
2779
3625
  requestId,
2780
- { promptTokens: 0, completionTokens: 0, billedMicros: Math.max(1, bytes) },
3626
+ { promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, billedMicros: Math.max(1, bytes) },
2781
3627
  this.parseSellerSettlementSummary(upstreamResponse.headers) ?? settlementTrailing.settlement ?? settlementExtractor.current(),
2782
3628
  this.inferPromptForHash(body),
2783
3629
  undefined,
@@ -2925,6 +3771,56 @@ export class TokenbuddyDaemon {
2925
3771
  });
2926
3772
  });
2927
3773
 
3774
+ controlApp.put("/payments/default", (req, res) => {
3775
+ try {
3776
+ const method = typeof req.body?.method === "string" ? req.body.method.trim() : "";
3777
+ const normalizedMethod = normalizePaymentMethodName(method);
3778
+ const payments = this.livePayments();
3779
+ const target = payments.find((payment) => normalizePaymentMethodName(payment.method) === normalizedMethod);
3780
+ if (!normalizedMethod || normalizedMethod === "mock" || !target) {
3781
+ res.status(400).json({
3782
+ error: {
3783
+ code: "payment_default_invalid",
3784
+ message: "payment method is not configured"
3785
+ }
3786
+ });
3787
+ return;
3788
+ }
3789
+ if (!target.enabled && !readConfigBoolean(target.config, "walletConfigPresent")) {
3790
+ res.status(400).json({
3791
+ error: {
3792
+ code: "payment_default_not_ready",
3793
+ message: "payment method must be bound before it can be selected"
3794
+ }
3795
+ });
3796
+ return;
3797
+ }
3798
+ for (const payment of payments) {
3799
+ this.tokenStore.savePayment({
3800
+ method: payment.method,
3801
+ enabled: payment.enabled,
3802
+ isDefault: normalizePaymentMethodName(payment.method) === normalizedMethod,
3803
+ config: payment.config
3804
+ });
3805
+ }
3806
+ logger.info("control.payment.default_selected", "default payment selected", {
3807
+ method: target.method
3808
+ });
3809
+ res.status(200).json({
3810
+ payments: this.livePayments()
3811
+ });
3812
+ } catch (error: unknown) {
3813
+ const errorMessage = error instanceof Error ? error.message : String(error);
3814
+ logger.warn("control.payment.default_select_failed", "default payment select failed", { errorMessage });
3815
+ res.status(400).json({
3816
+ error: {
3817
+ code: "payment_default_select_failed",
3818
+ message: errorMessage
3819
+ }
3820
+ });
3821
+ }
3822
+ });
3823
+
2928
3824
  controlApp.get("/init/state", (req, res) => {
2929
3825
  try {
2930
3826
  const state = this.initStateSnapshot();
@@ -3315,6 +4211,453 @@ export class TokenbuddyDaemon {
3315
4211
  // 所有端点都热生效,不需重启 daemon。改完 store 即下次推理生效。
3316
4212
  // ─────────────────────────────────────────────────────────────────
3317
4213
 
4214
+ controlApp.get("/routing/provider-mode", (req, res) => {
4215
+ try {
4216
+ res.status(200).json(this.providerModePayload());
4217
+ } catch (error: unknown) {
4218
+ const errorMessage = error instanceof Error ? error.message : String(error);
4219
+ logger.warn("routing.provider_mode.read_failed", "provider mode read failed", { errorMessage });
4220
+ res.status(500).json({ error: { code: "provider_mode_read_failed", message: errorMessage } });
4221
+ }
4222
+ });
4223
+
4224
+ controlApp.put("/routing/provider-mode", (req, res) => {
4225
+ try {
4226
+ const body = (req.body ?? {}) as Record<string, unknown>;
4227
+ const requested = normalizeProviderModeConfig({
4228
+ mode: body.mode,
4229
+ updatedAt: new Date().toISOString()
4230
+ });
4231
+ const payment = this.providerPaymentState();
4232
+ if (requested.mode === "auto" && !payment.paymentReady) {
4233
+ res.status(409).json({
4234
+ error: {
4235
+ code: "payment_required",
4236
+ message: "bind a payment method to enable auto provider"
4237
+ },
4238
+ paymentReady: false,
4239
+ paymentRequired: true,
4240
+ bindTarget: "/overview?bind=clawtip"
4241
+ });
4242
+ return;
4243
+ }
4244
+ const saved = this.saveProviderMode(requested.mode);
4245
+ let strategy: BuyerSellerRoutingConfig | undefined;
4246
+ if (saved.mode === "auto") {
4247
+ const nextAuto = {
4248
+ ...this.currentAutoProviderConfig(),
4249
+ enabled: true
4250
+ };
4251
+ const shouldRoute = this.autoProviderCanRoute(nextAuto);
4252
+ this.saveAutoProviderConfig(shouldRoute ? nextAuto : { ...nextAuto, enabled: false });
4253
+ if (shouldRoute) {
4254
+ strategy = this.applyAutoProviderRoutingConfig(nextAuto);
4255
+ }
4256
+ } else {
4257
+ this.saveAutoProviderConfig({
4258
+ ...this.currentAutoProviderConfig(),
4259
+ enabled: false
4260
+ });
4261
+ }
4262
+ logger.info("routing.provider_mode.applied", "provider mode applied", {
4263
+ mode: saved.mode,
4264
+ paymentReady: payment.paymentReady
4265
+ });
4266
+ res.status(200).json({
4267
+ applied: true,
4268
+ strategy,
4269
+ ...this.providerModePayload()
4270
+ });
4271
+ } catch (error: unknown) {
4272
+ const errorMessage = error instanceof Error ? error.message : String(error);
4273
+ logger.warn("routing.provider_mode.apply_failed", "provider mode apply failed", { errorMessage });
4274
+ res.status(400).json({ error: { code: "provider_mode_apply_failed", message: errorMessage } });
4275
+ }
4276
+ });
4277
+
4278
+ controlApp.get("/routing/manual-providers", (req, res) => {
4279
+ try {
4280
+ const config = this.currentManualProviders();
4281
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
4282
+ res.status(200).json({
4283
+ version: config.version,
4284
+ updatedAt: config.updatedAt,
4285
+ routing: config.routing,
4286
+ providers: config.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
4287
+ });
4288
+ } catch (error: unknown) {
4289
+ const errorMessage = error instanceof Error ? error.message : String(error);
4290
+ logger.warn("routing.manual_providers.read_failed", "manual providers read failed", { errorMessage });
4291
+ res.status(500).json({ error: { code: "manual_providers_read_failed", message: errorMessage } });
4292
+ }
4293
+ });
4294
+
4295
+ controlApp.post("/routing/manual-providers/probe", async (req, res) => {
4296
+ try {
4297
+ const body = (req.body ?? {}) as Record<string, unknown>;
4298
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
4299
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
4300
+ const result = await this.probeManualProviderModels({ baseUrl, apiKey });
4301
+ logger.info("routing.manual_provider.probed", "manual provider model probe succeeded", {
4302
+ modelCount: result.modelIds.length,
4303
+ elapsedMs: result.elapsedMs
4304
+ });
4305
+ res.status(200).json({
4306
+ ok: true,
4307
+ modelIds: result.modelIds,
4308
+ elapsedMs: result.elapsedMs
4309
+ });
4310
+ } catch (error: unknown) {
4311
+ const errorMessage = error instanceof Error ? error.message : String(error);
4312
+ logger.warn("routing.manual_provider.probe_failed", "manual provider model probe failed", { errorMessage });
4313
+ res.status(400).json({ error: { code: "manual_provider_probe_failed", message: errorMessage } });
4314
+ }
4315
+ });
4316
+
4317
+ controlApp.post("/routing/manual-providers/local", async (req, res) => {
4318
+ try {
4319
+ const body = (req.body ?? {}) as Record<string, unknown>;
4320
+ const rawApiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
4321
+ if (!rawApiKey) {
4322
+ res.status(400).json({ error: { code: "manual_provider_local_create_failed", message: "manual provider apiKey is required" } });
4323
+ return;
4324
+ }
4325
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
4326
+ const probe = await this.probeManualProviderModels({ baseUrl, apiKey: rawApiKey });
4327
+ const config = this.currentManualProviders();
4328
+ const existingIds = new Set(config.providers.map((provider) => provider.id));
4329
+ const rawId = typeof body.id === "string" && body.id.trim().length > 0 ? body.id : undefined;
4330
+ const generatedId = `manual-${crypto.randomUUID().slice(0, 8)}`;
4331
+ const providerId = rawId ?? generatedId;
4332
+ const secretRef = `local:${providerId}`;
4333
+ const providerInput = {
4334
+ ...body,
4335
+ apiKey: undefined,
4336
+ apiKeyEnv: undefined,
4337
+ secretRef,
4338
+ models: probe.modelIds,
4339
+ supportedProtocols: Array.isArray(body.supportedProtocols) ? body.supportedProtocols : ["chat_completions"],
4340
+ enabled: body.enabled === undefined ? true : body.enabled
4341
+ };
4342
+ delete (providerInput as Record<string, unknown>).apiKey;
4343
+ const provider = normalizeManualProviderConfig(providerInput, {
4344
+ id: providerId,
4345
+ existingIds
4346
+ });
4347
+ const nextConfig: ManualProvidersConfig = {
4348
+ version: 1,
4349
+ providers: [...config.providers, provider],
4350
+ routing: config.routing,
4351
+ updatedAt: new Date().toISOString()
4352
+ };
4353
+ this.saveManualProviderSecret(secretRef, rawApiKey);
4354
+ this.saveManualProviders(nextConfig);
4355
+ logger.info("routing.manual_provider.local_created", "local manual provider created", {
4356
+ providerId: provider.id,
4357
+ modelCount: provider.models.length,
4358
+ enabled: provider.enabled,
4359
+ keyRefKind: "secret"
4360
+ });
4361
+ res.status(201).json({
4362
+ provider: publicManualProviderConfig(provider),
4363
+ routing: nextConfig.routing,
4364
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
4365
+ });
4366
+ } catch (error: unknown) {
4367
+ const errorMessage = error instanceof Error ? error.message : String(error);
4368
+ logger.warn("routing.manual_provider.local_create_failed", "local manual provider create failed", { errorMessage });
4369
+ res.status(400).json({ error: { code: "manual_provider_local_create_failed", message: errorMessage } });
4370
+ }
4371
+ });
4372
+
4373
+ controlApp.post("/routing/manual-providers", (req, res) => {
4374
+ try {
4375
+ const config = this.currentManualProviders();
4376
+ const existingIds = new Set(config.providers.map((provider) => provider.id));
4377
+ const rawId = (req.body as Record<string, unknown> | undefined)?.id;
4378
+ const generatedId = `manual-${crypto.randomUUID().slice(0, 8)}`;
4379
+ const provider = normalizeManualProviderConfig(req.body, {
4380
+ id: typeof rawId === "string" && rawId.trim().length > 0 ? rawId : generatedId,
4381
+ existingIds
4382
+ });
4383
+ const nextConfig: ManualProvidersConfig = {
4384
+ version: 1,
4385
+ providers: [...config.providers, provider],
4386
+ routing: config.routing,
4387
+ updatedAt: new Date().toISOString()
4388
+ };
4389
+ this.saveManualProviders(nextConfig);
4390
+ logger.info("routing.manual_provider.created", "manual provider created", {
4391
+ providerId: provider.id,
4392
+ modelCount: provider.models.length,
4393
+ enabled: provider.enabled,
4394
+ keyRefKind: provider.apiKeyEnv ? "env" : "secret"
4395
+ });
4396
+ res.status(201).json({
4397
+ provider: publicManualProviderConfig(provider),
4398
+ routing: nextConfig.routing,
4399
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
4400
+ });
4401
+ } catch (error: unknown) {
4402
+ const errorMessage = error instanceof Error ? error.message : String(error);
4403
+ logger.warn("routing.manual_provider.create_failed", "manual provider create failed", { errorMessage });
4404
+ res.status(400).json({ error: { code: "manual_provider_create_failed", message: errorMessage } });
4405
+ }
4406
+ });
4407
+
4408
+ controlApp.put("/routing/manual-providers/local/:id", async (req, res) => {
4409
+ try {
4410
+ const providerId = req.params.id;
4411
+ const body = (req.body ?? {}) as Record<string, unknown>;
4412
+ const config = this.currentManualProviders();
4413
+ const provider = config.providers.find((entry) => entry.id === providerId);
4414
+ if (!provider) {
4415
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${providerId}` } });
4416
+ return;
4417
+ }
4418
+ const name = typeof body.name === "string" && body.name.trim().length > 0 ? body.name.trim() : provider.name;
4419
+ const baseUrl = typeof body.baseUrl === "string" && body.baseUrl.trim().length > 0 ? body.baseUrl.trim() : provider.baseUrl;
4420
+ const rawApiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
4421
+ const apiKey = rawApiKey || this.manualProviderApiKey(provider);
4422
+ const probe = await this.probeManualProviderModels({ baseUrl, apiKey });
4423
+ const secretRef = provider.secretRef ?? `local:${provider.id}`;
4424
+ const existingIds = new Set(config.providers.map((entry) => entry.id).filter((id) => id !== provider.id));
4425
+ const updatedProvider = normalizeManualProviderConfig({
4426
+ ...provider,
4427
+ name,
4428
+ baseUrl,
4429
+ secretRef,
4430
+ models: probe.modelIds,
4431
+ supportedProtocols: provider.supportedProtocols.length > 0 ? provider.supportedProtocols : ["chat_completions"],
4432
+ enabled: body.enabled === undefined ? provider.enabled : body.enabled,
4433
+ updatedAt: new Date().toISOString()
4434
+ }, {
4435
+ id: provider.id,
4436
+ existingIds
4437
+ });
4438
+ const nextConfig: ManualProvidersConfig = {
4439
+ version: 1,
4440
+ providers: config.providers.map((entry) => entry.id === provider.id ? updatedProvider : entry),
4441
+ routing: config.routing,
4442
+ updatedAt: new Date().toISOString()
4443
+ };
4444
+ if (rawApiKey || !provider.secretRef) {
4445
+ this.saveManualProviderSecret(secretRef, apiKey);
4446
+ }
4447
+ this.saveManualProviders(nextConfig);
4448
+ logger.info("routing.manual_provider.local_updated", "local manual provider updated", {
4449
+ providerId: updatedProvider.id,
4450
+ modelCount: updatedProvider.models.length,
4451
+ enabled: updatedProvider.enabled,
4452
+ keyRefKind: "secret"
4453
+ });
4454
+ res.status(200).json({
4455
+ provider: publicManualProviderConfig(updatedProvider),
4456
+ routing: nextConfig.routing,
4457
+ providers: nextConfig.providers.map((entry) => publicManualProviderConfig(entry))
4458
+ });
4459
+ } catch (error: unknown) {
4460
+ const errorMessage = error instanceof Error ? error.message : String(error);
4461
+ logger.warn("routing.manual_provider.local_update_failed", "local manual provider update failed", { errorMessage });
4462
+ res.status(400).json({ error: { code: "manual_provider_local_update_failed", message: errorMessage } });
4463
+ }
4464
+ });
4465
+
4466
+ controlApp.delete("/routing/manual-providers/:id", (req, res) => {
4467
+ try {
4468
+ const providerId = req.params.id;
4469
+ const config = this.currentManualProviders();
4470
+ const nextProviders = config.providers.filter((provider) => provider.id !== providerId);
4471
+ if (nextProviders.length === config.providers.length) {
4472
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${providerId}` } });
4473
+ return;
4474
+ }
4475
+ const nextConfig: ManualProvidersConfig = {
4476
+ version: 1,
4477
+ providers: nextProviders,
4478
+ routing: config.routing.policy === "locked" && config.routing.lockedProviderId === providerId
4479
+ ? { policy: "fallback" }
4480
+ : config.routing,
4481
+ updatedAt: new Date().toISOString()
4482
+ };
4483
+ const removed = config.providers.find((provider) => provider.id === providerId);
4484
+ this.removeManualProviderSecret(removed?.secretRef);
4485
+ this.saveManualProviders(nextConfig);
4486
+ logger.info("routing.manual_provider.deleted", "manual provider deleted", { providerId });
4487
+ res.status(200).json({
4488
+ deleted: true,
4489
+ providerId,
4490
+ routing: nextConfig.routing,
4491
+ providers: nextProviders.map((provider) => publicManualProviderConfig(provider))
4492
+ });
4493
+ } catch (error: unknown) {
4494
+ const errorMessage = error instanceof Error ? error.message : String(error);
4495
+ logger.warn("routing.manual_provider.delete_failed", "manual provider delete failed", { errorMessage });
4496
+ res.status(400).json({ error: { code: "manual_provider_delete_failed", message: errorMessage } });
4497
+ }
4498
+ });
4499
+
4500
+ controlApp.put("/routing/manual-providers/order", (req, res) => {
4501
+ try {
4502
+ const body = (req.body ?? {}) as Record<string, unknown>;
4503
+ const providerIds = Array.isArray(body.providerIds)
4504
+ ? body.providerIds.map((entry) => typeof entry === "string" ? entry.trim() : "")
4505
+ : [];
4506
+ const config = this.currentManualProviders();
4507
+ const currentIds = new Set(config.providers.map((provider) => provider.id));
4508
+ const requestedIds = new Set(providerIds);
4509
+ if (
4510
+ providerIds.length !== config.providers.length ||
4511
+ requestedIds.size !== providerIds.length ||
4512
+ requestedIds.size !== currentIds.size ||
4513
+ providerIds.some((providerId) => !currentIds.has(providerId))
4514
+ ) {
4515
+ res.status(400).json({
4516
+ error: {
4517
+ code: "manual_provider_order_invalid",
4518
+ message: "manual provider order must include each configured provider exactly once"
4519
+ }
4520
+ });
4521
+ return;
4522
+ }
4523
+ const providersById = new Map(config.providers.map((provider) => [provider.id, provider]));
4524
+ const nextConfig: ManualProvidersConfig = {
4525
+ version: 1,
4526
+ providers: providerIds.map((providerId) => providersById.get(providerId)).filter((provider): provider is ManualProviderConfig => Boolean(provider)),
4527
+ routing: config.routing,
4528
+ updatedAt: new Date().toISOString()
4529
+ };
4530
+ this.saveManualProviders(nextConfig);
4531
+ logger.info("routing.manual_provider.order_applied", "manual provider order applied", {
4532
+ providerIds
4533
+ });
4534
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
4535
+ res.status(200).json({
4536
+ version: nextConfig.version,
4537
+ updatedAt: nextConfig.updatedAt,
4538
+ routing: nextConfig.routing,
4539
+ providers: nextConfig.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
4540
+ });
4541
+ } catch (error: unknown) {
4542
+ const errorMessage = error instanceof Error ? error.message : String(error);
4543
+ logger.warn("routing.manual_provider.order_apply_failed", "manual provider order apply failed", { errorMessage });
4544
+ res.status(400).json({ error: { code: "manual_provider_order_apply_failed", message: errorMessage } });
4545
+ }
4546
+ });
4547
+
4548
+ controlApp.put("/routing/manual-providers/routing", (req, res) => {
4549
+ try {
4550
+ const body = (req.body ?? {}) as Record<string, unknown>;
4551
+ const policy = body.policy;
4552
+ const lockedProviderId = typeof body.lockedProviderId === "string" ? body.lockedProviderId.trim() : undefined;
4553
+ const config = this.currentManualProviders();
4554
+ const routing = policy === "locked"
4555
+ ? { policy: "locked" as const, lockedProviderId }
4556
+ : { policy: "fallback" as const };
4557
+ const normalized = normalizeManualProvidersConfig({
4558
+ ...config,
4559
+ routing
4560
+ }).routing;
4561
+ if (normalized.policy === "locked") {
4562
+ const provider = config.providers.find((entry) => entry.id === normalized.lockedProviderId);
4563
+ if (!provider) {
4564
+ res.status(404).json({ error: { code: "manual_provider_not_found", message: `manual provider not found: ${normalized.lockedProviderId}` } });
4565
+ return;
4566
+ }
4567
+ if (!provider.enabled) {
4568
+ res.status(400).json({ error: { code: "manual_provider_locked_disabled", message: `manual provider is disabled: ${provider.id}` } });
4569
+ return;
4570
+ }
4571
+ }
4572
+ const nextConfig: ManualProvidersConfig = {
4573
+ version: 1,
4574
+ providers: config.providers,
4575
+ routing: normalized,
4576
+ updatedAt: new Date().toISOString()
4577
+ };
4578
+ this.saveManualProviders(nextConfig);
4579
+ logger.info("routing.manual_provider.routing_applied", "manual provider routing applied", {
4580
+ policy: normalized.policy,
4581
+ lockedProviderId: normalized.lockedProviderId
4582
+ });
4583
+ const observations = new Map(this.currentManualProviderObservations().observations.map((entry) => [entry.providerId, entry]));
4584
+ res.status(200).json({
4585
+ version: nextConfig.version,
4586
+ updatedAt: nextConfig.updatedAt,
4587
+ routing: nextConfig.routing,
4588
+ providers: nextConfig.providers.map((provider) => publicManualProviderConfig(provider, observations.get(provider.id)))
4589
+ });
4590
+ } catch (error: unknown) {
4591
+ const errorMessage = error instanceof Error ? error.message : String(error);
4592
+ logger.warn("routing.manual_provider.routing_apply_failed", "manual provider routing apply failed", { errorMessage });
4593
+ res.status(400).json({ error: { code: "manual_provider_routing_apply_failed", message: errorMessage } });
4594
+ }
4595
+ });
4596
+
4597
+ controlApp.get("/routing/auto-provider", (req, res) => {
4598
+ try {
4599
+ const config = this.currentAutoProviderConfig();
4600
+ const payment = this.providerPaymentState();
4601
+ const mode = this.currentProviderMode();
4602
+ res.status(200).json({
4603
+ config,
4604
+ active: mode.mode === "auto" && this.autoProviderCanRoute(config) && payment.paymentReady,
4605
+ locked: !payment.paymentReady,
4606
+ paymentReady: payment.paymentReady,
4607
+ paymentRequired: !payment.paymentReady,
4608
+ paymentLabel: payment.paymentLabel
4609
+ });
4610
+ } catch (error: unknown) {
4611
+ const errorMessage = error instanceof Error ? error.message : String(error);
4612
+ logger.warn("routing.auto_provider.read_failed", "auto provider read failed", { errorMessage });
4613
+ res.status(500).json({ error: { code: "auto_provider_read_failed", message: errorMessage } });
4614
+ }
4615
+ });
4616
+
4617
+ controlApp.put("/routing/auto-provider", (req, res) => {
4618
+ try {
4619
+ const next = normalizeAutoProviderConfig(req.body);
4620
+ const payment = this.providerPaymentState();
4621
+ if (next.enabled && !payment.paymentReady) {
4622
+ res.status(409).json({
4623
+ error: {
4624
+ code: "payment_required",
4625
+ message: "bind a payment method to enable auto provider"
4626
+ },
4627
+ paymentReady: false,
4628
+ paymentRequired: true,
4629
+ bindTarget: "/overview?bind=clawtip"
4630
+ });
4631
+ return;
4632
+ }
4633
+ const shouldRoute = this.autoProviderCanRoute(next);
4634
+ this.saveAutoProviderConfig(shouldRoute ? next : { ...next, enabled: false });
4635
+ let strategy: BuyerSellerRoutingConfig | undefined;
4636
+ if (shouldRoute) {
4637
+ this.saveProviderMode("auto");
4638
+ strategy = this.applyAutoProviderRoutingConfig(next);
4639
+ }
4640
+ logger.info("routing.auto_provider.applied", "auto provider applied", {
4641
+ enabled: shouldRoute,
4642
+ range: next.range,
4643
+ scorer: next.scorer,
4644
+ modelCount: next.modelIds.length,
4645
+ sellerCount: next.sellerIds.length,
4646
+ paymentReady: payment.paymentReady
4647
+ });
4648
+ res.status(200).json({
4649
+ applied: true,
4650
+ config: this.currentAutoProviderConfig(),
4651
+ strategy,
4652
+ ...this.providerModePayload()
4653
+ });
4654
+ } catch (error: unknown) {
4655
+ const errorMessage = error instanceof Error ? error.message : String(error);
4656
+ logger.warn("routing.auto_provider.apply_failed", "auto provider apply failed", { errorMessage });
4657
+ res.status(400).json({ error: { code: "auto_provider_apply_failed", message: errorMessage } });
4658
+ }
4659
+ });
4660
+
3318
4661
  // 1) GET /routing/strategy — 读当前路由策略 + 来源
3319
4662
  controlApp.get("/routing/strategy", (req, res) => {
3320
4663
  try {
@@ -3741,6 +5084,13 @@ function withLiveClawtipWalletState(payment: PaymentConfig, home?: string): Paym
3741
5084
  };
3742
5085
  }
3743
5086
 
5087
+ function paymentLabel(method: string): string {
5088
+ if (method === "clawtip") {
5089
+ return "ClawTip";
5090
+ }
5091
+ return method;
5092
+ }
5093
+
3744
5094
  function normalizeClawtipActivationPayment(bootstrap: ClawtipBootstrapResponse): ClawtipBootstrapPayment {
3745
5095
  if (!bootstrap.payment?.orderNo || !bootstrap.payment.indicator || !bootstrap.payment.resourceUrl) {
3746
5096
  throw new Error("ClawTip bootstrap response missing orderNo, indicator, or resourceUrl.");
@@ -3779,6 +5129,10 @@ function readConfigBoolean(config: Record<string, unknown> | undefined, key: str
3779
5129
  return config?.[key] === true;
3780
5130
  }
3781
5131
 
5132
+ function normalizePaymentMethodName(method: string): string {
5133
+ return method.trim().toLowerCase();
5134
+ }
5135
+
3782
5136
  function selectedSellerIdForRouting(routing: BuyerSellerRoutingConfig): string | undefined {
3783
5137
  return routing.mode === "fixed" ? routing.sellerId : undefined;
3784
5138
  }
@@ -3848,6 +5202,25 @@ function catalogSnapshotFromRegistry(registry: SellerRegistryDocument): InitDoct
3848
5202
  };
3849
5203
  }
3850
5204
 
5205
+ function parseOpenAiModelIds(value: unknown): string[] {
5206
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5207
+ return [];
5208
+ }
5209
+ const data = (value as { data?: unknown }).data;
5210
+ if (!Array.isArray(data)) {
5211
+ return [];
5212
+ }
5213
+ return data
5214
+ .flatMap((entry) => {
5215
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
5216
+ return [];
5217
+ }
5218
+ const id = (entry as { id?: unknown }).id;
5219
+ return typeof id === "string" && id.trim().length > 0 ? [id.trim()] : [];
5220
+ })
5221
+ .filter((id, index, all) => all.indexOf(id) === index);
5222
+ }
5223
+
3851
5224
  function normalizeTrustedRegistryCache(value: unknown): TrustedRegistryCacheRecord | undefined {
3852
5225
  if (!value || typeof value !== "object") {
3853
5226
  return undefined;