runbios-sdk 0.2.22 → 0.3.0-dev.323

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.
@@ -1,63 +1,11 @@
1
- import type { HttpClient } from '../client.js';
2
- import type { LoopTrace, LoopTraceDeleteResponse, LoopTraceListResponse, LoopTraceListParams, LoopCaptureParams, LoopCaptureResult, LoopImportParams, LoopImportResult, LoopSignal, LoopSignalParams, LoopLabel, LoopLabelCount, LoopConfig, LoopConfigParams, LoopStats, LoopTracePipelinesResponse, LoopMetricsHistory, LoopMetricsHistoryParams, TrainingRuleDeleteResponse, TrainingRunListParams, TrainingRunListResponse, TrainingRunResponse, TrainingRunActionResponse, TrainingRunPromoteRequest, TrainingRunRejectRequest, TrainingRunRollbackRequest, TrainingRunCancelRequest, Evaluation, EvaluationItemListParams, EvaluationItemsResponse, Benchmark, BenchmarkCreateParams, BenchmarkHistoryParams, BenchmarkHistoryResponse, BenchmarkItemListParams, BenchmarkItemsResponse, BenchmarkListParams, BenchmarkListResponse, BenchmarkRetireParams, BenchmarkRun, BenchmarkRunItemListParams, BenchmarkRunItemsResponse, Pipeline, PipelineListParams, PipelineListResponse, PipelineWriteRequest, PipelineMutationResponse, LoopModelsResponse, PipelineImportParams, PipelineBenchmarksResponse, PipelineBenchmarkAttachParams, PipelineBenchmarkMoveParams, VersionCompareResponse, VersionCompareParams, AttemptCompareResponse, AttemptCompareParams } from '../types.js';
3
- /**
4
- * The Conscious Loop -- pipelines that keep improving a model from the
5
- * conversations you send them.
6
- *
7
- * A pipeline is one use case: a model, the tags whose samples train it, when
8
- * to train, and how a new version is promoted. Every time it trains it makes
9
- * an attempt; an attempt that goes live becomes the next version: one that
10
- * beats the live model, one a member approves, or, after a deleted
11
- * conversation, a rebuild without it that is not clearly worse (a `manual`
12
- * pipeline waits for a member even then).
13
- *
14
- * Nothing is captured until a source is switched on, and you can switch it
15
- * off again at any time. Creating a pipeline switches on the source named by
16
- * its own tag (`own_tag`), stamping that tag, and deleting it switches that
17
- * source off again. Anything that looks like a credential or a personal detail
18
- * is removed before the record is written, never afterwards.
19
- *
20
- * Requires an API key carrying `loop:read` and/or `loop:write`. Those scopes are
21
- * deliberately absent from the Read Only preset, because what they return is
22
- * your raw prompts and completions rather than catalog metadata.
23
- *
24
- * Offered on the development environment only for now. Anywhere else every
25
- * method here, reads included, rejects with a `ComingSoonError` whose
26
- * `code` is `LOOP_COMING_SOON` (HTTP 403): a deliberate product state, not an
27
- * outage, so retrying cannot succeed there.
28
- *
29
- * @example
30
- * ```ts
31
- * // 1. A pipeline: a model it can train, and its own tag.
32
- * const { models } = await client.loop.listModels();
33
- * const { pipeline } = await client.loop.createPipeline({
34
- * name: 'Support replies',
35
- * model: { id: models[0].id },
36
- * });
37
- *
38
- * // 2. Send samples from your app to the pipeline's own source: its create
39
- * // switched that source on, and it tags everything it records.
40
- * const sent = await client.loop.capture({
41
- * deployment_id: pipeline.own_tag!,
42
- * model: 'gpt-4o',
43
- * messages: [{ role: 'user', content: 'what is our refund window' }],
44
- * completion: 'thirty days',
45
- * });
46
- *
47
- * // 3. Say whether it was right, on the trace_id the capture answered with.
48
- * // A correction is the most valuable answer.
49
- * if (sent.trace_id) {
50
- * await client.loop.correct(sent.trace_id, 'Thirty days from delivery, no questions asked.');
51
- * }
52
- *
53
- * // 4. Once it has its minimum, it trains; versions appear on the pipeline.
54
- * const p = await client.loop.getPipeline(pipeline.rule_id);
55
- * ```
56
- */
1
+ import { type HttpClient } from '../client.js';
2
+ import type { LoopTrace, LoopTraceDeleteResponse, LoopTraceListResponse, LoopTraceListParams, LoopCaptureParams, LoopCaptureResult, LoopImportParams, LoopImportResult, LoopSignal, LoopSignalParams, LoopSignalManyItem, LoopSignalManyResult, LoopLabel, LoopLabelCount, LoopConfig, LoopConfigParams, LoopStats, LoopTracePipelinesResponse, LoopMetricsHistory, LoopMetricsHistoryParams, TrainingRuleDeleteResponse, TrainingRunListParams, TrainingRunListResponse, TrainingRunResponse, TrainingRunActionResponse, TrainingRunPromoteRequest, TrainingRunRejectRequest, TrainingRunRollbackRequest, TrainingRunCancelRequest, Evaluation, EvaluationItemListParams, EvaluationItemsResponse, Benchmark, BenchmarkCreateParams, BenchmarkHistoryParams, BenchmarkHistoryResponse, BenchmarkItemListParams, BenchmarkItemsResponse, BenchmarkListParams, BenchmarkListResponse, BenchmarkRetireParams, BenchmarkRun, BenchmarkRunItemListParams, BenchmarkRunItemsResponse, Pipeline, PipelineAttemptListParams, PipelineAttemptListResponse, PipelineListParams, PipelineListResponse, PipelineWriteRequest, PipelineMutationResponse, LoopModelsResponse, PipelineImportParams, PipelineBenchmarksResponse, PipelineBenchmarkAttachParams, PipelineBenchmarkMoveParams, VersionCompareResponse, VersionCompareParams, AttemptCompareResponse, AttemptCompareParams } from '../types.js';
57
3
  export declare class Loop {
58
4
  private readonly _http;
59
5
  /** @internal */
60
6
  constructor(_http: HttpClient);
7
+ /** @internal How feedback waits between tries; tests replace it so nothing sleeps. */
8
+ _sleep: (ms: number) => Promise<void>;
61
9
  /**
62
10
  * Send one exchange to be captured.
63
11
  *
@@ -79,6 +27,10 @@ export declare class Loop {
79
27
  * When the source is not switched on this resolves with `captured: false`
80
28
  * and a reason instead of throwing -- capture must never be the thing that
81
29
  * breaks your application.
30
+ *
31
+ * Already know the answer was good, bad or wrong? Pass `feedback` and it is
32
+ * written with the exchange, so no second call is needed:
33
+ * `feedback: { verdict: 'corrected', correction: 'Thirty days from delivery.' }`.
82
34
  */
83
35
  capture(params: LoopCaptureParams): Promise<LoopCaptureResult>;
84
36
  /**
@@ -139,7 +91,14 @@ export declare class Loop {
139
91
  * a shorter list moves every row after the gap and imports it again.
140
92
  */
141
93
  importRows(params: LoopImportParams): Promise<LoopImportResult>;
142
- /** List captured conversations, newest first. */
94
+ /**
95
+ * List captured conversations, newest first.
96
+ *
97
+ * `q` searches the text: 2 to 200 characters, any case, matched as written
98
+ * in every message, the answer and its tool calls. Samples kept in your own
99
+ * storage cannot be searched; a search answers how many were left out as
100
+ * `unsearched`.
101
+ */
143
102
  listTraces(params?: LoopTraceListParams): Promise<LoopTraceListResponse>;
144
103
  /**
145
104
  * Read one conversation, with every verdict and tag recorded on it.
@@ -176,8 +135,36 @@ export declare class Loop {
176
135
  * `edited` or `gold` verdict is the example, in place of the model's answer;
177
136
  * a `rejected` answer is left out. Nothing is learned from an answer that was
178
137
  * rejected or replaced. `source` defaults to `human`.
138
+ *
139
+ * Feedback on an `X-Loop-Sample-Id` sent right after the answer can reach
140
+ * the service before the sample does, which answers 409
141
+ * `SAMPLE_NOT_YET_RECORDED` and stores nothing. This method, and so `upvote`,
142
+ * `downvote`, `correct` and `gold`, sends it again after the `Retry-After`
143
+ * seconds (2 when none is given), at most 6 times in all, and then throws
144
+ * that error. No other error is retried.
179
145
  */
180
146
  signal(traceId: string, params: LoopSignalParams): Promise<LoopSignal>;
147
+ /**
148
+ * Send many feedback items at once, several in flight together.
149
+ *
150
+ * Each item is `signal(item.trace_id, item)`: the same call, the same
151
+ * `SAMPLE_NOT_YET_RECORDED` retry. At most `concurrency` calls are in flight
152
+ * (8 by default, at most 32). It never throws for one item: every item
153
+ * comes back, in the order given, with its `signal` or the `error` it ended
154
+ * with, so one refused item costs no other.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * const results = await client.loop.signalMany([
159
+ * { trace_id: idA, verdict: 'good' },
160
+ * { trace_id: idB, verdict: 'corrected', correction: 'Refunds take five working days.' },
161
+ * ]);
162
+ * const failed = results.filter((r) => r.error);
163
+ * ```
164
+ */
165
+ signalMany(items: LoopSignalManyItem[], options?: {
166
+ concurrency?: number;
167
+ }): Promise<LoopSignalManyResult[]>;
181
168
  /**
182
169
  * Thumbs up: this answer was good. Shorthand for `signal(id, { verdict:
183
170
  * 'accepted' })` — there is one stored vocabulary behind both, so a thumbs-up
@@ -298,6 +285,13 @@ export declare class Loop {
298
285
  *
299
286
  * `ready.sft` is an upper bound: duplicates, the held-back set and
300
287
  * conversations too long to train on come out of it when an attempt trains.
288
+ *
289
+ * `capture` is what the one-header capture did not record over the last
290
+ * `capture.days` days: per tag, `no_pipeline` (no pipeline has the tag),
291
+ * `too_large` or `incomplete` (the answer could not be kept). Empty when
292
+ * every call was recorded. Rows are per tag: a call naming two tags is
293
+ * counted under each, so never add them up as calls. One pipeline's share
294
+ * is `capture` on its detail.
301
295
  */
302
296
  stats(): Promise<LoopStats>;
303
297
  /**
@@ -356,15 +350,21 @@ export declare class Loop {
356
350
  */
357
351
  cancelTrainingRun(id: string, params?: TrainingRunCancelRequest): Promise<TrainingRunActionResponse>;
358
352
  /**
359
- * Every pipeline in the workspace: its versions (the attempts that became
360
- * the live model), every attempt, which version is live, the attempt in
361
- * flight, and what the pipeline is waiting for.
353
+ * The workspace's pipelines, a page at a time: its versions (the attempts
354
+ * that became the live model), its latest 5 attempts (`attempts_total` says
355
+ * how many; {@link listPipelineAttempts} pages through every one), which
356
+ * version is live, the attempt in flight, and what the pipeline is waiting
357
+ * for.
358
+ *
359
+ * `limit` is 100 when absent and at most 100; `q` matches the name, the tag
360
+ * or the model (case-insensitive); `status` keeps the pipelines `training`,
361
+ * waiting on you (`needs_you`), `serving` or `paused`.
362
362
  *
363
363
  * RETURNED WITH ITS ENVELOPE, because the list is not always all of them:
364
- * the service returns a page of 100, newest first, `total` is every
365
- * pipeline in the workspace and `truncated` is true when more exist after
366
- * this page. A caller that shows `pipelines` alone presents a short list as
367
- * the whole of it; the next page is `{ offset: offset + pipelines.length }`.
364
+ * the service returns one page, newest first, `total` is every pipeline
365
+ * that matches and `truncated` is true when more exist after this page. A
366
+ * caller that shows `pipelines` alone presents a short list as the whole of
367
+ * it; the next page is `{ offset: offset + pipelines.length }`.
368
368
  */
369
369
  listPipelines(params?: PipelineListParams): Promise<PipelineListResponse>;
370
370
  /**
@@ -373,11 +373,21 @@ export declare class Loop {
373
373
  * `status` says whether it is the platform working (`running`), the member
374
374
  * who has to act (`needs_review`, `needs_funds`), or nothing at all until
375
375
  * somebody does (`paused`, with `paused_reason` saying why; `complete` at
376
- * `max_versions`). `month_spent_cents` is the figure the
377
- * monthly limit is enforced against: what runs started this month have cost
378
- * at most, not an exact spend.
376
+ * `max_versions`). The monthly safety limit is the platform's and has no
377
+ * field: when it holds the pipeline, `next_reason` says so.
379
378
  */
380
379
  getPipeline(id: string): Promise<Pipeline>;
380
+ /**
381
+ * A pipeline's attempts, newest first, a page at a time: every training run
382
+ * it made, winner or not, each the shape `attempts` has on the pipeline
383
+ * (with its `explanation` in plain words). `limit` is 50 when absent and at
384
+ * most 200. Narrow by `result` (`became_version`, `not_better`,
385
+ * `inconclusive`, `failed`, `stopped`, `running` -- which includes one
386
+ * waiting for a decision -- or `waiting_for_review`) and by `model` (`main`,
387
+ * or `second` for the second model's attempts); `total` follows the
388
+ * filters.
389
+ */
390
+ listPipelineAttempts(id: string, params?: PipelineAttemptListParams): Promise<PipelineAttemptListResponse>;
381
391
  /**
382
392
  * The base models a pipeline can train: only those the engine trains with
383
393
  * LoRA SFT and the platform can serve for the comparison, recommended first.
@@ -391,9 +401,12 @@ export declare class Loop {
391
401
  * judged on at most `scored_rows` of them). They are read-only.
392
402
  * Where a pipeline builds on its live version (its `chain` is set), each
393
403
  * attempt trains a new adapter on top of the live version, on what it has
394
- * not learned yet plus a replay of what it already learned (about a fifth
404
+ * not learned yet plus a replay of what it already learned (about half
395
405
  * of what it trains on, picked at random, so it is not forgotten), and a
396
406
  * winner is merged in and served as one model.
407
+ * `unavailable` lists every other model the platform knows, each with
408
+ * why it is not offered here (`reason_code`, and `reason`, one plain
409
+ * sentence); `[]` from a service from before it was sent.
397
410
  * When the list cannot be read it throws `ApiError` 503
398
411
  * `MODELS_UNAVAILABLE`, whose `body.floors` still carries the floors.
399
412
  */
@@ -408,12 +421,14 @@ export declare class Loop {
408
421
  * Where the pipeline builds on its live version (`chain`), each attempt
409
422
  * waits for 1,000 new samples and trains a new adapter on top of the live
410
423
  * version, on what it has not learned plus a replay of what it already
411
- * learned (about a fifth of what it trains on, picked at random); a winner
424
+ * learned (about half of what it trains on, picked at random); a winner
412
425
  * is merged in.
413
426
  * What is charged is the GPU time the runs actually use; the platform's own
414
427
  * per-run and monthly limits stop a run that goes wrong. `holdout.percent`
415
428
  * (5 to 20, default 5) is the share of its samples held back to judge each
416
- * attempt and never trained on.
429
+ * attempt and never trained on. `training` sets any of its training
430
+ * settings (`epochs`, `learning_rate`, `lora_rank`, `lora_alpha`,
431
+ * `replay_percent`); left out, or null, every one is automatic.
417
432
  */
418
433
  createPipeline(params: PipelineWriteRequest): Promise<PipelineMutationResponse>;
419
434
  /**
@@ -421,7 +436,13 @@ export declare class Loop {
421
436
  * the limit and `challenger_model: null` removes the challenger. `promotion`
422
437
  * is how the promotion policy is set. A new `holdout.percent` is a new
423
438
  * revision: lowering it keeps every sample already held back, and raising it
424
- * holds back only new samples. Saving is the authorization, as on a create.
439
+ * holds back only new samples. `training` changes only the settings it
440
+ * carries -- `{ epochs: 8 }` sets the passes and keeps the rest,
441
+ * `{ epochs: null }` puts the passes back to automatic -- and
442
+ * `training: null` puts every one back to automatic; the next attempt
443
+ * trains with them, and earlier attempts keep the settings they trained
444
+ * with. Out of range is 422 `TRAINING_SETTING_OUT_OF_RANGE`, naming the
445
+ * field. Saving is the authorization, as on a create.
425
446
  */
426
447
  updatePipeline(id: string, params: PipelineWriteRequest): Promise<PipelineMutationResponse>;
427
448
  /**
@@ -585,6 +606,9 @@ export declare class Loop {
585
606
  * series that silently dropped the runs where the benchmark could not be
586
607
  * scored would read as an unbroken line and not be one, so those points come
587
608
  * back with `candidate_score` null and `status_reason` saying why.
609
+ *
610
+ * `pipeline` (a pipeline's id) keeps only the points of that pipeline's
611
+ * attempts, so the trend is one pipeline's attempts by attempt number.
588
612
  */
589
613
  getBenchmarkHistory(id: string, params?: BenchmarkHistoryParams): Promise<BenchmarkHistoryResponse>;
590
614
  /**
@@ -1,3 +1,4 @@
1
+ import { ApiError } from '../client.js';
1
2
  /**
2
3
  * The Conscious Loop -- pipelines that keep improving a model from the
3
4
  * conversations you send them.
@@ -52,12 +53,28 @@
52
53
  * const p = await client.loop.getPipeline(pipeline.rule_id);
53
54
  * ```
54
55
  */
56
+ /**
57
+ * The code feedback is refused with (409, `Retry-After`) when it names a sample
58
+ * id the gateway issued moments ago (`X-Loop-Sample-Id`) and the sample is not
59
+ * recorded yet. Nothing was stored, so sending the same feedback again is safe.
60
+ */
61
+ const SAMPLE_NOT_YET_RECORDED = 'SAMPLE_NOT_YET_RECORDED';
62
+ /** Feedback is sent at most this many times while its sample is on its way. */
63
+ const FEEDBACK_TRIES = 6;
64
+ /** The wait when the 409 names none, and the longest wait honoured. */
65
+ const FEEDBACK_RETRY_DEFAULT_SECONDS = 2;
66
+ const FEEDBACK_RETRY_MAX_SECONDS = 30;
67
+ /** How many feedback calls `signalMany` keeps in flight, by default and at most. */
68
+ const SIGNAL_MANY_CONCURRENCY = 8;
69
+ const SIGNAL_MANY_CONCURRENCY_MAX = 32;
55
70
  export class Loop {
56
71
  _http;
57
72
  /** @internal */
58
73
  constructor(_http) {
59
74
  this._http = _http;
60
75
  }
76
+ /** @internal How feedback waits between tries; tests replace it so nothing sleeps. */
77
+ _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
61
78
  // ── capture ───────────────────────────────────────────────────────────
62
79
  /**
63
80
  * Send one exchange to be captured.
@@ -80,6 +97,10 @@ export class Loop {
80
97
  * When the source is not switched on this resolves with `captured: false`
81
98
  * and a reason instead of throwing -- capture must never be the thing that
82
99
  * breaks your application.
100
+ *
101
+ * Already know the answer was good, bad or wrong? Pass `feedback` and it is
102
+ * written with the exchange, so no second call is needed:
103
+ * `feedback: { verdict: 'corrected', correction: 'Thirty days from delivery.' }`.
83
104
  */
84
105
  async capture(params) {
85
106
  return this._http.fetchPost('/api/loop/traces', params);
@@ -145,9 +166,18 @@ export class Loop {
145
166
  return this._http.fetchPost('/api/loop/import', params);
146
167
  }
147
168
  // ── traces ────────────────────────────────────────────────────────────
148
- /** List captured conversations, newest first. */
169
+ /**
170
+ * List captured conversations, newest first.
171
+ *
172
+ * `q` searches the text: 2 to 200 characters, any case, matched as written
173
+ * in every message, the answer and its tool calls. Samples kept in your own
174
+ * storage cannot be searched; a search answers how many were left out as
175
+ * `unsearched`.
176
+ */
149
177
  async listTraces(params = {}) {
150
178
  const q = new URLSearchParams();
179
+ if (params.q)
180
+ q.set('q', params.q);
151
181
  if (params.deployment_id)
152
182
  q.set('deployment_id', params.deployment_id);
153
183
  if (params.conversation_id)
@@ -226,10 +256,71 @@ export class Loop {
226
256
  * `edited` or `gold` verdict is the example, in place of the model's answer;
227
257
  * a `rejected` answer is left out. Nothing is learned from an answer that was
228
258
  * rejected or replaced. `source` defaults to `human`.
259
+ *
260
+ * Feedback on an `X-Loop-Sample-Id` sent right after the answer can reach
261
+ * the service before the sample does, which answers 409
262
+ * `SAMPLE_NOT_YET_RECORDED` and stores nothing. This method, and so `upvote`,
263
+ * `downvote`, `correct` and `gold`, sends it again after the `Retry-After`
264
+ * seconds (2 when none is given), at most 6 times in all, and then throws
265
+ * that error. No other error is retried.
229
266
  */
230
267
  async signal(traceId, params) {
231
- const res = await this._http.fetchPost(`/api/loop/traces/${encodeURIComponent(traceId)}/signals`, params);
232
- return res.signal;
268
+ const path = `/api/loop/traces/${encodeURIComponent(traceId)}/signals`;
269
+ for (let tries = 1;; tries++) {
270
+ try {
271
+ const res = await this._http.fetchPost(path, params);
272
+ return res.signal;
273
+ }
274
+ catch (err) {
275
+ const onItsWay = err instanceof ApiError && err.status === 409 && err.code === SAMPLE_NOT_YET_RECORDED;
276
+ if (!onItsWay || tries >= FEEDBACK_TRIES)
277
+ throw err;
278
+ const seconds = Math.min(err.retryAfter ?? FEEDBACK_RETRY_DEFAULT_SECONDS, FEEDBACK_RETRY_MAX_SECONDS);
279
+ await this._sleep(seconds * 1000);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * Send many feedback items at once, several in flight together.
285
+ *
286
+ * Each item is `signal(item.trace_id, item)`: the same call, the same
287
+ * `SAMPLE_NOT_YET_RECORDED` retry. At most `concurrency` calls are in flight
288
+ * (8 by default, at most 32). It never throws for one item: every item
289
+ * comes back, in the order given, with its `signal` or the `error` it ended
290
+ * with, so one refused item costs no other.
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * const results = await client.loop.signalMany([
295
+ * { trace_id: idA, verdict: 'good' },
296
+ * { trace_id: idB, verdict: 'corrected', correction: 'Refunds take five working days.' },
297
+ * ]);
298
+ * const failed = results.filter((r) => r.error);
299
+ * ```
300
+ */
301
+ async signalMany(items, options = {}) {
302
+ const asked = Math.trunc(options.concurrency ?? SIGNAL_MANY_CONCURRENCY);
303
+ const limit = Math.max(1, Math.min(Number.isFinite(asked) ? asked : SIGNAL_MANY_CONCURRENCY, SIGNAL_MANY_CONCURRENCY_MAX));
304
+ const out = new Array(items.length);
305
+ let next = 0;
306
+ const worker = async () => {
307
+ for (;;) {
308
+ const i = next++;
309
+ if (i >= items.length)
310
+ return;
311
+ const { trace_id, ...params } = items[i];
312
+ try {
313
+ if (!trace_id)
314
+ throw new Error('each feedback item needs its trace_id');
315
+ out[i] = { trace_id, signal: await this.signal(trace_id, params) };
316
+ }
317
+ catch (error) {
318
+ out[i] = { trace_id, error };
319
+ }
320
+ }
321
+ };
322
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
323
+ return out;
233
324
  }
234
325
  /**
235
326
  * Thumbs up: this answer was good. Shorthand for `signal(id, { verdict:
@@ -383,6 +474,13 @@ export class Loop {
383
474
  *
384
475
  * `ready.sft` is an upper bound: duplicates, the held-back set and
385
476
  * conversations too long to train on come out of it when an attempt trains.
477
+ *
478
+ * `capture` is what the one-header capture did not record over the last
479
+ * `capture.days` days: per tag, `no_pipeline` (no pipeline has the tag),
480
+ * `too_large` or `incomplete` (the answer could not be kept). Empty when
481
+ * every call was recorded. Rows are per tag: a call naming two tags is
482
+ * counted under each, so never add them up as calls. One pipeline's share
483
+ * is `capture` on its detail.
386
484
  */
387
485
  async stats() {
388
486
  const res = await this._http.fetchGet('/api/loop/stats');
@@ -481,20 +579,32 @@ export class Loop {
481
579
  }
482
580
  // ── pipelines ─────────────────────────────────────────────────────────
483
581
  /**
484
- * Every pipeline in the workspace: its versions (the attempts that became
485
- * the live model), every attempt, which version is live, the attempt in
486
- * flight, and what the pipeline is waiting for.
582
+ * The workspace's pipelines, a page at a time: its versions (the attempts
583
+ * that became the live model), its latest 5 attempts (`attempts_total` says
584
+ * how many; {@link listPipelineAttempts} pages through every one), which
585
+ * version is live, the attempt in flight, and what the pipeline is waiting
586
+ * for.
587
+ *
588
+ * `limit` is 100 when absent and at most 100; `q` matches the name, the tag
589
+ * or the model (case-insensitive); `status` keeps the pipelines `training`,
590
+ * waiting on you (`needs_you`), `serving` or `paused`.
487
591
  *
488
592
  * RETURNED WITH ITS ENVELOPE, because the list is not always all of them:
489
- * the service returns a page of 100, newest first, `total` is every
490
- * pipeline in the workspace and `truncated` is true when more exist after
491
- * this page. A caller that shows `pipelines` alone presents a short list as
492
- * the whole of it; the next page is `{ offset: offset + pipelines.length }`.
593
+ * the service returns one page, newest first, `total` is every pipeline
594
+ * that matches and `truncated` is true when more exist after this page. A
595
+ * caller that shows `pipelines` alone presents a short list as the whole of
596
+ * it; the next page is `{ offset: offset + pipelines.length }`.
493
597
  */
494
598
  async listPipelines(params = {}) {
495
599
  const q = new URLSearchParams();
600
+ if (params.limit != null)
601
+ q.set('limit', String(params.limit));
496
602
  if (params.offset)
497
603
  q.set('offset', String(params.offset));
604
+ if (params.q)
605
+ q.set('q', params.q);
606
+ if (params.status)
607
+ q.set('status', params.status);
498
608
  const qs = q.toString();
499
609
  const res = await this._http.fetchGet(`/api/loop/pipelines${qs ? `?${qs}` : ''}`);
500
610
  const pipelines = res.pipelines || [];
@@ -511,14 +621,38 @@ export class Loop {
511
621
  * `status` says whether it is the platform working (`running`), the member
512
622
  * who has to act (`needs_review`, `needs_funds`), or nothing at all until
513
623
  * somebody does (`paused`, with `paused_reason` saying why; `complete` at
514
- * `max_versions`). `month_spent_cents` is the figure the
515
- * monthly limit is enforced against: what runs started this month have cost
516
- * at most, not an exact spend.
624
+ * `max_versions`). The monthly safety limit is the platform's and has no
625
+ * field: when it holds the pipeline, `next_reason` says so.
517
626
  */
518
627
  async getPipeline(id) {
519
628
  const res = await this._http.fetchGet(`/api/loop/pipelines/${encodeURIComponent(id)}`);
520
629
  return res.pipeline;
521
630
  }
631
+ /**
632
+ * A pipeline's attempts, newest first, a page at a time: every training run
633
+ * it made, winner or not, each the shape `attempts` has on the pipeline
634
+ * (with its `explanation` in plain words). `limit` is 50 when absent and at
635
+ * most 200. Narrow by `result` (`became_version`, `not_better`,
636
+ * `inconclusive`, `failed`, `stopped`, `running` -- which includes one
637
+ * waiting for a decision -- or `waiting_for_review`) and by `model` (`main`,
638
+ * or `second` for the second model's attempts); `total` follows the
639
+ * filters.
640
+ */
641
+ async listPipelineAttempts(id, params = {}) {
642
+ const q = new URLSearchParams();
643
+ if (params.limit != null)
644
+ q.set('limit', String(params.limit));
645
+ if (params.offset)
646
+ q.set('offset', String(params.offset));
647
+ if (params.result)
648
+ q.set('result', params.result);
649
+ if (params.model)
650
+ q.set('model', params.model);
651
+ const qs = q.toString();
652
+ const res = await this._http.fetchGet(`/api/loop/pipelines/${encodeURIComponent(id)}/attempts${qs ? `?${qs}` : ''}`);
653
+ const attempts = res.attempts || [];
654
+ return { attempts, total: typeof res.total === 'number' ? res.total : attempts.length };
655
+ }
522
656
  // ── creating and changing pipelines ───────────────────────────────────
523
657
  //
524
658
  // One body -- a name, a model, tags, when to train, how to promote -- and
@@ -538,15 +672,18 @@ export class Loop {
538
672
  * judged on at most `scored_rows` of them). They are read-only.
539
673
  * Where a pipeline builds on its live version (its `chain` is set), each
540
674
  * attempt trains a new adapter on top of the live version, on what it has
541
- * not learned yet plus a replay of what it already learned (about a fifth
675
+ * not learned yet plus a replay of what it already learned (about half
542
676
  * of what it trains on, picked at random, so it is not forgotten), and a
543
677
  * winner is merged in and served as one model.
678
+ * `unavailable` lists every other model the platform knows, each with
679
+ * why it is not offered here (`reason_code`, and `reason`, one plain
680
+ * sentence); `[]` from a service from before it was sent.
544
681
  * When the list cannot be read it throws `ApiError` 503
545
682
  * `MODELS_UNAVAILABLE`, whose `body.floors` still carries the floors.
546
683
  */
547
684
  async listModels() {
548
685
  const res = await this._http.fetchGet('/api/loop/models');
549
- return { ...res, models: res.models || [] };
686
+ return { ...res, models: res.models || [], unavailable: res.unavailable || [] };
550
687
  }
551
688
  /**
552
689
  * Create a pipeline -- one use case.
@@ -558,12 +695,14 @@ export class Loop {
558
695
  * Where the pipeline builds on its live version (`chain`), each attempt
559
696
  * waits for 1,000 new samples and trains a new adapter on top of the live
560
697
  * version, on what it has not learned plus a replay of what it already
561
- * learned (about a fifth of what it trains on, picked at random); a winner
698
+ * learned (about half of what it trains on, picked at random); a winner
562
699
  * is merged in.
563
700
  * What is charged is the GPU time the runs actually use; the platform's own
564
701
  * per-run and monthly limits stop a run that goes wrong. `holdout.percent`
565
702
  * (5 to 20, default 5) is the share of its samples held back to judge each
566
- * attempt and never trained on.
703
+ * attempt and never trained on. `training` sets any of its training
704
+ * settings (`epochs`, `learning_rate`, `lora_rank`, `lora_alpha`,
705
+ * `replay_percent`); left out, or null, every one is automatic.
567
706
  */
568
707
  async createPipeline(params) {
569
708
  return this._http.fetchPost('/api/loop/pipelines', params);
@@ -573,7 +712,13 @@ export class Loop {
573
712
  * the limit and `challenger_model: null` removes the challenger. `promotion`
574
713
  * is how the promotion policy is set. A new `holdout.percent` is a new
575
714
  * revision: lowering it keeps every sample already held back, and raising it
576
- * holds back only new samples. Saving is the authorization, as on a create.
715
+ * holds back only new samples. `training` changes only the settings it
716
+ * carries -- `{ epochs: 8 }` sets the passes and keeps the rest,
717
+ * `{ epochs: null }` puts the passes back to automatic -- and
718
+ * `training: null` puts every one back to automatic; the next attempt
719
+ * trains with them, and earlier attempts keep the settings they trained
720
+ * with. Out of range is 422 `TRAINING_SETTING_OUT_OF_RANGE`, naming the
721
+ * field. Saving is the authorization, as on a create.
577
722
  */
578
723
  async updatePipeline(id, params) {
579
724
  return this._http.fetchPut(`/api/loop/pipelines/${encodeURIComponent(id)}`, params);
@@ -811,9 +956,14 @@ export class Loop {
811
956
  * series that silently dropped the runs where the benchmark could not be
812
957
  * scored would read as an unbroken line and not be one, so those points come
813
958
  * back with `candidate_score` null and `status_reason` saying why.
959
+ *
960
+ * `pipeline` (a pipeline's id) keeps only the points of that pipeline's
961
+ * attempts, so the trend is one pipeline's attempts by attempt number.
814
962
  */
815
963
  async getBenchmarkHistory(id, params = {}) {
816
964
  const q = new URLSearchParams();
965
+ if (params.pipeline)
966
+ q.set('pipeline', params.pipeline);
817
967
  if (params.limit != null)
818
968
  q.set('limit', String(params.limit));
819
969
  if (params.offset != null)
@@ -9,8 +9,9 @@ function trainingIdempotencyKey(explicit) {
9
9
  }
10
10
  /**
11
11
  * `start_from` on the wire. The adapters stay in the order given (oldest
12
- * first: the order they are merged in), and an empty list is refused here
13
- * rather than sent, since the API refuses it too.
12
+ * first: the order they are merged in). An empty list, an `exportStep` other
13
+ * than 'best' or 'final', and an `exportStep` without `exportMerged: true`
14
+ * are refused here rather than sent, since the API refuses each of them too.
14
15
  */
15
16
  function startFromBody(sf) {
16
17
  if (!Array.isArray(sf.adapters) || sf.adapters.length === 0) {
@@ -21,6 +22,15 @@ function startFromBody(sf) {
21
22
  };
22
23
  if (sf.exportMerged !== undefined)
23
24
  body.export_merged = sf.exportMerged;
25
+ if (sf.exportStep !== undefined) {
26
+ if (sf.exportStep !== 'best' && sf.exportStep !== 'final') {
27
+ throw new Error("RunBiOS: startFrom.exportStep must be 'best' (the step validation picked) or 'final' (the last step trained)");
28
+ }
29
+ if (sf.exportMerged !== true) {
30
+ throw new Error('RunBiOS: startFrom.exportStep names the step the merged model is made of, so it needs exportMerged: true');
31
+ }
32
+ body.export_step = sf.exportStep;
33
+ }
24
34
  return body;
25
35
  }
26
36
  function buildTrainingRequest(params) {