runware-sdk 1.2.0__py3-none-any.whl

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.
runware/client.py ADDED
@@ -0,0 +1,1130 @@
1
+ """Runware async client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import time
8
+ import uuid
9
+ from typing import Any, Self, cast, overload
10
+
11
+ import aiohttp
12
+
13
+ from .config import create_config
14
+ from .constants import SCHEMAS_BASE_URL
15
+ from .content import ContentClient
16
+ from .errors import create_runware_error, parse_api_error
17
+ from .registry import Registry, RegistryData, create_registry
18
+ from .stream import TextStream, create_text_stream
19
+ from .transport.rest import RestTransport
20
+ from .transport.websocket import WebSocketTransport
21
+ from .types.sdk import LoosePayload, RunOptions, SDKConfig, StreamOptions
22
+ from .types.task_map import (
23
+ AccountManagementParams,
24
+ AccountManagementResult,
25
+ AudioInferenceParams,
26
+ AudioInferenceResult,
27
+ CaptionImageParams,
28
+ CaptionParams,
29
+ CaptionResult,
30
+ CaptionVideoParams,
31
+ ControlnetPreprocessParams,
32
+ ControlNetPreprocessResult,
33
+ GetResponseParams,
34
+ GetResponseResult,
35
+ GetTaskDetailsParams,
36
+ GetTaskDetailsResult,
37
+ ImageInferenceParams,
38
+ ImageInferenceResult,
39
+ ImageMaskingResult,
40
+ ImageUploadParams,
41
+ ImageUploadResult,
42
+ MaskingParams,
43
+ ModelSearchParams,
44
+ ModelSearchResult,
45
+ ModelUploadParams,
46
+ ModelUploadResult,
47
+ PromptEnhanceParams,
48
+ PromptEnhanceResult,
49
+ RemoveBackgroundImageParams,
50
+ RemoveBackgroundParams,
51
+ RemoveBackgroundResult,
52
+ RemoveBackgroundVideoParams,
53
+ TextInferenceParams,
54
+ TextInferenceResult,
55
+ ThreeDInferenceParams,
56
+ ThreeDInferenceResult,
57
+ TrainingParams,
58
+ TrainingResult,
59
+ UpscaleImageParams,
60
+ UpscaleParams,
61
+ UpscaleResult,
62
+ UpscaleVideoParams,
63
+ VectorizeParams,
64
+ VectorizeResult,
65
+ VideoInferenceParams,
66
+ VideoInferenceResult,
67
+ )
68
+ from .types.task_map import (
69
+ architecture_task_types as _bundled_arch_task_types,
70
+ )
71
+ from .types.task_map import (
72
+ modality_task_types as _bundled_modality_task_types,
73
+ )
74
+ from .types.task_map import (
75
+ models as _bundled_models,
76
+ )
77
+ from .types.task_map import (
78
+ operation_task_types as _bundled_operation_task_types,
79
+ )
80
+ from .types.transport import RequestOptions, WireFrame
81
+ from .validate import validate_tasks
82
+
83
+
84
+ class Runware:
85
+ """Async Runware client. Use as a context manager or call `close()` manually."""
86
+
87
+ def __init__(self, *, api_key: str | None = None, **config_overrides: object) -> None:
88
+ self._config: SDKConfig = create_config(api_key=api_key, **config_overrides)
89
+ injected_session = (
90
+ self._config.dependencies.session if self._config.dependencies else None
91
+ )
92
+ self._registry: Registry = create_registry(
93
+ url=f"{SCHEMAS_BASE_URL}/registry.json",
94
+ log=self._config.log,
95
+ fallback=_build_registry_fallback(),
96
+ session=injected_session,
97
+ )
98
+ # Reused for both validate `/resolve` calls and SSE streaming. Pull from
99
+ # injected dependencies if available; otherwise create lazily.
100
+ self._validation_session: aiohttp.ClientSession | None = injected_session
101
+ self._owns_validation_session: bool = injected_session is None
102
+
103
+ self._rest_transport: RestTransport | None = None
104
+ self._ws_transport: WebSocketTransport | None = None
105
+ if self._config.transport_type == "websocket":
106
+ self._ws_transport = WebSocketTransport(self._config)
107
+ else:
108
+ self._rest_transport = RestTransport(self._config)
109
+
110
+ # Tracks in-flight TextStream pumps so close() can abort them cleanly
111
+ # instead of having them surface opaque ClientConnectorError when the
112
+ # session is yanked from underneath. Lazily created when needed so the
113
+ # event is bound to the current event loop.
114
+ self._active_streams: set[TextStream] = set()
115
+ self._closed: bool = False
116
+
117
+ # Public read-only metadata about Runware's curated model catalog
118
+ # (listings, single model details, examples, pricing, capabilities,
119
+ # creators). Hits the content service, separate from the inference API.
120
+ self.content: ContentClient = ContentClient(self._ensure_validation_session)
121
+
122
+ async def __aenter__(self) -> Self:
123
+ await self.connect()
124
+ return self
125
+
126
+ async def __aexit__(self, *exc_info: object) -> None:
127
+ await self.close()
128
+
129
+ async def close(self) -> None:
130
+ if self._closed:
131
+ return
132
+ self._closed = True
133
+ # Abort any in-flight streams first so they raise a clean `aborted`
134
+ # error instead of an opaque ClientConnectorError when the validation
135
+ # session closes underneath them.
136
+ for stream in list(self._active_streams):
137
+ stream._signal_shutdown() # pyright: ignore[reportPrivateUsage]
138
+ if self._rest_transport is not None:
139
+ await self._rest_transport.close()
140
+ if self._ws_transport is not None:
141
+ await self._ws_transport.disconnect()
142
+ if (
143
+ self._owns_validation_session
144
+ and self._validation_session is not None
145
+ ):
146
+ await self._validation_session.close()
147
+ self._validation_session = None
148
+ await self._registry.close()
149
+
150
+ async def connect(self) -> None:
151
+ """Open the underlying transport. No-op on REST."""
152
+ if self._rest_transport is not None:
153
+ await self._rest_transport.__aenter__()
154
+ if self._ws_transport is not None:
155
+ await self._ws_transport.connect()
156
+
157
+ async def disconnect(self) -> None:
158
+ """Close the underlying transport."""
159
+ await self.close()
160
+
161
+ @property
162
+ def is_connected(self) -> bool:
163
+ if self._ws_transport is not None:
164
+ return self._ws_transport.is_connected
165
+ return True
166
+
167
+ async def refresh_registry(self) -> None:
168
+ await self._registry.refresh()
169
+
170
+ # ----------------------------------------------------------- run / stream
171
+
172
+ # ---- Overloads narrow the return type when params is annotated with one
173
+ # of the canonical TypedDicts. Untyped dicts fall through to the last
174
+ # overload and get the loose `list[LoosePayload]` return.
175
+
176
+ # Modalities (5)
177
+ @overload
178
+ async def run(
179
+ self, params: ImageInferenceParams, options: RunOptions | None = None,
180
+ ) -> list[ImageInferenceResult]: ...
181
+ @overload
182
+ async def run(
183
+ self, params: VideoInferenceParams, options: RunOptions | None = None,
184
+ ) -> list[VideoInferenceResult]: ...
185
+ @overload
186
+ async def run(
187
+ self, params: AudioInferenceParams, options: RunOptions | None = None,
188
+ ) -> list[AudioInferenceResult]: ...
189
+ @overload
190
+ async def run(
191
+ self, params: TextInferenceParams, options: RunOptions | None = None,
192
+ ) -> list[TextInferenceResult]: ...
193
+ @overload
194
+ async def run(
195
+ self, params: ThreeDInferenceParams, options: RunOptions | None = None,
196
+ ) -> list[ThreeDInferenceResult]: ...
197
+
198
+ # Operations — slug variants collapse to one canonical Result per taskType.
199
+ @overload
200
+ async def run(
201
+ self,
202
+ params: CaptionParams | CaptionImageParams | CaptionVideoParams,
203
+ options: RunOptions | None = None,
204
+ ) -> list[CaptionResult]: ...
205
+ @overload
206
+ async def run(
207
+ self,
208
+ params: UpscaleParams | UpscaleImageParams | UpscaleVideoParams,
209
+ options: RunOptions | None = None,
210
+ ) -> list[UpscaleResult]: ...
211
+ @overload
212
+ async def run(
213
+ self,
214
+ params: (
215
+ RemoveBackgroundParams
216
+ | RemoveBackgroundImageParams
217
+ | RemoveBackgroundVideoParams
218
+ ),
219
+ options: RunOptions | None = None,
220
+ ) -> list[RemoveBackgroundResult]: ...
221
+ @overload
222
+ async def run(
223
+ self, params: MaskingParams, options: RunOptions | None = None,
224
+ ) -> list[ImageMaskingResult]: ...
225
+ @overload
226
+ async def run(
227
+ self, params: ControlnetPreprocessParams, options: RunOptions | None = None,
228
+ ) -> list[ControlNetPreprocessResult]: ...
229
+ @overload
230
+ async def run(
231
+ self, params: PromptEnhanceParams, options: RunOptions | None = None,
232
+ ) -> list[PromptEnhanceResult]: ...
233
+ @overload
234
+ async def run(
235
+ self, params: VectorizeParams, options: RunOptions | None = None,
236
+ ) -> list[VectorizeResult]: ...
237
+ @overload
238
+ async def run(
239
+ self, params: TrainingParams, options: RunOptions | None = None,
240
+ ) -> list[TrainingResult]: ...
241
+
242
+ # Fallback for untyped dicts.
243
+ @overload
244
+ async def run(
245
+ self,
246
+ params: dict[str, Any], # pyright: ignore[reportExplicitAny]
247
+ options: RunOptions | None = None,
248
+ ) -> list[dict[str, Any]]: ... # pyright: ignore[reportExplicitAny]
249
+
250
+ async def run( # pyright: ignore[reportInconsistentOverload]
251
+ self,
252
+ params: dict[str, Any], # pyright: ignore[reportExplicitAny]
253
+ options: RunOptions | None = None,
254
+ ) -> list[dict[str, Any]]: # pyright: ignore[reportExplicitAny]
255
+ """Run an inference task."""
256
+ params = await self._normalize_model_param(params)
257
+ task_type = await self._resolve_task_type(params)
258
+ task_uuid = str(params.get("taskUUID") or uuid.uuid4())
259
+ try:
260
+ expected_count = max(int(params.get("numberResults") or 1), 1)
261
+ except (ValueError, TypeError):
262
+ expected_count = 1
263
+ # Default to 'async' on both transports, matching the TS SDK. Users
264
+ # can opt into 'sync' explicitly for wire-level single-roundtrip delivery
265
+ # (server holds the response open instead of ACK + polling).
266
+ delivery_method = params.get("deliveryMethod") or "async"
267
+
268
+ # Pass-through of user-supplied params: values are inherently Any
269
+ # because the request shape is per-taskType. Server-side schemas
270
+ # and the optional `validate=True` path do the actual checking.
271
+ wire_task: LoosePayload = {
272
+ k: v
273
+ for k, v in params.items() # pyright: ignore[reportAny]
274
+ if k not in ("taskType", "taskUUID")
275
+ }
276
+ wire_task["taskType"] = task_type
277
+ wire_task["taskUUID"] = task_uuid
278
+ wire_task["deliveryMethod"] = delivery_method
279
+
280
+ await self._maybe_validate([wire_task], options)
281
+
282
+ if self._ws_transport is not None:
283
+ return await self._run_over_ws(
284
+ wire_task=wire_task,
285
+ expected_count=expected_count,
286
+ delivery_method=delivery_method,
287
+ options=options,
288
+ )
289
+
290
+ if self._rest_transport is None:
291
+ raise RuntimeError("REST transport unavailable on a REST-selected code path")
292
+ return await self._run_over_rest(
293
+ wire_task=wire_task,
294
+ task_type=task_type,
295
+ task_uuid=task_uuid,
296
+ expected_count=expected_count,
297
+ delivery_method=delivery_method,
298
+ options=options,
299
+ )
300
+
301
+ async def stream(
302
+ self,
303
+ params: LoosePayload,
304
+ options: StreamOptions | None = None,
305
+ ) -> TextStream:
306
+ """Stream LLM text via SSE. Forces the REST/SSE path regardless of transport."""
307
+ if isinstance(params.get("numberResults"), int) and params["numberResults"] > 1:
308
+ raise create_runware_error(
309
+ "notImplemented",
310
+ (
311
+ "stream() with numberResults > 1 is not supported "
312
+ "(the backend doesn't emit resultIndex on stream chunks)."
313
+ ),
314
+ )
315
+ params = await self._normalize_model_param(params)
316
+ task_type = await self._resolve_task_type(params)
317
+ # User-supplied passthrough: values are Any by the same per-taskType reasoning.
318
+ wire_task: LoosePayload = {
319
+ k: v
320
+ for k, v in params.items() # pyright: ignore[reportAny]
321
+ if k not in ("taskType",)
322
+ }
323
+ wire_task["taskType"] = task_type
324
+ wire_task["deliveryMethod"] = "stream"
325
+ wire_task.setdefault("taskUUID", str(uuid.uuid4()))
326
+
327
+ await self._maybe_validate([wire_task], options)
328
+
329
+ timeout_seconds: float | None = None
330
+ if options is not None and options.timeout is not None:
331
+ timeout_seconds = options.timeout / 1000.0
332
+
333
+ session = await self._ensure_validation_session()
334
+ stream = await create_text_stream(
335
+ config=self._config,
336
+ session=session,
337
+ params=wire_task,
338
+ cancel_event=options.cancel_event if options else None,
339
+ timeout_seconds=timeout_seconds,
340
+ )
341
+ # Track for shutdown. Stream removes itself when its pump completes.
342
+ self._active_streams.add(stream)
343
+ stream._set_on_finish( # pyright: ignore[reportPrivateUsage]
344
+ lambda: self._active_streams.discard(stream)
345
+ )
346
+ return stream
347
+
348
+ # ----------------------------------------------------------- utility methods
349
+
350
+ async def model_search(
351
+ self, params: ModelSearchParams, options: RunOptions | None = None,
352
+ ) -> list[ModelSearchResult]:
353
+ return cast(
354
+ list[ModelSearchResult],
355
+ await self._utility("modelSearch", dict(params), options),
356
+ )
357
+
358
+ async def model_upload(
359
+ self, params: ModelUploadParams, options: RunOptions | None = None,
360
+ ) -> list[ModelUploadResult]:
361
+ return cast(
362
+ list[ModelUploadResult],
363
+ await self._utility("modelUpload", dict(params), options),
364
+ )
365
+
366
+ async def image_upload(
367
+ self, params: ImageUploadParams, options: RunOptions | None = None,
368
+ ) -> list[ImageUploadResult]:
369
+ return cast(
370
+ list[ImageUploadResult],
371
+ await self._utility("imageUpload", dict(params), options),
372
+ )
373
+
374
+ async def account_management(
375
+ self,
376
+ params: AccountManagementParams,
377
+ options: RunOptions | None = None,
378
+ ) -> list[AccountManagementResult]:
379
+ return cast(
380
+ list[AccountManagementResult],
381
+ await self._utility("accountManagement", dict(params), options),
382
+ )
383
+
384
+ async def get_response(
385
+ self, params: GetResponseParams, options: RunOptions | None = None,
386
+ ) -> list[GetResponseResult]:
387
+ return cast(
388
+ list[GetResponseResult],
389
+ await self._utility("getResponse", dict(params), options),
390
+ )
391
+
392
+ async def get_task_details(
393
+ self, params: GetTaskDetailsParams, options: RunOptions | None = None,
394
+ ) -> list[GetTaskDetailsResult]:
395
+ return cast(
396
+ list[GetTaskDetailsResult],
397
+ await self._utility("getTaskDetails", dict(params), options),
398
+ )
399
+
400
+ # ----------------------------------------------------------- internal
401
+
402
+ async def _utility(
403
+ self,
404
+ task_type: str,
405
+ params: LoosePayload,
406
+ options: RunOptions | None = None,
407
+ ) -> list[LoosePayload]:
408
+ # User-supplied passthrough: values are Any by the same per-taskType reasoning.
409
+ wire_task: LoosePayload = {
410
+ k: v
411
+ for k, v in params.items() # pyright: ignore[reportAny]
412
+ if k not in ("taskType", "taskUUID")
413
+ }
414
+ wire_task["taskType"] = task_type
415
+ wire_task["taskUUID"] = str(params.get("taskUUID") or uuid.uuid4())
416
+
417
+ if self._ws_transport is not None:
418
+ timeout_seconds = (
419
+ (options.timeout if options else None) or self._config.timeout
420
+ ) / 1000.0
421
+ # Utility responses don't carry inference-result markers
422
+ # (imageURL/videoURL/etc.), so the inference-style terminal check
423
+ # never fires. Treat the first data frame as the full result.
424
+ return await self._ws_send_and_collect(
425
+ wire_task=wire_task,
426
+ task_uuid=wire_task["taskUUID"],
427
+ expected_count=1,
428
+ timeout_seconds=timeout_seconds,
429
+ options=options,
430
+ any_item_is_terminal=True,
431
+ )
432
+
433
+ if self._rest_transport is None:
434
+ raise RuntimeError("REST transport unavailable on a REST-selected code path")
435
+ request_options = RequestOptions(
436
+ timeout=options.timeout if options else None,
437
+ cancel_event=options.cancel_event if options else None,
438
+ )
439
+ response = await self._rest_transport.send_request(wire_task, request_options)
440
+ return _extract_items(response)
441
+
442
+ async def _resolve_task_type(self, params: LoosePayload) -> str:
443
+ explicit = params.get("taskType")
444
+ if isinstance(explicit, str) and explicit:
445
+ return explicit
446
+ model = params.get("model")
447
+ if isinstance(model, str):
448
+ resolved = await self._registry.get_model_task_type(model)
449
+ if resolved:
450
+ return resolved
451
+ raise create_runware_error(
452
+ "unknownModel",
453
+ (
454
+ f"Unknown model {params.get('model')!r}. Pass `taskType=...` explicitly "
455
+ "or call refresh_registry() if this is a recently launched model."
456
+ ),
457
+ parameter="taskType",
458
+ )
459
+
460
+ async def _normalize_model_param(self, params: LoosePayload) -> LoosePayload:
461
+ """
462
+ If the user passed a curated-model slug (e.g. ``flux-1-dev``) instead
463
+ of an AIR (e.g. ``runware:101@1``), swap it for the canonical AIR
464
+ before sending — the server only knows the AIR form. Non-curated
465
+ models, fine-tune AIRs, and unknown identifiers pass through.
466
+ """
467
+ model = params.get("model")
468
+ if not isinstance(model, str):
469
+ return params
470
+ # AIR-shaped? Skip the registry lookup. This is the hot path.
471
+ if ":" in model and "@" in model:
472
+ return params
473
+ air = await self._registry.resolve_model_air(model)
474
+ if not air or air == model:
475
+ return params
476
+ return {**params, "model": air}
477
+
478
+ async def _maybe_validate(
479
+ self,
480
+ tasks: list[LoosePayload],
481
+ options: RunOptions | StreamOptions | None,
482
+ ) -> None:
483
+ per_call = options.validate if options is not None else None
484
+ enabled = per_call if per_call is not None else self._config.validate
485
+ if not enabled:
486
+ return
487
+ session = await self._ensure_validation_session()
488
+ await validate_tasks(tasks, log=self._config.log, session=session)
489
+
490
+ async def _ensure_validation_session(self) -> aiohttp.ClientSession:
491
+ if self._validation_session is None or self._validation_session.closed:
492
+ self._validation_session = aiohttp.ClientSession()
493
+ self._owns_validation_session = True
494
+ return self._validation_session
495
+
496
+ # ----------------------------------------------------------- REST path
497
+
498
+ async def _run_over_rest(
499
+ self,
500
+ *,
501
+ wire_task: LoosePayload,
502
+ task_type: str,
503
+ task_uuid: str,
504
+ expected_count: int,
505
+ delivery_method: str,
506
+ options: RunOptions | None,
507
+ ) -> list[LoosePayload]:
508
+ if self._rest_transport is None:
509
+ raise RuntimeError("REST transport unavailable on a REST-selected code path")
510
+ request_options = RequestOptions(
511
+ timeout=options.timeout if options else None,
512
+ cancel_event=options.cancel_event if options else None,
513
+ )
514
+ response = await self._rest_transport.send_request(wire_task, request_options)
515
+
516
+ if delivery_method == "sync":
517
+ items = _extract_items(response)
518
+ # Use the collector so per-item errors fire callbacks and raise,
519
+ # matching the async-polling and WS paths.
520
+ collector = _AsyncCollector(
521
+ expected_count, options, any_item_is_terminal=False,
522
+ )
523
+ collector.ingest(items)
524
+ _maybe_raise_item_errors(
525
+ collector, task_type=task_type, task_uuid=task_uuid,
526
+ )
527
+ return items
528
+
529
+ return await self._poll_rest(
530
+ task_type=task_type,
531
+ task_uuid=task_uuid,
532
+ expected_count=expected_count,
533
+ initial_response=response,
534
+ options=options,
535
+ )
536
+
537
+ async def _poll_rest(
538
+ self,
539
+ *,
540
+ task_type: str,
541
+ task_uuid: str,
542
+ expected_count: int,
543
+ initial_response: WireFrame,
544
+ options: RunOptions | None,
545
+ ) -> list[LoosePayload]:
546
+ if self._rest_transport is None:
547
+ raise RuntimeError("REST transport unavailable on a REST-selected code path")
548
+ cancel_event = options.cancel_event if options else None
549
+ poll_timeout_seconds = (
550
+ (options.timeout if options else None) or self._config.poll_timeout
551
+ ) / 1000.0
552
+
553
+ collector = _AsyncCollector(expected_count, options)
554
+ collector.ingest(_extract_items(initial_response))
555
+ _maybe_raise_item_errors(collector, task_type=task_type, task_uuid=task_uuid)
556
+ if collector.done():
557
+ return collector.sorted_results()
558
+
559
+ start = time.monotonic()
560
+ delay = 1.0
561
+ poll_number = 0
562
+
563
+ while not collector.done():
564
+ if cancel_event is not None and cancel_event.is_set():
565
+ raise create_runware_error("aborted", "Request aborted during polling")
566
+ elapsed = time.monotonic() - start
567
+ if elapsed >= poll_timeout_seconds:
568
+ raise create_runware_error(
569
+ "timeout",
570
+ (
571
+ f"Polling for task {task_uuid} (taskType={task_type}) "
572
+ f"timed out after {elapsed:.0f}s"
573
+ ),
574
+ )
575
+
576
+ await _interruptible_sleep(delay, cancel_event)
577
+
578
+ poll_payload: LoosePayload = {
579
+ "taskType": "getResponse",
580
+ "taskUUID": task_uuid,
581
+ }
582
+ # Cap each inner POST at whichever is smaller: the remaining poll
583
+ # budget or the transport's per-call timeout. Without this, a hung
584
+ # `getResponse` can sit on the 20-min transport default and outrun
585
+ # the outer poll_timeout we're trying to enforce here.
586
+ remaining_ms = int(
587
+ max(poll_timeout_seconds - (time.monotonic() - start), 0) * 1000,
588
+ )
589
+ inner_timeout = min(remaining_ms, self._config.timeout) or None
590
+ poll_response = await self._rest_transport.send_request(
591
+ poll_payload,
592
+ RequestOptions(timeout=inner_timeout, cancel_event=cancel_event),
593
+ )
594
+ collector.ingest(_extract_items(poll_response))
595
+ _maybe_raise_item_errors(
596
+ collector, task_type=task_type, task_uuid=task_uuid
597
+ )
598
+ poll_number += 1
599
+ delay = _next_poll_delay(poll_number, delay)
600
+
601
+ return collector.sorted_results()
602
+
603
+ # ----------------------------------------------------------- WebSocket path
604
+
605
+ async def _run_over_ws(
606
+ self,
607
+ *,
608
+ wire_task: LoosePayload,
609
+ expected_count: int,
610
+ delivery_method: str,
611
+ options: RunOptions | None,
612
+ ) -> list[LoosePayload]:
613
+ timeout_seconds = (
614
+ (options.timeout if options else None) or self._config.timeout
615
+ ) / 1000.0
616
+ # wire_task is LoosePayload so wire_task["taskUUID"] is Any. Narrow
617
+ # at the call boundary — callers above already populated taskUUID with
618
+ # a str, so this is sound.
619
+ task_uuid = cast(str, wire_task["taskUUID"])
620
+ if delivery_method == "async":
621
+ poll_timeout_seconds = (
622
+ (options.timeout if options else None) or self._config.poll_timeout
623
+ ) / 1000.0
624
+ return await self._ws_run_async(
625
+ wire_task=wire_task,
626
+ task_uuid=task_uuid,
627
+ expected_count=expected_count,
628
+ poll_timeout_seconds=poll_timeout_seconds,
629
+ options=options,
630
+ )
631
+ return await self._ws_send_and_collect(
632
+ wire_task=wire_task,
633
+ task_uuid=task_uuid,
634
+ expected_count=expected_count,
635
+ timeout_seconds=timeout_seconds,
636
+ options=options,
637
+ )
638
+
639
+ async def _ws_run_async(
640
+ self,
641
+ *,
642
+ wire_task: LoosePayload,
643
+ task_uuid: str,
644
+ expected_count: int,
645
+ poll_timeout_seconds: float,
646
+ options: RunOptions | None,
647
+ ) -> list[LoosePayload]:
648
+ """
649
+ Two-phase WS async: subscribe, send original task, wait for ACK, then
650
+ poll getResponse over the same socket until each expected item arrives.
651
+
652
+ Both the original task's ACK frame and subsequent getResponse poll
653
+ responses arrive on the same subscription (keyed by the original
654
+ taskUUID, which getResponse echoes back).
655
+ """
656
+ if self._ws_transport is None:
657
+ raise RuntimeError("WebSocket transport unavailable on a WS-selected code path")
658
+ if not self._ws_transport.is_connected:
659
+ await self._ws_transport.connect()
660
+
661
+ cancel_event = options.cancel_event if options else None
662
+ loop = asyncio.get_running_loop()
663
+
664
+ ack_future: asyncio.Future[None] = loop.create_future()
665
+ terminal_error: LoosePayload = {"err": None}
666
+ poll_wake = asyncio.Event()
667
+ collector = _AsyncCollector(expected_count, options)
668
+
669
+ def on_frame(response: WireFrame) -> None:
670
+ errors_raw = response.get("error") or response.get("errors")
671
+ if errors_raw:
672
+ err_list: list[object] = (
673
+ cast(list[object], errors_raw)
674
+ if isinstance(errors_raw, list)
675
+ else [errors_raw]
676
+ )
677
+ err = parse_api_error(
678
+ {"errors": err_list},
679
+ task_type=cast(str | None, wire_task.get("taskType")),
680
+ model=cast(str | None, wire_task.get("model")),
681
+ )
682
+ if not err.task_uuid:
683
+ err.task_uuid = task_uuid
684
+ if not ack_future.done():
685
+ ack_future.set_exception(err)
686
+ else:
687
+ terminal_error["err"] = err
688
+ poll_wake.set()
689
+ return
690
+
691
+ data = response.get("data")
692
+ items: list[LoosePayload] = (
693
+ [
694
+ cast(LoosePayload, item)
695
+ for item in cast(list[object], data)
696
+ if isinstance(item, dict)
697
+ ]
698
+ if isinstance(data, list)
699
+ else []
700
+ )
701
+ if not ack_future.done():
702
+ # First frame doubles as ACK — also ingest any data the server
703
+ # may have eagerly returned in the same frame.
704
+ ack_future.set_result(None)
705
+ if items:
706
+ collector.ingest(items)
707
+ if collector.done():
708
+ poll_wake.set()
709
+ return
710
+
711
+ if items:
712
+ collector.ingest(items)
713
+ poll_wake.set()
714
+
715
+ self._ws_transport.subscribe_to_task(task_uuid, on_frame)
716
+
717
+ try:
718
+ await self._ws_transport.send_request(wire_task)
719
+
720
+ ack_timeout_seconds = 30.0
721
+ try:
722
+ await asyncio.wait_for(ack_future, timeout=ack_timeout_seconds)
723
+ except TimeoutError as exc:
724
+ raise create_runware_error(
725
+ "timeout",
726
+ (
727
+ f"ACK not received for task {task_uuid} after "
728
+ f"{ack_timeout_seconds:.0f}s"
729
+ ),
730
+ ) from exc
731
+
732
+ _maybe_raise_item_errors(
733
+ collector,
734
+ task_type=cast(str | None, wire_task.get("taskType")),
735
+ task_uuid=task_uuid,
736
+ )
737
+
738
+ if collector.done():
739
+ return collector.sorted_results()
740
+
741
+ start = time.monotonic()
742
+ delay = 1.0
743
+ poll_number = 0
744
+
745
+ while not collector.done():
746
+ if cancel_event is not None and cancel_event.is_set():
747
+ raise create_runware_error(
748
+ "aborted", "Request aborted during polling"
749
+ )
750
+ elapsed = time.monotonic() - start
751
+ if elapsed >= poll_timeout_seconds:
752
+ raise create_runware_error(
753
+ "timeout",
754
+ (
755
+ f"Polling for task {task_uuid} timed out after "
756
+ f"{elapsed:.0f}s"
757
+ ),
758
+ )
759
+
760
+ await _interruptible_sleep(delay, cancel_event)
761
+ poll_wake.clear()
762
+
763
+ poll_payload: LoosePayload = {
764
+ "taskType": "getResponse",
765
+ "taskUUID": task_uuid,
766
+ }
767
+ await self._ws_transport.send_request(poll_payload)
768
+
769
+ # Wait briefly for poll response. If the server doesn't reply,
770
+ # we still want to bound the loop iteration so we re-check
771
+ # cancel / timeout regularly.
772
+ with contextlib.suppress(TimeoutError):
773
+ await asyncio.wait_for(poll_wake.wait(), timeout=delay + 5.0)
774
+
775
+ if terminal_error["err"] is not None:
776
+ raise terminal_error["err"]
777
+
778
+ _maybe_raise_item_errors(
779
+ collector,
780
+ task_type=cast(str | None, wire_task.get("taskType")),
781
+ task_uuid=task_uuid,
782
+ )
783
+
784
+ poll_number += 1
785
+ delay = _next_poll_delay(poll_number, delay)
786
+
787
+ return collector.sorted_results()
788
+ finally:
789
+ self._ws_transport.unsubscribe_from_task(task_uuid)
790
+
791
+ async def _ws_send_and_collect(
792
+ self,
793
+ *,
794
+ wire_task: LoosePayload,
795
+ task_uuid: str,
796
+ expected_count: int,
797
+ timeout_seconds: float,
798
+ options: RunOptions | None,
799
+ any_item_is_terminal: bool = False,
800
+ ) -> list[LoosePayload]:
801
+ if self._ws_transport is None:
802
+ raise RuntimeError("WebSocket transport unavailable on a WS-selected code path")
803
+ if not self._ws_transport.is_connected:
804
+ await self._ws_transport.connect()
805
+
806
+ loop = asyncio.get_running_loop()
807
+ future: asyncio.Future[list[LoosePayload]] = loop.create_future()
808
+ collector = _AsyncCollector(
809
+ expected_count, options, any_item_is_terminal=any_item_is_terminal
810
+ )
811
+
812
+ def on_frame(response: WireFrame) -> None:
813
+ if future.done():
814
+ return
815
+ errors = response.get("error") or response.get("errors")
816
+ if errors:
817
+ err_list: list[object] = (
818
+ cast(list[object], errors) if isinstance(errors, list) else [errors]
819
+ )
820
+ first: LoosePayload = (
821
+ cast(LoosePayload, err_list[0])
822
+ if err_list and isinstance(err_list[0], dict)
823
+ else {}
824
+ )
825
+ err = parse_api_error(
826
+ {"errors": err_list},
827
+ task_type=cast(str | None, wire_task.get("taskType")),
828
+ model=cast(str | None, wire_task.get("model")),
829
+ )
830
+ if not err.task_uuid:
831
+ err.task_uuid = first.get("taskUUID") or task_uuid
832
+ future.set_exception(err)
833
+ return
834
+ items_raw: object = response.get("data")
835
+ if isinstance(items_raw, list):
836
+ collector.ingest(cast(list[LoosePayload], items_raw))
837
+ # Per-item errors (`status: error`) terminate the call. Fire
838
+ # callbacks BEFORE rejecting so partial successes are visible.
839
+ item_errors = collector.take_errors()
840
+ if item_errors:
841
+ flattened_errors: list[LoosePayload] = [
842
+ {**(e.get("error") or {}), "taskUUID": e.get("taskUUID") or task_uuid}
843
+ for e in item_errors
844
+ ]
845
+ err = parse_api_error(
846
+ {"errors": flattened_errors},
847
+ task_type=cast(str | None, wire_task.get("taskType")),
848
+ model=cast(str | None, wire_task.get("model")),
849
+ )
850
+ if not err.task_uuid:
851
+ err.task_uuid = task_uuid
852
+ future.set_exception(err)
853
+ return
854
+ if collector.done():
855
+ future.set_result(collector.sorted_results())
856
+
857
+ self._ws_transport.subscribe_to_task(task_uuid, on_frame)
858
+
859
+ try:
860
+ await self._ws_transport.send_request(wire_task)
861
+ except Exception:
862
+ self._ws_transport.unsubscribe_from_task(task_uuid)
863
+ raise
864
+
865
+ cancel_event = options.cancel_event if options else None
866
+ try:
867
+ return await asyncio.wait_for(
868
+ _await_with_cancel(future, cancel_event),
869
+ timeout=timeout_seconds,
870
+ )
871
+ except TimeoutError as exc:
872
+ raise create_runware_error(
873
+ "timeout",
874
+ f"WebSocket task {task_uuid} timed out after {timeout_seconds:.0f}s",
875
+ ) from exc
876
+ finally:
877
+ self._ws_transport.unsubscribe_from_task(task_uuid)
878
+
879
+
880
+ # ----------------------------------------------------------------- helpers
881
+
882
+ class _AsyncCollector:
883
+ """Per-task result collector with deduplication by resultIndex."""
884
+
885
+ # Per-item stable identifiers the server returns on terminal items, in
886
+ # priority order. The first one present wins; if none are present we fall
887
+ # back to a monotonic anonymous slot.
888
+ _STABLE_ID_KEYS: tuple[str, ...] = (
889
+ "imageUUID",
890
+ "videoUUID",
891
+ "audioUUID",
892
+ "modelUUID",
893
+ "taskUUID",
894
+ )
895
+
896
+ def __init__(
897
+ self,
898
+ expected_count: int,
899
+ options: RunOptions | None,
900
+ *,
901
+ any_item_is_terminal: bool = False,
902
+ ) -> None:
903
+ self._expected: int = expected_count
904
+ self._options: RunOptions | None = options
905
+ self._any_item_is_terminal: bool = any_item_is_terminal
906
+ self._results: dict[str | int, LoosePayload] = {}
907
+ self._seen_progress: dict[str | int, float] = {}
908
+ self._seen_terminal: set[str | int] = set()
909
+ # Per-item errors accumulated across `ingest()` calls. Drained by the
910
+ # caller via `take_errors()`. Mirrors TS executeRest/executeWebSocketAsync
911
+ # — error items fire on_result first, then the call rejects.
912
+ self._errors: list[LoosePayload] = []
913
+ # Fallback counter for terminal items that don't carry any stable id
914
+ # field — preserves ordering so the result list isn't shuffled.
915
+ self._next_anon_slot: int = 0
916
+
917
+ def take_errors(self) -> list[LoosePayload]:
918
+ """Return and clear any per-item errors seen since the last call."""
919
+ errs, self._errors = self._errors, []
920
+ return errs
921
+
922
+ def _safe_on_result(self, item: LoosePayload) -> None:
923
+ if self._options is None or self._options.on_result is None:
924
+ return
925
+ with contextlib.suppress(Exception): # user callback throws don't kill polling
926
+ self._options.on_result(item)
927
+
928
+ def _safe_on_progress(self, item: LoosePayload) -> None:
929
+ if self._options is None or self._options.on_progress is None:
930
+ return
931
+ with contextlib.suppress(Exception):
932
+ self._options.on_progress(item)
933
+
934
+ def ingest(self, items: list[LoosePayload]) -> None:
935
+ for item in items:
936
+ # An error item (status='error') is terminal — fire on_result for
937
+ # it so the user sees the partial outcome, record the error for the
938
+ # caller to raise, but do NOT add it to `_results` (which only
939
+ # holds successes the caller eventually returns).
940
+ if item.get("status") == "error":
941
+ key = self._stable_id(item)
942
+ if key is None:
943
+ key = self._next_anon_slot
944
+ self._next_anon_slot += 1
945
+ if key in self._seen_terminal:
946
+ continue
947
+ self._seen_terminal.add(key)
948
+ self._errors.append(item)
949
+ self._safe_on_result(item)
950
+ continue
951
+
952
+ is_terminal = self._any_item_is_terminal or _is_terminal(item)
953
+ if is_terminal:
954
+ key = self._stable_id(item)
955
+ if key is None:
956
+ key = self._next_anon_slot
957
+ self._next_anon_slot += 1
958
+ if key in self._seen_terminal:
959
+ # REST polling returns cumulative state on every poll;
960
+ # re-seeing the same UUID is expected and must not produce
961
+ # a duplicate entry.
962
+ continue
963
+ self._seen_terminal.add(key)
964
+ self._results[key] = item
965
+ self._safe_on_result(item)
966
+ else:
967
+ progress_key: str | int | None = self._stable_id(item)
968
+ if progress_key is None:
969
+ raw_idx = item.get("resultIndex")
970
+ progress_key = raw_idx if isinstance(raw_idx, int) else -1
971
+ progress = _progress_value(item)
972
+ if progress is None or self._seen_progress.get(progress_key) == progress:
973
+ continue
974
+ self._seen_progress[progress_key] = progress
975
+ self._safe_on_progress(item)
976
+
977
+ @classmethod
978
+ def _stable_id(cls, item: LoosePayload) -> str | int | None:
979
+ raw_idx = item.get("resultIndex")
980
+ if isinstance(raw_idx, int):
981
+ return raw_idx
982
+ for key in cls._STABLE_ID_KEYS:
983
+ value = item.get(key)
984
+ if isinstance(value, str) and value:
985
+ return value
986
+ return None
987
+
988
+ def done(self) -> bool:
989
+ return len(self._seen_terminal) >= self._expected
990
+
991
+ def sorted_results(self) -> list[LoosePayload]:
992
+ # When all keys are resultIndex ints, sort by them so the order
993
+ # matches the server's intended ordering. Otherwise (UUID-keyed),
994
+ # preserve insertion order — Python dicts have ordered insertion
995
+ # semantics since 3.7.
996
+ keys = list(self._results)
997
+ if keys and all(isinstance(k, int) for k in keys):
998
+ keys.sort()
999
+ return [self._results[k] for k in keys]
1000
+
1001
+
1002
+ def _maybe_raise_item_errors(
1003
+ collector: _AsyncCollector,
1004
+ *,
1005
+ task_type: str | None,
1006
+ task_uuid: str,
1007
+ ) -> None:
1008
+ """
1009
+ Drain per-item errors from the collector. If any are present, raise a
1010
+ `RunwareError` for the first one. Callbacks fire during ingest() so the
1011
+ user sees partial outcomes before this raise.
1012
+ """
1013
+ errors = collector.take_errors()
1014
+ if not errors:
1015
+ return
1016
+ # Each error item has the shape {status: 'error', error: {code, message, ...}, taskUUID?}.
1017
+ # parse_api_error accepts the standard {errors: [...]} envelope.
1018
+ err_payloads: list[LoosePayload] = [
1019
+ {
1020
+ **cast(LoosePayload, item.get("error") or {}),
1021
+ "taskUUID": item.get("taskUUID") or task_uuid,
1022
+ }
1023
+ for item in errors
1024
+ ]
1025
+ raise parse_api_error(
1026
+ {"errors": err_payloads},
1027
+ task_type=task_type,
1028
+ )
1029
+
1030
+
1031
+ def _build_registry_fallback() -> RegistryData:
1032
+ """Build a RegistryData snapshot from the bundled generated types.
1033
+
1034
+ Operation task types (caption-image, upscale-video, etc.) are merged into
1035
+ the architecture map — they're keyed the same way (slug → taskType) and
1036
+ looked up through the same code path, matching the TS SDK's fallback shape.
1037
+ """
1038
+ from .registry import RegistryModelEntry # avoid circular import at module load
1039
+
1040
+ models_data: dict[str, RegistryModelEntry] = {
1041
+ air: RegistryModelEntry(task_type=entry.task_type, id=entry.id)
1042
+ for air, entry in _bundled_models.items()
1043
+ }
1044
+ architecture_fallback: dict[str, str] = {
1045
+ **_bundled_arch_task_types,
1046
+ **_bundled_operation_task_types,
1047
+ }
1048
+ return RegistryData(
1049
+ models=models_data,
1050
+ architecture_task_types=architecture_fallback,
1051
+ modality_task_types=dict(_bundled_modality_task_types),
1052
+ )
1053
+
1054
+
1055
+ def _progress_value(item: LoosePayload) -> float | None:
1056
+ raw = item.get("progress")
1057
+ if isinstance(raw, (int, float)):
1058
+ return float(raw)
1059
+ return None
1060
+
1061
+
1062
+ def _is_terminal(item: LoosePayload) -> bool:
1063
+ for key in ("imageURL", "videoURL", "audioURL", "modelURL", "text", "result"):
1064
+ if item.get(key) is not None:
1065
+ return True
1066
+ status: object = item.get("status")
1067
+ return status == "completed"
1068
+
1069
+
1070
+ def _next_poll_delay(poll_number: int, current: float) -> float:
1071
+ if poll_number == 1:
1072
+ return 0.5
1073
+ return min(current * 1.5, 10.0)
1074
+
1075
+
1076
+ async def _interruptible_sleep(seconds: float, cancel_event: asyncio.Event | None) -> None:
1077
+ if cancel_event is None:
1078
+ await asyncio.sleep(seconds)
1079
+ return
1080
+ sleep_task = asyncio.create_task(asyncio.sleep(seconds))
1081
+ cancel_task = asyncio.create_task(cancel_event.wait())
1082
+ try:
1083
+ done, _pending = await asyncio.wait(
1084
+ {sleep_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED
1085
+ )
1086
+ if cancel_task in done:
1087
+ sleep_task.cancel()
1088
+ raise create_runware_error("aborted", "Request aborted during polling")
1089
+ cancel_task.cancel()
1090
+ except asyncio.CancelledError:
1091
+ sleep_task.cancel()
1092
+ cancel_task.cancel()
1093
+ raise
1094
+
1095
+
1096
+ async def _await_with_cancel(
1097
+ future: asyncio.Future[list[LoosePayload]],
1098
+ cancel_event: asyncio.Event | None,
1099
+ ) -> list[LoosePayload]:
1100
+ if cancel_event is None:
1101
+ return await future
1102
+ cancel_task = asyncio.create_task(cancel_event.wait())
1103
+ try:
1104
+ done, _pending = await asyncio.wait(
1105
+ {future, cancel_task}, return_when=asyncio.FIRST_COMPLETED
1106
+ )
1107
+ if cancel_task in done and not future.done():
1108
+ future.cancel()
1109
+ raise create_runware_error("aborted", "Request aborted")
1110
+ cancel_task.cancel()
1111
+ return await future
1112
+ finally:
1113
+ if not cancel_task.done():
1114
+ cancel_task.cancel()
1115
+
1116
+
1117
+ def _extract_items(response: WireFrame | object) -> list[LoosePayload]:
1118
+ if isinstance(response, dict):
1119
+ obj = cast(dict[str, object], response)
1120
+ if "errors" in obj or "error" in obj:
1121
+ raise parse_api_error(obj)
1122
+ data = obj.get("data")
1123
+ if isinstance(data, list):
1124
+ data_list = cast(list[object], data)
1125
+ return [cast(LoosePayload, item) for item in data_list if isinstance(item, dict)]
1126
+ return []
1127
+ if isinstance(response, list):
1128
+ resp_list = cast(list[object], response)
1129
+ return [cast(LoosePayload, item) for item in resp_list if isinstance(item, dict)]
1130
+ return []