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/__init__.py +245 -0
- runware/_docs_cache.py +26 -0
- runware/_schemas_version.py +12 -0
- runware/client.py +1130 -0
- runware/config.py +61 -0
- runware/constants.py +4 -0
- runware/content.py +184 -0
- runware/errors.py +410 -0
- runware/logger.py +124 -0
- runware/py.typed +0 -0
- runware/registry.py +361 -0
- runware/stream.py +390 -0
- runware/transport/__init__.py +0 -0
- runware/transport/rest.py +229 -0
- runware/transport/websocket.py +481 -0
- runware/types/__init__.py +0 -0
- runware/types/content.py +175 -0
- runware/types/sdk.py +111 -0
- runware/types/stream.py +33 -0
- runware/types/task_map.py +13725 -0
- runware/types/transport.py +31 -0
- runware/utils/__init__.py +0 -0
- runware/utils/file.py +43 -0
- runware/utils/retry.py +88 -0
- runware/validate.py +163 -0
- runware_sdk-1.2.0.dist-info/METADATA +665 -0
- runware_sdk-1.2.0.dist-info/RECORD +29 -0
- runware_sdk-1.2.0.dist-info/WHEEL +4 -0
- runware_sdk-1.2.0.dist-info/licenses/LICENSE +21 -0
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 []
|