@tokenbuddy/tb-admin 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +98 -25
- package/dist/src/config.d.ts +8 -2
- package/dist/src/config.js +17 -5
- package/dist/src/display-format.js +6 -14
- package/dist/src/init-command.d.ts +50 -0
- package/dist/src/init-command.js +347 -0
- package/dist/src/providers/fly-io.d.ts +3 -0
- package/dist/src/providers/fly-io.js +137 -0
- package/dist/src/providers/provider-definition.d.ts +38 -0
- package/dist/src/providers/provider-definition.js +2 -0
- package/dist/src/seller.d.ts +2 -0
- package/dist/src/seller.js +30 -13
- package/dist/src/server-cmd.d.ts +1 -0
- package/dist/src/server-cmd.js +9 -2
- package/dist/src/ui-actions.d.ts +3 -0
- package/dist/src/ui-actions.js +199 -27
- package/dist/src/ui-command.js +3 -2
- package/dist/src/ui-state.d.ts +1 -3
- package/dist/src/ui-state.js +4 -8
- package/dist/src/ui-static.js +43 -15
- package/dist/src/workdir.d.ts +21 -0
- package/dist/src/workdir.js +50 -0
- package/package.json +9 -3
- package/templates/providers/fly.io/admin.toml.example +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
- package/templates/providers/fly.io/env/deploy.env.example +12 -0
- package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
- package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
- package/templates/providers/fly.io/provider.toml.example +10 -0
- package/dist/src/bootstrap-registry.d.ts.map +0 -1
- package/dist/src/bootstrap-registry.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/client.d.ts.map +0 -1
- package/dist/src/client.js.map +0 -1
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js.map +0 -1
- package/dist/src/display-format.d.ts.map +0 -1
- package/dist/src/display-format.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/provider.d.ts.map +0 -1
- package/dist/src/provider.js.map +0 -1
- package/dist/src/seller.d.ts.map +0 -1
- package/dist/src/seller.js.map +0 -1
- package/dist/src/server-cmd.d.ts.map +0 -1
- package/dist/src/server-cmd.js.map +0 -1
- package/dist/src/ui-actions.d.ts.map +0 -1
- package/dist/src/ui-actions.js.map +0 -1
- package/dist/src/ui-command.d.ts.map +0 -1
- package/dist/src/ui-command.js.map +0 -1
- package/dist/src/ui-server.d.ts.map +0 -1
- package/dist/src/ui-server.js.map +0 -1
- package/dist/src/ui-state.d.ts.map +0 -1
- package/dist/src/ui-state.js.map +0 -1
- package/dist/src/ui-static.d.ts.map +0 -1
- package/dist/src/ui-static.js.map +0 -1
- package/dist/src/upstream-balance-probe.d.ts.map +0 -1
- package/dist/src/upstream-balance-probe.js.map +0 -1
- package/dist/src/vendor-client.d.ts.map +0 -1
- package/dist/src/vendor-client.js.map +0 -1
- package/dist/src/vendor-commands.d.ts.map +0 -1
- package/dist/src/vendor-commands.js.map +0 -1
- package/src/bootstrap-registry.ts +0 -90
- package/src/cli.ts +0 -1614
- package/src/client.ts +0 -179
- package/src/config.ts +0 -194
- package/src/display-format.ts +0 -411
- package/src/index.ts +0 -11
- package/src/provider.ts +0 -150
- package/src/seller.ts +0 -538
- package/src/server-cmd.ts +0 -362
- package/src/ui-actions.ts +0 -1040
- package/src/ui-command.ts +0 -44
- package/src/ui-server.ts +0 -353
- package/src/ui-state.ts +0 -1318
- package/src/ui-static.ts +0 -673
- package/src/upstream-balance-probe.ts +0 -13
- package/src/vendor-client.ts +0 -23
- package/src/vendor-commands.ts +0 -65
- package/tests/admin.test.ts +0 -2162
- package/tests/seller.test.ts +0 -388
- package/tests/ui-state-fleet.test.ts +0 -526
- package/tests/ui-static-row.test.ts +0 -467
- package/tests/vendor-cli.test.ts +0 -241
- package/tsconfig.json +0 -8
package/src/ui-actions.ts
DELETED
|
@@ -1,1040 +0,0 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as os from "os";
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import YAML from "js-yaml";
|
|
6
|
-
import type { SellerRegistryDocument, SellerRegistryEntry } from "./bootstrap-registry.js";
|
|
7
|
-
import { ConfigManager } from "./config.js";
|
|
8
|
-
import { AdminUiState } from "./ui-state.js";
|
|
9
|
-
|
|
10
|
-
export interface UiActionResult {
|
|
11
|
-
ok: boolean;
|
|
12
|
-
stdout: string;
|
|
13
|
-
stderr: string;
|
|
14
|
-
command: string[];
|
|
15
|
-
/**
|
|
16
|
-
* Step 13 (admin web -> CLI --json):
|
|
17
|
-
* 一些子命令 (`tb-admin seller <cmd> --json` 等) spawn 出来的 JSON.
|
|
18
|
-
* 1.0.32 行为 (无此字段) 不变; 新增字段不影响旧调用方.
|
|
19
|
-
*/
|
|
20
|
-
json?: unknown;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface CreateSellerRequest {
|
|
24
|
-
sellerName: string;
|
|
25
|
-
region: string;
|
|
26
|
-
image: string;
|
|
27
|
-
upstreamWebsite: string;
|
|
28
|
-
upstreamUrl: string;
|
|
29
|
-
upstreamApiKey: string;
|
|
30
|
-
upstreamBalanceProbeTemplate?: string;
|
|
31
|
-
upstreamBalanceProbeUrl?: string;
|
|
32
|
-
upstreamBalanceProbeUserId?: string;
|
|
33
|
-
upstreamBalanceProbeRechargeUrl?: string;
|
|
34
|
-
upstreamBalanceUrl?: string;
|
|
35
|
-
upstreamUserId?: string;
|
|
36
|
-
upstreamRechargeUrl?: string;
|
|
37
|
-
maxConnections: number;
|
|
38
|
-
maxQueueDepth: number;
|
|
39
|
-
markupRatio: number;
|
|
40
|
-
discountRatio: number;
|
|
41
|
-
paymentMethods?: string[] | string;
|
|
42
|
-
paymentMethod?: string;
|
|
43
|
-
clawtipPayTo?: string;
|
|
44
|
-
clawtipSm4KeyBase64?: string;
|
|
45
|
-
clawtipSkillSlug?: string;
|
|
46
|
-
clawtipSkillId?: string;
|
|
47
|
-
clawtipDescription?: string;
|
|
48
|
-
clawtipResourceUrl?: string;
|
|
49
|
-
clawtipActivationFeeFen?: number;
|
|
50
|
-
clawtipMicrosPerFen?: number;
|
|
51
|
-
flyConfig?: string;
|
|
52
|
-
operatorSecret?: string;
|
|
53
|
-
app?: string;
|
|
54
|
-
dryRun?: boolean;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface UiActionsOptions {
|
|
58
|
-
configManager: ConfigManager;
|
|
59
|
-
configPath?: string;
|
|
60
|
-
profile?: string;
|
|
61
|
-
url?: string;
|
|
62
|
-
token?: string;
|
|
63
|
-
fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
|
|
64
|
-
balanceFetch?: typeof fetch;
|
|
65
|
-
flyApps?: () => Promise<import("./seller.js").SellerAppJson[]>;
|
|
66
|
-
commandRunner?: (args: string[], timeoutMs: number) => Promise<UiActionResult>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type ConfigPatch = Record<string, unknown>;
|
|
70
|
-
export type UiActionProgressStatus = "pending" | "running" | "succeeded" | "failed" | "skipped";
|
|
71
|
-
export type UiActionProgressStepId = "check_registry" | "validate_config" | "create_deployment" | "wait_seller" | "apply_config" | "refresh_models" | "publish_registry";
|
|
72
|
-
|
|
73
|
-
export interface UiActionProgressEvent {
|
|
74
|
-
stepId: UiActionProgressStepId;
|
|
75
|
-
status: UiActionProgressStatus;
|
|
76
|
-
title: string;
|
|
77
|
-
message?: string;
|
|
78
|
-
result?: UiActionResult;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
type UiActionProgressReporter = (event: UiActionProgressEvent) => void;
|
|
82
|
-
|
|
83
|
-
export class UiActions {
|
|
84
|
-
private readonly state: AdminUiState;
|
|
85
|
-
private readonly options: UiActionsOptions;
|
|
86
|
-
private readonly commandRunner?: (args: string[], timeoutMs: number) => Promise<UiActionResult>;
|
|
87
|
-
|
|
88
|
-
constructor(options: UiActionsOptions) {
|
|
89
|
-
this.options = options;
|
|
90
|
-
this.commandRunner = options.commandRunner;
|
|
91
|
-
this.state = new AdminUiState(options);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
public async updateSellerConfig(id: string, patch: ConfigPatch): Promise<UiActionResult> {
|
|
95
|
-
const { entry, profileName, config } = await this.state.rawSellerConfig(id);
|
|
96
|
-
const next = mergeSellerConfigPatch(config, patch);
|
|
97
|
-
return this.withTempYaml(next, async (filePath) => {
|
|
98
|
-
const validateArgs = profileName
|
|
99
|
-
? profileArgs(this.options, profileName, ["seller-config", "validate", "--file", filePath])
|
|
100
|
-
: sellerOperatorArgs(this.options, entry, ["seller-config", "validate", "--file", filePath]);
|
|
101
|
-
const validate = await this.runAdmin(validateArgs, 30000);
|
|
102
|
-
if (!validate.ok) {
|
|
103
|
-
return validate;
|
|
104
|
-
}
|
|
105
|
-
const putArgs = profileName
|
|
106
|
-
? profileArgs(this.options, profileName, ["seller-config", "put", "--file", filePath])
|
|
107
|
-
: sellerOperatorArgs(this.options, entry, ["seller-config", "put", "--file", filePath]);
|
|
108
|
-
return this.runAdmin(putArgs, 30000);
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
public async createSeller(request: CreateSellerRequest, progress?: UiActionProgressReporter): Promise<{ result: UiActionResult; configValidation: UiActionResult; readiness?: UiActionResult; configPut?: UiActionResult; modelsRefresh?: UiActionResult; registryPublish?: UiActionResult; configPreview: Record<string, unknown>; publishRegistry: "completed" | "failed" | "skipped" }> {
|
|
113
|
-
const normalizedRequest = normalizeCreateSellerRequest(request);
|
|
114
|
-
const report = progress || (() => undefined);
|
|
115
|
-
validateCreateSellerRequest(normalizedRequest);
|
|
116
|
-
report({ stepId: "check_registry", status: "running", title: "Check bootstrap registry", message: "Checking seller name conflicts before creating Fly resources." });
|
|
117
|
-
const registry = await this.state.fetchRegistry();
|
|
118
|
-
const appName = normalizedRequest.app || normalizedRequest.sellerName;
|
|
119
|
-
const conflict = registry.sellers.find((seller) => {
|
|
120
|
-
return seller.id === normalizedRequest.sellerName || seller.name === normalizedRequest.sellerName || seller.app === appName;
|
|
121
|
-
});
|
|
122
|
-
if (conflict) {
|
|
123
|
-
throw new Error(`seller \`${normalizedRequest.sellerName}\` already exists in bootstrap registry`);
|
|
124
|
-
}
|
|
125
|
-
report({ stepId: "check_registry", status: "succeeded", title: "Check bootstrap registry", message: "No registry conflict found." });
|
|
126
|
-
const provider = this.options.configManager.getSellerProvider("fly");
|
|
127
|
-
const operatorSecret = normalizedRequest.operatorSecret || provider?.operator_secret;
|
|
128
|
-
const flyConfig = normalizedRequest.flyConfig;
|
|
129
|
-
if (!operatorSecret) {
|
|
130
|
-
throw new Error("operatorSecret is required in local admin config seller_providers.fly.operator_secret or request body");
|
|
131
|
-
}
|
|
132
|
-
if (!flyConfig) {
|
|
133
|
-
throw new Error("flyConfig is required before creating a seller deployment");
|
|
134
|
-
}
|
|
135
|
-
const configRequest = {
|
|
136
|
-
...normalizedRequest,
|
|
137
|
-
operatorSecret
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
return this.withTempYaml(initialSellerConfig(configRequest, false), async (filePath) => {
|
|
141
|
-
const normalizedUpstreamMessage = normalizedRequest.upstreamUrl !== request.upstreamUrl
|
|
142
|
-
? `Normalized upstreamUrl to ${normalizedRequest.upstreamUrl} before validation.`
|
|
143
|
-
: "";
|
|
144
|
-
report({ stepId: "validate_config", status: "running", title: "Validate seller config", message: normalizedUpstreamMessage || "Validating generated seller config." });
|
|
145
|
-
const configValidation = await this.runAdmin(globalArgs(this.options, ["seller-config", "validate", "--file", filePath]), 30000);
|
|
146
|
-
report({
|
|
147
|
-
stepId: "validate_config",
|
|
148
|
-
status: configValidation.ok ? "succeeded" : "failed",
|
|
149
|
-
title: "Validate seller config",
|
|
150
|
-
message: configValidation.ok ? `${normalizedUpstreamMessage} Seller config is valid.`.trim() : "Seller config validation failed.",
|
|
151
|
-
result: configValidation
|
|
152
|
-
});
|
|
153
|
-
if (!configValidation.ok) {
|
|
154
|
-
return {
|
|
155
|
-
result: configValidation,
|
|
156
|
-
configValidation,
|
|
157
|
-
configPreview: initialSellerConfig(configRequest, true),
|
|
158
|
-
publishRegistry: "skipped"
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
const args = [
|
|
162
|
-
"seller",
|
|
163
|
-
"create",
|
|
164
|
-
normalizedRequest.sellerName,
|
|
165
|
-
"--region",
|
|
166
|
-
normalizedRequest.region,
|
|
167
|
-
"--image",
|
|
168
|
-
normalizedRequest.image,
|
|
169
|
-
"--fly-config",
|
|
170
|
-
flyConfig,
|
|
171
|
-
"--initial-config",
|
|
172
|
-
filePath,
|
|
173
|
-
"--operator-secret",
|
|
174
|
-
operatorSecret
|
|
175
|
-
];
|
|
176
|
-
if (normalizedRequest.app) {
|
|
177
|
-
args.push("--app", normalizedRequest.app);
|
|
178
|
-
}
|
|
179
|
-
if (normalizedRequest.dryRun) {
|
|
180
|
-
args.push("--dry-run");
|
|
181
|
-
}
|
|
182
|
-
report({ stepId: "create_deployment", status: "running", title: "Create Fly deployment", message: `Creating ${appName} in ${normalizedRequest.region}.` });
|
|
183
|
-
// Step 13: 调 CLI --json 路径, admin web 拿到 result.json (结构化) + 1.0.32 result.stdout (兼容 UI)
|
|
184
|
-
const result = await this.runAdminJson(globalArgs(this.options, args), 10 * 60 * 1000);
|
|
185
|
-
report({
|
|
186
|
-
stepId: "create_deployment",
|
|
187
|
-
status: result.ok ? "succeeded" : "failed",
|
|
188
|
-
title: "Create Fly deployment",
|
|
189
|
-
message: result.ok ? "Fly deployment command completed." : "Fly deployment command failed.",
|
|
190
|
-
result
|
|
191
|
-
});
|
|
192
|
-
let configPut: UiActionResult | undefined;
|
|
193
|
-
let modelsRefresh: UiActionResult | undefined;
|
|
194
|
-
let readiness: UiActionResult | undefined;
|
|
195
|
-
if (result.ok && !normalizedRequest.dryRun) {
|
|
196
|
-
readiness = await this.waitForSellerReady(appName, operatorSecret, report);
|
|
197
|
-
if (!readiness.ok) {
|
|
198
|
-
return {
|
|
199
|
-
result,
|
|
200
|
-
configValidation,
|
|
201
|
-
readiness,
|
|
202
|
-
configPreview: initialSellerConfig(configRequest, true),
|
|
203
|
-
publishRegistry: "skipped"
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
report({ stepId: "apply_config", status: "running", title: "Apply seller config", message: `Writing config to ${sellerOperatorUrl(appName)}.` });
|
|
207
|
-
configPut = await this.runAdmin(globalArgs(
|
|
208
|
-
{
|
|
209
|
-
...this.options,
|
|
210
|
-
profile: undefined,
|
|
211
|
-
url: sellerOperatorUrl(appName),
|
|
212
|
-
token: operatorSecret
|
|
213
|
-
},
|
|
214
|
-
["seller-config", "put", "--file", filePath]
|
|
215
|
-
), 30000);
|
|
216
|
-
report({
|
|
217
|
-
stepId: "apply_config",
|
|
218
|
-
status: configPut.ok ? "succeeded" : "failed",
|
|
219
|
-
title: "Apply seller config",
|
|
220
|
-
message: configPut.ok ? "Seller config was written to the deployment." : "Seller config write failed.",
|
|
221
|
-
result: configPut
|
|
222
|
-
});
|
|
223
|
-
if (configPut.ok) {
|
|
224
|
-
report({ stepId: "refresh_models", status: "running", title: "Refresh upstream models", message: `Refreshing model catalog from ${sellerOperatorUrl(appName)}.` });
|
|
225
|
-
modelsRefresh = await this.refreshSellerModelsWithRetry(appName, operatorSecret, report);
|
|
226
|
-
report({
|
|
227
|
-
stepId: "refresh_models",
|
|
228
|
-
status: modelsRefresh.ok ? "succeeded" : "failed",
|
|
229
|
-
title: "Refresh upstream models",
|
|
230
|
-
message: modelsRefresh.ok ? "Upstream model catalog was refreshed." : "Upstream model refresh failed.",
|
|
231
|
-
result: modelsRefresh
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
} else if (normalizedRequest.dryRun) {
|
|
235
|
-
report({ stepId: "apply_config", status: "skipped", title: "Apply seller config", message: "Skipped because this is a dry run." });
|
|
236
|
-
report({ stepId: "refresh_models", status: "skipped", title: "Refresh upstream models", message: "Skipped because this is a dry run." });
|
|
237
|
-
}
|
|
238
|
-
const registryPublish = await this.publishCreatedSellerRegistryEntry({
|
|
239
|
-
registry,
|
|
240
|
-
request: normalizedRequest,
|
|
241
|
-
appName,
|
|
242
|
-
operatorSecret,
|
|
243
|
-
enabled: Boolean(result.ok && !normalizedRequest.dryRun && readiness?.ok && configPut?.ok && modelsRefresh?.ok),
|
|
244
|
-
report
|
|
245
|
-
});
|
|
246
|
-
if (registryPublish?.ok) {
|
|
247
|
-
this.options.configManager.setProfile(appName, {
|
|
248
|
-
url: sellerOperatorUrl(appName),
|
|
249
|
-
token: operatorSecret
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
return {
|
|
253
|
-
result,
|
|
254
|
-
configValidation,
|
|
255
|
-
readiness,
|
|
256
|
-
configPut,
|
|
257
|
-
modelsRefresh,
|
|
258
|
-
registryPublish,
|
|
259
|
-
configPreview: initialSellerConfig(configRequest, true),
|
|
260
|
-
publishRegistry: registryPublish ? (registryPublish.ok ? "completed" : "failed") : "skipped"
|
|
261
|
-
};
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
public async setRegistryStatus(id: string, status: "active" | "draining" | "offline"): Promise<UiActionResult> {
|
|
266
|
-
// Step 14 (v1.1): tb-admin ui 现在走 vendor-scoped /platform/sellers/{id}/status.
|
|
267
|
-
// 老路径 `tb-admin bootstrap sellers status` (operator-level) 保留给 platform operator 显式调用.
|
|
268
|
-
// Vendor client 用当前 active profile 的 token, 跟 admin web 鉴权一致.
|
|
269
|
-
const registry = await this.state.fetchManagedRegistry();
|
|
270
|
-
const target = registry.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
|
|
271
|
-
if (!target) {
|
|
272
|
-
throw new Error(`seller \`${id}\` not found in bootstrap registry`);
|
|
273
|
-
}
|
|
274
|
-
const { RegistryVendorClient } = await import("./vendor-client.js");
|
|
275
|
-
const profile = (this.state as unknown as { activeBootstrapProfile(): { profile?: { url?: string; token?: string } } }).activeBootstrapProfile();
|
|
276
|
-
const baseUrl = this.options.url || profile.profile?.url;
|
|
277
|
-
const token = this.options.token || profile.profile?.token;
|
|
278
|
-
if (!baseUrl || !token) {
|
|
279
|
-
throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
|
|
280
|
-
}
|
|
281
|
-
const client = new RegistryVendorClient(baseUrl, token);
|
|
282
|
-
const expectedVersion = Number.isFinite(registry.version) && registry.version > 0 ? registry.version : undefined;
|
|
283
|
-
const versionPart = expectedVersion === undefined ? "unversioned" : String(expectedVersion);
|
|
284
|
-
const idempotencyKey = `tb-admin-ui-${target.id}-${status}-${versionPart}-${Date.now()}`;
|
|
285
|
-
const response = await client.setSellerStatus(target.id, status, {
|
|
286
|
-
expectedVersion,
|
|
287
|
-
idempotencyKey
|
|
288
|
-
});
|
|
289
|
-
const newVersion = (response as { registry?: { version?: number } })?.registry?.version ?? (expectedVersion === undefined ? undefined : expectedVersion + 1);
|
|
290
|
-
return {
|
|
291
|
-
ok: true,
|
|
292
|
-
stdout: `Set seller ${target.id} status=${status}${newVersion === undefined ? "" : `: version=${newVersion}`} (vendor path)`,
|
|
293
|
-
stderr: "",
|
|
294
|
-
command: ["PUT", `/platform/sellers/${target.id}/status`, JSON.stringify({ status, ...(expectedVersion === undefined ? {} : { expectedVersion }) })],
|
|
295
|
-
json: response
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
public async deleteDeployment(id: string, confirm: boolean): Promise<UiActionResult> {
|
|
300
|
-
const registry = await this.state.fetchRegistry();
|
|
301
|
-
const target = registry.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
|
|
302
|
-
if (!target) {
|
|
303
|
-
throw new Error(`seller \`${id}\` not found in bootstrap registry`);
|
|
304
|
-
}
|
|
305
|
-
const app = target.app || target.id || target.name;
|
|
306
|
-
const args = ["seller", "remove", app, "--app", app];
|
|
307
|
-
if (!confirm) {
|
|
308
|
-
args.push("--dry-run");
|
|
309
|
-
}
|
|
310
|
-
// Step 13: 调 CLI --json 路径拿结构化 result.json (UI 暂仍按 1.0.32 用 stdout/stderr)
|
|
311
|
-
return this.runAdminJson(globalArgs(this.options, args), confirm ? 10 * 60 * 1000 : 30000);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private async withTempYaml<T>(document: unknown, callback: (filePath: string) => Promise<T>): Promise<T> {
|
|
315
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tb-admin-ui-"));
|
|
316
|
-
const filePath = path.join(dir, "seller-config.yaml");
|
|
317
|
-
try {
|
|
318
|
-
fs.writeFileSync(filePath, YAML.dump(document, { lineWidth: 120, noRefs: true, sortKeys: false }), "utf8");
|
|
319
|
-
return await callback(filePath);
|
|
320
|
-
} finally {
|
|
321
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private async withTempJson<T>(document: unknown, callback: (filePath: string) => Promise<T>): Promise<T> {
|
|
326
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tb-admin-ui-"));
|
|
327
|
-
const filePath = path.join(dir, "registry.json");
|
|
328
|
-
try {
|
|
329
|
-
fs.writeFileSync(filePath, JSON.stringify(document, null, 2), "utf8");
|
|
330
|
-
return await callback(filePath);
|
|
331
|
-
} finally {
|
|
332
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
private runAdmin(args: string[], timeoutMs: number): Promise<UiActionResult> {
|
|
337
|
-
if (this.commandRunner) {
|
|
338
|
-
return this.commandRunner(args, timeoutMs);
|
|
339
|
-
}
|
|
340
|
-
return runTbAdmin(args, timeoutMs);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Step 13: 跟 `runAdmin` 一样 spawn tb-admin, 但 args 末尾追加 `--json` 并解析.
|
|
345
|
-
* 1.0.32 `commandRunner` 注入路径 (test mock): 解析成功就挂 json, 解析失败时 result.json
|
|
346
|
-
* 保持 undefined 但 ok 不变 (mock 拼的 stdout 经常不是 JSON, 强制失败会破坏所有老 test).
|
|
347
|
-
* 生产 spawn 路径走 runTbAdminJson, 那里解析失败才算 ok=false (真的 CLI 异常).
|
|
348
|
-
*/
|
|
349
|
-
private runAdminJson(args: string[], timeoutMs: number): Promise<UiActionResult> {
|
|
350
|
-
if (this.commandRunner) {
|
|
351
|
-
return this.commandRunner([...args, "--json"], timeoutMs).then((result) => {
|
|
352
|
-
const parsed = parseJsonSafely(result.stdout);
|
|
353
|
-
return parsed === undefined ? { ...result } : { ...result, json: parsed };
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
return runTbAdminJson(args, timeoutMs);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private async waitForSellerReady(appName: string, operatorSecret: string, report: UiActionProgressReporter): Promise<UiActionResult> {
|
|
360
|
-
const maxAttempts = 18;
|
|
361
|
-
const delayMs = 5000;
|
|
362
|
-
let last: UiActionResult | undefined;
|
|
363
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
364
|
-
report({
|
|
365
|
-
stepId: "wait_seller",
|
|
366
|
-
status: "running",
|
|
367
|
-
title: "Wait for seller service",
|
|
368
|
-
message: `Waiting for operator API to become ready (${attempt}/${maxAttempts}).`
|
|
369
|
-
});
|
|
370
|
-
last = await this.runAdmin(globalArgs(
|
|
371
|
-
{
|
|
372
|
-
...this.options,
|
|
373
|
-
profile: undefined,
|
|
374
|
-
url: sellerOperatorUrl(appName),
|
|
375
|
-
token: operatorSecret
|
|
376
|
-
},
|
|
377
|
-
["status"]
|
|
378
|
-
), 30000);
|
|
379
|
-
if (last.ok) {
|
|
380
|
-
report({
|
|
381
|
-
stepId: "wait_seller",
|
|
382
|
-
status: "succeeded",
|
|
383
|
-
title: "Wait for seller service",
|
|
384
|
-
message: "Seller operator API is ready.",
|
|
385
|
-
result: last
|
|
386
|
-
});
|
|
387
|
-
return last;
|
|
388
|
-
}
|
|
389
|
-
if (attempt < maxAttempts) {
|
|
390
|
-
await sleep(delayMs);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
const failed = last || {
|
|
394
|
-
ok: false,
|
|
395
|
-
stdout: "",
|
|
396
|
-
stderr: "seller service did not become ready",
|
|
397
|
-
command: []
|
|
398
|
-
};
|
|
399
|
-
report({
|
|
400
|
-
stepId: "wait_seller",
|
|
401
|
-
status: "failed",
|
|
402
|
-
title: "Wait for seller service",
|
|
403
|
-
message: "Seller operator API did not become ready before config write.",
|
|
404
|
-
result: failed
|
|
405
|
-
});
|
|
406
|
-
return failed;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private async refreshSellerModelsWithRetry(appName: string, operatorSecret: string, report: UiActionProgressReporter): Promise<UiActionResult> {
|
|
410
|
-
const maxAttempts = 6;
|
|
411
|
-
const baseDelayMs = 3000;
|
|
412
|
-
let last: UiActionResult | undefined;
|
|
413
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
414
|
-
if (attempt > 1) {
|
|
415
|
-
report({
|
|
416
|
-
stepId: "refresh_models",
|
|
417
|
-
status: "running",
|
|
418
|
-
title: "Refresh upstream models",
|
|
419
|
-
message: `Retrying model catalog refresh (${attempt}/${maxAttempts}).`
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
last = await this.runAdmin(globalArgs(
|
|
423
|
-
{
|
|
424
|
-
...this.options,
|
|
425
|
-
profile: undefined,
|
|
426
|
-
url: sellerOperatorUrl(appName),
|
|
427
|
-
token: operatorSecret
|
|
428
|
-
},
|
|
429
|
-
["upstreams", "refresh", "--auto-models"]
|
|
430
|
-
), 45000);
|
|
431
|
-
if (last.ok) {
|
|
432
|
-
return last;
|
|
433
|
-
}
|
|
434
|
-
if (attempt < maxAttempts) {
|
|
435
|
-
await sleep(baseDelayMs * attempt);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
return last || {
|
|
439
|
-
ok: false,
|
|
440
|
-
stdout: "",
|
|
441
|
-
stderr: "model catalog refresh did not run",
|
|
442
|
-
command: ["tb-admin", "upstreams", "refresh", "--auto-models"]
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
private async publishCreatedSellerRegistryEntry(options: {
|
|
447
|
-
registry: SellerRegistryDocument;
|
|
448
|
-
request: CreateSellerRequest;
|
|
449
|
-
appName: string;
|
|
450
|
-
operatorSecret: string;
|
|
451
|
-
enabled: boolean;
|
|
452
|
-
report: UiActionProgressReporter;
|
|
453
|
-
}): Promise<UiActionResult | undefined> {
|
|
454
|
-
const { registry, request, appName, operatorSecret, enabled, report } = options;
|
|
455
|
-
if (!enabled) {
|
|
456
|
-
report({
|
|
457
|
-
stepId: "publish_registry",
|
|
458
|
-
status: "skipped",
|
|
459
|
-
title: "Publish bootstrap registry",
|
|
460
|
-
message: "Skipped because the deployment workflow did not complete successfully."
|
|
461
|
-
});
|
|
462
|
-
return undefined;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
report({
|
|
466
|
-
stepId: "publish_registry",
|
|
467
|
-
status: "running",
|
|
468
|
-
title: "Update bootstrap registry",
|
|
469
|
-
message: `Adding ${request.sellerName} to bootstrap registry.`
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
let entry: SellerRegistryEntry;
|
|
473
|
-
try {
|
|
474
|
-
const upstreams = await this.fetchSellerOperatorJson(appName, operatorSecret, "/operator/admin/upstreams");
|
|
475
|
-
const service = await this.fetchSellerOperatorJson(appName, operatorSecret, "/operator/admin/service");
|
|
476
|
-
const manifest = await this.fetchSellerOperatorJsonOptional(appName, operatorSecret, "/manifest");
|
|
477
|
-
entry = registryEntryFromCreatedSeller(request, appName, upstreams, service, manifest);
|
|
478
|
-
} catch (err: any) {
|
|
479
|
-
const failed = {
|
|
480
|
-
ok: false,
|
|
481
|
-
stdout: "",
|
|
482
|
-
stderr: redactSensitive(err.message || "failed to build bootstrap registry entry"),
|
|
483
|
-
command: ["fetch", `${sellerOperatorUrl(appName)}/operator/admin/upstreams`]
|
|
484
|
-
};
|
|
485
|
-
report({
|
|
486
|
-
stepId: "publish_registry",
|
|
487
|
-
status: "failed",
|
|
488
|
-
title: "Update bootstrap registry",
|
|
489
|
-
message: "Failed to read seller metadata for bootstrap registry.",
|
|
490
|
-
result: failed
|
|
491
|
-
});
|
|
492
|
-
return failed;
|
|
493
|
-
}
|
|
494
|
-
return this.withTempJson(entry, async (filePath) => {
|
|
495
|
-
const put = await this.runAdmin(globalArgs(this.options, [
|
|
496
|
-
"bootstrap",
|
|
497
|
-
"sellers",
|
|
498
|
-
"add",
|
|
499
|
-
"--file",
|
|
500
|
-
filePath,
|
|
501
|
-
"--expect-version",
|
|
502
|
-
String(registry.version)
|
|
503
|
-
]), 30000);
|
|
504
|
-
report({
|
|
505
|
-
stepId: "publish_registry",
|
|
506
|
-
status: put.ok ? "succeeded" : "failed",
|
|
507
|
-
title: "Update bootstrap registry",
|
|
508
|
-
message: put.ok ? "Bootstrap registry entry was added. Run registry publish to update R2." : "Bootstrap registry update failed.",
|
|
509
|
-
result: put
|
|
510
|
-
});
|
|
511
|
-
return put;
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
private async fetchSellerOperatorJson(appName: string, operatorSecret: string, pathName: string): Promise<Record<string, unknown>> {
|
|
516
|
-
const fetchJson = this.options.fetchJson || defaultFetchJson;
|
|
517
|
-
const response = await fetchJson(`${sellerOperatorUrl(appName)}${pathName}`, {
|
|
518
|
-
headers: { Authorization: `Bearer ${operatorSecret}` }
|
|
519
|
-
});
|
|
520
|
-
if (response && typeof response === "object" && !Array.isArray(response)) {
|
|
521
|
-
return response as Record<string, unknown>;
|
|
522
|
-
}
|
|
523
|
-
return {};
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
private async fetchSellerOperatorJsonOptional(appName: string, operatorSecret: string, pathName: string): Promise<Record<string, unknown>> {
|
|
527
|
-
try {
|
|
528
|
-
return await this.fetchSellerOperatorJson(appName, operatorSecret, pathName);
|
|
529
|
-
} catch {
|
|
530
|
-
return {};
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function registryEntryFromCreatedSeller(
|
|
536
|
-
request: CreateSellerRequest,
|
|
537
|
-
appName: string,
|
|
538
|
-
upstreams: Record<string, unknown>,
|
|
539
|
-
service: Record<string, unknown>,
|
|
540
|
-
manifest: Record<string, unknown>
|
|
541
|
-
): SellerRegistryEntry {
|
|
542
|
-
const wrappedUpstreams = upstreamPayload(upstreams);
|
|
543
|
-
const models = modelIdsFrom(upstreams.models)
|
|
544
|
-
|| modelIdsFrom(wrappedUpstreams?.models)
|
|
545
|
-
|| modelIdsFrom(service.models)
|
|
546
|
-
|| modelIdsFrom(manifest.models);
|
|
547
|
-
if (!models || models.length === 0) {
|
|
548
|
-
throw new Error("seller operator upstreams did not return models for bootstrap registry");
|
|
549
|
-
}
|
|
550
|
-
const supportedProtocols = supportedProtocolsFrom(service.supportedProtocols)
|
|
551
|
-
|| supportedProtocolsFrom(upstreams.supportedProtocols)
|
|
552
|
-
|| supportedProtocolsFrom(wrappedUpstreams?.supportedProtocols)
|
|
553
|
-
|| supportedProtocolsFrom(manifest.supportedProtocols)
|
|
554
|
-
|| supportedProtocolsFrom(manifest.supported_protocols)
|
|
555
|
-
|| ["chat_completions"];
|
|
556
|
-
const paymentMethods = paymentMethodsFromRequest(request);
|
|
557
|
-
return {
|
|
558
|
-
id: appName,
|
|
559
|
-
name: appName,
|
|
560
|
-
app: appName,
|
|
561
|
-
url: sellerOperatorUrl(appName),
|
|
562
|
-
status: "active",
|
|
563
|
-
region: stringValue(request.region),
|
|
564
|
-
modelsCount: models.length || undefined,
|
|
565
|
-
sampleModels: models.slice(0, 5),
|
|
566
|
-
models,
|
|
567
|
-
supportedProtocols,
|
|
568
|
-
paymentMethods,
|
|
569
|
-
recommendedFor: models.slice(0, 5)
|
|
570
|
-
};
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function modelIdsFrom(value: unknown): string[] | undefined {
|
|
574
|
-
if (!Array.isArray(value)) {
|
|
575
|
-
return undefined;
|
|
576
|
-
}
|
|
577
|
-
return Array.from(new Set(value.map((entry) => {
|
|
578
|
-
if (typeof entry === "string") {
|
|
579
|
-
return entry;
|
|
580
|
-
}
|
|
581
|
-
if (entry && typeof entry === "object" && "id" in entry) {
|
|
582
|
-
const id = (entry as { id?: unknown }).id;
|
|
583
|
-
return typeof id === "string" && id.trim() ? id.trim() : undefined;
|
|
584
|
-
}
|
|
585
|
-
return undefined;
|
|
586
|
-
}).filter((entry): entry is string => Boolean(entry))));
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function upstreamPayload(value: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
590
|
-
const wrapped = value.upstreams;
|
|
591
|
-
if (Array.isArray(wrapped)) {
|
|
592
|
-
return objectValue(wrapped[0]);
|
|
593
|
-
}
|
|
594
|
-
return objectValue(wrapped);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function supportedProtocolsFrom(value: unknown): string[] | undefined {
|
|
598
|
-
if (!Array.isArray(value)) {
|
|
599
|
-
return undefined;
|
|
600
|
-
}
|
|
601
|
-
const protocols = value.map((entry) => stringValue(entry)).filter((entry): entry is string => Boolean(entry));
|
|
602
|
-
return protocols.length > 0 ? Array.from(new Set(protocols)) : undefined;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
export function runTbAdmin(args: string[], timeoutMs: number): Promise<UiActionResult> {
|
|
606
|
-
const entry = adminEntryPath();
|
|
607
|
-
const command = [process.execPath, entry, ...args];
|
|
608
|
-
return new Promise((resolve) => {
|
|
609
|
-
const child = spawn(process.execPath, [entry, ...args], {
|
|
610
|
-
shell: false,
|
|
611
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
612
|
-
});
|
|
613
|
-
const timer = setTimeout(() => {
|
|
614
|
-
child.kill("SIGTERM");
|
|
615
|
-
}, timeoutMs);
|
|
616
|
-
let stdout = "";
|
|
617
|
-
let stderr = "";
|
|
618
|
-
child.stdout.on("data", (chunk) => {
|
|
619
|
-
stdout += chunk.toString();
|
|
620
|
-
});
|
|
621
|
-
child.stderr.on("data", (chunk) => {
|
|
622
|
-
stderr += chunk.toString();
|
|
623
|
-
});
|
|
624
|
-
child.on("close", (code) => {
|
|
625
|
-
clearTimeout(timer);
|
|
626
|
-
resolve({
|
|
627
|
-
ok: code === 0,
|
|
628
|
-
stdout: redactSensitive(stdout),
|
|
629
|
-
stderr: redactSensitive(stderr),
|
|
630
|
-
command: redactCommand(command)
|
|
631
|
-
});
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* Step 13 (admin web -> CLI --json):
|
|
638
|
-
* 跟 `runTbAdmin` 一样 spawn `tb-admin <args>`, **但 args 自动追加 `--json` flag**,
|
|
639
|
-
* 把 stdout 当 JSON 解析后挂在 result.json.
|
|
640
|
-
*
|
|
641
|
-
* 1.0.32 UI 调用方继续看 `result.stdout` / `result.ok` / `result.stderr`, 不破坏体验.
|
|
642
|
-
* UI 改造 (Step 14) 时改用 `result.json` 取结构化数据.
|
|
643
|
-
*
|
|
644
|
-
* 解析失败时 (CLI 输出不是 JSON, 比如老版本 CLI 没 --json), json 字段 = undefined,
|
|
645
|
-
* stdout 仍是原始字符串, ok=false (CLI exit 0 但 stdout 不可解析视为可恢复错).
|
|
646
|
-
*/
|
|
647
|
-
export function runTbAdminJson(args: string[], timeoutMs: number): Promise<UiActionResult> {
|
|
648
|
-
const jsonArgs = [...args, "--json"];
|
|
649
|
-
return runTbAdmin(jsonArgs, timeoutMs).then((result) => {
|
|
650
|
-
const parsed = parseJsonSafely(result.stdout);
|
|
651
|
-
if (parsed === undefined) {
|
|
652
|
-
return {
|
|
653
|
-
...result,
|
|
654
|
-
ok: false,
|
|
655
|
-
stderr: result.stderr
|
|
656
|
-
? `${result.stderr}\n[Step 13] failed to parse --json output`
|
|
657
|
-
: `[Step 13] failed to parse --json output`
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
return { ...result, json: parsed };
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function parseJsonSafely(text: string): unknown | undefined {
|
|
665
|
-
if (!text || text.trim().length === 0) {
|
|
666
|
-
return undefined;
|
|
667
|
-
}
|
|
668
|
-
try {
|
|
669
|
-
return JSON.parse(text);
|
|
670
|
-
} catch {
|
|
671
|
-
return undefined;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknown> {
|
|
676
|
-
const response = await fetch(url, init);
|
|
677
|
-
if (!response.ok) {
|
|
678
|
-
const text = await response.text();
|
|
679
|
-
throw new Error(`HTTP Error ${response.status}: ${redactSensitive(text || response.statusText)}`);
|
|
680
|
-
}
|
|
681
|
-
const text = await response.text();
|
|
682
|
-
return text ? JSON.parse(text) : {};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function adminEntryPath(): string {
|
|
686
|
-
const current = process.argv[1] || "";
|
|
687
|
-
if ((path.basename(current) === "tb-admin" || path.basename(current) === "tb-admin.js") && fs.existsSync(current)) {
|
|
688
|
-
return current;
|
|
689
|
-
}
|
|
690
|
-
const candidates = [
|
|
691
|
-
path.resolve(process.cwd(), "packages/admin-cli/bin/tb-admin.js"),
|
|
692
|
-
path.resolve(process.cwd(), "bin/tb-admin.js")
|
|
693
|
-
];
|
|
694
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[candidates.length - 1];
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function globalArgs(options: UiActionsOptions, args: string[]): string[] {
|
|
698
|
-
const output: string[] = [];
|
|
699
|
-
if (options.configPath) {
|
|
700
|
-
output.push("--config", options.configPath);
|
|
701
|
-
}
|
|
702
|
-
if (options.profile) {
|
|
703
|
-
output.push("--profile", options.profile);
|
|
704
|
-
}
|
|
705
|
-
if (options.url) {
|
|
706
|
-
output.push("--url", options.url);
|
|
707
|
-
}
|
|
708
|
-
if (options.token) {
|
|
709
|
-
output.push("--token", options.token);
|
|
710
|
-
}
|
|
711
|
-
output.push(...args);
|
|
712
|
-
return output;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function profileArgs(options: UiActionsOptions, profileName: string, args: string[]): string[] {
|
|
716
|
-
return globalArgs({ ...options, profile: profileName, url: undefined, token: undefined }, args);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function sellerOperatorArgs(options: UiActionsOptions, entry: SellerRegistryEntry, args: string[]): string[] {
|
|
720
|
-
const operatorSecret = options.configManager.getSellerProvider("fly")?.operator_secret;
|
|
721
|
-
if (!operatorSecret) {
|
|
722
|
-
throw new Error(`seller \`${entry.id}\` has no local profile and seller_providers.fly.operator_secret is not configured`);
|
|
723
|
-
}
|
|
724
|
-
return globalArgs({
|
|
725
|
-
...options,
|
|
726
|
-
profile: undefined,
|
|
727
|
-
url: entry.url,
|
|
728
|
-
token: operatorSecret
|
|
729
|
-
}, args);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function mergeSellerConfigPatch(config: Record<string, unknown>, patch: ConfigPatch): Record<string, unknown> {
|
|
733
|
-
const next = { ...config };
|
|
734
|
-
copyIfPresent(next, patch, "upstreamUrl");
|
|
735
|
-
copyIfPresent(next, patch, "upstreamApiKey");
|
|
736
|
-
applyBalanceProbePatch(next, patch);
|
|
737
|
-
copyIfPresent(next, patch, "upstreamBalanceUrl");
|
|
738
|
-
copyIfPresent(next, patch, "upstreamUserId");
|
|
739
|
-
copyIfPresent(next, patch, "upstreamRechargeUrl");
|
|
740
|
-
copyIfPresent(next, patch, "markupRatio");
|
|
741
|
-
copyIfPresent(next, patch, "discountRatio");
|
|
742
|
-
copyIfPresent(next, patch, "maxConnections");
|
|
743
|
-
copyIfPresent(next, patch, "maxQueueDepth");
|
|
744
|
-
if (Array.isArray(patch.models)) {
|
|
745
|
-
next.models = patch.models;
|
|
746
|
-
}
|
|
747
|
-
if (patch.modelAliases && typeof patch.modelAliases === "object" && !Array.isArray(patch.modelAliases)) {
|
|
748
|
-
next.modelAliases = patch.modelAliases;
|
|
749
|
-
}
|
|
750
|
-
return next;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function copyIfPresent(target: Record<string, unknown>, source: ConfigPatch, key: string): void {
|
|
754
|
-
if (source[key] !== undefined) {
|
|
755
|
-
target[key] = source[key];
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function validateCreateSellerRequest(request: CreateSellerRequest): void {
|
|
760
|
-
requireString(request.sellerName, "sellerName");
|
|
761
|
-
requireString(request.region, "region");
|
|
762
|
-
requireString(request.image, "image");
|
|
763
|
-
requireString(request.upstreamWebsite, "upstreamWebsite");
|
|
764
|
-
requireString(request.upstreamUrl, "upstreamUrl");
|
|
765
|
-
requireString(request.upstreamApiKey, "upstreamApiKey");
|
|
766
|
-
if (stringValue(request.upstreamBalanceProbeTemplate) !== "none") {
|
|
767
|
-
requireString(balanceProbeUrl(request), "upstreamBalanceProbeUrl");
|
|
768
|
-
}
|
|
769
|
-
requirePositiveInteger(request.maxConnections, "maxConnections");
|
|
770
|
-
requirePositiveInteger(request.maxQueueDepth, "maxQueueDepth");
|
|
771
|
-
requireFiniteNumber(request.markupRatio, "markupRatio");
|
|
772
|
-
requireFiniteNumber(request.discountRatio, "discountRatio");
|
|
773
|
-
const paymentMethods = paymentMethodsFromRequest(request);
|
|
774
|
-
const invalidPaymentMethod = paymentMethods.find((method) => !["clawtip", "mock"].includes(method));
|
|
775
|
-
if (invalidPaymentMethod) {
|
|
776
|
-
throw new Error("paymentMethods must contain only clawtip or mock");
|
|
777
|
-
}
|
|
778
|
-
if (paymentMethods.includes("clawtip")) {
|
|
779
|
-
requireString(request.clawtipPayTo, "clawtipPayTo");
|
|
780
|
-
requireString(request.clawtipSm4KeyBase64, "clawtipSm4KeyBase64");
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function normalizeCreateSellerRequest(request: CreateSellerRequest): CreateSellerRequest {
|
|
785
|
-
const upstreamUrl = stripTrailingV1(request.upstreamUrl);
|
|
786
|
-
const app = stringValue(request.app) || request.sellerName;
|
|
787
|
-
const normalized = {
|
|
788
|
-
...request,
|
|
789
|
-
app,
|
|
790
|
-
image: stringValue(request.image) || "registry.fly.io/tb-seller:latest",
|
|
791
|
-
flyConfig: stringValue(request.flyConfig) || "deploy/fly.io/fly.tb-seller.toml",
|
|
792
|
-
upstreamUrl
|
|
793
|
-
};
|
|
794
|
-
if (paymentMethodsFromRequest(normalized).includes("clawtip")) {
|
|
795
|
-
normalized.clawtipSkillSlug = stringValue(normalized.clawtipSkillSlug) || app;
|
|
796
|
-
normalized.clawtipSkillId = stringValue(normalized.clawtipSkillId) || `si-${app}`;
|
|
797
|
-
normalized.clawtipDescription = stringValue(normalized.clawtipDescription) || `TokenBuddy Seller ${request.sellerName}`;
|
|
798
|
-
normalized.clawtipResourceUrl = stringValue(normalized.clawtipResourceUrl) || sellerOperatorUrl(app);
|
|
799
|
-
normalized.clawtipActivationFeeFen = Number(normalized.clawtipActivationFeeFen || 1);
|
|
800
|
-
normalized.clawtipMicrosPerFen = Number(normalized.clawtipMicrosPerFen || 10000);
|
|
801
|
-
}
|
|
802
|
-
return normalized;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
function initialSellerConfig(request: CreateSellerRequest, masked: boolean): Record<string, unknown> {
|
|
806
|
-
const upstreamBalanceProbe = balanceProbeConfigFromRequest(request);
|
|
807
|
-
const upstreamBalanceUrl = balanceProbeUrl(request);
|
|
808
|
-
const upstreamUserId = balanceProbeUserId(request);
|
|
809
|
-
const upstreamRechargeUrl = balanceProbeRechargeUrl(request);
|
|
810
|
-
const paymentConfig = paymentConfigFromRequest(request, masked);
|
|
811
|
-
return {
|
|
812
|
-
sellerId: request.app || request.sellerName,
|
|
813
|
-
manifestVersion: "manifest.v1",
|
|
814
|
-
upstreamUrl: request.upstreamUrl,
|
|
815
|
-
upstreamApiKey: masked ? "********" : request.upstreamApiKey,
|
|
816
|
-
upstreamWebsite: request.upstreamWebsite,
|
|
817
|
-
upstreamBalanceUrl,
|
|
818
|
-
upstreamUserId,
|
|
819
|
-
upstreamRechargeUrl,
|
|
820
|
-
upstreamBalanceProbe,
|
|
821
|
-
maxConnections: request.maxConnections,
|
|
822
|
-
maxQueueDepth: request.maxQueueDepth,
|
|
823
|
-
markupRatio: request.markupRatio,
|
|
824
|
-
discountRatio: request.discountRatio,
|
|
825
|
-
operatorSecret: masked ? "********" : stringValue(request.operatorSecret),
|
|
826
|
-
...paymentConfig
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function paymentConfigFromRequest(request: CreateSellerRequest, masked: boolean): Record<string, unknown> {
|
|
831
|
-
const paymentMethods = paymentMethodsFromRequest(request);
|
|
832
|
-
const config: Record<string, unknown> = {
|
|
833
|
-
allowMock: paymentMethods.includes("mock")
|
|
834
|
-
};
|
|
835
|
-
if (!paymentMethods.includes("clawtip")) {
|
|
836
|
-
return config;
|
|
837
|
-
}
|
|
838
|
-
return {
|
|
839
|
-
...config,
|
|
840
|
-
clawtip: {
|
|
841
|
-
payTo: stringValue(request.clawtipPayTo),
|
|
842
|
-
sm4KeyBase64: masked ? "********" : stringValue(request.clawtipSm4KeyBase64),
|
|
843
|
-
skillSlug: stringValue(request.clawtipSkillSlug),
|
|
844
|
-
skillId: stringValue(request.clawtipSkillId),
|
|
845
|
-
description: stringValue(request.clawtipDescription),
|
|
846
|
-
resourceUrl: stringValue(request.clawtipResourceUrl),
|
|
847
|
-
activationFeeFen: Number(request.clawtipActivationFeeFen),
|
|
848
|
-
microsPerFen: Number(request.clawtipMicrosPerFen)
|
|
849
|
-
}
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
function paymentMethodsFromRequest(request: CreateSellerRequest): string[] {
|
|
854
|
-
const values = request.paymentMethods !== undefined
|
|
855
|
-
? arrayStringValues(request.paymentMethods)
|
|
856
|
-
: legacyPaymentMethodsFromRequest(request);
|
|
857
|
-
return Array.from(new Set(values));
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function legacyPaymentMethodsFromRequest(request: CreateSellerRequest): string[] {
|
|
861
|
-
const paymentMethod = stringValue(request.paymentMethod);
|
|
862
|
-
if (paymentMethod === "none") {
|
|
863
|
-
return [];
|
|
864
|
-
}
|
|
865
|
-
return [paymentMethod || "clawtip"];
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function arrayStringValues(value: unknown): string[] {
|
|
869
|
-
if (Array.isArray(value)) {
|
|
870
|
-
return value.map((entry) => stringValue(entry)).filter((entry): entry is string => Boolean(entry));
|
|
871
|
-
}
|
|
872
|
-
const single = stringValue(value);
|
|
873
|
-
return single ? single.split(",").map((entry) => entry.trim()).filter(Boolean) : [];
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function applyBalanceProbePatch(target: Record<string, unknown>, patch: ConfigPatch): void {
|
|
877
|
-
const template = stringValue(patch.upstreamBalanceProbeTemplate);
|
|
878
|
-
const url = stringValue(patch.upstreamBalanceProbeUrl) || stringValue(patch.upstreamBalanceUrl);
|
|
879
|
-
const userId = stringValue(patch.upstreamBalanceProbeUserId) || stringValue(patch.upstreamUserId);
|
|
880
|
-
const rechargeUrl = stringValue(patch.upstreamBalanceProbeRechargeUrl) || stringValue(patch.upstreamRechargeUrl);
|
|
881
|
-
if (template === undefined && url === undefined && userId === undefined && rechargeUrl === undefined) {
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
const current = objectValue(target.upstreamBalanceProbe) || {};
|
|
885
|
-
target.upstreamBalanceProbe = {
|
|
886
|
-
...current,
|
|
887
|
-
...(template !== undefined ? { template } : {}),
|
|
888
|
-
...(url !== undefined ? { url } : {}),
|
|
889
|
-
...(userId !== undefined ? { userId } : {}),
|
|
890
|
-
...(rechargeUrl !== undefined ? { rechargeUrl } : {})
|
|
891
|
-
};
|
|
892
|
-
if (url !== undefined) {
|
|
893
|
-
target.upstreamBalanceUrl = url;
|
|
894
|
-
}
|
|
895
|
-
if (userId !== undefined) {
|
|
896
|
-
target.upstreamUserId = userId;
|
|
897
|
-
}
|
|
898
|
-
if (rechargeUrl !== undefined) {
|
|
899
|
-
target.upstreamRechargeUrl = rechargeUrl;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function balanceProbeConfigFromRequest(request: CreateSellerRequest): Record<string, unknown> {
|
|
904
|
-
return {
|
|
905
|
-
template: stringValue(request.upstreamBalanceProbeTemplate) || "auto",
|
|
906
|
-
url: balanceProbeUrl(request),
|
|
907
|
-
userId: balanceProbeUserId(request),
|
|
908
|
-
rechargeUrl: balanceProbeRechargeUrl(request)
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function balanceProbeUrl(request: CreateSellerRequest): string | undefined {
|
|
913
|
-
const explicit = stringValue(request.upstreamBalanceProbeUrl) || stringValue(request.upstreamBalanceUrl);
|
|
914
|
-
if (explicit) {
|
|
915
|
-
return explicit;
|
|
916
|
-
}
|
|
917
|
-
const template = stringValue(request.upstreamBalanceProbeTemplate) || "auto";
|
|
918
|
-
const upstreamUrl = stringValue(request.upstreamUrl);
|
|
919
|
-
const host = hostName(upstreamUrl);
|
|
920
|
-
if (template === "none") {
|
|
921
|
-
return undefined;
|
|
922
|
-
}
|
|
923
|
-
if (template === "openrouter") {
|
|
924
|
-
return "https://openrouter.ai/api/v1/credits";
|
|
925
|
-
}
|
|
926
|
-
if (template === "deepseek") {
|
|
927
|
-
return "https://api.deepseek.com/user/balance";
|
|
928
|
-
}
|
|
929
|
-
if (template === "stepfun") {
|
|
930
|
-
return "https://api.stepfun.com/v1/accounts";
|
|
931
|
-
}
|
|
932
|
-
if (template === "novita") {
|
|
933
|
-
return "https://api.novita.ai/v3/user/balance";
|
|
934
|
-
}
|
|
935
|
-
if (template === "siliconflow") {
|
|
936
|
-
return host.endsWith(".com") ? "https://api.siliconflow.com/v1/user/info" : "https://api.siliconflow.cn/v1/user/info";
|
|
937
|
-
}
|
|
938
|
-
if (template === "usage_generic") {
|
|
939
|
-
return upstreamUrl ? usageUrl(upstreamUrl) : undefined;
|
|
940
|
-
}
|
|
941
|
-
if (template === "auto") {
|
|
942
|
-
if (host === "api.deepseek.com") {
|
|
943
|
-
return "https://api.deepseek.com/user/balance";
|
|
944
|
-
}
|
|
945
|
-
if (host === "api.stepfun.ai" || host === "api.stepfun.com") {
|
|
946
|
-
return "https://api.stepfun.com/v1/accounts";
|
|
947
|
-
}
|
|
948
|
-
if (host === "api.siliconflow.cn") {
|
|
949
|
-
return "https://api.siliconflow.cn/v1/user/info";
|
|
950
|
-
}
|
|
951
|
-
if (host === "api.siliconflow.com") {
|
|
952
|
-
return "https://api.siliconflow.com/v1/user/info";
|
|
953
|
-
}
|
|
954
|
-
if (host === "openrouter.ai") {
|
|
955
|
-
return "https://openrouter.ai/api/v1/credits";
|
|
956
|
-
}
|
|
957
|
-
if (host === "api.novita.ai") {
|
|
958
|
-
return "https://api.novita.ai/v3/user/balance";
|
|
959
|
-
}
|
|
960
|
-
return upstreamUrl ? usageUrl(upstreamUrl) : undefined;
|
|
961
|
-
}
|
|
962
|
-
return undefined;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function balanceProbeUserId(request: CreateSellerRequest): string | undefined {
|
|
966
|
-
return stringValue(request.upstreamBalanceProbeUserId) || stringValue(request.upstreamUserId);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function balanceProbeRechargeUrl(request: CreateSellerRequest): string | undefined {
|
|
970
|
-
return stringValue(request.upstreamBalanceProbeRechargeUrl) || stringValue(request.upstreamRechargeUrl);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function stringValue(value: unknown): string | undefined {
|
|
974
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
978
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
979
|
-
? value as Record<string, unknown>
|
|
980
|
-
: undefined;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function stripTrailingV1(value: string): string {
|
|
984
|
-
return String(value || "").trim().replace(/\/v1\/?$/i, "");
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function usageUrl(value: string): string {
|
|
988
|
-
const base = String(value || "").trim().replace(/\/+$/, "");
|
|
989
|
-
return /\/v1$/i.test(base) ? `${base}/usage` : `${base}/v1/usage`;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function hostName(value: unknown): string {
|
|
993
|
-
try {
|
|
994
|
-
return new URL(String(value || "")).hostname.toLowerCase();
|
|
995
|
-
} catch {
|
|
996
|
-
return "";
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function sellerOperatorUrl(appName: string): string {
|
|
1001
|
-
return `https://${appName}.fly.dev`;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function sleep(ms: number): Promise<void> {
|
|
1005
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function requireString(value: unknown, field: string): void {
|
|
1009
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
1010
|
-
throw new Error(`${field} is required`);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function requirePositiveInteger(value: unknown, field: string): void {
|
|
1015
|
-
const parsed = Number(value);
|
|
1016
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1017
|
-
throw new Error(`${field} must be >= 1`);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function requireFiniteNumber(value: unknown, field: string): void {
|
|
1022
|
-
if (!Number.isFinite(Number(value))) {
|
|
1023
|
-
throw new Error(`${field} must be a number`);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
function redactCommand(command: string[]): string[] {
|
|
1028
|
-
return command.map((part, index) => {
|
|
1029
|
-
const prev = command[index - 1];
|
|
1030
|
-
if (prev === "--operator-secret" || prev === "--token" || prev === "--api-key") {
|
|
1031
|
-
return "********";
|
|
1032
|
-
}
|
|
1033
|
-
return redactSensitive(part);
|
|
1034
|
-
});
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function redactSensitive(value: string): string {
|
|
1038
|
-
return value.replace(/(sk-[A-Za-z0-9_-]+)/g, "********")
|
|
1039
|
-
.replace(/(operatorSecret|upstreamApiKey|token|apiKey|sm4KeyBase64|clawtipSm4KeyBase64)["'=:\s]+([^"',\s]+)/gi, "$1=********");
|
|
1040
|
-
}
|