@tokenbuddy/tokenbuddy 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  manifestModelIds,
34
34
  manifestPaymentMethods,
35
35
  manifestProtocols,
36
+ isBuyerVisibleRegistrySeller,
36
37
  normalizeSellerUrl,
37
38
  RegistryTooLargeError,
38
39
  type RegistrySeller,
@@ -56,6 +57,16 @@ import {
56
57
  ROUTING_CONFIG_KEY,
57
58
  type BuyerSellerRoutingConfig
58
59
  } from "./seller-routing-config.js";
60
+ import {
61
+ assertInitSetupSteps,
62
+ buildCompletedInitSetupMarker,
63
+ INIT_SETUP_CONFIG_KEY,
64
+ INIT_SETUP_STEPS,
65
+ type InitSetupMarker,
66
+ isFreshInitMachine,
67
+ normalizeInitSetupMarker,
68
+ resolveInitRecommendedModels,
69
+ } from "./init-setup.js";
59
70
 
60
71
  const logger = createModuleLogger("tb-proxyd");
61
72
  const FOCUS_SET_CONFIG_KEY = "focus-set";
@@ -99,6 +110,31 @@ interface ClientToolStatus {
99
110
  };
100
111
  }
101
112
 
113
+ type InitDoctorCheckStatus = "passed" | "warning" | "failed" | "skipped";
114
+ type InitDoctorStatus = "passed" | "warning" | "failed";
115
+
116
+ interface InitDoctorCheck {
117
+ id: string;
118
+ label: string;
119
+ status: InitDoctorCheckStatus;
120
+ message: string;
121
+ details: string[];
122
+ }
123
+
124
+ interface InitDoctorReport {
125
+ status: InitDoctorStatus;
126
+ generatedAt: string;
127
+ checks: InitDoctorCheck[];
128
+ }
129
+
130
+ interface InitDoctorCatalogSnapshot {
131
+ available: boolean;
132
+ models: Array<{ id: string; sellerId: string; sellerName?: string; sellerUrl: string; supportedProtocols: string[]; paymentMethods: string[] }>;
133
+ sellers: Array<{ id: string; name?: string; url: string; status: string; manifestSellerId?: string; errorMessage?: string }>;
134
+ source: "live" | "cached" | "unavailable";
135
+ errorMessage?: string;
136
+ }
137
+
102
138
  function clientToolStatusFromProvider(provider: ProviderCandidate): ClientToolStatus {
103
139
  return {
104
140
  id: provider.id,
@@ -192,6 +228,8 @@ export interface DaemonConfig {
192
228
  * 缺省时由 BuyerStore 历史模型使用情况 + `TB_BUYER_WARMUP_MODELS` env 推导。
193
229
  */
194
230
  warmupModels?: string[];
231
+ /** Web init wizard recommended model ids. Env override: TB_PROXYD_INIT_RECOMMENDED_MODELS. */
232
+ initRecommendedModels?: string[];
195
233
  /** 预热模型目录刷新间隔(秒) */
196
234
  warmupRefreshIntervalSecs?: number;
197
235
  /** 预热探测超时(毫秒) */
@@ -843,6 +881,229 @@ export class TokenbuddyDaemon {
843
881
  };
844
882
  }
845
883
 
884
+ private livePayments(): PaymentConfig[] {
885
+ return this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir));
886
+ }
887
+
888
+ private clientToolsSummary(): { clients: ClientToolStatus[]; summary: { configuredCount: number; detectedCount: number; totalCount: number; installCommand: string } } {
889
+ const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
890
+ const clients = [
891
+ ...providerStatuses,
892
+ buildCustomClientToolStatus(this.activeProxyPort()),
893
+ ];
894
+ const configuredCount = clients.filter((client) => client.configured).length;
895
+ const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
896
+ return {
897
+ clients,
898
+ summary: {
899
+ configuredCount,
900
+ detectedCount,
901
+ totalCount: clients.length,
902
+ installCommand: "tb init"
903
+ }
904
+ };
905
+ }
906
+
907
+ private initRepairStatus(input: {
908
+ setup: InitSetupMarker;
909
+ payments: PaymentConfig[];
910
+ clientsSummary: { configuredCount: number };
911
+ focusSet: string[];
912
+ }): { repairMode: boolean; repairReasons: string[] } {
913
+ if (input.setup.status !== "completed") {
914
+ return { repairMode: false, repairReasons: [] };
915
+ }
916
+ const completedSteps = new Set(input.setup.completedSteps);
917
+ const missingSteps = INIT_SETUP_STEPS.filter((step) => !completedSteps.has(step));
918
+ const repairReasons: string[] = [];
919
+ if (missingSteps.length > 0) {
920
+ repairReasons.push(`missing_steps:${missingSteps.join(",")}`);
921
+ }
922
+ if (input.focusSet.length === 0) {
923
+ repairReasons.push("missing_focus_models");
924
+ }
925
+ if (!input.payments.some((payment) => payment.enabled)) {
926
+ repairReasons.push("missing_payment_method");
927
+ }
928
+ if (input.clientsSummary.configuredCount === 0) {
929
+ repairReasons.push("missing_connected_tools");
930
+ }
931
+ return {
932
+ repairMode: repairReasons.length > 0,
933
+ repairReasons,
934
+ };
935
+ }
936
+
937
+ private initStateSnapshot() {
938
+ const record = this.tokenStore.getDaemonRuntimeConfig<unknown>(INIT_SETUP_CONFIG_KEY);
939
+ const setup = normalizeInitSetupMarker(record?.config);
940
+ const storedRouting = this.tokenStore.getDaemonRuntimeConfig<unknown>(ROUTING_CONFIG_KEY)?.config;
941
+ const routingSource: "store" | "config" | "default" =
942
+ storedRouting !== undefined ? "store" : this.config.sellerRouting ? "config" : "default";
943
+ const clients = this.clientToolsSummary();
944
+ const payments = this.livePayments();
945
+ const focusSet = this.resolveFocusSet();
946
+ const recommendedModels = resolveInitRecommendedModels({
947
+ configuredModels: this.config.initRecommendedModels,
948
+ env: process.env,
949
+ });
950
+ const repair = this.initRepairStatus({
951
+ setup,
952
+ payments,
953
+ clientsSummary: clients.summary,
954
+ focusSet,
955
+ });
956
+ return {
957
+ setup: {
958
+ ...setup,
959
+ createdAt: record?.createdAt,
960
+ updatedAt: record?.updatedAt ?? setup.updatedAt,
961
+ },
962
+ freshMachine: isFreshInitMachine(setup),
963
+ repairMode: repair.repairMode,
964
+ repairReasons: repair.repairReasons,
965
+ runtime: this.runtimeSummary(),
966
+ payments,
967
+ clients: clients.clients,
968
+ clientsSummary: clients.summary,
969
+ routing: {
970
+ strategy: this.sellerRouting,
971
+ source: routingSource,
972
+ },
973
+ focusSet,
974
+ recommendedModels,
975
+ };
976
+ }
977
+
978
+ private async buildInitDoctorReport(): Promise<InitDoctorReport> {
979
+ const catalog = await this.initDoctorCatalogSnapshot();
980
+ const currentRouting = this.refreshSellerRoutingConfig();
981
+ const payments = this.livePayments().filter((payment) => payment.enabled);
982
+ const clients = this.clientToolsSummary();
983
+ const routeModelId = this.resolveFocusSet()[0] || catalog.models[0]?.id;
984
+ const routingPreview = routeModelId ? this.buildRoutingPreview({ modelId: routeModelId, routing: currentRouting }) : undefined;
985
+ const checks: InitDoctorCheck[] = [
986
+ {
987
+ id: "local_service",
988
+ label: "本地服务",
989
+ status: this.controlServer && this.proxyServer ? "passed" : "failed",
990
+ message: this.controlServer && this.proxyServer
991
+ ? "tb-proxyd 正在运行,控制面和代理端口已经打开。"
992
+ : "tb-proxyd 本地服务尚未完全启动。",
993
+ details: [
994
+ `控制面:http://127.0.0.1:${this.activeControlPort()}`,
995
+ `代理:http://127.0.0.1:${this.activeProxyPort()}`
996
+ ]
997
+ },
998
+ {
999
+ id: "proxy_interface",
1000
+ label: "代理接口",
1001
+ status: this.proxyServer ? "passed" : "failed",
1002
+ message: this.proxyServer
1003
+ ? "OpenAI 和 Anthropic 兼容本地接口已就绪。"
1004
+ : "本地代理接口尚未就绪。",
1005
+ details: [
1006
+ `OpenAI 兼容 Base URL:http://127.0.0.1:${this.activeProxyPort()}/v1`,
1007
+ `Anthropic 兼容 Base URL:http://127.0.0.1:${this.activeProxyPort()}`
1008
+ ]
1009
+ },
1010
+ {
1011
+ id: "seller_registry",
1012
+ label: "供应商注册表",
1013
+ status: catalog.available && catalog.sellers.length > 0 ? "passed" : "failed",
1014
+ message: catalog.available && catalog.sellers.length > 0
1015
+ ? `已加载 ${catalog.sellers.length} 个供应商。`
1016
+ : "TokenBuddy 还没有可用供应商注册表。",
1017
+ details: [
1018
+ `来源:${formatInitDoctorCatalogSource(catalog.source)}`,
1019
+ ...(catalog.errorMessage ? [catalog.errorMessage] : [])
1020
+ ]
1021
+ },
1022
+ {
1023
+ id: "model_catalog",
1024
+ label: "模型目录",
1025
+ status: catalog.available && catalog.models.length > 0 ? "passed" : "failed",
1026
+ message: catalog.available && catalog.models.length > 0
1027
+ ? `已发现 ${new Set(catalog.models.map((model) => model.id)).size} 个可用模型。`
1028
+ : "TokenBuddy 还没有加载到供应商支持的模型。",
1029
+ details: catalog.models.slice(0, 5).map((model) => model.id)
1030
+ },
1031
+ {
1032
+ id: "routing_strategy",
1033
+ label: "路由策略",
1034
+ status: routingPreview && !("error" in routingPreview.plan) && routingPreview.plan.routes.length > 0 ? "passed" : "failed",
1035
+ message: routingPreview && !("error" in routingPreview.plan) && routingPreview.plan.routes.length > 0
1036
+ ? `当前策略可以为 ${routingPreview.modelId} 找到 ${routingPreview.plan.routes.length} 条供应商路径。`
1037
+ : "当前模型和路由策略下没有可用供应商。",
1038
+ details: routingPreview
1039
+ ? ["error" in routingPreview.plan
1040
+ ? `原因:${routingPreview.plan.error}`
1041
+ : `策略:${routingPreview.plan.mode}:${routingPreview.plan.scorer}`]
1042
+ : ["没有可用于路由验证的模型。"]
1043
+ },
1044
+ {
1045
+ id: "payment_method",
1046
+ label: "支付方式",
1047
+ status: payments.length > 0 ? "passed" : "warning",
1048
+ message: payments.length > 0
1049
+ ? `已启用 ${payments.length} 个支付方式。`
1050
+ : "尚未启用支付方式;自动购买会在绑定支付前暂停。",
1051
+ details: payments.map((payment) => payment.method)
1052
+ },
1053
+ {
1054
+ id: "connected_tools",
1055
+ label: "已连接工具",
1056
+ status: clients.summary.configuredCount > 0 ? "passed" : "warning",
1057
+ message: clients.summary.configuredCount > 0
1058
+ ? `已配置 ${clients.summary.configuredCount} 个 AI 工具。`
1059
+ : "尚未检测到已接入 TokenBuddy 的 AI 工具。",
1060
+ details: clients.clients
1061
+ .filter((client) => client.configured)
1062
+ .map((client) => client.name)
1063
+ }
1064
+ ];
1065
+ const status: InitDoctorStatus = checks.some((check) => check.status === "failed")
1066
+ ? "failed"
1067
+ : checks.some((check) => check.status === "warning" || check.status === "skipped")
1068
+ ? "warning"
1069
+ : "passed";
1070
+ return {
1071
+ status,
1072
+ generatedAt: new Date().toISOString(),
1073
+ checks
1074
+ };
1075
+ }
1076
+
1077
+ private async initDoctorCatalogSnapshot(): Promise<InitDoctorCatalogSnapshot> {
1078
+ try {
1079
+ const catalog = await this.listSellerBackedModels();
1080
+ return {
1081
+ available: true,
1082
+ models: catalog.models,
1083
+ sellers: catalog.sellers,
1084
+ source: "live"
1085
+ };
1086
+ } catch (error: unknown) {
1087
+ const errorMessage = error instanceof Error ? error.message : String(error);
1088
+ if (this.lastRegistrySnapshot) {
1089
+ const cached = catalogSnapshotFromRegistry(this.lastRegistrySnapshot);
1090
+ return {
1091
+ ...cached,
1092
+ available: cached.sellers.length > 0,
1093
+ source: "cached",
1094
+ errorMessage
1095
+ };
1096
+ }
1097
+ return {
1098
+ available: false,
1099
+ models: [],
1100
+ sellers: [],
1101
+ source: "unavailable",
1102
+ errorMessage
1103
+ };
1104
+ }
1105
+ }
1106
+
846
1107
  private endpointProtocol(endpoint: string): string | undefined {
847
1108
  if (endpoint === "/v1/chat/completions") {
848
1109
  return "chat_completions";
@@ -1273,6 +1534,7 @@ export class TokenbuddyDaemon {
1273
1534
  sellers: Array<{ id: string; name?: string; url: string; status: string; manifestSellerId?: string; errorMessage?: string }>;
1274
1535
  }> {
1275
1536
  const catalog = await discoverSellerBackedModels(this.config.sellerRegistryUrl);
1537
+ this.lastRegistrySnapshot = sellerCatalogResultToRegistrySnapshot(catalog);
1276
1538
  return {
1277
1539
  models: catalog.models,
1278
1540
  sellers: catalog.sellers
@@ -2320,10 +2582,75 @@ export class TokenbuddyDaemon {
2320
2582
  controlApp.get("/payments", (req, res) => {
2321
2583
  logger.info("control.payments.requested", "control payments requested", {});
2322
2584
  res.status(200).json({
2323
- payments: this.tokenStore.listPayments().map((payment) => withLiveClawtipWalletState(payment, this.config.clawtipHomeDir))
2585
+ payments: this.livePayments()
2324
2586
  });
2325
2587
  });
2326
2588
 
2589
+ controlApp.get("/init/state", (req, res) => {
2590
+ try {
2591
+ const state = this.initStateSnapshot();
2592
+ logger.info("init.state.requested", "init setup state requested", {
2593
+ setupStatus: state.setup.status,
2594
+ freshMachine: state.freshMachine,
2595
+ configuredClientCount: state.clientsSummary.configuredCount,
2596
+ paymentCount: state.payments.length
2597
+ });
2598
+ res.status(200).json(state);
2599
+ } catch (error: unknown) {
2600
+ const errorMessage = error instanceof Error ? error.message : String(error);
2601
+ logger.warn("init.state.failed", "init setup state failed", { errorMessage });
2602
+ res.status(500).json({
2603
+ error: {
2604
+ code: "init_state_failed",
2605
+ message: errorMessage
2606
+ }
2607
+ });
2608
+ }
2609
+ });
2610
+
2611
+ controlApp.post("/init/complete", (req, res) => {
2612
+ try {
2613
+ const completedSteps = assertInitSetupSteps(req.body?.completedSteps);
2614
+ const setup = buildCompletedInitSetupMarker(completedSteps);
2615
+ this.tokenStore.saveDaemonRuntimeConfig(INIT_SETUP_CONFIG_KEY, setup);
2616
+ const state = this.initStateSnapshot();
2617
+ logger.info("init.setup.completed", "init setup marker completed", {
2618
+ completedStepCount: setup.completedSteps.length
2619
+ });
2620
+ res.status(200).json(state);
2621
+ } catch (error: unknown) {
2622
+ const errorMessage = error instanceof Error ? error.message : String(error);
2623
+ logger.warn("init.setup.complete_failed", "init setup complete failed", { errorMessage });
2624
+ res.status(400).json({
2625
+ error: {
2626
+ code: "init_setup_complete_failed",
2627
+ message: errorMessage
2628
+ }
2629
+ });
2630
+ }
2631
+ });
2632
+
2633
+ controlApp.post("/init/doctor/run", async (_req, res) => {
2634
+ try {
2635
+ const report = await this.buildInitDoctorReport();
2636
+ logger.info("init.doctor.completed", "init doctor completed", {
2637
+ status: report.status,
2638
+ failedCount: report.checks.filter((check) => check.status === "failed").length,
2639
+ warningCount: report.checks.filter((check) => check.status === "warning").length
2640
+ });
2641
+ res.status(200).json(report);
2642
+ } catch (error: unknown) {
2643
+ const errorMessage = error instanceof Error ? error.message : String(error);
2644
+ logger.warn("init.doctor.failed", "init doctor failed", { errorMessage });
2645
+ res.status(500).json({
2646
+ error: {
2647
+ code: "init_doctor_failed",
2648
+ message: errorMessage
2649
+ }
2650
+ });
2651
+ }
2652
+ });
2653
+
2327
2654
  controlApp.post("/payments/clawtip/activate", async (req, res) => {
2328
2655
  try {
2329
2656
  const qr = await this.startClawtipActivationQr();
@@ -2499,27 +2826,13 @@ export class TokenbuddyDaemon {
2499
2826
 
2500
2827
  controlApp.get("/providers/status", (_req, res) => {
2501
2828
  try {
2502
- const providerStatuses = detectProviders({ home: this.config.providerHomeDir }).map(clientToolStatusFromProvider);
2503
- const clients = [
2504
- ...providerStatuses,
2505
- buildCustomClientToolStatus(this.activeProxyPort()),
2506
- ];
2507
- const configuredCount = clients.filter((client) => client.configured).length;
2508
- const detectedCount = clients.filter((client) => client.detected && client.status !== "manual").length;
2829
+ const status = this.clientToolsSummary();
2509
2830
  logger.info("provider.status.requested", "provider status requested", {
2510
- clientCount: clients.length,
2511
- configuredCount,
2512
- detectedCount
2513
- });
2514
- res.status(200).json({
2515
- clients,
2516
- summary: {
2517
- configuredCount,
2518
- detectedCount,
2519
- totalCount: clients.length,
2520
- installCommand: "tb init"
2521
- }
2831
+ clientCount: status.clients.length,
2832
+ configuredCount: status.summary.configuredCount,
2833
+ detectedCount: status.summary.detectedCount
2522
2834
  });
2835
+ res.status(200).json(status);
2523
2836
  } catch (error: unknown) {
2524
2837
  const errorMessage = error instanceof Error ? error.message : String(error);
2525
2838
  logger.warn("provider.status.failed", "provider status failed", { errorMessage });
@@ -3098,6 +3411,58 @@ function routingKey(routing: BuyerSellerRoutingConfig): string {
3098
3411
  ].join("\u0001");
3099
3412
  }
3100
3413
 
3414
+ function formatInitDoctorCatalogSource(source: InitDoctorCatalogSnapshot["source"]): string {
3415
+ if (source === "live") {
3416
+ return "实时注册表";
3417
+ }
3418
+ if (source === "cached") {
3419
+ return "本机缓存";
3420
+ }
3421
+ return "不可用";
3422
+ }
3423
+
3424
+ function sellerCatalogResultToRegistrySnapshot(catalog: Awaited<ReturnType<typeof discoverSellerBackedModels>>): SellerRegistryDocument {
3425
+ return {
3426
+ version: catalog.version,
3427
+ defaultSeller: catalog.defaultSeller,
3428
+ sellers: catalog.sellers
3429
+ .filter((seller) => seller.status === "ok")
3430
+ .map((seller) => ({
3431
+ id: seller.id,
3432
+ name: seller.name,
3433
+ status: "active",
3434
+ url: seller.url,
3435
+ supportedProtocols: seller.supportedProtocols,
3436
+ paymentMethods: seller.paymentMethods,
3437
+ models: catalog.models
3438
+ .filter((model) => model.sellerId === seller.id)
3439
+ .map((model) => model.id)
3440
+ }))
3441
+ };
3442
+ }
3443
+
3444
+ function catalogSnapshotFromRegistry(registry: SellerRegistryDocument): InitDoctorCatalogSnapshot {
3445
+ const visibleSellers = registry.sellers.filter(isBuyerVisibleRegistrySeller);
3446
+ return {
3447
+ available: visibleSellers.length > 0,
3448
+ source: "cached",
3449
+ models: visibleSellers.flatMap((seller) => (seller.models ?? []).map((modelId) => ({
3450
+ id: modelId,
3451
+ sellerId: seller.id,
3452
+ sellerName: seller.name,
3453
+ sellerUrl: seller.url,
3454
+ supportedProtocols: seller.supportedProtocols ?? [],
3455
+ paymentMethods: seller.paymentMethods ?? []
3456
+ }))),
3457
+ sellers: visibleSellers.map((seller) => ({
3458
+ id: seller.id,
3459
+ name: seller.name,
3460
+ url: seller.url,
3461
+ status: seller.status ?? "active"
3462
+ }))
3463
+ };
3464
+ }
3465
+
3101
3466
  /**
3102
3467
  * 从 query string 构造 `BuyerSellerRoutingConfig` override(用于 GET /routing/preview)。
3103
3468
  * 接受 `mode` / `scorer` / `sellerId` / `sellerIds`(逗号分隔)。
@@ -0,0 +1,165 @@
1
+ export const INIT_SETUP_CONFIG_KEY = "init-setup";
2
+
3
+ export const INIT_SETUP_VERSION = 1;
4
+
5
+ export const INIT_SETUP_STEPS = [
6
+ "gateway_intro",
7
+ "model_access",
8
+ "supplier_routing",
9
+ "auto_purchase",
10
+ "connect_tools",
11
+ "verify_gateway",
12
+ "install_app",
13
+ ] as const;
14
+
15
+ export const DEFAULT_INIT_RECOMMENDED_MODELS = [
16
+ "deepseek-v4-flash",
17
+ "kimi-2.6",
18
+ "gpt-5.5",
19
+ "gpt-5.4",
20
+ "claude-opus-4.7",
21
+ "claude-opus-4.8",
22
+ "claude-sonnet-4.6",
23
+ "gemma-4-26b-a4b-it",
24
+ "qwen3-235b-a22b",
25
+ "google/gemini-3.5-flash",
26
+ "google/gemini-3.1-pro-preview",
27
+ "qwen/qwen3.7-max",
28
+ "qwen/qwen3.7-plus",
29
+ ] as const;
30
+
31
+ export type InitSetupStep = typeof INIT_SETUP_STEPS[number];
32
+ export type InitSetupStatus = "not_started" | "in_progress" | "completed";
33
+
34
+ export interface InitSetupMarker {
35
+ status: InitSetupStatus;
36
+ version: typeof INIT_SETUP_VERSION;
37
+ completedSteps: InitSetupStep[];
38
+ startedAt?: string;
39
+ updatedAt?: string;
40
+ completedAt?: string;
41
+ }
42
+
43
+ const INIT_SETUP_STEP_SET = new Set<string>(INIT_SETUP_STEPS);
44
+
45
+ export function normalizeInitSetupMarker(value: unknown): InitSetupMarker {
46
+ if (!value || typeof value !== "object") {
47
+ return defaultInitSetupMarker();
48
+ }
49
+ const raw = value as {
50
+ status?: unknown;
51
+ completedSteps?: unknown;
52
+ startedAt?: unknown;
53
+ updatedAt?: unknown;
54
+ completedAt?: unknown;
55
+ };
56
+ const status = normalizeInitSetupStatus(raw.status);
57
+ return {
58
+ status,
59
+ version: INIT_SETUP_VERSION,
60
+ completedSteps: normalizeInitSetupSteps(raw.completedSteps),
61
+ startedAt: typeof raw.startedAt === "string" ? raw.startedAt : undefined,
62
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined,
63
+ completedAt: typeof raw.completedAt === "string" ? raw.completedAt : undefined,
64
+ };
65
+ }
66
+
67
+ export function buildInProgressInitSetupMarker(
68
+ previous: InitSetupMarker = defaultInitSetupMarker(),
69
+ now: Date = new Date(),
70
+ ): InitSetupMarker {
71
+ const updatedAt = now.toISOString();
72
+ return {
73
+ ...previous,
74
+ status: previous.status === "completed" ? "completed" : "in_progress",
75
+ version: INIT_SETUP_VERSION,
76
+ startedAt: previous.startedAt || updatedAt,
77
+ updatedAt,
78
+ };
79
+ }
80
+
81
+ export function buildCompletedInitSetupMarker(
82
+ completedSteps: readonly string[] = INIT_SETUP_STEPS,
83
+ now: Date = new Date(),
84
+ ): InitSetupMarker {
85
+ const normalizedSteps = normalizeInitSetupSteps(completedSteps);
86
+ const updatedAt = now.toISOString();
87
+ return {
88
+ status: "completed",
89
+ version: INIT_SETUP_VERSION,
90
+ completedSteps: normalizedSteps.length > 0 ? normalizedSteps : [...INIT_SETUP_STEPS],
91
+ startedAt: updatedAt,
92
+ updatedAt,
93
+ completedAt: updatedAt,
94
+ };
95
+ }
96
+
97
+ export function assertInitSetupSteps(value: unknown): InitSetupStep[] {
98
+ if (value === undefined) {
99
+ return [...INIT_SETUP_STEPS];
100
+ }
101
+ if (!Array.isArray(value)) {
102
+ throw new Error("completedSteps must be an array");
103
+ }
104
+ const steps: InitSetupStep[] = [];
105
+ for (const entry of value) {
106
+ if (typeof entry !== "string" || !INIT_SETUP_STEP_SET.has(entry)) {
107
+ throw new Error(`unknown init setup step: ${String(entry)}`);
108
+ }
109
+ if (!steps.includes(entry as InitSetupStep)) {
110
+ steps.push(entry as InitSetupStep);
111
+ }
112
+ }
113
+ return steps;
114
+ }
115
+
116
+ export function isFreshInitMachine(marker: InitSetupMarker): boolean {
117
+ return marker.status !== "completed";
118
+ }
119
+
120
+ export function normalizeInitRecommendedModels(value: readonly string[] | string | undefined): string[] {
121
+ const entries = typeof value === "string"
122
+ ? value.split(",")
123
+ : value ?? [];
124
+ return Array.from(new Set(entries.map((entry) => entry.trim()).filter(Boolean)));
125
+ }
126
+
127
+ export function resolveInitRecommendedModels(input?: {
128
+ configuredModels?: readonly string[];
129
+ env?: Record<string, string | undefined>;
130
+ }): string[] {
131
+ const configured = normalizeInitRecommendedModels(input?.configuredModels);
132
+ if (configured.length > 0) {
133
+ return configured;
134
+ }
135
+ const envModels = normalizeInitRecommendedModels(input?.env?.TB_PROXYD_INIT_RECOMMENDED_MODELS);
136
+ if (envModels.length > 0) {
137
+ return envModels;
138
+ }
139
+ return [...DEFAULT_INIT_RECOMMENDED_MODELS];
140
+ }
141
+
142
+ function defaultInitSetupMarker(): InitSetupMarker {
143
+ return {
144
+ status: "not_started",
145
+ version: INIT_SETUP_VERSION,
146
+ completedSteps: [],
147
+ };
148
+ }
149
+
150
+ function normalizeInitSetupStatus(value: unknown): InitSetupStatus {
151
+ return value === "in_progress" || value === "completed" ? value : "not_started";
152
+ }
153
+
154
+ function normalizeInitSetupSteps(value: unknown): InitSetupStep[] {
155
+ if (!Array.isArray(value)) {
156
+ return [];
157
+ }
158
+ const steps: InitSetupStep[] = [];
159
+ for (const entry of value) {
160
+ if (typeof entry === "string" && INIT_SETUP_STEP_SET.has(entry) && !steps.includes(entry as InitSetupStep)) {
161
+ steps.push(entry as InitSetupStep);
162
+ }
163
+ }
164
+ return steps;
165
+ }