runapi-core 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (32) hide show
  1. {runapi_core-0.1.0 → runapi_core-0.1.2}/PKG-INFO +33 -2
  2. runapi_core-0.1.2/README.md +64 -0
  3. {runapi_core-0.1.0 → runapi_core-0.1.2}/pyproject.toml +3 -0
  4. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/__init__.py +3 -0
  5. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/contract_gen.py +28 -8
  6. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/errors.py +19 -1
  7. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/files.py +52 -15
  8. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/http_client.py +25 -1
  9. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/models.py +18 -1
  10. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/polling.py +19 -3
  11. runapi_core-0.1.2/src/runapi/core/resource.py +214 -0
  12. runapi_core-0.1.2/src/runapi/core/response.py +56 -0
  13. runapi_core-0.1.2/src/runapi/core/version.py +1 -0
  14. runapi_core-0.1.2/tests/test_files.py +170 -0
  15. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_http_client.py +50 -1
  16. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_models.py +7 -0
  17. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_polling.py +24 -0
  18. runapi_core-0.1.2/tests/test_resource.py +125 -0
  19. runapi_core-0.1.0/README.md +0 -33
  20. runapi_core-0.1.0/src/runapi/core/resource.py +0 -65
  21. runapi_core-0.1.0/src/runapi/core/version.py +0 -1
  22. runapi_core-0.1.0/tests/test_files.py +0 -113
  23. runapi_core-0.1.0/tests/test_resource.py +0 -55
  24. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/auth.py +0 -0
  25. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/config.py +0 -0
  26. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/constants.py +0 -0
  27. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/multipart.py +0 -0
  28. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/options.py +0 -0
  29. {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/py.typed +0 -0
  30. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_auth.py +0 -0
  31. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_config.py +0 -0
  32. {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapi-core
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Core module for the RunAPI Python SDK
5
5
  Project-URL: Homepage, https://runapi.ai
6
6
  Project-URL: Documentation, https://runapi.ai/docs#sdk
@@ -23,7 +23,7 @@ pip install runapi-core
23
23
 
24
24
  ## Notes
25
25
 
26
- Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that model SDKs share. Configure it globally or per client:
26
+ Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
27
27
 
28
28
  ```python
29
29
  import runapi.core as runapi
@@ -31,11 +31,42 @@ import runapi.core as runapi
31
31
  runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
32
32
  ```
33
33
 
34
+ ## Request identifiers
35
+
36
+ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
37
+
38
+ High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
39
+
40
+ ```python
41
+ import os
42
+
43
+ from runapi.core import RequestOptions
44
+ from runapi.suno import SunoClient
45
+
46
+ client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
47
+ options = RequestOptions(
48
+ headers={"X-Client-Request-Id": "order-123"},
49
+ )
50
+
51
+ response = client.text_to_music.create(
52
+ prompt="A chill lo-fi beat",
53
+ model="suno-v4.5-plus",
54
+ vocal_mode="instrumental",
55
+ options=options,
56
+ )
57
+
58
+ runapi_task_id = response.runapi_task_id
59
+ # Equivalent case-insensitive lookup:
60
+ runapi_task_id = response.response_headers["X-RunAPI-Task-Id"]
61
+ ```
62
+
34
63
  ```python
35
64
  from runapi.core import FilesClient
36
65
 
37
66
  files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
38
67
  uploaded = files.create(file="./input.png")
68
+ remote = files.create(source="https://cdn.runapi.ai/public/samples/input.png")
69
+ inline = files.create(source="iVBORw0KGgo...")
39
70
  print(uploaded.url)
40
71
  ```
41
72
 
@@ -0,0 +1,64 @@
1
+ # RunAPI Core Python SDK
2
+
3
+ The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install runapi-core
9
+ ```
10
+
11
+ ## Notes
12
+
13
+ Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
14
+
15
+ ```python
16
+ import runapi.core as runapi
17
+
18
+ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
19
+ ```
20
+
21
+ ## Request identifiers
22
+
23
+ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
24
+
25
+ High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
26
+
27
+ ```python
28
+ import os
29
+
30
+ from runapi.core import RequestOptions
31
+ from runapi.suno import SunoClient
32
+
33
+ client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
34
+ options = RequestOptions(
35
+ headers={"X-Client-Request-Id": "order-123"},
36
+ )
37
+
38
+ response = client.text_to_music.create(
39
+ prompt="A chill lo-fi beat",
40
+ model="suno-v4.5-plus",
41
+ vocal_mode="instrumental",
42
+ options=options,
43
+ )
44
+
45
+ runapi_task_id = response.runapi_task_id
46
+ # Equivalent case-insensitive lookup:
47
+ runapi_task_id = response.response_headers["X-RunAPI-Task-Id"]
48
+ ```
49
+
50
+ ```python
51
+ from runapi.core import FilesClient
52
+
53
+ files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
54
+ uploaded = files.create(file="./input.png")
55
+ remote = files.create(source="https://cdn.runapi.ai/public/samples/input.png")
56
+ inline = files.create(source="iVBORw0KGgo...")
57
+ print(uploaded.url)
58
+ ```
59
+
60
+ Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
61
+
62
+ ## License
63
+
64
+ Licensed under the Apache License, Version 2.0.
@@ -28,3 +28,6 @@ package = true
28
28
 
29
29
  [dependency-groups]
30
30
  dev = ["pytest>=8", "httpx>=0.27"]
31
+
32
+ [tool.runapi]
33
+ slug = "core"
@@ -29,6 +29,7 @@ from .http_client import HttpClient
29
29
  from .models import BaseModel, DynamicModel, TaskResponse, optional, required
30
30
  from .multipart import MultipartBody, MultipartFile
31
31
  from .options import ClientOptions, PollingOptions, RequestOptions
32
+ from .response import ApiResponse, ResponseHeaders
32
33
  from .resource import Resource
33
34
  from .version import __version__
34
35
 
@@ -57,6 +58,8 @@ __all__ = [
57
58
  "ClientOptions",
58
59
  "RequestOptions",
59
60
  "PollingOptions",
61
+ "ApiResponse",
62
+ "ResponseHeaders",
60
63
  "BaseModel",
61
64
  "DynamicModel",
62
65
  "TaskResponse",
@@ -1,5 +1,3 @@
1
- # Code generated by SdkContractGenerator. DO NOT EDIT.
2
-
3
1
  CONTRACT = {
4
2
  "elevenlabs/isolate-audio": {
5
3
  "models": ["audio-isolation"],
@@ -148,6 +146,9 @@ CONTRACT = {
148
146
  "models": ["gpt-image-2"],
149
147
  "fields_by_model": {
150
148
  "gpt-image-2": {
149
+ "aspect_ratio": {
150
+ "enum": ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "2:1", "1:2", "3:1", "1:3", "21:9", "9:21"]
151
+ },
151
152
  "output_resolution": {
152
153
  "enum": ["1k", "2k", "4k"]
153
154
  }
@@ -158,6 +159,9 @@ CONTRACT = {
158
159
  "models": ["gpt-image-2"],
159
160
  "fields_by_model": {
160
161
  "gpt-image-2": {
162
+ "aspect_ratio": {
163
+ "enum": ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "2:1", "1:2", "3:1", "1:3", "21:9", "9:21"]
164
+ },
161
165
  "output_resolution": {
162
166
  "enum": ["1k", "2k", "4k"]
163
167
  }
@@ -167,13 +171,27 @@ CONTRACT = {
167
171
  "gpt-image/edit-image": {
168
172
  "models": ["gpt-image-1.5"],
169
173
  "fields_by_model": {
170
- "gpt-image-1.5": {}
174
+ "gpt-image-1.5": {
175
+ "aspect_ratio": {
176
+ "enum": ["1:1", "2:3", "3:2"]
177
+ },
178
+ "quality": {
179
+ "enum": ["medium", "high"]
180
+ }
181
+ }
171
182
  }
172
183
  },
173
184
  "gpt-image/text-to-image": {
174
185
  "models": ["gpt-image-1.5"],
175
186
  "fields_by_model": {
176
- "gpt-image-1.5": {}
187
+ "gpt-image-1.5": {
188
+ "aspect_ratio": {
189
+ "enum": ["1:1", "2:3", "3:2"]
190
+ },
191
+ "quality": {
192
+ "enum": ["medium", "high"]
193
+ }
194
+ }
177
195
  }
178
196
  },
179
197
  "grok-imagine/edit-image": {
@@ -585,7 +603,7 @@ CONTRACT = {
585
603
  }
586
604
  },
587
605
  "nano-banana/text-to-image": {
588
- "models": ["nano-banana", "nano-banana-2", "nano-banana-pro"],
606
+ "models": ["nano-banana", "nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"],
589
607
  "fields_by_model": {
590
608
  "nano-banana": {
591
609
  "aspect_ratio": {
@@ -606,6 +624,11 @@ CONTRACT = {
606
624
  "enum": ["1k", "2k", "4k"]
607
625
  }
608
626
  },
627
+ "nano-banana-2-lite": {
628
+ "aspect_ratio": {
629
+ "enum": ["1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9", "auto"]
630
+ }
631
+ },
609
632
  "nano-banana-pro": {
610
633
  "aspect_ratio": {
611
634
  "enum": ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "auto"]
@@ -713,9 +736,6 @@ CONTRACT = {
713
736
  "aspect_ratio": {
714
737
  "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
715
738
  },
716
- "duration_seconds": {
717
- "enum": [4, 8, 12]
718
- },
719
739
  "output_resolution": {
720
740
  "enum": ["480p", "720p", "1080p"]
721
741
  }
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
8
8
  from email.utils import parsedate_to_datetime
9
9
  from typing import Any, Dict, Optional
10
10
 
11
+ from .response import ResponseHeaders
12
+
11
13
  _HTML_MARKER = re.compile(r"<!doctype|<html", re.IGNORECASE)
12
14
  _TITLE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
13
15
  _H1 = re.compile(r"<h1>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
@@ -29,12 +31,23 @@ class Error(Exception):
29
31
  status: Optional[int] = None,
30
32
  request_id: Optional[str] = None,
31
33
  details: Any = None,
34
+ response_headers: Any = None,
32
35
  ) -> None:
33
36
  super().__init__(message)
34
37
  self.message = message
35
38
  self.status = status
36
39
  self.request_id = request_id
37
40
  self.details = details
41
+ self.response_headers = (
42
+ response_headers if isinstance(response_headers, ResponseHeaders) else ResponseHeaders(response_headers)
43
+ )
44
+
45
+ def response_header(self, name: str) -> Optional[str]:
46
+ return self.response_headers.get(name)
47
+
48
+ @property
49
+ def runapi_task_id(self) -> Optional[str]:
50
+ return self.response_header("X-RunAPI-Task-Id")
38
51
 
39
52
  def to_dict(self) -> Dict[str, Any]:
40
53
  data = {
@@ -184,7 +197,12 @@ def error_from_response(response: "Any") -> Error:
184
197
 
185
198
  error_class = STATUS_MAP.get(status, Error)
186
199
 
187
- kwargs: Dict[str, Any] = {"status": status, "request_id": request_id, "details": parsed_body}
200
+ kwargs: Dict[str, Any] = {
201
+ "status": status,
202
+ "request_id": request_id,
203
+ "details": parsed_body,
204
+ "response_headers": response.headers,
205
+ }
188
206
  if error_class is RateLimitError:
189
207
  kwargs["retry_after"] = _parse_retry_after(response.headers.get("retry-after"))
190
208
 
@@ -6,13 +6,14 @@ local file or register a remote URL and receive a usable file reference.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import base64
10
+ import hashlib
9
11
  import os
10
- from typing import Any, Optional
12
+ from typing import Any, Mapping, Optional, Union
11
13
 
12
14
  from . import auth
13
15
  from .http_client import HttpClient
14
16
  from .models import BaseModel, optional, required
15
- from .multipart import MultipartBody, MultipartFile
16
17
  from .options import ClientOptions, RequestOptions
17
18
  from .resource import Resource
18
19
 
@@ -28,6 +29,8 @@ class UploadResponse(BaseModel):
28
29
 
29
30
  class FilesClient(Resource):
30
31
  ENDPOINT = "/api/v1/files"
32
+ PREPARE_ENDPOINT = f"{ENDPOINT}/prepare"
33
+ CONFIRM_ENDPOINT = f"{ENDPOINT}/confirm"
31
34
 
32
35
  RESPONSE_CLASS = UploadResponse
33
36
 
@@ -40,7 +43,7 @@ class FilesClient(Resource):
40
43
  def create(
41
44
  self,
42
45
  file: Any = None,
43
- source: Optional[str] = None,
46
+ source: Optional[Union[str, Mapping[str, Any]]] = None,
44
47
  file_name: Optional[str] = None,
45
48
  options: Optional[RequestOptions] = None,
46
49
  ) -> Any:
@@ -50,7 +53,8 @@ class FilesClient(Resource):
50
53
 
51
54
  Args:
52
55
  file: Local file path or file-like object to upload.
53
- source: Remote URL to register instead of uploading a local file.
56
+ source: Remote URL, base64 data, or canonical source object to
57
+ register instead of uploading a local file.
54
58
  file_name: Optional name to record for the uploaded file.
55
59
  options: Optional per-request options.
56
60
 
@@ -60,14 +64,42 @@ class FilesClient(Resource):
60
64
  self._validate_source(file, source)
61
65
 
62
66
  if file is not None:
63
- body: Any = self._multipart_body(file, file_name)
64
- else:
65
- body = self._compact_params({"source": source, "file_name": file_name})
67
+ return self._upload_direct(file, file_name, options)
66
68
 
69
+ body = self._compact_params(
70
+ {"source": self._source_object(source), "file_name": file_name}
71
+ )
67
72
  return self._request("post", self.ENDPOINT, body=body, options=options)
68
73
 
74
+ def _upload_direct(self, file: Any, file_name: Optional[str], options: Optional[RequestOptions]) -> Any:
75
+ """Local files upload straight to storage: ask for a pre-authorized target,
76
+ PUT the bytes there (never through the API), then confirm. The caller still
77
+ makes a single create call."""
78
+ path = self._file_path(file)
79
+ with open(path, "rb") as handle:
80
+ data = handle.read()
81
+
82
+ prepared = self._http.request(
83
+ "post",
84
+ self.PREPARE_ENDPOINT,
85
+ body=self._compact_params(
86
+ {
87
+ "filename": file_name or os.path.basename(path),
88
+ "byte_size": len(data),
89
+ "checksum": base64.b64encode(hashlib.md5(data).digest()).decode("ascii"),
90
+ }
91
+ ),
92
+ options=options,
93
+ )
94
+
95
+ self._http.upload(prepared["upload_url"], headers=prepared["headers"], body=data)
96
+
97
+ return self._request(
98
+ "post", self.CONFIRM_ENDPOINT, body={"signed_id": prepared["signed_id"]}, options=options
99
+ )
100
+
69
101
  @staticmethod
70
- def _validate_source(file: Any, source: Optional[str]) -> None:
102
+ def _validate_source(file: Any, source: Optional[Union[str, Mapping[str, Any]]]) -> None:
71
103
  def present(value: Any) -> bool:
72
104
  if value is None:
73
105
  return False
@@ -79,13 +111,18 @@ class FilesClient(Resource):
79
111
  if provided != 1:
80
112
  raise ValueError("Exactly one source is required: file or source")
81
113
 
82
- def _multipart_body(self, file: Any, file_name: Optional[str]) -> MultipartBody:
83
- path = self._file_path(file)
84
- filename = file_name or os.path.basename(path)
85
- return MultipartBody(
86
- fields=self._compact_params({"file_name": file_name}),
87
- files={"file": MultipartFile(path=path, filename=filename)},
88
- )
114
+ @staticmethod
115
+ def _source_object(source: Optional[Union[str, Mapping[str, Any]]]) -> Any:
116
+ if isinstance(source, Mapping):
117
+ return dict(source)
118
+
119
+ if isinstance(source, str):
120
+ value = source.strip()
121
+ if value.lower().startswith(("http://", "https://")):
122
+ return {"type": "url", "url": value}
123
+ return {"type": "base64", "data": value}
124
+
125
+ return source
89
126
 
90
127
  @staticmethod
91
128
  def _file_path(file: Any) -> str:
@@ -13,6 +13,7 @@ from . import constants
13
13
  from .errors import NetworkError, RateLimitError, TimeoutError, error_from_response
14
14
  from .multipart import MultipartBody
15
15
  from .options import ClientOptions, RequestOptions
16
+ from .response import ApiResponse
16
17
 
17
18
  _NO_BODY = object()
18
19
 
@@ -37,6 +38,9 @@ class HttpClient:
37
38
  "User-Agent": constants.SDK_USER_AGENT,
38
39
  },
39
40
  )
41
+ # A bare client for direct uploads: the pre-authorized upload URL lives
42
+ # outside the API host and must not receive the API key.
43
+ self._upload_client = httpx.Client(timeout=options.timeout, transport=transport)
40
44
 
41
45
  def request(
42
46
  self,
@@ -74,7 +78,12 @@ class HttpClient:
74
78
  raise NetworkError(str(exc))
75
79
 
76
80
  if response.is_success:
77
- return self._parse_body(response.text)
81
+ body = self._parse_body(response.text)
82
+ if body is None:
83
+ return None
84
+ if isinstance(body, (dict, list)):
85
+ return ApiResponse(body, response.headers)
86
+ return body
78
87
 
79
88
  error = error_from_response(response)
80
89
  if self._retryable(method, response.status_code) and retries < max_retries:
@@ -87,8 +96,23 @@ class HttpClient:
87
96
  for handle in opened:
88
97
  handle.close()
89
98
 
99
+ def upload(self, url: str, headers: Dict[str, str], body: bytes) -> None:
100
+ """PUT bytes straight to a pre-authorized upload URL with the exact headers
101
+ issued for it. No auth, no retries: the URL is single-use and the body is
102
+ not safe to replay."""
103
+ try:
104
+ response = self._upload_client.put(url, content=body, headers=headers)
105
+ except httpx.TimeoutException as exc:
106
+ raise TimeoutError(str(exc))
107
+ except httpx.TransportError as exc:
108
+ raise NetworkError(str(exc))
109
+
110
+ if not response.is_success:
111
+ raise error_from_response(response)
112
+
90
113
  def close(self) -> None:
91
114
  self._client.close()
115
+ self._upload_client.close()
92
116
 
93
117
  def _build_payload(
94
118
  self, body: Any
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  from typing import Any, Callable, Dict, List, Optional, Type, Union
21
21
 
22
22
  from .errors import ValidationError
23
+ from .response import ResponseHeaders
23
24
 
24
25
  # A field's declared type. ``None`` means "untyped" (coerced dynamically); a
25
26
  # class is used directly; a callable is invoked lazily to resolve a forward
@@ -86,6 +87,7 @@ class BaseModel:
86
87
  def __init__(self, attributes: Optional[Dict[str, Any]] = None) -> None:
87
88
  source = self._normalize_input(attributes)
88
89
  self._attributes: Dict[str, Any] = {}
90
+ self._response_headers = ResponseHeaders()
89
91
 
90
92
  for field in self._fields.values():
91
93
  if field.name in source:
@@ -115,7 +117,7 @@ class BaseModel:
115
117
  return None
116
118
  if isinstance(value, BaseModel):
117
119
  if isinstance(target, type) and issubclass(target, BaseModel) and not isinstance(value, target):
118
- return target.from_dict(value.to_dict())
120
+ return target.from_dict(value.to_dict())._with_response_headers(value.response_headers)
119
121
  return value
120
122
  if isinstance(value, dict):
121
123
  model = target if (isinstance(target, type) and issubclass(target, BaseModel)) else DynamicModel
@@ -153,6 +155,21 @@ class BaseModel:
153
155
  def to_dict(self) -> Dict[str, Any]:
154
156
  return {key: self._serialize(value) for key, value in self._attributes.items()}
155
157
 
158
+ @property
159
+ def response_headers(self) -> ResponseHeaders:
160
+ return self._response_headers
161
+
162
+ def response_header(self, name: str) -> Optional[str]:
163
+ return self._response_headers.get(name)
164
+
165
+ @property
166
+ def runapi_task_id(self) -> Optional[str]:
167
+ return self.response_header("X-RunAPI-Task-Id")
168
+
169
+ def _with_response_headers(self, headers: Any) -> "BaseModel":
170
+ self._response_headers = headers if isinstance(headers, ResponseHeaders) else ResponseHeaders(headers)
171
+ return self
172
+
156
173
  def __eq__(self, other: Any) -> bool:
157
174
  if isinstance(other, BaseModel):
158
175
  return self.to_dict() == other.to_dict()
@@ -32,13 +32,25 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
32
32
 
33
33
  if status == "failed":
34
34
  message = _value_for(response, "error") or "Task failed"
35
- raise TaskFailedError(message, details=_details_for(response))
35
+ raise TaskFailedError(
36
+ message,
37
+ details=_details_for(response),
38
+ response_headers=_response_headers_for(response),
39
+ )
36
40
 
37
41
  if time.monotonic() >= deadline:
38
- raise TaskTimeoutError(f"Task polling timed out after {options.max_wait}s")
42
+ raise TaskTimeoutError(
43
+ f"Task polling timed out after {options.max_wait}s",
44
+ details=_details_for(response),
45
+ response_headers=_response_headers_for(response),
46
+ )
39
47
 
40
48
  if status not in ACTIVE_STATUSES:
41
- raise TaskFailedError(f"Unknown task status: {status}", details=_details_for(response))
49
+ raise TaskFailedError(
50
+ f"Unknown task status: {status}",
51
+ details=_details_for(response),
52
+ response_headers=_response_headers_for(response),
53
+ )
42
54
 
43
55
  time.sleep(options.poll_interval)
44
56
 
@@ -53,3 +65,7 @@ def _value_for(response: Any, key: str) -> Any:
53
65
 
54
66
  def _details_for(response: Any) -> Any:
55
67
  return response.to_dict() if isinstance(response, BaseModel) else response
68
+
69
+
70
+ def _response_headers_for(response: Any) -> Any:
71
+ return response.response_headers if hasattr(response, "response_headers") else None