@civitai/blocks-react 0.69.1 → 0.71.0

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/README.md CHANGED
@@ -482,7 +482,7 @@ if (quote.cost) showPrice(priceLine(quote.cost));
482
482
 
483
483
  | `cost.authorFee` | Means | Where |
484
484
  |---|---|---|
485
- | a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage` or registered-`step` body, and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
485
+ | a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage`, registered-`step` or `workflow` body (one fee for the whole workflow), and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
486
486
  | `0` | a fee was looked up and none applies to this request | the same replies |
487
487
  | absent | the total is **not itemised** | a host that predates the field; every snapshot of a submitted workflow (`submit` success, `poll`, `cancel`), whose `total` is the generation's realized cost with the fee charged separately; the registered-`step` `submit()` refusal for a missing orchestrator price quote, which refuses before any fee is looked up; and `customComfy`, pass-through `step` and `training` quotes, which price no fee |
488
488
 
@@ -541,6 +541,164 @@ try {
541
541
  (`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
542
542
  the inbound validator accepts any string. Keep a `default` branch.
543
543
  - The `kind: 'training'` arm does not set it.
544
+ - A refused `kind: 'workflow'` body sets its own three codes, in the same
545
+ shape — see the next section.
546
+
547
+ #### Several steps as one workflow: `kind: 'workflow'`
548
+
549
+ A `kind: 'workflow'` body submits **several orchestrator steps as one
550
+ workflow**, where a later step can consume an earlier step's result. It goes
551
+ through the hooks you already use: `useBuzzWorkflow().estimate / submit /
552
+ watch / cancel`, a `useBatchEstimate()` cell, and `useWorkflowBatch()`. There
553
+ is no new hook and no new message.
554
+
555
+ ```tsx
556
+ import { useBuzzWorkflow } from '@civitai/blocks-react';
557
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
558
+
559
+ const { estimate, submit, watch } = useBuzzWorkflow();
560
+
561
+ // Step 1 draws an image; step 2 removes the background from step 1's result.
562
+ const body: WorkflowBody = {
563
+ kind: 'workflow',
564
+ arguments: { prompt: 'a lighthouse at dusk' },
565
+ steps: [
566
+ {
567
+ name: 'draw',
568
+ $type: 'imageGen',
569
+ input: {
570
+ engine: 'google',
571
+ model: 'nano-banana-2',
572
+ // Read from `arguments` above.
573
+ prompt: { $ref: '$arguments', path: 'prompt' },
574
+ },
575
+ // An intermediate step: its image is not surfaced.
576
+ suppressOutput: true,
577
+ },
578
+ {
579
+ name: 'cutout',
580
+ $type: 'imageBackgroundRemoval',
581
+ // Read from the EARLIER step named `draw`.
582
+ input: { image: { $ref: 'draw', path: 'output.images[0].url' } },
583
+ },
584
+ ],
585
+ };
586
+
587
+ const quote = await estimate(body);
588
+ showPrice(`About ${quote.cost?.total} Buzz`); // an estimate, not a maximum
589
+
590
+ const submitted = await submit(body);
591
+ const done = await watch(submitted.workflowId);
592
+
593
+ for (const step of done.steps ?? []) {
594
+ if (step.status === 'failed') showError(`${step.name} failed`);
595
+ }
596
+ // Even when `done.status === 'failed'`, earlier steps may have left images.
597
+ showImages(done.imageUrls ?? []);
598
+ ```
599
+
600
+ **The body**
601
+
602
+ - `steps`: 1 to 8 (`BLOCK_WORKFLOW_MAX_STEPS`). Each is `{ name?, $type, input,
603
+ suppressOutput? }`, where `$type` is an orchestrator step type and `input` is
604
+ that step's own input.
605
+ - **A step `name`** is optional. It is a lowercase letter followed by up to 31
606
+ of `a-z`, `0-9`, `_` or `-`, unique within the body, and not `block-step`
607
+ (reserved). A step you do not name is named by its position: `$0`, `$1`, ….
608
+ - **A reference** is `{ $ref, path, default?, format? }` and may stand in for
609
+ any value in `input`, at any depth. `$ref` is `'$arguments'`, the `name` of an
610
+ **earlier** step, or — for an earlier step you did not name — its position
611
+ (`'$0'` is the first step). A step can never reference itself or a later
612
+ step. An object is a reference only when it has both `$ref` and `path` and no
613
+ other key but `default` / `format`.
614
+ - **Limits**: 64 references per body, inputs nested at most 32 levels, `repeat`
615
+ templates nested at most 4 deep, a `$type` of at most 64 characters, and 256
616
+ KB (262144 bytes) for every step's `input` plus `arguments` together. An
617
+ unknown key on the body or on a step is rejected. The app's velocity limit
618
+ counts one unit per step, and a `repeat` step one per iteration instead.
619
+ - `suppressOutput: true` marks an intermediate step. Its images are left out of
620
+ `snapshot.imageUrls` and out of its own `steps[i].imageUrls`. It still runs
621
+ and is still charged.
622
+ - There is **no `maxBuzz`**. The spend ceiling is the app's per-generation
623
+ budget × the number of steps, enforced by the host.
624
+ - Any orchestrator `$type` is accepted except the platform-internal ones:
625
+ scanners, moderation classifiers, hashing, web scrape/search, and the retired
626
+ `textToImage` step type.
627
+ - **`repeat` over `arguments` only, at most 16 iterations**
628
+ (`BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS`, nested repeats multiplied). Its input
629
+ is exactly `{ for: { $ref: '$arguments', path, as }, template: { $type,
630
+ name?, input? } }`, where `path` names an array in `arguments`. Inside the
631
+ template, `as` is a source a reference may name; it may not equal a step
632
+ name. The template's `$type` is held to the denylist too. A `repeat` over
633
+ another step's output is refused, because its length is unknown until that
634
+ step has run. The single-step pass-through `kind: 'step'` arm refuses
635
+ `repeat` outright.
636
+ - **`merge`**: every entry of `input.fields` needs a string `$ref` (`$arguments`
637
+ or an earlier step, never a repeat variable) and a `reduce`; each counts
638
+ toward the 64-reference limit.
639
+ - `WithWorkflowStepRefs<T>` (from `@civitai/app-sdk/blocks`) types an input you
640
+ already have a type for while still allowing references in it.
641
+
642
+ **🔴 Three things about the price**
643
+
644
+ 1. **The estimate is not a maximum.** When a step reads from an earlier step,
645
+ the orchestrator re-prices it once those values are known and charges the
646
+ difference. Never show the estimate as "you will pay at most".
647
+ 2. **A workflow the host cannot price still proceeds.** `submit()` reserves the
648
+ ceiling above instead of a quote. Two exceptions are refused without a
649
+ quote, before any reservation: a workflow with a training step (a
650
+ `training-quote-*` `errorCode`, as on the single-step arm), and a workflow
651
+ with a `repeat` step at any depth (`workflow_repeat_unbounded`; a retry may
652
+ succeed).
653
+ 3. **One author fee per workflow**, not one per step, on the quoted base. The
654
+ estimate's `cost.authorFee` is that single fee, already inside `cost.total`.
655
+ If the workflow bills more than the quote, the fee charged is held to the
656
+ fee shown. A workflow that does not succeed has its **whole** fee reversed,
657
+ even if earlier steps delivered output.
658
+
659
+ **The result**
660
+
661
+ `snapshot.steps` lists every step in order, as `{ name, $type, status,
662
+ imageUrls?, output?, error? }`. Read it from `poll()`, `watch()` and
663
+ `cancel()`; do not rely on it being on the `submit()` reply. The steps a
664
+ `repeat` step generates follow it, named `<repeat name>.<n>`. The top-level
665
+ fields keep their meaning: `status` and `cost` are the whole workflow's, and
666
+ `imageUrls` is the union of the steps that did not set `suppressOutput`.
667
+
668
+ 🔴 **A `failed` workflow is not an empty one.** Steps that succeeded before the
669
+ failing step stay charged and keep their results, so a snapshot with `status:
670
+ 'failed'` can carry `cost.total > 0` and usable `imageUrls`. Show what did
671
+ finish, and do not tell the viewer nothing was charged — though the app fee is
672
+ reversed for any workflow that does not succeed.
673
+
674
+ **Refusals**
675
+
676
+ A body the host will not run is refused before anything is priced or reserved:
677
+ `estimate()` rejects with `WorkflowEstimateError`, `submit()` with
678
+ `WorkflowSubmitError`, and a batch estimate returns it as that cell. Branch on
679
+ `err.snapshot.errorCode`:
680
+
681
+ | `errorCode` | Means |
682
+ |---|---|
683
+ | `'workflow_too_many_steps'` | more than 8 steps |
684
+ | `'workflow_step_denied'` | a step names a platform-internal `$type` |
685
+ | `'workflow_ref_invalid'` | a reference points at the step itself, a later step, or nothing; a step name breaks the rules above; a `repeat` or `merge` is malformed; or a repeat `as` name is also a step name |
686
+ | `'workflow_repeat_unbounded'` | a `repeat` iterates something other than an array in `arguments`, or more than 16 times (nested repeats multiplied); or the workflow has a `repeat` and no price quote could be had (retrying may work) |
687
+
688
+ - **Requires a host that knows the kind.** An older host refuses the body.
689
+ - **Test it locally.** The mock host prices each step with
690
+ `generation.costPerGen` (a function gets the step as its second argument) and
691
+ multiplies it by a `repeat` step's iteration count, then adds
692
+ `generation.authorFee` once; refuses with the four codes above, in the host's
693
+ order; reports
694
+ `steps` on every poll, finishing them in order; and
695
+ `generation.failWorkflowStep: '<name>'` (`'$<index>'` for an unnamed step)
696
+ makes one step fail, so you can render a partial failure. The mock checks
697
+ where a reference points, not whether its `path` exists; it never re-prices;
698
+ and it does not expand a `repeat` step into generated `<name>.<n>` steps. It
699
+ quotes every workflow unless you set `generation.workflowUnpriced: true`,
700
+ which simulates no quote: a workflow with a training step or a `repeat` is
701
+ refused, and any other is reserved at `buzzBudget` × steps with no fee.
544
702
 
545
703
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
546
704
  > with the *exact* same logic as `submit` — same seed decision especially. The
@@ -704,7 +862,10 @@ Any other `'timeout'` is a slow host.
704
862
 
705
863
  **Mock host:** `createMockHost` and `Harness` price each cell exactly as they
706
864
  price one `estimate()` of that body (`costPerGen` and `failEstimate` apply per
707
- cell). Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
865
+ cell). Like the host, they refuse the **whole call** while the token lacks
866
+ `ai:write:budgeted` — `'failed'`, with `hostError` `block lacks
867
+ ai:write:budgeted scope` — so pass `consentGranted: true`, or drive
868
+ `requestConsent()` and estimate again once the scope lands. Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
708
869
  or to `'silent'` to test a call that never gets a reply (pass a short
709
870
  `timeoutMs` in a test). Set `generation.batchEstimateTimedOutCells` to a list
710
871
  of cell indices to answer those cells the way the host does when its time
@@ -2071,6 +2232,46 @@ on every parameter change. Prompting there would open a consent dialog with no
2071
2232
  user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
2072
2233
  it always has; show no price and let `submit()` do the asking.
2073
2234
 
2235
+ The same goes for `useBatchEstimate()`. Until the viewer grants
2236
+ `ai:write:budgeted` the host refuses **every** estimate, single and batch, with
2237
+ `block lacks ai:write:budgeted scope` (`WorkflowEstimateError` / `BatchEstimateError`,
2238
+ code `'failed'`). To show a price before the first Generate, ask on a user
2239
+ gesture and estimate again once the grant lands:
2240
+
2241
+ ```tsx
2242
+ import { useEffect, useState } from 'react';
2243
+ import { useBlockToken, useBuzzWorkflow, useRequestConsent } from '@civitai/blocks-react';
2244
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
2245
+
2246
+ function Price({ body }: { body: WorkflowBody }) {
2247
+ const token = useBlockToken();
2248
+ const { requestConsent } = useRequestConsent();
2249
+ const { estimate } = useBuzzWorkflow();
2250
+ const [price, setPrice] = useState<number | null>(null);
2251
+ const canPrice = token.scopes.includes('ai:write:budgeted');
2252
+
2253
+ // Keyed on `canPrice`, so the quote is fetched again when the grant lands.
2254
+ useEffect(() => {
2255
+ if (!canPrice) return;
2256
+ estimate(body).then((s) => setPrice(s.cost?.total ?? null), () => setPrice(null));
2257
+ }, [canPrice, body, estimate]);
2258
+
2259
+ if (!canPrice) {
2260
+ return (
2261
+ <button onClick={() => requestConsent({ scopes: ['ai:write:budgeted'] })}>
2262
+ Allow generations
2263
+ </button>
2264
+ );
2265
+ }
2266
+ return <span>{price === null ? '…' : `${price} Buzz`}</span>;
2267
+ }
2268
+ ```
2269
+
2270
+ The mock host enforces this too (since `@civitai/blocks-react` 0.70.0, #608): with
2271
+ the default `consentGranted: false` its estimate, batch estimate and submit
2272
+ refuse exactly as the host does, `REQUEST_CONSENT` grants and pushes the
2273
+ refreshed token, and `consentGrantable: false` keeps the refusal in place.
2274
+
2074
2275
  ```tsx
2075
2276
  // This is the whole thing. No try/catch around a consent prompt, no watching
2076
2277
  // useBlockToken().scopes, no manual retry.
@@ -2622,6 +2823,7 @@ MockHostScenarioPatch
2622
2823
  MockSharedScenario
2623
2824
  MockSharedSeed
2624
2825
  MockStorageScenario
2826
+ MockWorkflowStepInfo
2625
2827
  PopoverShimHandle
2626
2828
  PopoverShimOptions
2627
2829
  ```
@@ -2653,6 +2855,29 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
2653
2855
  uninstall();
2654
2856
  ```
2655
2857
 
2858
+ 🔴 **The mock enforces the spend scope, as the host does** (since `0.70.0`,
2859
+ [#608](https://github.com/civitai/civitai-app-starters/issues/608)).
2860
+ `consentGranted` defaults to `false` — production's first token does not carry
2861
+ `ai:write:budgeted` — and while it is `false`, an estimate, a batch estimate and
2862
+ a submit are refused with `block lacks ai:write:budgeted scope`, each in the
2863
+ shape the host sends:
2864
+
2865
+ | Call | What your code sees before consent |
2866
+ |---|---|
2867
+ | `estimate()` | rejects `WorkflowEstimateError`, code `'failed'`, reason on `err.snapshot.error` |
2868
+ | `estimateBatch()` | rejects `BatchEstimateError`, code `'failed'`, reason on `err.hostError` |
2869
+ | `submit()` | prompts for consent, the mock grants it and pushes a refreshed token, and the call is retried once with the **same** idempotency key — it resolves, and is charged once |
2870
+
2871
+ `consentGrantable: false` (or `?consent=ungrantable`) keeps the refusal: the
2872
+ prompt is answered with `CONSENT_UNAVAILABLE` and `submit()` rejects with code
2873
+ `'exception'`.
2874
+
2875
+ **Migrating a test:** one that estimates or submits and is not *about* consent
2876
+ passes `consentGranted: true` (or uses `?consent=granted` in a harness). One that
2877
+ is about consent drives it: estimate, see the refusal, call
2878
+ `requestConsent({ scopes: ['ai:write:budgeted'] })`, wait for
2879
+ `useBlockToken().scopes` to include it, estimate again.
2880
+
2656
2881
  ### Testing overlay elements
2657
2882
 
2658
2883
  `<civitai-menu>`, and anything else that opens a panel, live in an environment
@@ -698,6 +698,22 @@ export interface UseBuzzWorkflow {
698
698
  * the paragraph above records, one member over: an incomplete description of a
699
699
  * union, trusted because it read as authoritative.
700
700
  *
701
+ * 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
702
+ * capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
703
+ * orchestrator steps as one workflow through these same calls, and:
704
+ * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
705
+ * step once the values it references are known and charges the difference;
706
+ * - a workflow the host cannot price still proceeds on `submit()`, reserved at
707
+ * the app's per-generation budget × the number of steps (one with a training
708
+ * step, or with a `repeat` step at any depth, is refused instead);
709
+ * - one author fee applies to the whole workflow, not one per step, and it is
710
+ * reversed whole if the workflow does not succeed;
711
+ * - `repeat` iterates only an array in the body's `arguments`, at most 16
712
+ * times, nested repeats multiplied;
713
+ * - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
714
+ * steps that succeeded before the failing one stay charged — read
715
+ * `result.steps`.
716
+ *
701
717
  * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
702
718
  *
703
719
  * @example
@@ -523,6 +523,22 @@ export class WorkflowSubmitError extends Error {
523
523
  * the paragraph above records, one member over: an incomplete description of a
524
524
  * union, trusted because it read as authoritative.
525
525
  *
526
+ * 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
527
+ * capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
528
+ * orchestrator steps as one workflow through these same calls, and:
529
+ * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
530
+ * step once the values it references are known and charges the difference;
531
+ * - a workflow the host cannot price still proceeds on `submit()`, reserved at
532
+ * the app's per-generation budget × the number of steps (one with a training
533
+ * step, or with a `repeat` step at any depth, is refused instead);
534
+ * - one author fee applies to the whole workflow, not one per step, and it is
535
+ * reversed whole if the workflow does not succeed;
536
+ * - `repeat` iterates only an array in the body's `arguments`, at most 16
537
+ * times, nested repeats multiplied;
538
+ * - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
539
+ * steps that succeeded before the failing one stay charged — read
540
+ * `result.steps`.
541
+ *
526
542
  * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
527
543
  *
528
544
  * @example
@@ -77,7 +77,21 @@ export type MockCannedImageScan = 'scanned' | {
77
77
  * A per-generation cost: a fixed number, or a function of the submitted
78
78
  * {@link WorkflowBody} (so a dev can vary cost by model / step count).
79
79
  */
80
- export type CostSpec = number | ((req: WorkflowBody) => number);
80
+ export type CostSpec = number | ((req: WorkflowBody, step?: MockWorkflowStepInfo) => number);
81
+ /**
82
+ * The step a {@link CostSpec} function is pricing, passed as its SECOND
83
+ * argument when the body is `kind: 'workflow'`: the mock calls
84
+ * {@link MockGenerationScenario.costPerGen} once per step and sums the results.
85
+ * `undefined` for every other kind, which is priced with one call.
86
+ */
87
+ export interface MockWorkflowStepInfo {
88
+ /** Zero-based position in the body's `steps`. */
89
+ index: number;
90
+ /** The step's `name`, or `'$<index>'` for an unnamed step. */
91
+ name: string;
92
+ $type: string;
93
+ input: Record<string, unknown>;
94
+ }
81
95
  /**
82
96
  * A result image url: a fixed string, or a function of the submitted body
83
97
  * (so a dev can echo the prompt into a placeholder).
@@ -91,6 +105,12 @@ export interface MockGenerationScenario {
91
105
  /**
92
106
  * Cost reported on `ESTIMATE_RESULT` + the succeeded snapshot. A number, or
93
107
  * a `(body) => number`. Overrides the legacy top-level `cost`. Default `8`.
108
+ *
109
+ * A `kind: 'workflow'` body is priced PER STEP: this is called once for each
110
+ * step (a function receives the step as its second argument, see
111
+ * {@link MockWorkflowStepInfo}), multiplied by that step's generations (a
112
+ * `repeat` counts its iterations, nested repeats multiplied), and summed, so a
113
+ * plain number prices an N-step workflow with no `repeat` at N times it.
94
114
  */
95
115
  costPerGen?: CostSpec;
96
116
  /**
@@ -100,9 +120,10 @@ export interface MockGenerationScenario {
100
120
  * real host does. A number, or a `(body) => number`. Default `0`, which is
101
121
  * reported as `authorFee: 0` ("no fee on this request").
102
122
  *
103
- * Like the real host, only a `textToImage` or registered-`step` body is
104
- * itemised; `customComfy`, pass-through `step` and `training` quotes carry no
105
- * `authorFee`. The succeeded snapshot's `cost.total` stays {@link costPerGen}
123
+ * Like the real host, only a `textToImage`, registered-`step` or `workflow`
124
+ * body is itemised; `customComfy`, pass-through `step` and `training` quotes
125
+ * carry no `authorFee`. A `workflow` body is charged this ONCE, however many
126
+ * steps it has. The succeeded snapshot's `cost.total` stays {@link costPerGen}
106
127
  * (a submitted workflow reports the generation's own cost), while a simulated
107
128
  * {@link MockBuzzScenario.balance} is debited `costPerGen + authorFee`.
108
129
  *
@@ -265,7 +286,44 @@ export interface MockGenerationScenario {
265
286
  * Default: unset.
266
287
  */
267
288
  submitCapRefusal?: string | true;
268
- /** A single result image url (or `(body) => url`). */
289
+ /**
290
+ * Make ONE step of a `kind: 'workflow'` run fail, so a block can exercise a
291
+ * PARTIAL failure. Name the step as its snapshot entry names it: its `name`,
292
+ * or `'$<index>'` for a step the body left unnamed. The poll that finishes
293
+ * the workflow then answers `status: 'failed'` with:
294
+ * - `steps`: every step before it `'succeeded'`, the named step `'failed'`
295
+ * with an `error`, every step after it `'canceled'`;
296
+ * - `cost.total`: the sum of the steps that succeeded (they stay charged), so
297
+ * it is `> 0` unless the FIRST step is the one that fails;
298
+ * - `imageUrls`: the images of the succeeded steps that did not set
299
+ * `suppressOutput`.
300
+ *
301
+ * What the real orchestrator reports for the steps AFTER a failed one is its
302
+ * own business; `'canceled'` here is the mock's stand-in for "did not run".
303
+ *
304
+ * Ignored by every other kind, and when no step matches. Takes precedence
305
+ * over {@link workflowOutcome} for the workflow it applies to. Live-tunable
306
+ * via `setScenario({ generation: { failWorkflowStep } })`. Default: unset.
307
+ */
308
+ failWorkflowStep?: string;
309
+ /**
310
+ * Simulate the host getting NO price quote for a `kind: 'workflow'` body. The
311
+ * host then refuses, before any reservation, a workflow with a training step
312
+ * (`errorCode: 'training-quote-unavailable'`) or a `repeat` step at any depth
313
+ * (`'workflow_repeat_unbounded'`); any other workflow proceeds reserved at the
314
+ * CEILING — {@link MockHostOptions.buzzBudget} × the number of steps — which
315
+ * the estimate reports as `cost.total`, with `authorFee: 0` (no quote, no
316
+ * fee base). Ignored by every other kind. Live-tunable via
317
+ * `setScenario({ generation: { workflowUnpriced } })`. Default: unset (every
318
+ * workflow is quoted at its per-step `costPerGen`).
319
+ */
320
+ workflowUnpriced?: boolean;
321
+ /**
322
+ * A single result image url (or `(body) => url`). For a `kind: 'workflow'`
323
+ * run this (and {@link images}) is the LAST step that did not set
324
+ * `suppressOutput`; each earlier visible step gets its own labelled
325
+ * placeholder.
326
+ */
269
327
  image?: ImageSpec;
270
328
  /**
271
329
  * Multiple result image urls (or `(body) => url[]`). Takes precedence over
@@ -327,8 +385,14 @@ export declare const MOCK_TRAINING_BOUNDS: {
327
385
  readonly captionMaxChars: 1000;
328
386
  readonly maxBuzzPerRun: 5000;
329
387
  };
330
- /** The real server's refusal for a training request from a token without the spend scope. */
331
- export declare const MOCK_TRAINING_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
388
+ /**
389
+ * The real server's refusal for ANY money call from a token without the spend
390
+ * scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
391
+ * and the training procedures all throw `FORBIDDEN` with exactly this message
392
+ * BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
393
+ * path that refuses on `consentGranted === false`, so the copies cannot drift.
394
+ */
395
+ export declare const MOCK_SPEND_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
332
396
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
333
397
  export declare const MOCK_TRAINING_QUOTE_GONE_ERROR = "training quote not found, expired or already used \u2014 estimate again";
334
398
  /** The real server's refusal for a training estimate naming a dataset it does not hold. */
@@ -535,6 +599,16 @@ export interface MockHostOptions {
535
599
  * real mint WITHHOLDS it until the viewer consents, so this defaults to
536
600
  * `false` — the first token carries NO budgeted scope, and `REQUEST_CONSENT`
537
601
  * grants it + pushes a `TOKEN_REFRESH` (the lazy-consent round-trip).
602
+ *
603
+ * 🔴 WHILE IT IS `false` THE MOCK ENFORCES THE SCOPE, as the real server does:
604
+ * `ESTIMATE_WORKFLOW`, `ESTIMATE_WORKFLOW_BATCH` and `SUBMIT_WORKFLOW` (and the
605
+ * training paths) refuse the whole call with `block lacks ai:write:budgeted
606
+ * scope`, in the shape the page host sends for each. `submit()` recovers on
607
+ * its own (its consent prompt-and-retry grants and re-sends with the same
608
+ * idempotency key); `estimate()` and the batch estimate do NOT retry, so a
609
+ * block must call `requestConsent({ scopes: ['ai:write:budgeted'] })` and
610
+ * estimate again once the grant lands. A test that is not ABOUT consent sets
611
+ * this to `true`.
538
612
  */
539
613
  consentGranted?: boolean;
540
614
  /**