@tokenbuddy/tokenbuddy 1.0.14 → 1.0.16

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 (79) hide show
  1. package/dist/src/buyer-store.d.ts +23 -0
  2. package/dist/src/buyer-store.d.ts.map +1 -1
  3. package/dist/src/buyer-store.js +31 -6
  4. package/dist/src/buyer-store.js.map +1 -1
  5. package/dist/src/clawtip-bootstrap.d.ts +23 -0
  6. package/dist/src/clawtip-bootstrap.d.ts.map +1 -0
  7. package/dist/src/clawtip-bootstrap.js +47 -0
  8. package/dist/src/clawtip-bootstrap.js.map +1 -0
  9. package/dist/src/cli.d.ts +24 -33
  10. package/dist/src/cli.d.ts.map +1 -1
  11. package/dist/src/cli.js +157 -58
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/daemon.d.ts +79 -1
  14. package/dist/src/daemon.d.ts.map +1 -1
  15. package/dist/src/daemon.js +1007 -23
  16. package/dist/src/daemon.js.map +1 -1
  17. package/dist/src/model-index.d.ts +1 -1
  18. package/dist/src/model-index.d.ts.map +1 -1
  19. package/dist/src/model-index.js +4 -0
  20. package/dist/src/model-index.js.map +1 -1
  21. package/dist/src/prewarm-cache.d.ts +4 -0
  22. package/dist/src/prewarm-cache.d.ts.map +1 -1
  23. package/dist/src/prewarm-cache.js +2 -1
  24. package/dist/src/prewarm-cache.js.map +1 -1
  25. package/dist/src/prewarm-scheduler.d.ts +2 -0
  26. package/dist/src/prewarm-scheduler.d.ts.map +1 -1
  27. package/dist/src/prewarm-scheduler.js +4 -2
  28. package/dist/src/prewarm-scheduler.js.map +1 -1
  29. package/dist/src/route-failover.d.ts.map +1 -1
  30. package/dist/src/route-failover.js +10 -0
  31. package/dist/src/route-failover.js.map +1 -1
  32. package/dist/src/seller-catalog.d.ts +17 -0
  33. package/dist/src/seller-catalog.d.ts.map +1 -1
  34. package/dist/src/seller-catalog.js +15 -1
  35. package/dist/src/seller-catalog.js.map +1 -1
  36. package/dist/src/seller-pool.d.ts +12 -1
  37. package/dist/src/seller-pool.d.ts.map +1 -1
  38. package/dist/src/seller-pool.js +61 -7
  39. package/dist/src/seller-pool.js.map +1 -1
  40. package/dist/src/seller-route-planner.d.ts +11 -1
  41. package/dist/src/seller-route-planner.d.ts.map +1 -1
  42. package/dist/src/seller-route-planner.js +21 -9
  43. package/dist/src/seller-route-planner.js.map +1 -1
  44. package/dist/src/seller-routing-config.d.ts +2 -0
  45. package/dist/src/seller-routing-config.d.ts.map +1 -1
  46. package/dist/src/seller-routing-config.js +11 -1
  47. package/dist/src/seller-routing-config.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/buyer-store.ts +70 -7
  50. package/src/clawtip-bootstrap.ts +64 -0
  51. package/src/cli.ts +201 -76
  52. package/src/daemon.ts +1159 -25
  53. package/src/model-index.ts +4 -1
  54. package/src/prewarm-cache.ts +6 -1
  55. package/src/prewarm-scheduler.ts +6 -2
  56. package/src/route-failover.ts +11 -0
  57. package/src/seller-catalog.ts +24 -1
  58. package/src/seller-pool.ts +69 -7
  59. package/src/seller-route-planner.ts +33 -11
  60. package/src/seller-routing-config.ts +14 -1
  61. package/static/clawtip/recharge.png +0 -0
  62. package/static/ui/assets/index-UMiTTeo8.css +1 -0
  63. package/static/ui/assets/index-YHs-Ca0f.js +206 -0
  64. package/static/ui/assets/index-YHs-Ca0f.js.map +1 -0
  65. package/static/ui/icons/apple-touch-icon.png +0 -0
  66. package/static/ui/icons/tokenbuddy-192.png +0 -0
  67. package/static/ui/icons/tokenbuddy-512.png +0 -0
  68. package/static/ui/index.html +21 -0
  69. package/static/ui/manifest.webmanifest +28 -0
  70. package/static/ui/sw.js +59 -0
  71. package/tests/control-plane-ui-endpoints.test.ts +589 -0
  72. package/tests/daemon-classify.test.ts +9 -0
  73. package/tests/model-index.test.ts +14 -0
  74. package/tests/route-failover.test.ts +16 -0
  75. package/tests/seller-catalog-utilities.test.ts +54 -0
  76. package/tests/seller-pool.test.ts +56 -0
  77. package/tests/seller-route-planner.test.ts +40 -0
  78. package/tests/seller-routing-config.test.ts +13 -0
  79. package/tests/tokenbuddy.test.ts +200 -7
@@ -1,5 +1,5 @@
1
1
  import { createModuleLogger } from "@tokenbuddy/logging";
2
- import type { RegistrySeller } from "./seller-catalog.js";
2
+ import { isBuyerVisibleRegistrySeller, type RegistrySeller } from "./seller-catalog.js";
3
3
 
4
4
  const logger = createModuleLogger("tb-proxyd:model-index");
5
5
 
@@ -73,6 +73,9 @@ export class ModelIndex {
73
73
  if (!seller || !seller.id) {
74
74
  continue;
75
75
  }
76
+ if (!isBuyerVisibleRegistrySeller(seller)) {
77
+ continue;
78
+ }
76
79
  // v1.2 registry schema 用 "anthropic_messages" 作为协议名(OpenAI / ClawTip
77
80
  // / 外部 client 都用这个),但 buyer 内部 `endpointProtocol` 对 /v1/messages
78
81
  // 返 "messages"(更短、更易读)。在 modelIndex 重建时做 alias 映射,让两边
@@ -46,6 +46,8 @@ export interface PrewarmCandidate {
46
46
  upstreamStatus?: "healthy" | "degraded" | "unhealthy" | "unknown";
47
47
  /** 上游错误类名(HTTP status / 错误码),仅在失败时存在 */
48
48
  upstreamErrorClass?: string;
49
+ /** 临时容量避让截止时间;大于当前时间时不参与路由 */
50
+ capacityBlockedUntil?: number;
49
51
  }
50
52
 
51
53
  /**
@@ -100,6 +102,8 @@ export interface PrewarmCandidateInput {
100
102
  upstreamStatus?: "healthy" | "degraded" | "unhealthy" | "unknown";
101
103
  /** 上游错误类名,可选 */
102
104
  upstreamErrorClass?: string;
105
+ /** 临时容量避让截止时间;大于当前时间时不参与路由 */
106
+ capacityBlockedUntil?: number;
103
107
  }
104
108
 
105
109
  /**
@@ -449,7 +453,8 @@ function toCandidate(input: PrewarmCandidateInput): PrewarmCandidate {
449
453
  ttftMs: finiteNonNegative(input.ttftMs),
450
454
  avgInferenceMs: finiteNonNegative(input.avgInferenceMs),
451
455
  upstreamStatus: input.upstreamStatus,
452
- upstreamErrorClass: input.upstreamErrorClass
456
+ upstreamErrorClass: input.upstreamErrorClass,
457
+ capacityBlockedUntil: finiteNonNegative(input.capacityBlockedUntil)
453
458
  };
454
459
  }
455
460
 
@@ -35,6 +35,8 @@ export interface ProbeResult {
35
35
  ttftMs?: number;
36
36
  /** 平均推理延迟(毫秒),可选 */
37
37
  avgInferenceMs?: number;
38
+ /** 临时容量避让截止时间;大于当前时间时不参与路由 */
39
+ capacityBlockedUntil?: number;
38
40
  }
39
41
 
40
42
  /**
@@ -498,7 +500,8 @@ export class PrewarmScheduler {
498
500
  ttftMs: result.ttftMs,
499
501
  avgInferenceMs: result.avgInferenceMs,
500
502
  upstreamStatus: result.upstreamStatus,
501
- upstreamErrorClass: result.upstreamErrorClass
503
+ upstreamErrorClass: result.upstreamErrorClass,
504
+ capacityBlockedUntil: result.capacityBlockedUntil
502
505
  });
503
506
  logger.info("prewarm.succeeded", "seller probe succeeded", {
504
507
  taskId: task.id,
@@ -524,7 +527,8 @@ export class PrewarmScheduler {
524
527
  ttftMs: result.ttftMs,
525
528
  avgInferenceMs: result.avgInferenceMs,
526
529
  upstreamStatus: result.upstreamStatus,
527
- upstreamErrorClass: result.upstreamErrorClass
530
+ upstreamErrorClass: result.upstreamErrorClass,
531
+ capacityBlockedUntil: result.capacityBlockedUntil
528
532
  });
529
533
  logger.warn("prewarm.failed", "seller probe failed", {
530
534
  taskId: task.id,
@@ -169,6 +169,7 @@ export class RouteFailover {
169
169
  decide(context: DecideContext, totalCandidates: number): FailoverDecision {
170
170
  const isHard = context.errorKind === "hard_4xx" || context.errorKind === "auth_invalid" || context.errorKind === "no_compatible";
171
171
  const isSoft = context.errorKind === "soft_5xx" || context.errorKind === "deadline";
172
+ const isBusyCapacity = context.errorKind === "busy_capacity";
172
173
  const info = this.pool.inspect(context.sellerId);
173
174
  const freshPurchase = info.freshPurchase;
174
175
  const budgetExceeded = !this.creditTracker.canAutoPurchase(this.now());
@@ -179,6 +180,16 @@ export class RouteFailover {
179
180
  now: this.now()
180
181
  });
181
182
 
183
+ if (isBusyCapacity) {
184
+ return {
185
+ action: "failover_next",
186
+ reason: "busy_capacity",
187
+ freshPurchase,
188
+ retryAttemptsBeforeFailover: context.attempt,
189
+ budgetExceeded
190
+ };
191
+ }
192
+
182
193
  if (isHard) {
183
194
  // Hard failures are not eligible for retry; the seller is wrong
184
195
  // for this request. The pool has already transferred leftover
@@ -17,6 +17,8 @@ export interface RegistrySeller {
17
17
  id: string;
18
18
  /** 人类可读名称 */
19
19
  name?: string;
20
+ /** registry 发布状态;只有 `active` 参与 buyer 自动路由,缺省兼容旧 registry 为可用 */
21
+ status?: string;
20
22
  /** seller 服务的公网 URL(去掉尾部 `/`) */
21
23
  url: string;
22
24
  /** seller 支持的协议列表(包含 `anthropic_messages` 时内部 alias 到 `messages`) */
@@ -44,6 +46,19 @@ export interface SellerRegistryDocument {
44
46
  sellers: RegistrySeller[];
45
47
  }
46
48
 
49
+ /**
50
+ * Buyer 自动路由 / 模型目录可见性门禁。
51
+ * 新 registry 会显式写 `status`,只有 `active` 参与 buyer 可见路径;
52
+ * 旧 registry 缺省 status 时按历史行为保留可用,避免升级后隐藏存量节点。
53
+ *
54
+ * @param seller registry seller
55
+ * @returns seller 是否应进入 buyer 路由和模型目录
56
+ */
57
+ export function isBuyerVisibleRegistrySeller(seller: RegistrySeller): boolean {
58
+ const status = seller.status?.trim().toLowerCase();
59
+ return !status || status === "active";
60
+ }
61
+
47
62
  /**
48
63
  * 单个 seller 的 `/manifest` 响应,兼容 snake_case 与 camelCase 字段。
49
64
  */
@@ -72,6 +87,10 @@ export interface SellerManifest {
72
87
  discountRatio?: number;
73
88
  /** 折扣系数(snake_case 兼容) */
74
89
  discount_ratio?: number;
90
+ /** 服务手续费系数(camelCase) */
91
+ serviceFeeRatio?: number;
92
+ /** 服务手续费系数(snake_case 兼容) */
93
+ service_fee_ratio?: number;
75
94
  };
76
95
  }
77
96
 
@@ -132,6 +151,8 @@ export interface SellerCatalogEntry {
132
151
  manifestSellerId?: string;
133
152
  /** 折扣系数(来自 manifest.selection) */
134
153
  discountRatio?: number;
154
+ /** 服务手续费系数(来自 manifest.selection) */
155
+ serviceFeeRatio?: number;
135
156
  /** 模型数(来自 manifest) */
136
157
  modelCount?: number;
137
158
  /** seller 支持的协议(manifest > registry fallback) */
@@ -305,7 +326,8 @@ export async function fetchSellerManifest(seller: RegistrySeller): Promise<Selle
305
326
  */
306
327
  export async function discoverSellerBackedModels(registryUrl: string): Promise<SellerCatalogResult> {
307
328
  const registry = await fetchSellerRegistry(registryUrl);
308
- const sellerResults = await Promise.all(registry.sellers.map(async (seller) => {
329
+ const visibleSellers = registry.sellers.filter(isBuyerVisibleRegistrySeller);
330
+ const sellerResults = await Promise.all(visibleSellers.map(async (seller) => {
309
331
  try {
310
332
  const manifest = await fetchSellerManifest(seller);
311
333
  const protocols = manifestProtocols(manifest, seller);
@@ -328,6 +350,7 @@ export async function discoverSellerBackedModels(registryUrl: string): Promise<S
328
350
  status: "ok",
329
351
  manifestSellerId: manifest.sellerId || manifest.seller_id || seller.id,
330
352
  discountRatio: manifest.selection?.discountRatio ?? manifest.selection?.discount_ratio,
353
+ serviceFeeRatio: manifest.selection?.serviceFeeRatio ?? manifest.selection?.service_fee_ratio,
331
354
  modelCount: models.length,
332
355
  supportedProtocols: protocols,
333
356
  paymentMethods,
@@ -22,6 +22,7 @@ export type FailureKind =
22
22
  | "hard_4xx" // 400/404/422 — the seller is wrong for this request
23
23
  | "auth_invalid" // 401/403 token invalid
24
24
  | "insufficient_funds" // 402
25
+ | "busy_capacity" // 429 busy_capacity — seller is temporarily full
25
26
  | "soft_5xx" // 429/5xx/timeout/network
26
27
  | "deadline" // buyer deadline exceeded
27
28
  | "stream_aborted" // upstream stream broken after first chunk
@@ -67,6 +68,8 @@ export interface PoolEntry {
67
68
  upstreamStatus?: "healthy" | "degraded" | "unhealthy" | "unknown";
68
69
  /** 上游错误类名,可选 */
69
70
  upstreamErrorClass?: string;
71
+ /** 临时容量避让截止时间;大于当前时间时不参与路由 */
72
+ capacityBlockedUntil?: number;
70
73
  }
71
74
 
72
75
  /**
@@ -133,6 +136,8 @@ export interface SellerPoolOptions {
133
136
  windowFailureRate?: number; // default 0.5
134
137
  /** open 态保持时间(毫秒),过期后降级 half_open,默认 30000 */
135
138
  openStateMs?: number; // default 30_000
139
+ /** `busy_capacity` 的短期避让时间,默认 2000ms */
140
+ capacityBlockMs?: number;
136
141
  /** 注入时钟(测试用),默认 `Date.now` */
137
142
  now?: () => number;
138
143
  // PoolEntry -> CircuitState transition hooks for tests.
@@ -144,7 +149,8 @@ const DEFAULTS = {
144
149
  failureThreshold: 3,
145
150
  windowMs: 60_000,
146
151
  windowFailureRate: 0.5,
147
- openStateMs: 30_000
152
+ openStateMs: 30_000,
153
+ capacityBlockMs: 2_000
148
154
  };
149
155
 
150
156
  /**
@@ -162,6 +168,7 @@ export class SellerPool {
162
168
  private readonly windowMs: number;
163
169
  private readonly windowFailureRate: number;
164
170
  private readonly openStateMs: number;
171
+ private readonly capacityBlockMs: number;
165
172
  private readonly now: () => number;
166
173
 
167
174
  private entries = new Map<string, PoolEntry>();
@@ -174,6 +181,7 @@ export class SellerPool {
174
181
  this.windowMs = options.windowMs ?? DEFAULTS.windowMs;
175
182
  this.windowFailureRate = options.windowFailureRate ?? DEFAULTS.windowFailureRate;
176
183
  this.openStateMs = options.openStateMs ?? DEFAULTS.openStateMs;
184
+ this.capacityBlockMs = options.capacityBlockMs ?? DEFAULTS.capacityBlockMs;
177
185
  this.now = options.now ?? Date.now;
178
186
  }
179
187
 
@@ -183,7 +191,7 @@ export class SellerPool {
183
191
  * etc.) so the pool always reflects the latest probe results.
184
192
  */
185
193
  sync(): number {
186
- const fresh = new Map<string, PoolEntry>();
194
+ const fresh = new Map<string, PoolEntry>(this.entries);
187
195
  for (const entry of this.cache.snapshot()) {
188
196
  for (const candidate of entry.candidates) {
189
197
  const registry = this.modelIndex.getSeller(candidate.sellerId);
@@ -208,14 +216,52 @@ export class SellerPool {
208
216
  ttftMs: candidate.ttftMs,
209
217
  avgInferenceMs: candidate.avgInferenceMs,
210
218
  upstreamStatus: candidate.upstreamStatus,
211
- upstreamErrorClass: candidate.upstreamErrorClass
219
+ upstreamErrorClass: candidate.upstreamErrorClass,
220
+ capacityBlockedUntil: candidate.capacityBlockedUntil ?? previous?.capacityBlockedUntil
212
221
  });
213
222
  }
214
223
  }
224
+ for (const sellerId of fresh.keys()) {
225
+ if (!this.modelIndex.getSeller(sellerId)) {
226
+ fresh.delete(sellerId);
227
+ }
228
+ }
215
229
  this.entries = fresh;
216
230
  return this.entries.size;
217
231
  }
218
232
 
233
+ /**
234
+ * Ensure registry-fallback candidates also have runtime state. A seller
235
+ * may be selected before prewarm has produced a cache entry; failures
236
+ * from that first live request still need to affect the next route plan.
237
+ */
238
+ ensureRegistrySellers(sellers: RegistrySeller[], now: number = this.now()): void {
239
+ for (const seller of sellers) {
240
+ const previous = this.entries.get(seller.id);
241
+ if (previous) {
242
+ this.entries.set(seller.id, {
243
+ ...previous,
244
+ registrySeller: seller,
245
+ url: seller.url.replace(/\/+$/, "")
246
+ });
247
+ continue;
248
+ }
249
+ this.entries.set(seller.id, {
250
+ sellerId: seller.id,
251
+ url: seller.url.replace(/\/+$/, ""),
252
+ registrySeller: seller,
253
+ circuit: "closed",
254
+ consecutiveFailures: 0,
255
+ recentFailures: [],
256
+ lastSuccessAt: 0,
257
+ lastFailAt: 0,
258
+ lastProbeAt: now,
259
+ healthScore: 60,
260
+ avgLatencyMs: 0
261
+ });
262
+ }
263
+ }
264
+
219
265
  /**
220
266
  * Pick up to `limit` candidates for a (model, protocol, payment) triple.
221
267
  * Sellers in the `open` circuit are skipped unless their open state has
@@ -253,6 +299,7 @@ export class SellerPool {
253
299
  return { entry, registrySeller: row.registrySeller };
254
300
  })
255
301
  .filter((row) => row.entry.circuit !== "open")
302
+ .filter((row) => !isCapacityBlocked(row.entry, now))
256
303
  .sort((a, b) => b.entry.healthScore - a.entry.healthScore)
257
304
  .slice(0, limit);
258
305
 
@@ -279,7 +326,8 @@ export class SellerPool {
279
326
  consecutiveFailures: 0,
280
327
  recentFailures: [],
281
328
  lastSuccessAt: now,
282
- healthScore: Math.min(100, Math.max(entry.healthScore, 60))
329
+ healthScore: Math.min(100, Math.max(entry.healthScore, 60)),
330
+ capacityBlockedUntil: undefined
283
331
  };
284
332
  this.entries.set(sellerId, next);
285
333
  this.creditTracker.recordSpend(sellerId, balanceMicros);
@@ -309,8 +357,11 @@ export class SellerPool {
309
357
  return undefined;
310
358
  }
311
359
  const now = options.now ?? this.now();
312
- const recentFailures = [...entry.recentFailures, now].filter((ts) => ts >= now - this.windowMs);
313
- const consecutiveFailures = entry.consecutiveFailures + 1;
360
+ const isBusyCapacity = kind === "busy_capacity";
361
+ const recentFailures = (
362
+ isBusyCapacity ? entry.recentFailures : [...entry.recentFailures, now]
363
+ ).filter((ts) => ts >= now - this.windowMs);
364
+ const consecutiveFailures = isBusyCapacity ? entry.consecutiveFailures : entry.consecutiveFailures + 1;
314
365
  const failureRate = recentFailures.length / Math.max(1, this.windowMs / 1000);
315
366
  const overThreshold = consecutiveFailures >= this.failureThreshold;
316
367
  const overRate = failureRate >= this.windowFailureRate;
@@ -321,7 +372,8 @@ export class SellerPool {
321
372
  circuit,
322
373
  consecutiveFailures,
323
374
  recentFailures,
324
- lastFailAt: now
375
+ lastFailAt: now,
376
+ capacityBlockedUntil: isBusyCapacity ? now + this.capacityBlockMs : entry.capacityBlockedUntil
325
377
  };
326
378
  this.entries.set(sellerId, next);
327
379
  if (options.transferLeftover || isHard) {
@@ -335,6 +387,12 @@ export class SellerPool {
335
387
  recentFailureRate: failureRate,
336
388
  threshold: this.failureThreshold
337
389
  });
390
+ } else if (isBusyCapacity) {
391
+ logger.warn("pool.capacity_blocked", "seller pool entry temporarily blocked by busy capacity", {
392
+ sellerId,
393
+ capacityBlockMs: this.capacityBlockMs,
394
+ blockedUntil: next.capacityBlockedUntil
395
+ });
338
396
  }
339
397
  return next;
340
398
  }
@@ -397,6 +455,10 @@ export class SellerPool {
397
455
  }
398
456
  }
399
457
 
458
+ function isCapacityBlocked(entry: PoolEntry, now: number): boolean {
459
+ return Number.isFinite(entry.capacityBlockedUntil) && (entry.capacityBlockedUntil as number) > now;
460
+ }
461
+
400
462
  function asResolution(resolved: { modelId: string; matched: boolean; sellers: RegistrySeller[]; missingModelsFlag: number }): ModelIndexResolution {
401
463
  return {
402
464
  modelId: resolved.modelId,
@@ -1,4 +1,4 @@
1
- import type { RegistrySeller } from "./seller-catalog.js";
1
+ import { isBuyerVisibleRegistrySeller, type RegistrySeller } from "./seller-catalog.js";
2
2
  import {
3
3
  planSellerRoutes,
4
4
  type RoutingCandidate,
@@ -24,10 +24,16 @@ export interface SellerRouteMetric {
24
24
  healthScore?: number;
25
25
  /** 平均延迟(毫秒),可选 */
26
26
  avgLatencyMs?: number;
27
+ /** TTFT(毫秒),可选 */
28
+ ttftMs?: number;
29
+ /** 平均推理延迟(毫秒),可选 */
30
+ avgInferenceMs?: number;
27
31
  /** 折扣系数(0-1),可选;缺省时 scoring 视为"无折扣信息" */
28
32
  discountRatio?: number;
29
33
  /** 当前熔断状态,可选;`open` 的 seller 直接被剔除候选 */
30
34
  circuit?: SellerCircuitState;
35
+ /** 临时容量避让截止时间;大于当前时间时直接剔除候选 */
36
+ capacityBlockedUntil?: number;
31
37
  }
32
38
 
33
39
  /**
@@ -75,6 +81,8 @@ export interface SellerRoutePlannerInput {
75
81
  prewarmCandidates?: SellerRoutePrewarmCandidate[];
76
82
  /** seller 实时指标(可选;`circuit=open` 的 seller 被剔除) */
77
83
  sellerMetrics?: SellerRouteMetric[];
84
+ /** 当前时间戳,用于判断容量避让窗口;默认 `Date.now()` */
85
+ now?: number;
78
86
  }
79
87
 
80
88
  /**
@@ -89,6 +97,8 @@ export interface PlannedSellerRoute {
89
97
  metrics: {
90
98
  healthScore?: number;
91
99
  avgLatencyMs?: number;
100
+ ttftMs?: number;
101
+ avgInferenceMs?: number;
92
102
  discountRatio?: number;
93
103
  /** 在 registry 里的声明顺序(0-based,tie-breaker) */
94
104
  registryOrder: number;
@@ -128,7 +138,7 @@ interface IndexedSeller {
128
138
 
129
139
  interface MetricIndex {
130
140
  bySellerId: Map<string, SellerRouteMetric>;
131
- openSellerIds: Set<string>;
141
+ blockedSellerIds: Set<string>;
132
142
  }
133
143
 
134
144
  /**
@@ -145,7 +155,7 @@ interface MetricIndex {
145
155
  */
146
156
  export function planSellerRouteSet(input: SellerRoutePlannerInput): SellerRoutePlan {
147
157
  const indexed = indexRegistrySellers(input.registrySellers);
148
- const metrics = indexMetrics(input.sellerMetrics);
158
+ const metrics = indexMetrics(input.sellerMetrics, input.now ?? Date.now());
149
159
  const source = chooseCandidateSource(input, indexed, metrics);
150
160
  const strategyPlan = planSellerRoutes(source.candidates, input.routing);
151
161
  const routes = strategyPlan.routes.map((candidate) => {
@@ -159,6 +169,8 @@ export function planSellerRouteSet(input: SellerRoutePlannerInput): SellerRouteP
159
169
  metrics: {
160
170
  healthScore: candidate.healthScore,
161
171
  avgLatencyMs: candidate.avgLatencyMs,
172
+ ttftMs: candidate.ttftMs,
173
+ avgInferenceMs: candidate.avgInferenceMs,
162
174
  discountRatio: candidate.discountRatio,
163
175
  registryOrder: candidate.registryOrder
164
176
  }
@@ -189,7 +201,7 @@ function chooseCandidateSource(
189
201
  if (!indexedSeller) {
190
202
  return undefined;
191
203
  }
192
- if (metrics.openSellerIds.has(indexedSeller.seller.id)) {
204
+ if (metrics.blockedSellerIds.has(indexedSeller.seller.id)) {
193
205
  return undefined;
194
206
  }
195
207
  return buildCandidate({
@@ -217,7 +229,7 @@ function chooseCandidateSource(
217
229
  source: "registry_fallback",
218
230
  sourceReason: prewarm.length > 0 ? "prewarm_no_compatible_candidates" : "prewarm_missing",
219
231
  candidates: indexed.ordered
220
- .filter((entry) => !metrics.openSellerIds.has(entry.seller.id))
232
+ .filter((entry) => !metrics.blockedSellerIds.has(entry.seller.id))
221
233
  .map((entry) => buildCandidate({
222
234
  seller: entry.seller,
223
235
  registryOrder: entry.registryOrder,
@@ -246,6 +258,8 @@ function buildCandidate(input: {
246
258
  supportsPayment: sellerSupportsPayment(input.seller, input.paymentMethod),
247
259
  healthScore: input.metric?.healthScore,
248
260
  avgLatencyMs: input.metric?.avgLatencyMs,
261
+ ttftMs: input.metric?.ttftMs,
262
+ avgInferenceMs: input.metric?.avgInferenceMs,
249
263
  discountRatio: input.metric?.discountRatio,
250
264
  registryOrder: input.registryOrder
251
265
  };
@@ -261,6 +275,7 @@ function indexRegistrySellers(sellers: RegistrySeller[]): {
261
275
  } {
262
276
  const ordered = sellers
263
277
  .filter((seller) => Boolean(seller?.id && seller.url))
278
+ .filter((seller) => isBuyerVisibleRegistrySeller(seller))
264
279
  .map((seller, registryOrder) => ({ seller, registryOrder }));
265
280
  return {
266
281
  ordered,
@@ -268,15 +283,15 @@ function indexRegistrySellers(sellers: RegistrySeller[]): {
268
283
  };
269
284
  }
270
285
 
271
- function indexMetrics(metrics: SellerRouteMetric[] | undefined): MetricIndex {
272
- const openSellerIds = new Set((metrics ?? [])
273
- .filter((metric) => metric.circuit === "open")
286
+ function indexMetrics(metrics: SellerRouteMetric[] | undefined, now: number): MetricIndex {
287
+ const blockedSellerIds = new Set((metrics ?? [])
288
+ .filter((metric) => metric.circuit === "open" || isCapacityBlocked(metric, now))
274
289
  .map((metric) => metric.sellerId));
275
290
  return {
276
291
  bySellerId: new Map((metrics ?? [])
277
- .filter((metric) => metric.circuit !== "open")
292
+ .filter((metric) => !blockedSellerIds.has(metric.sellerId))
278
293
  .map((metric) => [metric.sellerId, metric])),
279
- openSellerIds
294
+ blockedSellerIds
280
295
  };
281
296
  }
282
297
 
@@ -288,11 +303,18 @@ function mergeMetric(
288
303
  sellerId: prewarm.sellerId,
289
304
  healthScore: prewarm.healthScore ?? metric?.healthScore,
290
305
  avgLatencyMs: prewarm.avgLatencyMs ?? metric?.avgLatencyMs,
306
+ ttftMs: metric?.ttftMs,
307
+ avgInferenceMs: metric?.avgInferenceMs,
291
308
  discountRatio: metric?.discountRatio,
292
- circuit: metric?.circuit
309
+ circuit: metric?.circuit,
310
+ capacityBlockedUntil: metric?.capacityBlockedUntil
293
311
  };
294
312
  }
295
313
 
314
+ function isCapacityBlocked(metric: SellerRouteMetric, now: number): boolean {
315
+ return Number.isFinite(metric.capacityBlockedUntil) && (metric.capacityBlockedUntil as number) > now;
316
+ }
317
+
296
318
  function sellerSupportsModel(seller: RegistrySeller, modelId: string): boolean {
297
319
  const normalized = normalizeLookupValue(modelId);
298
320
  return (seller.models ?? []).some((model) => normalizeLookupValue(model) === normalized);
@@ -18,6 +18,8 @@ export interface BuyerSellerRoutingConfig extends SellerRoutingStrategyConfig {
18
18
  mode: SellerRoutingMode;
19
19
  /** 评分器:`speed` / `discount` / `balanced` */
20
20
  scorer: SellerRoutingScorer;
21
+ /** fixed 模式下按模型固定 seller;缺省时回退到全局 sellerId。 */
22
+ fixedByModel?: Record<string, string>;
21
23
  }
22
24
 
23
25
  /**
@@ -53,6 +55,7 @@ export function normalizeSellerRoutingConfig(value: unknown): BuyerSellerRouting
53
55
  return {
54
56
  mode,
55
57
  sellerId: readOptionalString(value.sellerId),
58
+ fixedByModel: readFixedByModel(value.fixedByModel),
56
59
  scorer
57
60
  };
58
61
  }
@@ -151,7 +154,7 @@ export function parseSellerIdList(value: string): string[] {
151
154
  * @throws 当 `fixedSet` 模式缺少 `sellerIds` 时
152
155
  */
153
156
  export function assertSellerRoutingConfig(config: BuyerSellerRoutingConfig): void {
154
- if (config.mode === "fixed" && !config.sellerId?.trim()) {
157
+ if (config.mode === "fixed" && !config.sellerId?.trim() && Object.keys(config.fixedByModel ?? {}).length === 0) {
155
158
  throw new Error("fixed routing requires --seller <sellerId>");
156
159
  }
157
160
  if (config.mode === "fixedSet" && (!config.sellerIds || config.sellerIds.length === 0)) {
@@ -193,6 +196,16 @@ function readSellerIds(value: unknown): string[] {
193
196
  return [];
194
197
  }
195
198
 
199
+ function readFixedByModel(value: unknown): Record<string, string> | undefined {
200
+ if (!isObject(value)) {
201
+ return undefined;
202
+ }
203
+ const entries = Object.entries(value)
204
+ .map(([modelId, sellerId]) => [modelId.trim(), typeof sellerId === "string" ? sellerId.trim() : ""] as const)
205
+ .filter(([modelId, sellerId]) => modelId.length > 0 && sellerId.length > 0);
206
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
207
+ }
208
+
196
209
  function isObject(value: unknown): value is Record<string, unknown> {
197
210
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
198
211
  }
Binary file
@@ -0,0 +1 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-950:oklch(27.9% .077 45.635);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-900:oklch(38.6% .063 188.416);--color-blue-50:oklch(97% .014 254.604);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-900:oklch(35.9% .144 278.697);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-700:oklch(51.4% .222 16.935);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-white:#fff;--spacing:.25rem;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wider:.05em;--leading-snug:1.375;--radius-lg:.5rem;--radius-xl:.75rem;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-page:#f8f7ff;--color-ink:#201a38;--color-text:#312a4f;--color-muted:#665f80;--color-faint:#837d9b;--color-line:#ddd6f1;--color-line-2:#eee9fb;--color-lavender:#efe8ff;--color-lavender-2:#e3d7ff;--color-purple:#7c3df0;--color-purple-2:#9b6dff;--color-green:#34d399;--color-amber:#f6b73c;--color-red:#ef5b78}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.left-0{left:calc(var(--spacing) * 0)}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-30{z-index:30}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.h-\[60px\]{height:60px}.h-\[66px\]{height:66px}.h-\[160px\]{height:160px}.h-full{height:100%}.h-px{height:1px}.max-h-\[86dvh\]{max-height:86dvh}.max-h-\[calc\(86dvh-65px\)\]{max-height:calc(86dvh - 65px)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-14{min-height:calc(var(--spacing) * 14)}.min-h-\[7rem\]{min-height:7rem}.min-h-\[86px\]{min-height:86px}.min-h-\[94px\]{min-height:94px}.min-h-\[124px\]{min-height:124px}.min-h-\[220px\]{min-height:220px}.min-h-full{min-height:100%}.w-1{width:calc(var(--spacing) * 1)}.w-4{width:calc(var(--spacing) * 4)}.w-10{width:calc(var(--spacing) * 10)}.w-14{width:calc(var(--spacing) * 14)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[9rem\]{max-width:9rem}.max-w-\[180px\]{max-width:180px}.max-w-\[190px\]{max-width:190px}.max-w-\[210px\]{max-width:210px}.max-w-\[240px\]{max-width:240px}.max-w-\[360px\]{max-width:360px}.max-w-\[420px\]{max-width:420px}.max-w-\[820px\]{max-width:820px}.max-w-\[1180px\]{max-width:1180px}.max-w-full{max-width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[260px\]{min-width:260px}.min-w-\[980px\]{min-width:980px}.min-w-\[1080px\]{min-width:1080px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)_74px_82px\]{grid-template-columns:minmax(0,1fr) 74px 82px}.grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2\.5{column-gap:calc(var(--spacing) * 2.5)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-\[var\(--color-line-2\)\]>:not(:last-child)){border-color:var(--color-line-2)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:16px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-\[9px\]{border-radius:9px}.rounded-\[10px\]{border-radius:10px}.rounded-\[12px\]{border-radius:12px}.rounded-\[14px\]{border-radius:14px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-\[3px\]{border-top-left-radius:3px;border-top-right-radius:3px}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-\[\#cceee6\]{border-color:#cceee6}.border-\[\#d9e7ff\]{border-color:#d9e7ff}.border-\[\#dff8ec\]{border-color:#dff8ec}.border-\[\#f8d5de\]{border-color:#f8d5de}.border-\[var\(--color-ink\)\]{border-color:var(--color-ink)}.border-\[var\(--color-lavender-2\)\]{border-color:var(--color-lavender-2)}.border-\[var\(--color-line\)\]{border-color:var(--color-line)}.border-\[var\(--color-line-2\)\]{border-color:var(--color-line-2)}.border-\[var\(--color-purple\)\]{border-color:var(--color-purple)}.border-amber-100{border-color:var(--color-amber-100)}.border-emerald-100{border-color:var(--color-emerald-100)}.border-indigo-100{border-color:var(--color-indigo-100)}.border-rose-100{border-color:var(--color-rose-100)}.border-rose-200{border-color:var(--color-rose-200)}.border-teal-100{border-color:var(--color-teal-100)}.border-violet-100{border-color:var(--color-violet-100)}.bg-\[\#0f766e\]{background-color:#0f766e}.bg-\[\#201a38\]\/35{background-color:#201a3859}.bg-\[\#2563eb\]{background-color:#2563eb}.bg-\[\#e8fbf7\]{background-color:#e8fbf7}.bg-\[\#e8fff6\]{background-color:#e8fff6}.bg-\[\#eaf2ff\]{background-color:#eaf2ff}.bg-\[\#ef5b78\]{background-color:#ef5b78}.bg-\[\#fff0f3\]{background-color:#fff0f3}.bg-\[rgba\(32\,26\,56\,0\.28\)\]{background-color:#201a3847}.bg-\[var\(--color-amber\)\]{background-color:var(--color-amber)}.bg-\[var\(--color-card\)\]{background-color:var(--color-card)}.bg-\[var\(--color-green\)\]{background-color:var(--color-green)}.bg-\[var\(--color-lavender\)\]{background-color:var(--color-lavender)}.bg-\[var\(--color-lavender\)\]\/35{background-color:#efe8ff59}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-lavender\)\]\/35{background-color:color-mix(in oklab,var(--color-lavender) 35%,transparent)}}.bg-\[var\(--color-lavender\)\]\/55{background-color:#efe8ff8c}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-lavender\)\]\/55{background-color:color-mix(in oklab,var(--color-lavender) 55%,transparent)}}.bg-\[var\(--color-lavender\)\]\/70{background-color:#efe8ffb3}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-lavender\)\]\/70{background-color:color-mix(in oklab,var(--color-lavender) 70%,transparent)}}.bg-\[var\(--color-page\)\]{background-color:var(--color-page)}.bg-\[var\(--color-page\)\]\/60{background-color:#f8f7ff99}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-page\)\]\/60{background-color:color-mix(in oklab,var(--color-page) 60%,transparent)}}.bg-\[var\(--color-page\)\]\/70{background-color:#f8f7ffb3}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-page\)\]\/70{background-color:color-mix(in oklab,var(--color-page) 70%,transparent)}}.bg-\[var\(--color-purple\)\]{background-color:var(--color-purple)}.bg-\[var\(--color-purple\)\]\/75{background-color:#7c3df0bf}@supports (color:color-mix(in lab,red,red)){.bg-\[var\(--color-purple\)\]\/75{background-color:color-mix(in oklab,var(--color-purple) 75%,transparent)}}.bg-\[var\(--color-red\)\]{background-color:var(--color-red)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-current{background-color:currentColor}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-teal-50{background-color:var(--color-teal-50)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white) 70%,transparent)}}.fill-\[var\(--color-muted\)\]{fill:var(--color-muted)}.object-contain{object-fit:contain}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.8rem\]{font-size:.8rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.66rem\]{font-size:.66rem}.text-\[0\.68rem\]{font-size:.68rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.74rem\]{font-size:.74rem}.text-\[0\.75rem\]{font-size:.75rem}.text-\[0\.76rem\]{font-size:.76rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.84rem\]{font-size:.84rem}.text-\[0\.85rem\]{font-size:.85rem}.text-\[0\.86rem\]{font-size:.86rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[0\.94rem\]{font-size:.94rem}.text-\[1\.05rem\]{font-size:1.05rem}.text-\[1\.25rem\]{font-size:1.25rem}.text-\[1\.55rem\]{font-size:1.55rem}.text-\[1\.95rem\]{font-size:1.95rem}.text-\[1rem\]{font-size:1rem}.text-\[10px\]{font-size:10px}.text-\[12px\]{font-size:12px}.leading-3{--tw-leading:calc(var(--spacing) * 3);line-height:calc(var(--spacing) * 3)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-none{--tw-leading:1;line-height:1}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.06em\]{--tw-tracking:.06em;letter-spacing:.06em}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-\[\#0f766e\]{color:#0f766e}.text-\[\#201a38\]{color:#201a38}.text-\[\#2563eb\]{color:#2563eb}.text-\[\#ef5b78\]{color:#ef5b78}.text-\[var\(--color-ink\)\]{color:var(--color-ink)}.text-\[var\(--color-muted\)\]{color:var(--color-muted)}.text-\[var\(--color-purple\)\]{color:var(--color-purple)}.text-\[var\(--color-purple\)\]\/70{color:#7c3df0b3}@supports (color:color-mix(in lab,red,red)){.text-\[var\(--color-purple\)\]\/70{color:color-mix(in oklab,var(--color-purple) 70%,transparent)}}.text-\[var\(--color-purple\)\]\/72{color:#7c3df0b8}@supports (color:color-mix(in lab,red,red)){.text-\[var\(--color-purple\)\]\/72{color:color-mix(in oklab,var(--color-purple) 72%,transparent)}}.text-\[var\(--color-purple-2\)\]{color:var(--color-purple-2)}.text-\[var\(--color-red\)\]{color:var(--color-red)}.text-\[var\(--color-text\)\]{color:var(--color-text)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-800\/70{color:#953d00b3}@supports (color:color-mix(in lab,red,red)){.text-amber-800\/70{color:color-mix(in oklab,var(--color-amber-800) 70%,transparent)}}.text-amber-950\/58{color:#46190194}@supports (color:color-mix(in lab,red,red)){.text-amber-950\/58{color:color-mix(in oklab,var(--color-amber-950) 58%,transparent)}}.text-blue-700{color:var(--color-blue-700)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-700\/72{color:#007956b8}@supports (color:color-mix(in lab,red,red)){.text-emerald-700\/72{color:color-mix(in oklab,var(--color-emerald-700) 72%,transparent)}}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-950\/58{color:#002c2294}@supports (color:color-mix(in lab,red,red)){.text-emerald-950\/58{color:color-mix(in oklab,var(--color-emerald-950) 58%,transparent)}}.text-indigo-700{color:var(--color-indigo-700)}.text-indigo-900{color:var(--color-indigo-900)}.text-indigo-900\/70{color:#312c85b3}@supports (color:color-mix(in lab,red,red)){.text-indigo-900\/70{color:color-mix(in oklab,var(--color-indigo-900) 70%,transparent)}}.text-rose-700{color:var(--color-rose-700)}.text-rose-700\/70{color:#c20039b3}@supports (color:color-mix(in lab,red,red)){.text-rose-700\/70{color:color-mix(in oklab,var(--color-rose-700) 70%,transparent)}}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-teal-700{color:var(--color-teal-700)}.text-teal-700\/72{color:#00776eb8}@supports (color:color-mix(in lab,red,red)){.text-teal-700\/72{color:color-mix(in oklab,var(--color-teal-700) 72%,transparent)}}.text-teal-900\/62{color:#0b4f4a9e}@supports (color:color-mix(in lab,red,red)){.text-teal-900\/62{color:color-mix(in oklab,var(--color-teal-900) 62%,transparent)}}.text-transparent{color:#0000}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.decoration-\[var\(--color-purple\)\]{-webkit-text-decoration-color:var(--color-purple);text-decoration-color:var(--color-purple)}.decoration-2{text-decoration-thickness:2px}.underline-offset-\[10px\]{text-underline-offset:10px}.opacity-60{opacity:.6}.shadow-\[0_0_0_3px_rgba\(52\,211\,153\,0\.18\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,#34d3992e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_3px_rgba\(203\,213\,225\,0\.25\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,#cbd5e140);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_3px_rgba\(239\,91\,120\,0\.18\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,#ef5b782e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_0_3px_rgba\(246\,183\,60\,0\.18\)\]{--tw-shadow:0 0 0 3px var(--tw-shadow-color,#f6b73c2e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_18px_rgba\(79\,70\,229\,0\.16\)\]{--tw-shadow:0 8px 18px var(--tw-shadow-color,#4f46e529);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_18px_rgba\(124\,61\,240\,0\.16\)\]{--tw-shadow:0 8px 18px var(--tw-shadow-color,#7c3df029);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_24px_rgba\(15\,118\,110\,0\.08\)\]{--tw-shadow:0 10px 24px var(--tw-shadow-color,#0f766e14);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_24px_rgba\(16\,185\,129\,0\.08\)\]{--tw-shadow:0 10px 24px var(--tw-shadow-color,#10b98114);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_24px_rgba\(124\,61\,240\,0\.08\)\]{--tw-shadow:0 10px 24px var(--tw-shadow-color,#7c3df014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_10px_24px_rgba\(245\,158\,11\,0\.08\)\]{--tw-shadow:0 10px 24px var(--tw-shadow-color,#f59e0b14);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_28px_rgba\(41\,31\,84\,0\.06\)\]{--tw-shadow:0 12px 28px var(--tw-shadow-color,#291f540f);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_12px_36px_rgba\(60\,41\,112\,0\.08\)\]{--tw-shadow:0 12px 36px var(--tw-shadow-color,#3c297014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_14px_30px_rgba\(124\,61\,240\,0\.14\)\]{--tw-shadow:0 14px 30px var(--tw-shadow-color,#7c3df024);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_20px_56px_rgba\(32\,26\,56\,0\.20\)\]{--tw-shadow:0 20px 56px var(--tw-shadow-color,#201a3833);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_24px_70px_rgba\(32\,26\,56\,0\.22\)\]{--tw-shadow:0 24px 70px var(--tw-shadow-color,#201a3838);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-\[var\(--color-lavender-2\)\]{--tw-ring-color:var(--color-lavender-2)}.ring-emerald-100{--tw-ring-color:var(--color-emerald-100)}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-white{--tw-ring-offset-color:var(--color-white)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.placeholder\:text-\[var\(--color-faint\)\]::placeholder{color:var(--color-faint)}.placeholder\:text-\[var\(--color-muted\)\]::placeholder{color:var(--color-muted)}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:inset-y-0:before{content:var(--tw-content);inset-block:calc(var(--spacing) * 0)}.before\:left-0:before{content:var(--tw-content);left:calc(var(--spacing) * 0)}.before\:w-1:before{content:var(--tw-content);width:calc(var(--spacing) * 1)}.before\:bg-\[var\(--color-purple\)\]:before{content:var(--tw-content);background-color:var(--color-purple)}.before\:bg-amber-500:before{content:var(--tw-content);background-color:var(--color-amber-500)}.before\:bg-emerald-500:before{content:var(--tw-content);background-color:var(--color-emerald-500)}.before\:bg-teal-600:before{content:var(--tw-content);background-color:var(--color-teal-600)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-bottom-1:after{content:var(--tw-content);bottom:calc(var(--spacing) * -1)}.after\:left-0:after{content:var(--tw-content);left:calc(var(--spacing) * 0)}.after\:h-0\.5:after{content:var(--tw-content);height:calc(var(--spacing) * .5)}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:origin-left:after{content:var(--tw-content);transform-origin:0}.after\:scale-x-75:after{content:var(--tw-content);--tw-scale-x:75%;scale:var(--tw-scale-x) var(--tw-scale-y)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:bg-\[var\(--color-purple\)\]:after{content:var(--tw-content);background-color:var(--color-purple)}.after\:opacity-0:after{content:var(--tw-content);opacity:0}.after\:transition:after{content:var(--tw-content);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.group-hover\:after\:scale-x-100:is(:where(.group):hover *):after{content:var(--tw-content);--tw-scale-x:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:after\:opacity-100:is(:where(.group):hover *):after{content:var(--tw-content);opacity:1}}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){.hover\:-translate-y-0\.5:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.hover\:border-\[var\(--color-lavender-2\)\]:hover{border-color:var(--color-lavender-2)}.hover\:border-\[var\(--color-line\)\]:hover{border-color:var(--color-line)}.hover\:border-\[var\(--color-purple\)\]:hover{border-color:var(--color-purple)}.hover\:border-amber-200:hover{border-color:var(--color-amber-200)}.hover\:border-emerald-200:hover{border-color:var(--color-emerald-200)}.hover\:border-teal-200:hover{border-color:var(--color-teal-200)}.hover\:border-violet-200:hover{border-color:var(--color-violet-200)}.hover\:bg-\[\#6e32dc\]:hover{background-color:#6e32dc}.hover\:bg-\[var\(--color-lavender\)\]:hover{background-color:var(--color-lavender)}.hover\:bg-\[var\(--color-lavender\)\]\/25:hover{background-color:#efe8ff40}@supports (color:color-mix(in lab,red,red)){.hover\:bg-\[var\(--color-lavender\)\]\/25:hover{background-color:color-mix(in oklab,var(--color-lavender) 25%,transparent)}}.hover\:bg-\[var\(--color-lavender\)\]\/35:hover{background-color:#efe8ff59}@supports (color:color-mix(in lab,red,red)){.hover\:bg-\[var\(--color-lavender\)\]\/35:hover{background-color:color-mix(in oklab,var(--color-lavender) 35%,transparent)}}.hover\:bg-\[var\(--color-lavender\)\]\/40:hover{background-color:#efe8ff66}@supports (color:color-mix(in lab,red,red)){.hover\:bg-\[var\(--color-lavender\)\]\/40:hover{background-color:color-mix(in oklab,var(--color-lavender) 40%,transparent)}}.hover\:bg-\[var\(--color-page\)\]:hover{background-color:var(--color-page)}.hover\:bg-\[var\(--color-purple-2\)\]:hover{background-color:var(--color-purple-2)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-rose-50:hover{background-color:var(--color-rose-50)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-\[var\(--color-ink\)\]:hover{color:var(--color-ink)}.hover\:text-\[var\(--color-purple\)\]:hover{color:var(--color-purple)}.hover\:text-rose-700:hover{color:var(--color-rose-700)}}.focus\:border-\[var\(--color-purple\)\]:focus{border-color:var(--color-purple)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[var\(--color-purple\)\]:focus{--tw-ring-color:var(--color-purple)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:translate-y-px:active{--tw-translate-y:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}@media(min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[140px_minmax\(0\,1fr\)\]{grid-template-columns:140px minmax(0,1fr)}.sm\:grid-cols-\[150px_minmax\(0\,1fr\)\]{grid-template-columns:150px minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(260px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(260px,.8fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_96px_92px\]{grid-template-columns:minmax(0,1fr) 96px 92px}.sm\:grid-cols-\[minmax\(0\,1fr\)_190px\]{grid-template-columns:minmax(0,1fr) 190px}.sm\:gap-1{gap:calc(var(--spacing) * 1)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-7{padding-block:calc(var(--spacing) * 7)}.sm\:text-\[0\.86rem\]{font-size:.86rem}.sm\:text-\[0\.92rem\]{font-size:.92rem}}@media(min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:64rem){.lg\:grid-cols-\[minmax\(220px\,1fr\)_160px_200px\]{grid-template-columns:minmax(220px,1fr) 160px 200px}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media(min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}}html,body,#root{height:100%}body{color:var(--color-text);background:var(--color-page);font-family:var(--font-sans);margin:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}