@giveitsmaller/sdk 0.21.0 → 0.25.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/dist/errors.js CHANGED
@@ -172,6 +172,23 @@ export class GislLongFormConcurrencyError extends GislApiError {
172
172
  super(statusCode, errorMessage, path, undefined, buildOptionsWithPayload(payload, extra));
173
173
  this.name = 'GislLongFormConcurrencyError';
174
174
  }
175
+ /**
176
+ * ALWAYS `false`, overriding the base 429-implies-retryable heuristic
177
+ * (UO1xYecu). This 429 is not a rate limit: it carries no `Retry-After` and
178
+ * clears only when an in-flight long-form workflow finishes, so a back-off
179
+ * retries into a wall that no amount of waiting-then-retrying opens. The base
180
+ * accessor reported `true` purely from the status, contradicting this class's
181
+ * own documented handling ("wait on completion or upgrade — do NOT back off")
182
+ * and instructing the one recovery that cannot work.
183
+ *
184
+ * Overridden per-class rather than via a code table because this is the only
185
+ * such code today; the general fix — an explicit taxonomy verdict outranking
186
+ * the status heuristic — arrives with the `error-taxonomy.yaml` `retryable`
187
+ * enum (contracts `plwcAqBr`), tracked on UO1xYecu.
188
+ */
189
+ get retryable() {
190
+ return false;
191
+ }
175
192
  /** The pricing / upgrade deep link (`links.upgrade`), or `undefined` when absent. */
176
193
  get upgradeUrl() {
177
194
  return this.payload.links?.upgrade;
@@ -381,6 +398,34 @@ export class GislMissingCredentialsError extends GislConfigError {
381
398
  this.name = 'GislMissingCredentialsError';
382
399
  }
383
400
  }
401
+ /**
402
+ * `streamEvents` was called on a client whose configuration has **no declared
403
+ * SSE stream host**. Local-only — thrown before any I/O.
404
+ *
405
+ * ⚠️ **THIS ERROR IS A CONTROL, NOT A DEFECT.** The stream lives on a second
406
+ * host, and the SDK will not guess it from `baseUrl`. Deriving `stream.*` from
407
+ * `api.*` by string surgery is a *convention*, and a convention is precisely
408
+ * what put production on the gateway path: the frontend's prod build had no
409
+ * stream host configured, fell back to the API host silently, and the failure
410
+ * was invisible until it was measured. Raising here is the loud version of
411
+ * that same situation.
412
+ *
413
+ * Both named environments resolve as of contracts `v2.195.0` (#410), which
414
+ * declared the production stream host. This now fires only for a
415
+ * configuration nothing declares — e.g. a bare `baseUrl` with no
416
+ * `environment` and no `streamBaseUrl`.
417
+ *
418
+ * Recover by passing `{streamBaseUrl}` to `gisl.create()` / `new GislClient()`,
419
+ * setting `GISL_STREAM_BASE_URL`, or constructing with an `{environment}` that
420
+ * declares one. `run()` does NOT surface this error — it treats an undeclared
421
+ * stream host as "SSE unavailable for this configuration" and polls instead.
422
+ */
423
+ export class GislStreamHostNotDeclaredError extends GislConfigError {
424
+ constructor(message) {
425
+ super(message);
426
+ this.name = 'GislStreamHostNotDeclaredError';
427
+ }
428
+ }
384
429
  /**
385
430
  * The caller used `gisl.anonymous()` and then invoked an operation that is
386
431
  * not in the anonymous-capable allowlist. Local-only — thrown before any I/O.
@@ -506,13 +551,71 @@ export class GislTimeoutError extends GislError {
506
551
  }
507
552
  }
508
553
  /**
509
- * Transport-level failure: the underlying `fetch` (or other transport) could
510
- * not produce a usable response DNS, TCP, TLS, a mid-stream disconnect, or a
511
- * non-ok status / empty body when fetching a result download. Mirrors the PHP
512
- * `Gisl\Sdk\Errors\GislNetworkError`. Subclasses `GislError` (not
513
- * `GislApiError`) because it carries no contract error envelope. The concrete
514
- * file-first {@link Downloader} raises this when the output URL cannot be read
515
- * (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
554
+ * A `mapEach` fan-out timed out mid-batch — the deadline elapsed either while a
555
+ * child was still running (the common case) or cleanly between child runs. The
556
+ * parent and some children have ALREADY completed, so re-running the whole batch
557
+ * re-does finished work. This carries their ids so the caller can poll them (via
558
+ * `client.getWorkflowStatus` / `getWorkflowDownloads`) to recover the finished
559
+ * work and re-run ONLY the children that were never created.
560
+ *
561
+ * Subclasses {@link GislTimeoutError}, so an existing
562
+ * `catch (e) { if (e instanceof GislTimeoutError) … }` still catches it. The
563
+ * inherited `workflowId` carries the IN-FLIGHT child — the one that was running
564
+ * when the deadline elapsed (a child's own timeout, the common path) — or stays
565
+ * `undefined` when the deadline elapsed cleanly BETWEEN children (no in-flight
566
+ * child). To recover, poll `workflowId` (if set) + {@link parentWorkflowId} +
567
+ * {@link completedWorkflowIds}, then re-run only the children that never started.
568
+ *
569
+ * NOTE on double-charge: the server-side create-dedupe (DSxwCetg) is what
570
+ * prevents a byte-identical child re-create from settling a SECOND charge within
571
+ * the dedup window; this error's job is efficient RECOVERY (skip the completed
572
+ * work) + defense-in-depth, not the sole charge guard.
573
+ */
574
+ export class GislFanOutTimeoutError extends GislTimeoutError {
575
+ /** The child workflows that completed before the deadline elapsed. */
576
+ completedWorkflowIds;
577
+ /** The parent workflow, which ran to completion before the fan-out began. */
578
+ parentWorkflowId;
579
+ constructor(message, opts) {
580
+ // The inherited workflowId is the in-flight child (or undefined between children).
581
+ super(message, opts.workflowId);
582
+ this.name = 'GislFanOutTimeoutError';
583
+ this.completedWorkflowIds = [...opts.completedWorkflowIds];
584
+ this.parentWorkflowId = opts.parentWorkflowId === '' ? undefined : opts.parentWorkflowId;
585
+ if (opts.cause !== undefined) {
586
+ this.cause = opts.cause;
587
+ }
588
+ }
589
+ }
590
+ /**
591
+ * Base for every failure that happened **off the contract envelope** — the
592
+ * request did not come back as a typed API error, it came back (or failed to)
593
+ * at the transport or raw-HTTP level. Subclasses `GislError` rather than
594
+ * `GislApiError` because there is no error envelope to carry.
595
+ *
596
+ * ⚠️ **NEVER THROWN DIRECTLY — it is a hierarchy node, not an error code
597
+ * (`t2qCrjdr`).** Everything that used to throw it now throws
598
+ * {@link GislTransportError} or {@link GislDownloadHttpError}, because the two
599
+ * cases cannot share one honest answer to "should I retry this?":
600
+ *
601
+ * | case | retry? |
602
+ * |---|---|
603
+ * | DNS / TCP / TLS / mid-stream disconnect | **yes** — transient by nature |
604
+ * | a `404` on a signed download URL | **no** — permanent, retrying burns time |
605
+ *
606
+ * `retryable: true` would recommend retrying a permanent failure and
607
+ * `retryable: false` would discourage retrying a genuine transient one, so
608
+ * contracts correctly refused to declare this class in
609
+ * `sdk-spec/error-taxonomy.yaml` at all. The fix is the split, not a caveat in
610
+ * a description field: **a claim must hold on every path that reaches it.**
611
+ *
612
+ * **Kept as the base ON PURPOSE, so this is not a breaking change.** Every
613
+ * existing `catch (e) { if (e instanceof GislNetworkError) … }` — including the
614
+ * SSE poll-fallback in `builder.ts` / `merge.ts` / `handle.ts` /
615
+ * `file-first.ts` — keeps catching exactly what it caught before. Narrow to a
616
+ * subclass only where you actually need to tell the two apart.
617
+ *
618
+ * Mirrors the PHP `Gisl\Sdk\Errors\GislNetworkError`.
516
619
  */
517
620
  export class GislNetworkError extends GislError {
518
621
  constructor(message) {
@@ -520,6 +623,99 @@ export class GislNetworkError extends GislError {
520
623
  this.name = 'GislNetworkError';
521
624
  }
522
625
  }
626
+ /**
627
+ * The transport could not deliver a usable response: DNS, TCP, TLS, a
628
+ * mid-stream disconnect, a `fetch` rejection, or a 2xx that arrived with no
629
+ * body at all. **Always retryable** — nothing about these says the request was
630
+ * wrong, only that it did not get through.
631
+ *
632
+ * The empty-body case lives here rather than with
633
+ * {@link GislDownloadHttpError} deliberately: the server said 2xx, so it is not
634
+ * an HTTP-level refusal — a response that promised bytes and delivered none is
635
+ * a delivery failure, and retrying is the right advice.
636
+ *
637
+ * The concrete file-first `Downloader` raises this when an output URL cannot be
638
+ * read (a destination-WRITE failure is `GislSinkError` reason `write_failed`).
639
+ */
640
+ export class GislTransportError extends GislNetworkError {
641
+ constructor(message) {
642
+ super(message);
643
+ this.name = 'GislTransportError';
644
+ }
645
+ /**
646
+ * Always `true`. The request did not get through; nothing about that says it
647
+ * was wrong, so retrying is the correct advice.
648
+ *
649
+ * Present as a real accessor rather than only as prose — an unbacked claim in
650
+ * a docblock is the exact defect `t2qCrjdr` exists to remove, and shipping
651
+ * the split without it would have reproduced it one level down.
652
+ */
653
+ get retryable() {
654
+ return true;
655
+ }
656
+ }
657
+ /**
658
+ * The request was never put on the wire because the client refused to send it —
659
+ * a malformed URI or an otherwise unsendable request. **Never retryable:**
660
+ * re-issuing the identical request fails identically, so backing off only
661
+ * wastes the caller's deadline.
662
+ *
663
+ * ⚠️ **THE TWO LANGUAGES DETECT THIS DIFFERENTLY, AND TS DETECTS LESS.** PSR-18
664
+ * distinguishes a network failure (`NetworkExceptionInterface`) from an
665
+ * unsendable request (`RequestExceptionInterface`), so the PHP SDK classifies
666
+ * every such failure. `fetch` surfaces both as an indistinguishable
667
+ * `TypeError`, so the TS SDK can only catch the cases it can see BEFORE the
668
+ * call — today, a URL that does not parse (`http-downloader`). A `fetch`
669
+ * rejection is still reported as {@link GislTransportError}, because guessing
670
+ * would put a permanent failure back in the retryable bucket, which is the very
671
+ * thing this split removed.
672
+ *
673
+ * So: same class, same meaning, same `retryable` in both SDKs — narrower
674
+ * detection in TypeScript. Stated here because a cross-language consumer would
675
+ * otherwise reasonably assume parity of COVERAGE from parity of TYPE.
676
+ */
677
+ export class GislRequestNotSentError extends GislNetworkError {
678
+ constructor(message) {
679
+ super(message);
680
+ this.name = 'GislRequestNotSentError';
681
+ }
682
+ /** Always `false` — the request never left, and re-sending it will not change that. */
683
+ get retryable() {
684
+ return false;
685
+ }
686
+ }
687
+ /**
688
+ * A download URL answered with a **non-2xx status**. The server was reached and
689
+ * replied; it simply refused. Distinct from {@link GislTransportError} because
690
+ * retrying is usually pointless — and `retryable` says so honestly, derived
691
+ * from the status rather than fixed for the class.
692
+ *
693
+ * `status` is carried as a field so a consumer distinguishing a permanent `404`
694
+ * from a transient `503` does not have to parse the message string — the second
695
+ * half of `t2qCrjdr`.
696
+ *
697
+ * Raised on result-download fetches (signed URLs), NOT on GISL-API calls: an
698
+ * API non-2xx carries a contract error envelope and surfaces as the matching
699
+ * {@link GislApiError} subclass instead.
700
+ */
701
+ export class GislDownloadHttpError extends GislNetworkError {
702
+ /** The HTTP status the download URL responded with. */
703
+ status;
704
+ constructor(message, status) {
705
+ super(message);
706
+ this.name = 'GislDownloadHttpError';
707
+ this.status = status;
708
+ }
709
+ /**
710
+ * Whether retrying this download could plausibly succeed. Derived from the
711
+ * status by the same rule the API errors use (`408` / `429` / `5xx`), so a
712
+ * `404` reports `false` and a `503` reports `true` — the distinction the
713
+ * unsplit class could not express.
714
+ */
715
+ get retryable() {
716
+ return isApiRetryableStatus(this.status);
717
+ }
718
+ }
523
719
  /**
524
720
  * Internal control-flow marker (TDqmkWpX): the SSE event stream closed cleanly
525
721
  * WITHOUT a terminal (`workflow_completed`/`failed`/`partially_failed`) event.
@@ -9,12 +9,12 @@
9
9
  *
10
10
  * Mirrors `packages/php/src/FileFirst/*`.
11
11
  */
12
- import { GislConfigError, GislItemFailedError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
12
+ import { GislConfigError, GislItemFailedError, GislNetworkError, GislNoSuchKeyError, GislSinkError, GislStreamHostNotDeclaredError, GislTimeoutError, SseEndedWithoutTerminal } from './errors.js';
13
13
  import { _detectCompressMedia, _detectAudioLossless, _consumeSseToTerminal, _pollToTerminal, _parseMaxWait, _checkAborted, _cappedProbeTimeoutMs, } from './builder.js';
14
14
  import { LazyHttpDownloader } from './lazy-downloader.js';
15
15
  import { resolveCompressOptions, } from './ergonomic/preset_resolver.js';
16
16
  import { validateVerbOptions, assertThumbnailDimensions } from './ergonomic/option_validation.js';
17
- import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
17
+ import { resolveOutputRoute, tokenForMime, tokenForPath, isPlannedValue, isUnknownEnumValue, dependsOnViolation, FACADE_MANAGED_OUTPUTS, } from './ergonomic/image_output_routes.js';
18
18
  import { OptimizeFor } from './generated/sdk_spec/enums.js';
19
19
  import { uploadSource, jobOutputSource } from './types.js';
20
20
  // Value import used only at call-time (inside MergedRecipe.toWorkflowPayload),
@@ -629,7 +629,17 @@ async function _awaitTerminal(client, args) {
629
629
  // unexpected — MUST propagate; re-issuing the same doomed request via poll
630
630
  // would mask it. Mirrors the PHP BuilderInternals::awaitTerminal sealed-
631
631
  // marker discipline.
632
- if (!(err instanceof SseEndedWithoutTerminal || err instanceof GislNetworkError)) {
632
+ if (!(err instanceof SseEndedWithoutTerminal ||
633
+ err instanceof GislNetworkError ||
634
+ // VUozk5Bc: no stream host is DECLARED for this configuration (a
635
+ // configuration nothing declares; both named environments resolve as of
636
+ // contracts v2.195.0). That is not a failure to recover from,
637
+ // it is SSE being unavailable here, and polling is a working
638
+ // transport. Failing hard instead would strand every caller on a host
639
+ // nobody has declared yet. A DIRECT `streamEvents` caller still gets
640
+ // the hard error — they asked for the stream specifically; a `run()`
641
+ // caller asked for a result.
642
+ err instanceof GislStreamHostNotDeclaredError)) {
633
643
  throw err;
634
644
  }
635
645
  }
@@ -1101,7 +1111,7 @@ export class Recipe {
1101
1111
  if (requested !== undefined && FACADE_MANAGED_OUTPUTS.includes(requested)) {
1102
1112
  const facade = { output_format: requested };
1103
1113
  for (const [key, value] of Object.entries(step.options)) {
1104
- if (key === 'output_format' || value === undefined)
1114
+ if (key === 'output_format' || value === undefined || value === null)
1105
1115
  continue;
1106
1116
  if (key !== 'quality') {
1107
1117
  throw new GislConfigError(`output(): '${key}' needs a detectable input format to route; reference the file by ` +
@@ -1122,7 +1132,9 @@ export class Recipe {
1122
1132
  }
1123
1133
  const wireOptions = { output_format: resolved.outputFormatWire };
1124
1134
  for (const [key, value] of Object.entries(step.options)) {
1125
- if (key === 'output_format' || value === undefined)
1135
+ // Drop a null value (as PHP does) so a null option never reaches the wire
1136
+ // and is treated as absent by the depends_on gate — full null parity (codex).
1137
+ if (key === 'output_format' || value === undefined || value === null)
1126
1138
  continue;
1127
1139
  if (resolved.planned.has(key)) {
1128
1140
  throw new GislConfigError(`output(): '${key}' is advertised but not available yet on the ${resolved.route} route ` +
@@ -1147,6 +1159,28 @@ export class Recipe {
1147
1159
  }
1148
1160
  wireOptions[key] = value;
1149
1161
  }
1162
+ // quality_preset's contract `depends_on: { encoding_mode: auto_quality }`
1163
+ // (86gAu5Tr) — infer the mode when the caller set NONE so the preset forms a
1164
+ // VALID request. SAME_FORMAT only: encoding_mode is a compress optimiser and
1165
+ // quality_preset isn't honored on a format_change. An explicitly-conflicting
1166
+ // mode is rejected by the general depends_on gate below.
1167
+ if (resolved.route === 'same_format' &&
1168
+ wireOptions.quality_preset !== undefined &&
1169
+ wireOptions.encoding_mode === undefined) {
1170
+ wireOptions.encoding_mode = 'auto_quality';
1171
+ }
1172
+ // General contract `depends_on` validation (ehHU08Hu), scoped per route: the
1173
+ // universal fit→width|height dep runs on BOTH routes (identical in compress +
1174
+ // convert); the encoding_mode-family deps run on same_format only. Subsumes
1175
+ // the 86gAu5Tr auto_quality gate plus target_size_bytes-without-target_size,
1176
+ // fit-without-width/height, and any future compress-image depends_on.
1177
+ const dependency = dependsOnViolation(wireOptions, resolved.route);
1178
+ if (dependency !== undefined) {
1179
+ throw new GislConfigError(dependency.message, {
1180
+ reason: 'invalid_option_combination',
1181
+ conflictingFields: [...dependency.conflictingFields],
1182
+ });
1183
+ }
1150
1184
  return { type: resolved.sourceOp, options: wireOptions };
1151
1185
  }
1152
1186
  /**
@@ -1466,9 +1500,22 @@ function _validateWatermarkOverlay(overlay) {
1466
1500
  }
1467
1501
  }
1468
1502
  function _lowerWatermarkOp(wireOp, options) {
1469
- // Watermark options (anchor/opacity/margin_x/margin_y/overlay_width, or the
1470
- // multi-overlay overlays[] stack) are already wire keys; empty options omit
1471
- // the `options` key (byte-identical to PHP).
1503
+ // `overlays[]` (the multi-overlay stack) is a live contract option but is NOT
1504
+ // reachable through watermark(): the facade composites exactly ONE overlay
1505
+ // the positional `overlay` (wire source src_1) — so overlays[1..] reference
1506
+ // sources it cannot create, any entry is invalid on a non-image base, and the
1507
+ // contract's `minItems: 1` makes an empty array invalid too. Reject it here at
1508
+ // lowering (mutation-safe — reads the FINAL options, catching a post-watermark()
1509
+ // `opts.overlays = [...]`) and point callers at the single-overlay knobs.
1510
+ // Real multi-overlay stacking is a future feature (Vbbdq9C4).
1511
+ if (options.overlays !== undefined) {
1512
+ throw new GislConfigError("watermark(): 'overlays[]' (multi-overlay stacking) is not supported — watermark() composites a " +
1513
+ 'single overlay (the positional overlay argument). Use the top-level anchor / opacity / margin_x / ' +
1514
+ 'margin_y / overlay_width options to place it. Multi-overlay stacking is a future feature.', { reason: 'overlays_unsupported', conflictingFields: ['overlays'] });
1515
+ }
1516
+ // The remaining watermark options (anchor/opacity/margin_x/margin_y/
1517
+ // overlay_width) are already wire keys; empty options omit the `options` key
1518
+ // (byte-identical to PHP).
1472
1519
  const wire = { ...options };
1473
1520
  return Object.keys(wire).length === 0 ? { type: wireOp } : { type: wireOp, options: wire };
1474
1521
  }
@@ -1489,6 +1536,14 @@ function _lowerWatermarkOp(wireOp, options) {
1489
1536
  */
1490
1537
  async function _uploadInputsAndCreate(client, inputs, toPayload, opts) {
1491
1538
  const { webhook, deadline, onProgress, signal, probeBeforeCreate, probeTimeoutMs, uploadsLabel, workflowLabel } = opts;
1539
+ // Preflight: lower the composed chains with placeholder ids so a route-invalid
1540
+ // option (or any lowering-time gate — overlays, sole_op split, route/enum) in
1541
+ // ANY input's chain — a watermark base/overlay, a merge/archive member — throws
1542
+ // BEFORE we spend a single upload byte. The multi-input analog of the
1543
+ // single-input Recipe.assertOperationsLowerable preflight (0azjb6Rg); mirrors
1544
+ // PHP. The placeholder ids never reach the wire — the payload is discarded
1545
+ // (T3ltXsou). toWorkflowPayload is pure, so re-lowering at create is cheap.
1546
+ toPayload(inputs.map((_, i) => `preflight_${i}`));
1492
1547
  const fileIds = [];
1493
1548
  // Track each freshly-uploaded input's probe-gate inputs (a pre-uploaded id
1494
1549
  // carries no local mime/size, so it is excluded — never probed).
@@ -1,4 +1,4 @@
1
- export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "validation_error" | "cyclic_workflow_edges" | "workflow_edge_references_unknown_job" | "reserved_job_id_pattern" | "cyclic_job_output_source_graph" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "upload_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "image_dimensions_too_large" | "upload_failed" | "workflow_failed";
1
+ export type ErrorCode = "missing_credentials" | "feature_requires_auth" | "undeclared_asset" | "unused_asset" | "per_input_options_not_supported" | "chain_cardinality_mismatch" | "multipart_part_invalid" | "multipart_part_count_exceeded" | "timeout" | "aborted" | "validation_failed" | "validation_error" | "cyclic_workflow_edges" | "workflow_edge_references_unknown_job" | "reserved_job_id_pattern" | "cyclic_job_output_source_graph" | "auth_failed" | "feature_tier_restricted" | "tier_restriction" | "multipart_session_ownership" | "multipart_session_auth_required" | "multipart_session_not_found" | "upload_not_found" | "workflow_expired" | "balance_exhausted" | "feature_not_available" | "upload_size_exceeds_tier" | "upload_duration_exceeds_tier" | "probe_pending" | "requires_reencode" | "invalid_options" | "invalid_combination" | "missing_dependency" | "unsupported_value" | "type_mismatch" | "image_dimensions_too_large" | "upload_failed" | "workflow_failed" | "sse_connection_limit_exceeded" | "sse_capacity_exhausted" | "long_form_concurrency_limit_exceeded" | "unprocessable_entity" | "email_same" | "config_error" | "bundle_already_archived" | "fan_out_timeout" | "no_such_key" | "result_not_ready" | "sink_error" | "item_failed";
2
2
  export type ErrorCategory = 'api' | 'config' | 'network' | 'auth' | 'validation' | 'chain';
3
3
  export type ErrorStatus = 'wired' | 'planned';
4
4
  export interface ErrorEntry {
@@ -377,7 +377,7 @@ export const ERROR_CODES = Object.freeze({
377
377
  status: "wired",
378
378
  httpStatus: 422,
379
379
  retryable: true,
380
- sdkClass: "GislApiError",
380
+ sdkClass: "GislProbePendingError",
381
381
  description: "422 on workflow create — upload probing not yet complete; retry after the upload finishes probing. Wire `error_type: \"probe_pending\"`.",
382
382
  metadataSchema: Object.freeze({
383
383
  "jobRef": "string",
@@ -506,6 +506,169 @@ export const ERROR_CODES = Object.freeze({
506
506
  "jobErrors": "array",
507
507
  }),
508
508
  }),
509
+ "sse_connection_limit_exceeded": Object.freeze({
510
+ code: "sse_connection_limit_exceeded",
511
+ category: "api",
512
+ source: "ErrorEnvelope.error",
513
+ status: "planned",
514
+ httpStatus: 429,
515
+ retryable: true,
516
+ sdkClass: "GislSseConnectionLimitError",
517
+ description: "429 — the CALLER's own concurrent event-stream allowance is exhausted. Same semantics as the tier long-form concurrency limit and therefore the same status. NOT for a global-capacity refusal; see sse_capacity_exhausted.",
518
+ metadataSchema: Object.freeze({
519
+ "openStreams": "integer",
520
+ "maxStreams": "integer",
521
+ }),
522
+ }),
523
+ "sse_capacity_exhausted": Object.freeze({
524
+ code: "sse_capacity_exhausted",
525
+ category: "api",
526
+ source: "ErrorEnvelope.error",
527
+ status: "planned",
528
+ httpStatus: 503,
529
+ retryable: true,
530
+ sdkClass: "GislSseCapacityError",
531
+ description: "503 — GLOBAL event-stream capacity is exhausted, and it says nothing about this caller. A caller who has opened no streams can receive it. ⚠️ `EventSource` does not expose the HTTP status to page script, so a browser client cannot distinguish this from 500 — the status serves non-browser clients, proxies and observability.",
532
+ metadataSchema: Object.freeze({
533
+ "links": "object",
534
+ }),
535
+ }),
536
+ "long_form_concurrency_limit_exceeded": Object.freeze({
537
+ code: "long_form_concurrency_limit_exceeded",
538
+ category: "api",
539
+ source: "ErrorEnvelope.error",
540
+ status: "wired",
541
+ httpStatus: 429,
542
+ retryable: false,
543
+ sdkClass: "GislLongFormConcurrencyError",
544
+ description: "429 — the caller's tier long-form concurrency allowance is exhausted. Wire value is UPPERCASE `LONG_FORM_CONCURRENCY_LIMIT_EXCEEDED` (api.yaml:1995); this file's `code:` follows the taxonomy's lowercase convention. Dispatched on the machine `error` code, NOT on the status: a generic infra rate-limit 429 carries a different/absent code and falls through to base GislApiError where retryAfterSeconds applies. Do not widen this entry to the status.",
545
+ metadataSchema: Object.freeze({
546
+ "currentTier": "string",
547
+ "maxDurationSeconds": "number",
548
+ }),
549
+ }),
550
+ "unprocessable_entity": Object.freeze({
551
+ code: "unprocessable_entity",
552
+ category: "auth",
553
+ source: "error_type",
554
+ status: "wired",
555
+ httpStatus: 422,
556
+ retryable: false,
557
+ sdkClass: "GislAuthRejectionError",
558
+ description: "422 on an auth-side-effect endpoint (register / verify-email / api-keys) — the request was well-formed but rejected on domain grounds. Dispatched on MEMBERSHIP of AuthRejectionEnvelope.error_type, per ADR-0019.",
559
+ metadataSchema: Object.freeze({
560
+ "errorType": "string",
561
+ }),
562
+ }),
563
+ "email_same": Object.freeze({
564
+ code: "email_same",
565
+ category: "auth",
566
+ source: "error_type",
567
+ status: "wired",
568
+ httpStatus: 422,
569
+ retryable: false,
570
+ sdkClass: "GislAuthRejectionError",
571
+ description: "422 on profile PATCH — the submitted email matches the current one, so there is nothing to change. Same envelope and sdkClass as unprocessable_entity; separate row because the wire value differs.",
572
+ metadataSchema: Object.freeze({
573
+ "errorType": "string",
574
+ }),
575
+ }),
576
+ "config_error": Object.freeze({
577
+ code: "config_error",
578
+ category: "config",
579
+ source: "SDK_local",
580
+ status: "wired",
581
+ httpStatus: null,
582
+ retryable: false,
583
+ sdkClass: "GislConfigError",
584
+ description: "Client-side configuration rejected before any request — thrown directly (preset resolver, output() gates), not only as a base class. `reason` carries the discriminator: `unknown_field` and `type_mismatch` are values of THIS field and deliberately have no rows of their own.",
585
+ metadataSchema: Object.freeze({
586
+ "reason": "string",
587
+ "conflictingFields": "array",
588
+ "resolvedSnapshot": "object",
589
+ "suggestion": "string",
590
+ }),
591
+ }),
592
+ "bundle_already_archived": Object.freeze({
593
+ code: "bundle_already_archived",
594
+ category: "config",
595
+ source: "SDK_local",
596
+ status: "planned",
597
+ httpStatus: null,
598
+ retryable: false,
599
+ sdkClass: "GislBundleAlreadyArchivedError",
600
+ description: "PLANNED — not reachable in any shipped build. Raised when a bundle operation targets an already-archived bundle, once `.bundle()` ships. Do not write handler code against this yet.",
601
+ metadataSchema: Object.freeze({}),
602
+ }),
603
+ "fan_out_timeout": Object.freeze({
604
+ code: "fan_out_timeout",
605
+ category: "network",
606
+ source: "SDK_local",
607
+ status: "wired",
608
+ httpStatus: null,
609
+ retryable: true,
610
+ sdkClass: "GislFanOutTimeoutError",
611
+ description: "A fan-out deadline elapsed before all children finished. The three metadata fields are the reason this is declared separately from GislTimeoutError: completedWorkflowIds are the children that DID finish, parentWorkflowId ran to completion before the fan-out began, and the inherited workflowId is the in-flight child — absent on a clean between-children timeout.",
612
+ metadataSchema: Object.freeze({
613
+ "completedWorkflowIds": "array",
614
+ "parentWorkflowId": "string",
615
+ "workflowId": "string",
616
+ }),
617
+ }),
618
+ "no_such_key": Object.freeze({
619
+ code: "no_such_key",
620
+ category: "chain",
621
+ source: "SDK_local",
622
+ status: "wired",
623
+ httpStatus: null,
624
+ retryable: false,
625
+ sdkClass: "GislNoSuchKeyError",
626
+ description: "The caller asked for a mapEach result key that does not exist in the output. NOTE the key itself is NOT captured in metadata today — it is interpolated into the message only, so consumers cannot branch on which key was missing.",
627
+ metadataSchema: Object.freeze({}),
628
+ }),
629
+ "result_not_ready": Object.freeze({
630
+ code: "result_not_ready",
631
+ category: "validation",
632
+ source: "SDK_local",
633
+ status: "wired",
634
+ httpStatus: null,
635
+ retryable: true,
636
+ sdkClass: "GislResultNotReadyError",
637
+ description: "The caller read a result before the workflow reached a terminal state. `state` carries the non-terminal status observed.",
638
+ metadataSchema: Object.freeze({
639
+ "workflowId": "string",
640
+ "state": "string",
641
+ }),
642
+ }),
643
+ "sink_error": Object.freeze({
644
+ code: "sink_error",
645
+ category: "config",
646
+ source: "SDK_local",
647
+ status: "wired",
648
+ httpStatus: null,
649
+ retryable: false,
650
+ sdkClass: "GislSinkError",
651
+ description: "A caller-supplied output sink could not be used. Branch on `reason` — the values span both caller setup (invalid_directory, duplicate_filename) and runtime write outcomes (write_failed, partial_failure), so the category is a best fit rather than an exact one.",
652
+ metadataSchema: Object.freeze({
653
+ "reason": "string",
654
+ }),
655
+ }),
656
+ "item_failed": Object.freeze({
657
+ code: "item_failed",
658
+ category: "api",
659
+ source: "SDK_local",
660
+ status: "wired",
661
+ httpStatus: null,
662
+ retryable: false,
663
+ sdkClass: "GislItemFailedError",
664
+ description: "CARRIED, not thrown — an entry in a per-item failed[] collection describing one item's server-side failure. `errorCode` carries the server's own code where present; prefer branching on that over this entry.",
665
+ metadataSchema: Object.freeze({
666
+ "key": "string",
667
+ "state": "string",
668
+ "errorMessage": "string",
669
+ "errorCode": "string",
670
+ }),
671
+ }),
509
672
  });
510
673
  export const ERROR_CATEGORIES = Object.freeze({
511
674
  api: Object.freeze([
@@ -522,20 +685,30 @@ export const ERROR_CATEGORIES = Object.freeze({
522
685
  "requires_reencode",
523
686
  "image_dimensions_too_large",
524
687
  "workflow_failed",
688
+ "sse_connection_limit_exceeded",
689
+ "sse_capacity_exhausted",
690
+ "long_form_concurrency_limit_exceeded",
691
+ "item_failed",
525
692
  ]),
526
693
  config: Object.freeze([
527
694
  "missing_credentials",
528
695
  "feature_requires_auth",
696
+ "config_error",
697
+ "bundle_already_archived",
698
+ "sink_error",
529
699
  ]),
530
700
  network: Object.freeze([
531
701
  "timeout",
532
702
  "aborted",
533
703
  "upload_failed",
704
+ "fan_out_timeout",
534
705
  ]),
535
706
  auth: Object.freeze([
536
707
  "auth_failed",
537
708
  "multipart_session_ownership",
538
709
  "multipart_session_auth_required",
710
+ "unprocessable_entity",
711
+ "email_same",
539
712
  ]),
540
713
  validation: Object.freeze([
541
714
  "multipart_part_invalid",
@@ -551,11 +724,13 @@ export const ERROR_CATEGORIES = Object.freeze({
551
724
  "missing_dependency",
552
725
  "unsupported_value",
553
726
  "type_mismatch",
727
+ "result_not_ready",
554
728
  ]),
555
729
  chain: Object.freeze([
556
730
  "undeclared_asset",
557
731
  "unused_asset",
558
732
  "per_input_options_not_supported",
559
733
  "chain_cardinality_mismatch",
734
+ "no_such_key",
560
735
  ]),
561
736
  });
package/dist/gisl.d.ts CHANGED
@@ -41,7 +41,7 @@ import { Handle } from './handle.js';
41
41
  * @internal
42
42
  */
43
43
  export declare const ANONYMOUS_ALLOWLIST: readonly [];
44
- export interface GislCreateOptions extends ResolveCredentialsOptions, ResolveEndpointOptions, Omit<GislClientConfig, 'baseUrl' | 'apiKey' | 'useSessionCookie'> {
44
+ export interface GislCreateOptions extends ResolveCredentialsOptions, ResolveEndpointOptions, Omit<GislClientConfig, 'baseUrl' | 'apiKey' | 'useSessionCookie' | 'streamBaseUrl'> {
45
45
  /**
46
46
  * Layered ergonomic preset defaults (T4a / VhIj4S7T). Built via
47
47
  * `presetDefaults().<cell>(level, overrides?)…`. The resolver wiring
package/dist/gisl.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { GislClient } from './client.js';
20
20
  import { GislConfigError, GislFeatureRequiresAuthError, GislMissingCredentialsError, } from './errors.js';
21
- import { resolveApiKey, resolveEndpoint, } from './credentials.js';
21
+ import { resolveApiKey, resolveEndpoint, resolveStreamEndpoint, } from './credentials.js';
22
22
  import { OperationBuilder } from './builder.js';
23
23
  import { validateVerbOptions, validateSingleOpConvertOptions, assertThumbnailDimensions, } from './ergonomic/option_validation.js';
24
24
  import { MergeBuilder, asset } from './merge.js';
@@ -279,13 +279,19 @@ function isMergeOptions(value) {
279
279
  * @internal
280
280
  */
281
281
  async function _createInternal(opts) {
282
- const { apiKey: explicitKey, profile, profilePath, useSessionCookie, baseUrl, environment, allowAnonymous,
282
+ const { apiKey: explicitKey, profile, profilePath, useSessionCookie, baseUrl, environment, streamBaseUrl, allowAnonymous,
283
283
  // T4a slot — stripped from transportConfig so it does not leak
284
284
  // into the low-level `GislClientConfig` spread. The T4b resolver
285
285
  // reads `opts.presetDefaults` directly via its own path.
286
286
  presetDefaults: _presetDefaults, ...transportConfig } = opts;
287
287
  void _presetDefaults;
288
288
  const resolvedBaseUrl = resolveEndpoint({ baseUrl, environment });
289
+ // Resolved SEPARATELY and never from `resolvedBaseUrl`. `null` here means
290
+ // "nothing declares a stream host for this configuration" — a legitimate
291
+ // state that `streamEvents` reports and `run()` handles by polling. Both
292
+ // resolvers throw on an unknown explicit `environment`, so a typo cannot
293
+ // split the two hosts across environments.
294
+ const resolvedStreamBaseUrl = resolveStreamEndpoint({ baseUrl, environment, streamBaseUrl });
289
295
  // Anonymous mode entirely BYPASSES the credential chain. Any env / profile
290
296
  // key that happens to exist on the host MUST NOT leak into the request
291
297
  // (codex r1 high e9e1c1182d56). Cookie-mode also bypasses, since the
@@ -295,6 +301,9 @@ async function _createInternal(opts) {
295
301
  baseUrl: resolvedBaseUrl,
296
302
  ...transportConfig,
297
303
  };
304
+ if (resolvedStreamBaseUrl !== null) {
305
+ config.streamBaseUrl = resolvedStreamBaseUrl;
306
+ }
298
307
  if (useSessionCookie !== undefined) {
299
308
  config.useSessionCookie = useSessionCookie;
300
309
  }
@@ -327,6 +336,9 @@ async function _createInternal(opts) {
327
336
  baseUrl: resolvedBaseUrl,
328
337
  ...transportConfig,
329
338
  };
339
+ if (resolvedStreamBaseUrl !== null) {
340
+ config.streamBaseUrl = resolvedStreamBaseUrl;
341
+ }
330
342
  if (resolvedKey !== null) {
331
343
  config.apiKey = resolvedKey;
332
344
  }