@toloka-platform/sdk 0.2.0-preview.2 → 0.2.0-preview.4

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 (110) hide show
  1. package/README.md +10 -4
  2. package/dist/cli/toloka.js +162 -154
  3. package/dist/cli/toloka.js.jsc +0 -0
  4. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  5. package/dist/cli/toloka.js.sha256 +1 -1
  6. package/docs/authoring.md +18 -1
  7. package/docs/cli-v2.md +6 -4
  8. package/docs/cli.md +58 -11
  9. package/docs/getting-started.md +3 -3
  10. package/docs/limitations.md +1 -1
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +1 -1
  13. package/examples/face-image-collection/README.md +2 -2
  14. package/openapi/gen/api.ts +78 -0
  15. package/openapi/gen/models/baseModel.ts +1 -0
  16. package/openapi/gen/models/checkReport.ts +2 -1
  17. package/openapi/gen/models/checkReportAnyOfTwoseven.ts +5 -0
  18. package/openapi/gen/models/checkReportAnyOfTwosevenResultsItem.ts +6 -0
  19. package/openapi/gen/models/checkReportAnyOfTwosevenResultsItemVerdict.ts +2 -0
  20. package/openapi/gen/models/currentUser.ts +3 -0
  21. package/openapi/gen/models/currentUserTeamsItem.ts +6 -0
  22. package/openapi/gen/models/deliveryOrder.ts +9 -0
  23. package/openapi/gen/models/deliveryOrderClientName.ts +2 -0
  24. package/openapi/gen/models/getTrainingRunMetricHistoryParams.ts +4 -0
  25. package/openapi/gen/models/gistingTrainingConfiguration.ts +6 -2
  26. package/openapi/gen/models/gistingTrainingConfigurationBatchSize.ts +2 -0
  27. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +2 -0
  28. package/openapi/gen/models/gistingTrainingConfigurationInput.ts +6 -2
  29. package/openapi/gen/models/gistingTrainingConfigurationInputBatchSize.ts +2 -0
  30. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +2 -0
  31. package/openapi/gen/models/index.ts +28 -4
  32. package/openapi/gen/models/listDeliveryOrders200.ts +5 -0
  33. package/openapi/gen/models/sftTrainingConfiguration.ts +6 -3
  34. package/openapi/gen/models/sftTrainingConfigurationBatchSize.ts +2 -0
  35. package/openapi/gen/models/sftTrainingConfigurationInput.ts +6 -3
  36. package/openapi/gen/models/sftTrainingConfigurationInputBatchSize.ts +2 -0
  37. package/openapi/gen/models/sftTrainingConfigurationInputLoraRank.ts +2 -0
  38. package/openapi/gen/models/sftTrainingConfigurationLoraRank.ts +2 -0
  39. package/openapi/gen/models/trainingRunMetricHistory.ts +9 -0
  40. package/openapi/gen/models/trainingRunMetricHistoryMetric.ts +2 -0
  41. package/openapi/gen/models/trainingRunMetricHistoryPointsItem.ts +9 -0
  42. package/openapi/gen/models/trainingRunMetricHistoryPointsItemNonFinite.ts +2 -0
  43. package/openapi/gen/models/trainingRunMetricHistoryPointsItemValue.ts +2 -0
  44. package/openapi/gen/models/trainingRunMetrics.ts +9 -0
  45. package/openapi/gen/models/trainingRunMetricsMetricsItem.ts +8 -0
  46. package/openapi/gen/models/trainingRunMetricsMetricsItemLatest.ts +9 -0
  47. package/openapi/gen/models/trainingRunMetricsMetricsItemLatestNonFinite.ts +2 -0
  48. package/openapi/gen/models/trainingRunMetricsMetricsItemLatestValue.ts +2 -0
  49. package/openapi/gen/models/trainingRunMetricsMetricsItemMetric.ts +2 -0
  50. package/openapi/gen/models/trainingRunMetricsUnavailableReason.ts +2 -0
  51. package/package.json +2 -2
  52. package/skills/catalog.json +55 -7
  53. package/skills/toloka-build/SKILL.md +35 -21
  54. package/skills/toloka-build/references/node-semantics.md +13 -11
  55. package/skills/toloka-build/references/workflow-and-cli.md +31 -20
  56. package/skills/toloka-fine-tuning/SKILL.md +155 -0
  57. package/skills/toloka-get-started/SKILL.md +70 -0
  58. package/skills/toloka-gisting/SKILL.md +166 -0
  59. package/skills/toloka-report-issue/references/report-template.md +1 -1
  60. package/src/authoring/index.ts +14 -0
  61. package/src/checks/task-ui-typecheck.ts +12 -10
  62. package/src/cli/analytics.ts +88 -15
  63. package/src/cli/auth.ts +93 -21
  64. package/src/cli/auto-update.ts +36 -39
  65. package/src/cli/check.ts +88 -39
  66. package/src/cli/cli-entry.ts +3 -2
  67. package/src/cli/cli-error.ts +43 -0
  68. package/src/cli/commands/auth.ts +89 -0
  69. package/src/cli/commands/config.ts +28 -0
  70. package/src/cli/commands/dataset.ts +85 -0
  71. package/src/cli/commands/delivery-order.ts +18 -0
  72. package/src/cli/commands/models.ts +121 -0
  73. package/src/cli/commands/pipeline.ts +131 -0
  74. package/src/cli/commands/project.ts +168 -0
  75. package/src/cli/commands/report.ts +43 -0
  76. package/src/cli/commands/secret.ts +40 -0
  77. package/src/cli/commands/skill.ts +36 -0
  78. package/src/cli/commands/source.ts +204 -0
  79. package/src/cli/commands/status.ts +17 -0
  80. package/src/cli/commands/training.ts +261 -0
  81. package/src/cli/commands/version.ts +14 -0
  82. package/src/cli/config.ts +7 -8
  83. package/src/cli/context.ts +251 -0
  84. package/src/cli/credentials.ts +12 -4
  85. package/src/cli/data.ts +96 -34
  86. package/src/cli/delivery-orders.ts +47 -0
  87. package/src/cli/environment.ts +26 -0
  88. package/src/cli/file-system.ts +6 -2
  89. package/src/cli/format-error.ts +45 -2
  90. package/src/cli/init.ts +14 -11
  91. package/src/cli/models.ts +43 -12
  92. package/src/cli/program.ts +120 -1347
  93. package/src/cli/project-context.ts +126 -43
  94. package/src/cli/project.ts +80 -28
  95. package/src/cli/report.ts +24 -9
  96. package/src/cli/runs.ts +33 -8
  97. package/src/cli/sdk-version.ts +38 -0
  98. package/src/cli/settings.ts +13 -5
  99. package/src/cli/skill.ts +67 -24
  100. package/src/cli/source.ts +3 -2
  101. package/src/cli/terminal-io.ts +110 -0
  102. package/src/cli/trainings.ts +242 -57
  103. package/src/cli/version.ts +3 -3
  104. package/src/client.ts +21 -9
  105. package/src/importer.ts +32 -2
  106. package/src/transport.ts +13 -1
  107. package/openapi/gen/models/gistingTrainingConfigurationInputRegime.ts +0 -2
  108. package/openapi/gen/models/gistingTrainingConfigurationRegime.ts +0 -2
  109. package/openapi/gen/models/sftTrainingConfigurationInputRegime.ts +0 -2
  110. package/openapi/gen/models/sftTrainingConfigurationRegime.ts +0 -2
@@ -3,10 +3,21 @@ import { readFile, stat } from "node:fs/promises";
3
3
 
4
4
  import { randomUUID } from "node:crypto";
5
5
 
6
- import { assert } from "es-toolkit";
7
-
8
- import type { PotatoClient, Training, TrainingCostEstimate, TrainingRun } from "../client";
6
+ import type {
7
+ PotatoClient,
8
+ Training,
9
+ TrainingCostEstimate,
10
+ TrainingRun,
11
+ TrainingRunMetricHistory,
12
+ TrainingRunMetrics,
13
+ } from "../client";
9
14
  import type { BaseModelId, TrainingConfigurationInput } from "../../openapi/gen/models";
15
+ import { assertCli, RETRY_OR_REPORT_HINT, TolokaCliError } from "./cli-error";
16
+ import type { CliErrorHint } from "./cli-error";
17
+
18
+ const ESTIMATE_FILE_HINT: CliErrorHint = {
19
+ description: "Regenerate it with toloka training cost <training> --json > <file>.",
20
+ };
10
21
 
11
22
  type TrainingMethod = TrainingConfigurationInput["method"];
12
23
  type TrainingSource = TrainingConfigurationInput["source"];
@@ -23,9 +34,12 @@ export type TrainingDraftOptions = {
23
34
  validationDatasetId?: string;
24
35
  trainingFilter?: string;
25
36
  validationFilter?: string;
26
- regime?: "low" | "medium" | "high";
37
+ loraRank?: SftConfigurationInput["loraRank"];
38
+ gistTokens?: GistingConfigurationInput["gistTokens"];
27
39
  epochs?: number;
28
- evalSteps?: number;
40
+ learningRate?: number;
41
+ batchSize?: SftConfigurationInput["batchSize"];
42
+ maxSequenceLength?: number;
29
43
  promptFilePath?: string;
30
44
  fetch?: typeof globalThis.fetch;
31
45
  };
@@ -33,6 +47,7 @@ export type TrainingDraftOptions = {
33
47
  type TrainingApi = Pick<PotatoClient, "trainings">;
34
48
  type TrainingDraftApi = Pick<PotatoClient, "trainings" | "files" | "datasets">;
35
49
  type TrainingCostApi = Pick<PotatoClient, "trainings" | "waitFor">;
50
+ type TrainingRunApi = Pick<PotatoClient, "trainings" | "waitFor">;
36
51
  const GISTING_PROMPT_MAX_BYTES = 256 * 1024;
37
52
  type TrainingIo = { readonly stdout: (text: string) => void };
38
53
  type TableRow = readonly string[];
@@ -64,6 +79,13 @@ const formatTraining = (training: Training): string =>
64
79
  "Method",
65
80
  training.configuration.method === "sft" ? "Supervised fine-tuning (SFT)" : "Prompt gisting",
66
81
  ],
82
+ training.configuration.method === "sft"
83
+ ? ["LoRA rank", String(training.configuration.loraRank)]
84
+ : ["Gist tokens", String(training.configuration.gistTokens)],
85
+ ["Epochs", String(training.configuration.epochs)],
86
+ ["Learning rate", String(training.configuration.learningRate)],
87
+ ["Batch size", String(training.configuration.batchSize)],
88
+ ["Max sequence length", String(training.configuration.maxSequenceLength)],
67
89
  ["Output Model", training.modelId ?? "-"],
68
90
  ["Archived at", training.archivedAt ?? "-"],
69
91
  ["Created at", training.createdAt],
@@ -107,27 +129,32 @@ type ReadyEstimateFile = {
107
129
  };
108
130
 
109
131
  const parseReadyEstimateFile = (value: unknown): ReadyEstimateFile => {
110
- assert(
132
+ assertCli(
111
133
  typeof value === "object" && value !== null && !Array.isArray(value),
112
134
  "The estimate file must contain a JSON object.",
135
+ { hint: ESTIMATE_FILE_HINT },
113
136
  );
114
- assert(
137
+ assertCli(
115
138
  "status" in value && value.status === "ready",
116
139
  "The estimate file is not a ready Training Cost Estimate.",
140
+ { hint: ESTIMATE_FILE_HINT },
117
141
  );
118
- assert(
142
+ assertCli(
119
143
  "trainingId" in value && typeof value.trainingId === "string",
120
144
  "The estimate file has no Training ID.",
145
+ { hint: ESTIMATE_FILE_HINT },
121
146
  );
122
- assert(
147
+ assertCli(
123
148
  "inputSetId" in value && typeof value.inputSetId === "string",
124
149
  "The estimate file has no Input Set ID.",
150
+ { hint: ESTIMATE_FILE_HINT },
125
151
  );
126
- assert(
152
+ assertCli(
127
153
  "fingerprint" in value &&
128
154
  typeof value.fingerprint === "string" &&
129
155
  /^[0-9a-f]{64}$/.test(value.fingerprint),
130
156
  "The estimate file has no valid fingerprint.",
157
+ { hint: ESTIMATE_FILE_HINT },
131
158
  );
132
159
 
133
160
  return {
@@ -163,9 +190,10 @@ const resolveTraining = async (
163
190
  ): Promise<Training> => {
164
191
  if (nameOrId.startsWith("trg.")) {
165
192
  const training = await client.trainings.get({ trainingId: nameOrId });
166
- assert(
193
+ assertCli(
167
194
  training.projectId === projectId,
168
195
  `Training ${nameOrId} was not found in project ${projectId}.`,
196
+ { hint: { description: "Run toloka training list; a Training belongs to one project." } },
169
197
  );
170
198
  return training;
171
199
  }
@@ -174,7 +202,9 @@ const resolveTraining = async (
174
202
  for await (const training of client.trainings.listAll({ projectId, limit: 100 })) {
175
203
  if (training.name === nameOrId) matches.push(training);
176
204
  }
177
- assert(matches.length === 1, `Training ${nameOrId} was not found or is ambiguous.`);
205
+ assertCli(matches.length === 1, `Training ${nameOrId} was not found or is ambiguous.`, {
206
+ hint: { description: "Run toloka training list and pass the training id (trg.…)." },
207
+ });
178
208
  return matches[0]!;
179
209
  };
180
210
 
@@ -191,12 +221,18 @@ const resolveSelection = async (
191
221
  datasetId: string,
192
222
  filter: string | undefined,
193
223
  ): Promise<TrainingSelection> => {
194
- assert(datasetId, "A Dataset is required when specifying a filter.");
224
+ assertCli(datasetId, "A Dataset is required when specifying a filter.", {
225
+ hint: {
226
+ description:
227
+ "Pass --dataset together with --train-filter, and --validation-dataset together with --validation-filter.",
228
+ },
229
+ });
195
230
  if (filter === undefined) return { datasetId };
196
231
  const separator = filter.indexOf("=");
197
- assert(
232
+ assertCli(
198
233
  separator > 0 && separator < filter.length - 1,
199
234
  "Dataset filters must use FIELD=VALUE syntax.",
235
+ { hint: { description: "Write the filter as field=value, for example split=train." } },
200
236
  );
201
237
  const fieldName = filter.slice(0, separator);
202
238
  const rawValue = filter.slice(separator + 1);
@@ -205,9 +241,10 @@ const resolveSelection = async (
205
241
  if (field.name === fieldName) matches.push(field);
206
242
  }
207
243
  const [field] = matches;
208
- assert(
244
+ assertCli(
209
245
  field && matches.length === 1,
210
246
  `Dataset field ${fieldName} was not found or is ambiguous.`,
247
+ { hint: { description: "Run toloka dataset fields <dataset> to see the field names." } },
211
248
  );
212
249
  return {
213
250
  datasetId,
@@ -232,14 +269,17 @@ const sourceFromOptions = (
232
269
  options: Pick<TrainingDraftOptions, "baseModelId" | "modelVersionId">,
233
270
  fallback?: TrainingSource,
234
271
  ): TrainingSource => {
235
- assert(
272
+ assertCli(
236
273
  !(options.baseModelId && options.modelVersionId),
237
274
  "Choose exactly one of --base-model and --model-version.",
275
+ { hint: { disabled: true } },
238
276
  );
239
277
  if (options.baseModelId) return { type: "base_model", baseModelId: options.baseModelId };
240
278
  if (options.modelVersionId)
241
279
  return { type: "model_version", modelVersionId: options.modelVersionId };
242
- assert(fallback, "Choose exactly one of --base-model and --model-version.");
280
+ assertCli(fallback, "Choose exactly one of --base-model and --model-version.", {
281
+ hint: { disabled: true },
282
+ });
243
283
  return fallback;
244
284
  };
245
285
 
@@ -250,20 +290,29 @@ const uploadPromptFile = async (
250
290
  fetchImplementation: typeof globalThis.fetch,
251
291
  ): Promise<string> => {
252
292
  const metadata = await stat(filePath);
253
- assert(metadata.isFile(), "--prompt-file must point to a file.");
254
- assert(metadata.size > 0, "The gisting prompt file is empty.");
255
- assert(
293
+ assertCli(metadata.isFile(), "--prompt-file must point to a file.", {
294
+ hint: { description: "Pass the absolute path of the prompt text file." },
295
+ });
296
+ assertCli(metadata.size > 0, "The gisting prompt file is empty.", {
297
+ hint: { description: "Put the full prompt text into the file before creating the Training." },
298
+ });
299
+ assertCli(
256
300
  metadata.size <= GISTING_PROMPT_MAX_BYTES,
257
301
  "The gisting prompt file exceeds the 256 KiB limit.",
302
+ { hint: { description: "Shorten the prompt to under 256 KiB." } },
258
303
  );
259
304
  const payload = await readFile(filePath);
260
305
  let prompt: string;
261
306
  try {
262
307
  prompt = new TextDecoder("utf-8", { fatal: true }).decode(payload);
263
308
  } catch {
264
- throw new Error("The gisting prompt file must contain valid UTF-8 text.");
309
+ throw new TolokaCliError("The gisting prompt file must contain valid UTF-8 text.", {
310
+ hint: { description: "Save the prompt file as UTF-8 text." },
311
+ });
265
312
  }
266
- assert(prompt.trim().length > 0, "The gisting prompt file is empty.");
313
+ assertCli(prompt.trim().length > 0, "The gisting prompt file is empty.", {
314
+ hint: { description: "Put the full prompt text into the file before creating the Training." },
315
+ });
267
316
  const started = await client.files.startUpload({
268
317
  projectId,
269
318
  filename: basename(filePath),
@@ -275,7 +324,9 @@ const uploadPromptFile = async (
275
324
  headers: { "x-ms-blob-type": "BlockBlob", "Content-Type": "text/plain; charset=utf-8" },
276
325
  body: payload,
277
326
  });
278
- assert(response.ok, `Failed to upload gisting prompt: HTTP ${response.status}.`);
327
+ assertCli(response.ok, `Failed to upload gisting prompt: HTTP ${response.status}.`, {
328
+ hint: RETRY_OR_REPORT_HINT,
329
+ });
279
330
  return (await client.files.finalizeUpload({ projectId, fileId: started.file.id })).id;
280
331
  } catch (error) {
281
332
  await client.files.delete({ projectId, fileId: started.file.id }).catch(() => undefined);
@@ -293,7 +344,9 @@ const resolveSelections = async ({
293
344
  options: TrainingDraftOptions;
294
345
  }): Promise<ResolvedSelections> => {
295
346
  const trainingDatasetId = options.datasetId ?? current?.training.datasetId;
296
- assert(trainingDatasetId, "--dataset is required.");
347
+ assertCli(trainingDatasetId, "--dataset is required.", {
348
+ hint: { description: "Pass --dataset <dataset-id>; toloka dataset list shows the ids." },
349
+ });
297
350
  const training =
298
351
  options.datasetId === undefined && options.trainingFilter === undefined && current
299
352
  ? current.training
@@ -309,6 +362,14 @@ const resolveSelections = async ({
309
362
  return { training, validation };
310
363
  };
311
364
 
365
+ const sharedHyperparameters = (options: TrainingDraftOptions) => ({
366
+ ...(options.learningRate === undefined ? {} : { learningRate: options.learningRate }),
367
+ ...(options.batchSize === undefined ? {} : { batchSize: options.batchSize }),
368
+ ...(options.maxSequenceLength === undefined
369
+ ? {}
370
+ : { maxSequenceLength: options.maxSequenceLength }),
371
+ });
372
+
312
373
  const buildSftConfiguration = ({
313
374
  source,
314
375
  selections,
@@ -320,15 +381,28 @@ const buildSftConfiguration = ({
320
381
  current: SftConfigurationInput | undefined;
321
382
  options: TrainingDraftOptions;
322
383
  }): SftConfigurationInput => {
323
- assert(options.promptFilePath === undefined, "Gisting options cannot be used with SFT.");
384
+ assertCli(options.promptFilePath === undefined, "--prompt-file applies to gisting only.", {
385
+ hint: {
386
+ description: "Pass --method gisting to use --prompt-file, or omit --prompt-file for SFT.",
387
+ },
388
+ });
389
+ assertCli(
390
+ options.gistTokens === undefined,
391
+ "--gist-tokens applies to gisting; SFT takes --lora-rank.",
392
+ {
393
+ hint: {
394
+ description: "Use --lora-rank for SFT, or pass --method gisting to use --gist-tokens.",
395
+ },
396
+ },
397
+ );
324
398
  return {
325
399
  ...current,
326
400
  method: "sft",
327
401
  source,
328
402
  ...selections,
329
- ...(options.regime === undefined ? {} : { regime: options.regime }),
403
+ ...(options.loraRank === undefined ? {} : { loraRank: options.loraRank }),
330
404
  ...(options.epochs === undefined ? {} : { epochs: options.epochs }),
331
- ...(options.evalSteps === undefined ? {} : { evalSteps: options.evalSteps }),
405
+ ...sharedHyperparameters(options),
332
406
  };
333
407
  };
334
408
 
@@ -347,7 +421,15 @@ const buildGistingConfiguration = async ({
347
421
  current: GistingConfigurationInput | undefined;
348
422
  options: TrainingDraftOptions;
349
423
  }): Promise<GistingConfigurationInput> => {
350
- assert(options.evalSteps === undefined, "SFT options cannot be used with gisting.");
424
+ assertCli(
425
+ options.loraRank === undefined,
426
+ "--lora-rank applies to SFT; gisting takes --gist-tokens.",
427
+ {
428
+ hint: {
429
+ description: "Use --gist-tokens for gisting, or omit --method gisting to create SFT.",
430
+ },
431
+ },
432
+ );
351
433
  const promptFileId = options.promptFilePath
352
434
  ? await uploadPromptFile(
353
435
  client,
@@ -356,15 +438,18 @@ const buildGistingConfiguration = async ({
356
438
  options.fetch ?? globalThis.fetch,
357
439
  )
358
440
  : current?.promptFileId;
359
- assert(promptFileId, "--prompt-file is required for gisting.");
441
+ assertCli(promptFileId, "--prompt-file is required for gisting.", {
442
+ hint: { description: "Pass --prompt-file <absolute-path> with the prompt text." },
443
+ });
360
444
  return {
361
445
  ...current,
362
446
  method: "gisting",
363
447
  source,
364
448
  ...selections,
365
449
  promptFileId,
366
- ...(options.regime === undefined ? {} : { regime: options.regime }),
450
+ ...(options.gistTokens === undefined ? {} : { gistTokens: options.gistTokens }),
367
451
  ...(options.epochs === undefined ? {} : { epochs: options.epochs }),
452
+ ...sharedHyperparameters(options),
368
453
  };
369
454
  };
370
455
 
@@ -445,8 +530,14 @@ export const createTraining = async (
445
530
  },
446
531
  io: TrainingIo,
447
532
  ): Promise<void> => {
448
- assert(input.datasetId, "--dataset is required.");
449
- assert(!input.validationFilter || input.validationDatasetId, "--validation-filter requires --validation-dataset.");
533
+ assertCli(input.datasetId, "--dataset is required.", {
534
+ hint: { description: "Pass --dataset <dataset-id>; toloka dataset list shows the ids." },
535
+ });
536
+ assertCli(
537
+ !input.validationFilter || input.validationDatasetId,
538
+ "--validation-filter requires --validation-dataset.",
539
+ { hint: { disabled: true } },
540
+ );
450
541
 
451
542
  const configuration = await buildTrainingConfiguration({
452
543
  client,
@@ -458,12 +549,7 @@ export const createTraining = async (
458
549
  name: input.name,
459
550
  configuration,
460
551
  });
461
- writeOutput(
462
- io,
463
- input.json,
464
- training,
465
- `Created training ${training.id}`,
466
- );
552
+ writeOutput(io, input.json, training, `Created training ${training.id}`);
467
553
  };
468
554
 
469
555
  export const updateTraining = async (
@@ -485,19 +571,13 @@ export const updateTraining = async (
485
571
  options: input,
486
572
  }),
487
573
  });
488
- writeOutput(
489
- io,
490
- input.json,
491
- updated,
492
- `Updated training ${updated.id}`,
493
- );
574
+ writeOutput(io, input.json, updated, `Updated training ${updated.id}`);
494
575
  };
495
576
 
496
- export const estimateTrainingCost = async (
577
+ const prepareTrainingCostEstimate = async (
497
578
  client: TrainingCostApi,
498
- input: { projectId: string; nameOrId: string; idempotencyKey?: string; json: boolean },
499
- io: TrainingIo,
500
- ): Promise<void> => {
579
+ input: { nameOrId: string; projectId: string; idempotencyKey?: string },
580
+ ): Promise<{ training: Training; estimate: TrainingCostEstimate }> => {
501
581
  const training = await resolveTraining(client, input.projectId, input.nameOrId);
502
582
  const inputSet = await client.trainings.prepareCostEstimateInputs({
503
583
  trainingId: training.id,
@@ -511,33 +591,65 @@ export const estimateTrainingCost = async (
511
591
  exportIds.map((exportId) => client.waitFor({ type: "datasetExport", exportId })),
512
592
  );
513
593
 
514
- const estimate = await client.trainings.estimateCost({
515
- trainingId: training.id,
516
- inputSetId: inputSet.id,
594
+ return {
595
+ training,
596
+ estimate: await client.trainings.estimateCost({
597
+ trainingId: training.id,
598
+ inputSetId: inputSet.id,
599
+ }),
600
+ };
601
+ };
602
+
603
+ export const estimateTrainingCost = async (
604
+ client: TrainingCostApi,
605
+ input: { projectId: string; nameOrId: string; idempotencyKey?: string; json: boolean },
606
+ io: TrainingIo,
607
+ ): Promise<void> => {
608
+ const { estimate } = await prepareTrainingCostEstimate(client, input);
609
+ assertCli(estimate.status === "ready", formatCostEstimate(estimate), {
610
+ hint: { description: "Fix the blocked issues above, then run toloka training cost again." },
517
611
  });
518
612
  writeOutput(io, input.json, estimate, formatCostEstimate(estimate));
519
613
  };
520
614
 
521
615
  export const createTrainingRun = async (
522
- client: TrainingApi,
616
+ client: TrainingRunApi,
523
617
  input: {
524
618
  projectId: string;
525
619
  nameOrId: string;
526
- estimateFile: string;
527
- versionName: string;
620
+ estimateFile?: string;
621
+ versionName?: string;
528
622
  idempotencyKey?: string;
529
623
  json: boolean;
530
624
  },
531
625
  io: TrainingIo,
532
626
  ): Promise<void> => {
533
627
  const training = await resolveTraining(client, input.projectId, input.nameOrId);
534
- const estimate = parseReadyEstimateFile(JSON.parse(await readFile(input.estimateFile, "utf8")));
535
- assert(estimate.trainingId === training.id, "The estimate file belongs to another Training.");
628
+ const estimate = input.estimateFile
629
+ ? parseReadyEstimateFile(JSON.parse(await readFile(input.estimateFile, "utf8")))
630
+ : await (async () => {
631
+ const prepared = await prepareTrainingCostEstimate(client, {
632
+ projectId: input.projectId,
633
+ nameOrId: input.nameOrId,
634
+ });
635
+ assertCli(prepared.estimate.status === "ready", formatCostEstimate(prepared.estimate), {
636
+ hint: {
637
+ description: "Fix the blocked issues above, then run toloka training cost again.",
638
+ },
639
+ });
640
+ return parseReadyEstimateFile(prepared.estimate);
641
+ })();
642
+ assertCli(estimate.trainingId === training.id, "The estimate file belongs to another Training.", {
643
+ hint: {
644
+ description:
645
+ "Run toloka training cost <training> --json > <file> for this Training, get its maximumCharge approved, then re-run with that file.",
646
+ },
647
+ });
536
648
  const run = await client.trainings.createRun({
537
649
  trainingId: training.id,
538
650
  inputSetId: estimate.inputSetId,
539
651
  estimateFingerprint: estimate.fingerprint,
540
- versionName: input.versionName,
652
+ versionName: input.versionName ?? training.name,
541
653
  idempotencyKey: input.idempotencyKey ?? randomUUID(),
542
654
  });
543
655
  writeOutput(io, input.json, run, formatTrainingRun(run));
@@ -625,3 +737,76 @@ export const readTrainingRunLogs = async (
625
737
  });
626
738
  }
627
739
  };
740
+
741
+ const formatMetricValue = (point: {
742
+ value: number | null;
743
+ nonFinite: "nan" | "positive_infinity" | "negative_infinity" | null;
744
+ }): string => {
745
+ if (point.nonFinite === "nan") return "NaN";
746
+ if (point.nonFinite === "positive_infinity") return "Infinity";
747
+ if (point.nonFinite === "negative_infinity") return "-Infinity";
748
+ return point.value === null ? "-" : String(point.value);
749
+ };
750
+
751
+ const METRICS_UNAVAILABLE: Record<"not_configured" | "unresolved", string> = {
752
+ not_configured:
753
+ "This Training Run has no metrics: it started before metrics were available, or the server records none.",
754
+ unresolved:
755
+ "Metrics are temporarily unreachable. The Training Run itself is unaffected; try again later.",
756
+ };
757
+
758
+ const formatTrainingRunMetrics = (metrics: TrainingRunMetrics): string => {
759
+ if (!metrics.available) {
760
+ return metrics.unavailableReason === null
761
+ ? "No metrics are available for this Training Run."
762
+ : METRICS_UNAVAILABLE[metrics.unavailableReason];
763
+ }
764
+ if (metrics.metrics.length === 0) return "No metrics have been reported yet.";
765
+
766
+ const rows = metrics.metrics.map((entry) => [
767
+ entry.key,
768
+ entry.metric ?? "-",
769
+ formatMetricValue(entry.latest),
770
+ String(entry.latest.step),
771
+ ]);
772
+ const table = formatTable([["Metric", "Meaning", "Latest", "Step"], ...rows]);
773
+ return metrics.truncated ? `${table}\n\nMore metrics exist than the server returns.` : table;
774
+ };
775
+
776
+ const formatTrainingRunMetricHistory = (history: TrainingRunMetricHistory): string => {
777
+ const header = formatTable([
778
+ ["Metric", history.key],
779
+ ["Meaning", history.metric ?? "-"],
780
+ ["Points", String(history.points.length)],
781
+ ]);
782
+ const points = formatTable([
783
+ ["Step", "Value"],
784
+ ...history.points.map((point) => [String(point.step), formatMetricValue(point)]),
785
+ ]);
786
+ const note = history.truncated
787
+ ? "\n\nNot every recorded point is shown: the series is thinned to fit --resolution, and a very long run keeps only its most recent points."
788
+ : "";
789
+ return `${header}\n\n${points}${note}`;
790
+ };
791
+
792
+ export const readTrainingRunMetrics = async (
793
+ client: TrainingApi,
794
+ input: { trainingRunId: string; json: boolean },
795
+ io: TrainingIo,
796
+ ): Promise<void> => {
797
+ const metrics = await client.trainings.getRunMetrics({ trainingRunId: input.trainingRunId });
798
+ writeOutput(io, input.json, metrics, formatTrainingRunMetrics(metrics));
799
+ };
800
+
801
+ export const readTrainingRunMetricHistory = async (
802
+ client: TrainingApi,
803
+ input: { trainingRunId: string; metricKey: string; resolution?: number; json: boolean },
804
+ io: TrainingIo,
805
+ ): Promise<void> => {
806
+ const history = await client.trainings.getRunMetricHistory({
807
+ trainingRunId: input.trainingRunId,
808
+ metricKey: input.metricKey,
809
+ resolution: input.resolution,
810
+ });
811
+ writeOutput(io, input.json, history, formatTrainingRunMetricHistory(history));
812
+ };
@@ -2,8 +2,8 @@ import { realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
 
5
- import { assert } from "es-toolkit";
6
- import { SDK_VERSION } from "./init";
5
+ import { SDK_VERSION } from "./sdk-version";
6
+ import { assertCli, REPORT_HINT } from "./cli-error";
7
7
 
8
8
  export type CliInstallChannel = "global-registry" | "linked" | "project-local";
9
9
 
@@ -36,7 +36,7 @@ const classifyInstallChannel = (
36
36
  };
37
37
 
38
38
  export const cliVersionInfo = (entryPath = process.argv[1], options: VersionInfoOptions = {}) => {
39
- assert(entryPath, "CLI entry path is unavailable");
39
+ assertCli(entryPath, "CLI entry path is unavailable", { hint: REPORT_HINT });
40
40
  const globalBinDirectory =
41
41
  options.globalBinDirectory ??
42
42
  resolve(process.env.BUN_INSTALL ?? join(homedir(), ".bun"), "bin");
package/src/client.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
25
  ImportPipelineBody,
26
26
  InferredField as GeneratedInferredField,
27
27
  GetTrainingRunLogsParams,
28
+ GetTrainingRunMetricHistoryParams,
28
29
  ListDatasetFieldsParams,
29
30
  ListModelVersionsParams,
30
31
  ListDatasetItemsParams,
@@ -45,6 +46,8 @@ import type {
45
46
  TrainingCostEstimate as GeneratedTrainingCostEstimate,
46
47
  TrainingCostEstimateInputSet as GeneratedTrainingCostEstimateInputSet,
47
48
  TrainingRun as GeneratedTrainingRun,
49
+ TrainingRunMetricHistory as GeneratedTrainingRunMetricHistory,
50
+ TrainingRunMetrics as GeneratedTrainingRunMetrics,
48
51
  TrainingRunLogs as GeneratedTrainingRunLogs,
49
52
  ReplaceTrainingConfigurationBody,
50
53
  UpdateTrainingMetadataBody,
@@ -164,6 +167,8 @@ export type TrainingCostEstimateInputSet = BrandedResource<
164
167
  export type TrainingCostEstimate = BrandResourceUnion<GeneratedTrainingCostEstimate>;
165
168
  export type TrainingRun = BrandedResource<GeneratedTrainingRun, TrainingRunId>;
166
169
  export type TrainingRunLogs = GeneratedTrainingRunLogs;
170
+ export type TrainingRunMetrics = GeneratedTrainingRunMetrics;
171
+ export type TrainingRunMetricHistory = GeneratedTrainingRunMetricHistory;
167
172
  export type BaseModel = GeneratedBaseModel;
168
173
  export type BaseModelId = GeneratedBaseModelId;
169
174
  export type Pipeline = BrandedResource<GeneratedPipeline, PipelineId>;
@@ -223,12 +228,6 @@ export type WaitForTarget =
223
228
  const createClientImplementation = (config: ClientConfig) => {
224
229
  const requestOptions = createRequestOptions(config.baseUrl, config.apiKey, config.fetch);
225
230
 
226
- const emptyJsonRequestOptions = (): RequestInit => {
227
- const headers = new Headers(requestOptions.headers);
228
- headers.set("Content-Type", "application/json");
229
- return { ...requestOptions, headers, body: "{}" };
230
- };
231
-
232
231
  function waitFor(
233
232
  target: DatasetUploadWaitTarget,
234
233
  options?: WaitForOptions,
@@ -482,6 +481,14 @@ const createClientImplementation = (config: ClientConfig) => {
482
481
  ...params
483
482
  }: { trainingRunId: TrainingRunId } & GetTrainingRunLogsParams) =>
484
483
  wire.getTrainingRunLogs(trainingRunId, params, requestOptions),
484
+ getRunMetrics: ({ trainingRunId }: { trainingRunId: TrainingRunId }) =>
485
+ wire.getTrainingRunMetrics(trainingRunId, requestOptions),
486
+ getRunMetricHistory: ({
487
+ trainingRunId,
488
+ metricKey,
489
+ ...params
490
+ }: { trainingRunId: TrainingRunId; metricKey: string } & GetTrainingRunMetricHistoryParams) =>
491
+ wire.getTrainingRunMetricHistory(trainingRunId, metricKey, params, requestOptions),
485
492
  };
486
493
 
487
494
  const datasets = {
@@ -551,9 +558,9 @@ const createClientImplementation = (config: ClientConfig) => {
551
558
  startUpload: ({ datasetId, ...body }: { datasetId: DatasetId } & CreateDatasetUploadBody) =>
552
559
  wire.createDatasetUpload(datasetId, body, requestOptions),
553
560
  finalizeUpload: ({ uploadId }: { uploadId: UploadId }) =>
554
- wire.finalizeDatasetUpload(uploadId, emptyJsonRequestOptions()),
561
+ wire.finalizeDatasetUpload(uploadId, requestOptions),
555
562
  cancelUpload: ({ uploadId }: { uploadId: UploadId }) =>
556
- wire.cancelDatasetUpload(uploadId, emptyJsonRequestOptions()),
563
+ wire.cancelDatasetUpload(uploadId, requestOptions),
557
564
  getUpload: ({ uploadId }: { uploadId: UploadId }) =>
558
565
  wire.getDatasetUpload(uploadId, requestOptions),
559
566
  startExport: ({ datasetId, ...body }: { datasetId: DatasetId } & CreateDatasetExportBody) =>
@@ -564,7 +571,7 @@ const createClientImplementation = (config: ClientConfig) => {
564
571
 
565
572
  const runs = {
566
573
  start: ({ pipelineId }: { pipelineId: PipelineId }) =>
567
- wire.createPipelineRun(pipelineId, emptyJsonRequestOptions()),
574
+ wire.createPipelineRun(pipelineId, requestOptions),
568
575
  get: ({ runId }: { runId: RunId }) => wire.getPipelineRun(runId, requestOptions),
569
576
  cancel: ({ runId }: { runId: RunId }) => wire.cancelPipelineRun(runId, requestOptions),
570
577
  listPage: ({ pipelineId, ...params }: { pipelineId: PipelineId } & ListPipelineRunsParams) =>
@@ -624,6 +631,10 @@ const createClientImplementation = (config: ClientConfig) => {
624
631
  wire.getPipelineCheck(checkRunId, requestOptions),
625
632
  };
626
633
 
634
+ const deliveryOrders = {
635
+ list: async () => (await wire.listDeliveryOrders(requestOptions)).deliveryOrders,
636
+ };
637
+
627
638
  return {
628
639
  me: () => wire.getCurrentUser(requestOptions),
629
640
  projects,
@@ -636,6 +647,7 @@ const createClientImplementation = (config: ClientConfig) => {
636
647
  secrets,
637
648
  files,
638
649
  checks,
650
+ deliveryOrders,
639
651
  waitFor,
640
652
  };
641
653
  };