runapi-core 0.7.0__tar.gz → 0.8.0__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 (36) hide show
  1. {runapi_core-0.7.0 → runapi_core-0.8.0}/PKG-INFO +16 -2
  2. {runapi_core-0.7.0 → runapi_core-0.8.0}/README.md +14 -0
  3. {runapi_core-0.7.0 → runapi_core-0.8.0}/pyproject.toml +1 -1
  4. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/__init__.py +4 -0
  5. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/contract_gen.py +6 -2
  6. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/errors.py +13 -6
  7. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/http_client.py +42 -6
  8. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/models.py +17 -0
  9. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/polling.py +40 -11
  10. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/resource.py +140 -19
  11. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/response.py +8 -1
  12. runapi_core-0.8.0/src/runapi/core/version.py +1 -0
  13. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_http_client.py +68 -0
  14. runapi_core-0.8.0/tests/test_resource.py +420 -0
  15. runapi_core-0.7.0/src/runapi/core/version.py +0 -1
  16. runapi_core-0.7.0/tests/test_resource.py +0 -188
  17. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/account.py +0 -0
  18. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/auth.py +0 -0
  19. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/config.py +0 -0
  20. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/constants.py +0 -0
  21. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/files.py +0 -0
  22. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/multipart.py +0 -0
  23. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/options.py +0 -0
  24. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/pricing.py +0 -0
  25. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/provider_client.py +0 -0
  26. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/py.typed +0 -0
  27. {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/uploads.py +0 -0
  28. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_account.py +0 -0
  29. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_auth.py +0 -0
  30. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_config.py +0 -0
  31. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_errors.py +0 -0
  32. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_files.py +0 -0
  33. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_models.py +0 -0
  34. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_polling.py +0 -0
  35. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_pricing.py +0 -0
  36. {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_uploads.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runapi-core
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
5
5
  Project-URL: Homepage, https://runapi.ai/models
6
6
  Project-URL: Documentation, https://runapi.ai/docs/resources/sdks
7
7
  Project-URL: Source, https://github.com/runapi-ai/core-sdk
8
8
  Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
9
9
  Project-URL: Changelog, https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
10
- Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.7.0
10
+ Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.8.0
11
11
  Author-email: RunAPI <contact@runapi.ai>
12
12
  License-Expression: Apache-2.0
13
13
  Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
@@ -41,6 +41,20 @@ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use
41
41
 
42
42
  Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
43
43
 
44
+ ## Recovering Interrupted Requests
45
+
46
+ Normal `run()` calls wait for and return the endpoint's terminal result. It also generates an `Idempotency-Key` before the initial POST when one was not supplied. If a lower-level request is interrupted after RunAPI accepted it, persist the opaque `Location` from the response and call `subscribe(location)` to recover the terminal result. Completed JSON responses keep their endpoint-specific type; text, SRT, and VTT responses return `str`.
47
+
48
+ ```python
49
+ from runapi.midjourney import MidjourneyClient
50
+
51
+ client = MidjourneyClient(api_key="sk-...")
52
+ result = client.shorten_prompt.run(prompt="A detailed cinematic mountain landscape")
53
+
54
+ # Persist this opaque URL after receiving 202 from a lower-level request.
55
+ result = client.shorten_prompt.subscribe("https://runapi.ai/api/v1/tasks/task-id")
56
+ ```
57
+
44
58
  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.
45
59
 
46
60
  ```python
@@ -24,6 +24,20 @@ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use
24
24
 
25
25
  Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
26
26
 
27
+ ## Recovering Interrupted Requests
28
+
29
+ Normal `run()` calls wait for and return the endpoint's terminal result. It also generates an `Idempotency-Key` before the initial POST when one was not supplied. If a lower-level request is interrupted after RunAPI accepted it, persist the opaque `Location` from the response and call `subscribe(location)` to recover the terminal result. Completed JSON responses keep their endpoint-specific type; text, SRT, and VTT responses return `str`.
30
+
31
+ ```python
32
+ from runapi.midjourney import MidjourneyClient
33
+
34
+ client = MidjourneyClient(api_key="sk-...")
35
+ result = client.shorten_prompt.run(prompt="A detailed cinematic mountain landscape")
36
+
37
+ # Persist this opaque URL after receiving 202 from a lower-level request.
38
+ result = client.shorten_prompt.subscribe("https://runapi.ai/api/v1/tasks/task-id")
39
+ ```
40
+
27
41
  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.
28
42
 
29
43
  ```python
@@ -19,7 +19,7 @@ Documentation = "https://runapi.ai/docs/resources/sdks"
19
19
  Source = "https://github.com/runapi-ai/core-sdk"
20
20
  Issues = "https://github.com/runapi-ai/core-sdk/issues"
21
21
  Changelog = "https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md"
22
- "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.7.0"
22
+ "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.8.0"
23
23
 
24
24
  [tool.hatch.version]
25
25
  path = "src/runapi/core/version.py"
@@ -34,6 +34,8 @@ from .models import (
34
34
  BillingSettlement,
35
35
  DynamicModel,
36
36
  TaskBillingFacts,
37
+ TaskResult,
38
+ TaskResultResponse,
37
39
  TaskResponse,
38
40
  optional,
39
41
  required,
@@ -82,6 +84,8 @@ __all__ = [
82
84
  "BaseModel",
83
85
  "DynamicModel",
84
86
  "TaskResponse",
87
+ "TaskResult",
88
+ "TaskResultResponse",
85
89
  "BillingReservation",
86
90
  "BillingSettlement",
87
91
  "BillingRefund",
@@ -341,7 +341,11 @@ CONTRACT = {
341
341
  "models": ["grok-imagine-edit-image", "grok-imagine-image-2-0"],
342
342
  "fields_by_model": {
343
343
  "grok-imagine-edit-image": {},
344
- "grok-imagine-image-2-0": {}
344
+ "grok-imagine-image-2-0": {
345
+ "aspect_ratio": {
346
+ "enum": ["1:1", "2:3", "3:2", "16:9", "9:16", "auto"]
347
+ }
348
+ }
345
349
  }
346
350
  },
347
351
  "grok-imagine/extend": {
@@ -2072,7 +2076,7 @@ CONTRACT = {
2072
2076
  "fields_by_model": {
2073
2077
  "topaz-upscale-image": {
2074
2078
  "upscale_factor": {
2075
- "enum": [1, 2, 4, 8]
2079
+ "enum": [1, 2, 4]
2076
2080
  }
2077
2081
  }
2078
2082
  }
@@ -204,10 +204,17 @@ def error_from_response(response: "Any") -> Error:
204
204
  Maps the status code to a specific error class and extracts the message,
205
205
  request id, response details, and (for 429) the retry-after delay.
206
206
  """
207
- status = response.status_code
208
- request_id = response.headers.get("x-request-id")
209
-
210
- parsed_body = _parse_body(response.text)
207
+ return error_from_response_data(
208
+ response.status_code,
209
+ _parse_body(response.text),
210
+ response.headers,
211
+ )
212
+
213
+ def error_from_response_data(status: int, body: Any, headers: Any = None) -> Error:
214
+ """Build a public SDK error from a stored terminal response checkpoint."""
215
+ response_headers = ResponseHeaders(headers)
216
+ request_id = response_headers.get("x-request-id")
217
+ parsed_body = body
211
218
  message = _extract_message(parsed_body) or DEFAULT_MESSAGES.get(status) or "Request failed"
212
219
 
213
220
  error_class = STATUS_MAP.get(status, Error)
@@ -217,10 +224,10 @@ def error_from_response(response: "Any") -> Error:
217
224
  "status": status,
218
225
  "request_id": request_id,
219
226
  "details": parsed_body,
220
- "response_headers": response.headers,
227
+ "response_headers": response_headers,
221
228
  }
222
229
  if error_class is RateLimitError:
223
- kwargs["retry_after"] = _parse_retry_after(response.headers.get("retry-after"))
230
+ kwargs["retry_after"] = _parse_retry_after(response_headers.get("retry-after"))
224
231
 
225
232
  return error_class(message, **kwargs)
226
233
 
@@ -6,11 +6,12 @@ import json
6
6
  import random
7
7
  import time
8
8
  from typing import Any, Dict, List, Optional, Tuple
9
+ from urllib.parse import urlsplit
9
10
 
10
11
  import httpx
11
12
 
12
13
  from . import constants
13
- from .errors import NetworkError, RateLimitError, TimeoutError, error_from_response
14
+ from .errors import NetworkError, RateLimitError, TimeoutError, ValidationError, error_from_response
14
15
  from .multipart import MultipartBody
15
16
  from .options import ClientOptions, RequestOptions
16
17
  from .response import ApiResponse
@@ -51,6 +52,7 @@ class HttpClient:
51
52
  body: Any = None,
52
53
  options: Optional[RequestOptions] = None,
53
54
  ) -> Any:
55
+ self._validate_request_url(path)
54
56
  max_retries = self._options.max_retries
55
57
  if options is not None and options.max_retries is not None:
56
58
  max_retries = options.max_retries
@@ -75,8 +77,16 @@ class HttpClient:
75
77
  timeout=timeout,
76
78
  )
77
79
  except httpx.TimeoutException as exc:
80
+ if self._retryable_request(method, headers) and retries < max_retries:
81
+ retries += 1
82
+ time.sleep(self._retry_delay(retries, TimeoutError(str(exc))))
83
+ continue
78
84
  raise TimeoutError(str(exc))
79
85
  except httpx.TransportError as exc:
86
+ if self._retryable_request(method, headers) and retries < max_retries:
87
+ retries += 1
88
+ time.sleep(self._retry_delay(retries, NetworkError(str(exc))))
89
+ continue
80
90
  raise NetworkError(str(exc))
81
91
 
82
92
  if response.status_code == 304:
@@ -87,11 +97,15 @@ class HttpClient:
87
97
  if body is None:
88
98
  return None
89
99
  if isinstance(body, (dict, list)):
90
- return ApiResponse(body, response.headers)
100
+ return ApiResponse(body, response.headers, status_code=response.status_code)
91
101
  return body
92
102
 
93
103
  error = error_from_response(response)
94
- if self._retryable(method, response.status_code) and retries < max_retries:
104
+ if (
105
+ self._retryable_request(method, headers)
106
+ and response.status_code in constants.RETRYABLE_STATUS_CODES
107
+ and retries < max_retries
108
+ ):
95
109
  retries += 1
96
110
  time.sleep(self._retry_delay(retries, error))
97
111
  continue
@@ -122,6 +136,7 @@ class HttpClient:
122
136
  options: Optional[RequestOptions] = None,
123
137
  ) -> bytes:
124
138
  """Return a successful response body without text decoding."""
139
+ self._validate_request_url(path)
125
140
  max_retries = self._options.max_retries
126
141
  if options is not None and options.max_retries is not None:
127
142
  max_retries = options.max_retries
@@ -140,7 +155,11 @@ class HttpClient:
140
155
  if response.is_success:
141
156
  return response.content
142
157
  error = error_from_response(response)
143
- if self._retryable(method, response.status_code) and retries < max_retries:
158
+ if (
159
+ self._retryable_request(method, headers)
160
+ and response.status_code in constants.RETRYABLE_STATUS_CODES
161
+ and retries < max_retries
162
+ ):
144
163
  retries += 1
145
164
  time.sleep(self._retry_delay(retries, error))
146
165
  continue
@@ -172,8 +191,25 @@ class HttpClient:
172
191
  return body, None, None, []
173
192
  return None, None, None, []
174
193
 
175
- def _retryable(self, method: str, status: int) -> bool:
176
- return method in constants.IDEMPOTENT_METHODS and status in constants.RETRYABLE_STATUS_CODES
194
+ def _retryable_request(self, method: str, headers: Dict[str, str]) -> bool:
195
+ return method in constants.IDEMPOTENT_METHODS or (
196
+ method == "POST"
197
+ and any(name.lower() == "idempotency-key" and str(value).strip() for name, value in headers.items())
198
+ )
199
+
200
+ def _validate_request_url(self, path: str) -> None:
201
+ requested = urlsplit(path)
202
+ if not requested.scheme and not requested.netloc:
203
+ return
204
+ configured = urlsplit(str(self._options.base_url))
205
+ if self._origin(requested) != self._origin(configured):
206
+ raise ValidationError("Request URL must use the configured RunAPI origin")
207
+
208
+ @staticmethod
209
+ def _origin(url: Any) -> Tuple[str, str, Optional[int]]:
210
+ scheme = url.scheme.lower()
211
+ port = url.port or ({"http": 80, "https": 443}.get(scheme))
212
+ return scheme, (url.hostname or "").lower(), port
177
213
 
178
214
  def _retry_delay(self, attempt: int, error: Any) -> float:
179
215
  if isinstance(error, RateLimitError) and error.retry_after and error.retry_after > 0:
@@ -266,6 +266,23 @@ class TaskResponse(BaseModel):
266
266
  billing = optional(lambda: TaskBillingFacts)
267
267
 
268
268
 
269
+ class TaskResultResponse(BaseModel):
270
+ """The persisted public response checkpoint for a terminal Task."""
271
+
272
+ status = required(int)
273
+ content_type = required(str)
274
+ headers = optional(dict)
275
+ body = optional()
276
+
277
+
278
+ class TaskResult(TaskResponse):
279
+ """The account-scoped Task Result resource returned from an opaque Location."""
280
+
281
+ id = required(str)
282
+ status = required(str, enum=lambda: TaskResponse.Status.ALL)
283
+ response = optional(lambda: TaskResultResponse)
284
+
285
+
269
286
  class BillingReservation(BaseModel):
270
287
  amount_cents = required(int)
271
288
 
@@ -5,14 +5,21 @@ from __future__ import annotations
5
5
  import time
6
6
  from typing import Any, Callable, Optional
7
7
 
8
- from .errors import TaskFailedError, TaskTimeoutError
8
+ from .errors import TaskFailedError, TaskTimeoutError, _parse_retry_after
9
9
  from .models import BaseModel
10
10
  from .options import PollingOptions
11
11
 
12
12
  ACTIVE_STATUSES = ("pending", "processing")
13
13
 
14
14
 
15
- def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptions] = None) -> Any:
15
+ def poll_until_complete(
16
+ fetch: Callable[[], Any],
17
+ options: Optional[PollingOptions] = None,
18
+ *,
19
+ initial_delay: Optional[float] = None,
20
+ completed: Optional[Callable[[Any], Any]] = None,
21
+ failed: Optional[Callable[[Any], Exception]] = None,
22
+ ) -> Any:
16
23
  """Call ``fetch`` repeatedly until the task completes.
17
24
 
18
25
  Returns the completed response. Raises :class:`TaskFailedError` on a failed
@@ -22,15 +29,30 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
22
29
  options = PollingOptions()
23
30
 
24
31
  deadline = time.monotonic() + options.max_wait
32
+ wait = initial_delay
33
+ response = None
25
34
 
26
35
  while True:
36
+ if wait and wait > 0:
37
+ remaining = deadline - time.monotonic()
38
+ if remaining <= 0:
39
+ raise _timeout_error(options, response)
40
+ time.sleep(min(wait, remaining))
41
+
42
+ if (response is not None or (wait and wait > 0)) and time.monotonic() >= deadline:
43
+ raise _timeout_error(options, response)
44
+
27
45
  response = fetch()
46
+ if time.monotonic() >= deadline:
47
+ raise _timeout_error(options, response)
28
48
  status = str(_value_for(response, "status") or "").lower()
29
49
 
30
50
  if status == "completed":
31
- return response
51
+ return completed(response) if completed else response
32
52
 
33
53
  if status == "failed":
54
+ if failed:
55
+ raise failed(response)
34
56
  message = _value_for(response, "error") or "Task failed"
35
57
  raise TaskFailedError(
36
58
  message,
@@ -38,13 +60,6 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
38
60
  response_headers=_response_headers_for(response),
39
61
  )
40
62
 
41
- if time.monotonic() >= deadline:
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
- )
47
-
48
63
  if status not in ACTIVE_STATUSES:
49
64
  raise TaskFailedError(
50
65
  f"Unknown task status: {status}",
@@ -52,7 +67,15 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
52
67
  response_headers=_response_headers_for(response),
53
68
  )
54
69
 
55
- time.sleep(options.poll_interval)
70
+ wait = _retry_after_for(response) or options.poll_interval
71
+
72
+
73
+ def _timeout_error(options: PollingOptions, response: Any) -> TaskTimeoutError:
74
+ return TaskTimeoutError(
75
+ f"Task polling timed out after {options.max_wait}s",
76
+ details=_details_for(response) if response is not None else None,
77
+ response_headers=_response_headers_for(response) if response is not None else None,
78
+ )
56
79
 
57
80
 
58
81
  def _value_for(response: Any, key: str) -> Any:
@@ -69,3 +92,9 @@ def _details_for(response: Any) -> Any:
69
92
 
70
93
  def _response_headers_for(response: Any) -> Any:
71
94
  return response.response_headers if hasattr(response, "response_headers") else None
95
+
96
+
97
+ def _retry_after_for(response: Any) -> Optional[float]:
98
+ headers = _response_headers_for(response)
99
+ delay = _parse_retry_after(headers.get("Retry-After") if headers else None)
100
+ return delay if delay and delay > 0 else None
@@ -2,11 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import replace
5
6
  from typing import Any, Callable, Dict, Optional, Sequence
7
+ from uuid import uuid4
6
8
 
7
9
  from . import polling
8
- from .errors import ValidationError
9
- from .models import BaseModel, TaskResponse
10
+ from .errors import TaskFailedError, ValidationError, _parse_retry_after, error_from_response_data
11
+ from .models import BaseModel, TaskResult, TaskResponse
10
12
  from .options import PollingOptions, RequestOptions
11
13
  from .response import ApiResponse
12
14
 
@@ -19,7 +21,7 @@ class Resource:
19
21
  re-coerces to once the task completes).
20
22
  """
21
23
 
22
- RESPONSE_CLASS: type = TaskResponse
24
+ RESPONSE_CLASS: Optional[type] = TaskResponse
23
25
  COMPLETED_RESPONSE_CLASS: Optional[type] = None
24
26
 
25
27
  def __init__(self, http: Any) -> None:
@@ -34,12 +36,146 @@ class Resource:
34
36
  response_class: Optional[type] = None,
35
37
  ) -> Any:
36
38
  response = self._http.request(method, path, body=body, options=options)
39
+ return self._coerce_response(response, response_class)
40
+
41
+ def _coerce_response(self, response: Any, response_class: Optional[type] = None) -> Any:
37
42
  payload = response.body if isinstance(response, ApiResponse) else response
38
- result = BaseModel.coerce(payload, as_=response_class or type(self).RESPONSE_CLASS)
43
+ target = response_class or type(self).RESPONSE_CLASS
44
+ result = payload if target is None else BaseModel.coerce(payload, as_=target)
39
45
  if isinstance(response, ApiResponse):
40
46
  self._attach_response_headers(result, response.response_headers)
41
47
  return result
42
48
 
49
+ def _run_hybrid(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ body: Any = None,
54
+ options: Optional[RequestOptions] = None,
55
+ polling_options: Optional[PollingOptions] = None,
56
+ ) -> Any:
57
+ """Run a terminal-or-accepted Task endpoint through one stable API."""
58
+ request_options = self._with_idempotency_key(options) if method.upper() == "POST" else options
59
+ response = self._http.request(method, path, body=body, options=request_options)
60
+ if isinstance(response, ApiResponse) and response.status_code == 202:
61
+ location = response.response_headers.get("Location")
62
+ if not location:
63
+ raise TaskFailedError(
64
+ "Accepted Task response is missing Location",
65
+ response_headers=response.response_headers,
66
+ )
67
+ return self.subscribe(
68
+ location,
69
+ options=request_options,
70
+ polling_options=polling_options,
71
+ initial_delay=self._retry_after(response.response_headers),
72
+ )
73
+ return self._coerce_response(response)
74
+
75
+ def subscribe(
76
+ self,
77
+ location: str,
78
+ *,
79
+ options: Optional[RequestOptions] = None,
80
+ polling_options: Optional[PollingOptions] = None,
81
+ initial_delay: Optional[float] = None,
82
+ ) -> Any:
83
+ """Follow an opaque Task Result URL until its stored terminal response is ready."""
84
+ return self._poll_until_complete(
85
+ lambda: self._request("get", location, options=options, response_class=TaskResult),
86
+ polling_options,
87
+ initial_delay=initial_delay,
88
+ completed=self._decode_task_result,
89
+ failed=self._task_result_error,
90
+ )
91
+
92
+ def _with_idempotency_key(self, options: Optional[RequestOptions]) -> RequestOptions:
93
+ headers = {}
94
+ idempotency_key = None
95
+ source_headers = (options.headers or {}) if options else {}
96
+ for name, value in source_headers.items():
97
+ if name.lower() != "idempotency-key":
98
+ headers[name] = value
99
+ continue
100
+
101
+ normalized = str(value).strip()
102
+ if not normalized:
103
+ continue
104
+ if idempotency_key is not None and idempotency_key != normalized:
105
+ raise ValidationError("conflicting Idempotency-Key headers")
106
+ idempotency_key = normalized
107
+
108
+ headers["Idempotency-Key"] = idempotency_key or str(uuid4())
109
+ return replace(options, headers=headers) if options else RequestOptions(headers=headers)
110
+
111
+ def _decode_task_result(self, task: TaskResult) -> Any:
112
+ response = task.response
113
+ if response is None:
114
+ raise TaskFailedError("Completed Task is missing its terminal response", details=task.to_dict())
115
+ if not 200 <= response.status < 300:
116
+ raise self._task_result_error(task)
117
+
118
+ body = self._plain_value(response.body)
119
+ content_type = response.content_type.lower().split(";", 1)[0].strip()
120
+ if content_type == "application/json" or content_type.endswith("+json"):
121
+ response_class = type(self).RESPONSE_CLASS
122
+ result = body if response_class is None else BaseModel.coerce(body, as_=response_class)
123
+ self._attach_response_headers(result, response.headers or {})
124
+ return result
125
+ if self._textual_content_type(content_type):
126
+ if isinstance(body, bytes):
127
+ return body.decode("utf-8")
128
+ return "" if body is None else str(body)
129
+ if isinstance(body, bytes):
130
+ return body
131
+ if isinstance(body, str):
132
+ return body.encode("utf-8")
133
+ return body
134
+
135
+ def _task_result_error(self, task: TaskResult) -> Exception:
136
+ response = task.response
137
+ if response is None:
138
+ return TaskFailedError("Task failed without a terminal response", details=task.to_dict())
139
+ return error_from_response_data(
140
+ response.status,
141
+ self._plain_value(response.body),
142
+ response.headers or {},
143
+ )
144
+
145
+ def _poll_until_complete(
146
+ self,
147
+ fetch: Callable[[], Any],
148
+ polling_opts: Optional[PollingOptions] = None,
149
+ **kwargs: Any,
150
+ ) -> Any:
151
+ response = polling.poll_until_complete(fetch, polling_opts or PollingOptions(), **kwargs)
152
+
153
+ if kwargs.get("completed"):
154
+ return response
155
+
156
+ completed_class = type(self).COMPLETED_RESPONSE_CLASS
157
+ if completed_class is None or isinstance(response, completed_class):
158
+ return response
159
+
160
+ payload = response.to_dict() if isinstance(response, BaseModel) else response
161
+ completed = completed_class.from_dict(payload)
162
+ if isinstance(response, BaseModel):
163
+ completed._with_response_headers(response.response_headers)
164
+ return completed
165
+
166
+ @staticmethod
167
+ def _plain_value(value: Any) -> Any:
168
+ return value.to_dict() if isinstance(value, BaseModel) else value
169
+
170
+ @staticmethod
171
+ def _textual_content_type(content_type: str) -> bool:
172
+ return content_type.startswith("text/") or content_type in {"application/srt", "application/x-subrip"}
173
+
174
+ @staticmethod
175
+ def _retry_after(headers: Any) -> Optional[float]:
176
+ delay = _parse_retry_after(headers.get("Retry-After") if headers else None)
177
+ return delay if delay and delay > 0 else None
178
+
43
179
  @staticmethod
44
180
  def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
45
181
  return {
@@ -249,21 +385,6 @@ class Resource:
249
385
  return len(value) > 0
250
386
  return True
251
387
 
252
- def _poll_until_complete(
253
- self, fetch: Callable[[], Any], polling_opts: Optional[PollingOptions] = None
254
- ) -> Any:
255
- response = polling.poll_until_complete(fetch, polling_opts or PollingOptions())
256
-
257
- completed_class = type(self).COMPLETED_RESPONSE_CLASS
258
- if completed_class is None or isinstance(response, completed_class):
259
- return response
260
-
261
- payload = response.to_dict() if isinstance(response, BaseModel) else response
262
- completed = completed_class.from_dict(payload)
263
- if isinstance(response, BaseModel):
264
- completed._with_response_headers(response.response_headers)
265
- return completed
266
-
267
388
  def _attach_response_headers(self, result: Any, headers: Any) -> None:
268
389
  if isinstance(result, BaseModel):
269
390
  result._with_response_headers(headers)
@@ -35,11 +35,18 @@ class ApiResponse(dict[str, Any]):
35
35
  that index the returned JSON body keep working.
36
36
  """
37
37
 
38
- def __init__(self, body: Mapping[str, Any] | Sequence[Any], headers: Optional[Mapping[str, Any]] = None) -> None:
38
+ def __init__(
39
+ self,
40
+ body: Mapping[str, Any] | Sequence[Any],
41
+ headers: Optional[Mapping[str, Any]] = None,
42
+ *,
43
+ status_code: Optional[int] = None,
44
+ ) -> None:
39
45
  super().__init__(body if isinstance(body, Mapping) else {})
40
46
  self.body = self if isinstance(body, Mapping) else body
41
47
  self.response_headers = headers if isinstance(headers, ResponseHeaders) else ResponseHeaders(headers)
42
48
  self.headers = self.response_headers
49
+ self.status_code = status_code
43
50
 
44
51
  def __getitem__(self, key: Any) -> Any:
45
52
  if self.body is not self:
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -31,6 +31,25 @@ def test_returns_parsed_json_on_success():
31
31
  assert client.request("get", "/api/v1/test") == {"id": "123"}
32
32
 
33
33
 
34
+ def test_rejects_cross_origin_absolute_url_before_sending_credentials():
35
+ requests = []
36
+ client = make_client(lambda request: requests.append(request) or httpx.Response(200, json={}))
37
+
38
+ with pytest.raises(errors.ValidationError, match="configured RunAPI origin"):
39
+ client.request("get", "https://attacker.example/tasks/task-1")
40
+
41
+ assert requests == []
42
+
43
+
44
+ def test_accepts_absolute_url_on_configured_origin():
45
+ requested_urls = []
46
+ client = make_client(lambda request: requested_urls.append(str(request.url)) or httpx.Response(200, json={}))
47
+
48
+ client.request("get", "https://runapi.ai/api/v1/tasks/task-1")
49
+
50
+ assert requested_urls == ["https://runapi.ai/api/v1/tasks/task-1"]
51
+
52
+
34
53
  def test_keeps_response_headers_on_success():
35
54
  client = make_client(
36
55
  lambda request: httpx.Response(
@@ -198,6 +217,55 @@ def test_does_not_retry_post_on_503():
198
217
  assert calls["n"] == 1
199
218
 
200
219
 
220
+ def test_retries_post_with_idempotency_key_and_reuses_the_key():
221
+ calls = []
222
+
223
+ def handler(request):
224
+ calls.append(request.headers["idempotency-key"])
225
+ if len(calls) == 1:
226
+ return httpx.Response(503, json={"error": "down"})
227
+ return httpx.Response(200, json={"ok": True})
228
+
229
+ assert make_client(handler).request(
230
+ "post",
231
+ "/api/v1/test",
232
+ body={"prompt": "hello"},
233
+ options=RequestOptions(headers={"Idempotency-Key": "task-key"}),
234
+ ) == {"ok": True}
235
+ assert calls == ["task-key", "task-key"]
236
+
237
+
238
+ def test_does_not_retry_post_with_blank_idempotency_key():
239
+ calls = {"n": 0}
240
+
241
+ def handler(_request):
242
+ calls["n"] += 1
243
+ return httpx.Response(503, json={"error": "down"})
244
+
245
+ with pytest.raises(errors.ServiceUnavailableError):
246
+ make_client(handler).request(
247
+ "post",
248
+ "/api/v1/test",
249
+ body={"prompt": "hello"},
250
+ options=RequestOptions(headers={"Idempotency-Key": " "}),
251
+ )
252
+
253
+ assert calls["n"] == 1
254
+
255
+
256
+ def test_request_bytes_retries_idempotent_get():
257
+ calls = {"n": 0}
258
+
259
+ def handler(_request):
260
+ calls["n"] += 1
261
+ if calls["n"] == 1:
262
+ return httpx.Response(503, json={"error": "down"})
263
+ return httpx.Response(200, content=b"audio")
264
+
265
+ assert make_client(handler).request_bytes("get", "/api/v1/files/audio") == b"audio"
266
+ assert calls["n"] == 2
267
+
268
+
201
269
  def test_respects_retry_after(monkeypatch):
202
270
  slept = []
203
271
  monkeypatch.setattr(http_client.time, "sleep", lambda seconds: slept.append(seconds))