runapi-core 0.5.0__tar.gz → 0.6.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 (35) hide show
  1. {runapi_core-0.5.0 → runapi_core-0.6.0}/PKG-INFO +21 -4
  2. {runapi_core-0.5.0 → runapi_core-0.6.0}/README.md +18 -1
  3. {runapi_core-0.5.0 → runapi_core-0.6.0}/pyproject.toml +1 -1
  4. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/__init__.py +10 -3
  5. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/contract_gen.py +46 -1
  6. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/errors.py +0 -5
  7. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/files.py +80 -5
  8. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/http_client.py +31 -0
  9. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/provider_client.py +2 -0
  10. runapi_core-0.6.0/src/runapi/core/uploads.py +91 -0
  11. runapi_core-0.6.0/src/runapi/core/version.py +1 -0
  12. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_errors.py +6 -7
  13. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_files.py +42 -0
  14. runapi_core-0.6.0/tests/test_uploads.py +36 -0
  15. runapi_core-0.5.0/src/runapi/core/version.py +0 -1
  16. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/account.py +0 -0
  17. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/auth.py +0 -0
  18. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/config.py +0 -0
  19. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/constants.py +0 -0
  20. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/models.py +0 -0
  21. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/multipart.py +0 -0
  22. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/options.py +0 -0
  23. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/polling.py +0 -0
  24. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/pricing.py +0 -0
  25. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/py.typed +0 -0
  26. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/resource.py +0 -0
  27. {runapi_core-0.5.0 → runapi_core-0.6.0}/src/runapi/core/response.py +0 -0
  28. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_account.py +0 -0
  29. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_auth.py +0 -0
  30. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_config.py +0 -0
  31. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_http_client.py +0 -0
  32. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_models.py +0 -0
  33. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_polling.py +0 -0
  34. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_pricing.py +0 -0
  35. {runapi_core-0.5.0 → runapi_core-0.6.0}/tests/test_resource.py +0 -0
@@ -1,13 +1,13 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: runapi-core
3
- Version: 0.5.0
3
+ Version: 0.6.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.5.0
10
+ Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.6.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
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # RunAPI Core Python SDK
19
19
 
20
- 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`.
20
+ The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, Files, Uploads, 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`.
21
21
 
22
22
  ## Install
23
23
 
@@ -79,6 +79,23 @@ inline = files.create(source="iVBORw0KGgo...")
79
79
  print(uploaded.url)
80
80
  ```
81
81
 
82
+ The existing `files.create()` method keeps its temporary URL behavior. Every Provider Client also exposes persistent Files and multipart Uploads:
83
+
84
+ ```python
85
+ file = client.files.create_file(file="./knowledge.pdf", purpose="user_data")
86
+ content = client.files.content(file.id)
87
+
88
+ upload = client.uploads.create(
89
+ bytes=1048576,
90
+ filename="archive.bin",
91
+ mime_type="application/octet-stream",
92
+ )
93
+ part = client.uploads.add_part(upload.id, data="./archive.part-01")
94
+ completed = client.uploads.complete(upload.id, part_ids=[part.id])
95
+ ```
96
+
97
+ Use `files.list()`, `retrieve()`, and `delete_file()` for the remaining File lifecycle. See https://runapi.ai/docs/resources/files for limits and REST examples.
98
+
82
99
  Public SDK docs live at https://runapi.ai/docs/resources/sdks and the model catalog lives at https://runapi.ai/models.
83
100
 
84
101
  ## Universal resources
@@ -1,6 +1,6 @@
1
1
  # RunAPI Core Python SDK
2
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`.
3
+ The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, Files, Uploads, 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
4
 
5
5
  ## Install
6
6
 
@@ -62,6 +62,23 @@ inline = files.create(source="iVBORw0KGgo...")
62
62
  print(uploaded.url)
63
63
  ```
64
64
 
65
+ The existing `files.create()` method keeps its temporary URL behavior. Every Provider Client also exposes persistent Files and multipart Uploads:
66
+
67
+ ```python
68
+ file = client.files.create_file(file="./knowledge.pdf", purpose="user_data")
69
+ content = client.files.content(file.id)
70
+
71
+ upload = client.uploads.create(
72
+ bytes=1048576,
73
+ filename="archive.bin",
74
+ mime_type="application/octet-stream",
75
+ )
76
+ part = client.uploads.add_part(upload.id, data="./archive.part-01")
77
+ completed = client.uploads.complete(upload.id, part_ids=[part.id])
78
+ ```
79
+
80
+ Use `files.list()`, `retrieve()`, and `delete_file()` for the remaining File lifecycle. See https://runapi.ai/docs/resources/files for limits and REST examples.
81
+
65
82
  Public SDK docs live at https://runapi.ai/docs/resources/sdks and the model catalog lives at https://runapi.ai/models.
66
83
 
67
84
  ## Universal resources
@@ -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.5.0"
22
+ "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.6.0"
23
23
 
24
24
  [tool.hatch.version]
25
25
  path = "src/runapi/core/version.py"
@@ -1,7 +1,7 @@
1
1
  """RunAPI Python SDK — shared core.
2
2
 
3
- Configuration, errors, models, HTTP transport, polling, and the file upload
4
- client shared by every per-model-line package.
3
+ Configuration, errors, models, HTTP transport, polling, Files, and Uploads
4
+ shared by every per-model-line package.
5
5
  """
6
6
 
7
7
  from . import config, errors, polling
@@ -25,7 +25,7 @@ from .errors import (
25
25
  ValidationError,
26
26
  error_from_response,
27
27
  )
28
- from .files import FilesClient, UploadResponse
28
+ from .files import DeletedFile, FileList, FileObject, FilesClient, UploadResponse
29
29
  from .http_client import HttpClient
30
30
  from .models import (
31
31
  BaseModel,
@@ -44,6 +44,7 @@ from .response import ApiResponse, ResponseHeaders
44
44
  from .resource import Resource
45
45
  from .pricing import PriceQuote, PriceQuoteResponse, PriceSchedule, PriceScheduleListResponse, Pricing, PricingClient
46
46
  from .provider_client import ProviderClient
47
+ from .uploads import UploadObject, UploadPart, Uploads
47
48
  from .version import __version__
48
49
 
49
50
  __all__ = [
@@ -93,6 +94,12 @@ __all__ = [
93
94
  "Resource",
94
95
  "FilesClient",
95
96
  "UploadResponse",
97
+ "DeletedFile",
98
+ "FileList",
99
+ "FileObject",
100
+ "UploadObject",
101
+ "UploadPart",
102
+ "Uploads",
96
103
  "Pricing",
97
104
  "PricingClient",
98
105
  "PriceSchedule",
@@ -38,6 +38,24 @@ CONTRACT = {
38
38
  "text-to-speech-turbo-v2.5": {}
39
39
  }
40
40
  },
41
+ "fish-audio/create-voice": {
42
+ "models": [],
43
+ "fields_by_model": {
44
+ "_": {}
45
+ }
46
+ },
47
+ "fish-audio/get-voice": {
48
+ "models": [],
49
+ "fields_by_model": {
50
+ "_": {}
51
+ }
52
+ },
53
+ "fish-audio/list-voices": {
54
+ "models": [],
55
+ "fields_by_model": {
56
+ "_": {}
57
+ }
58
+ },
41
59
  "fish-audio/text-to-speech": {
42
60
  "models": ["s1", "s2-pro", "s2.1-pro"],
43
61
  "fields_by_model": {
@@ -1342,7 +1360,7 @@ CONTRACT = {
1342
1360
  }
1343
1361
  },
1344
1362
  "seedance/text-to-video": {
1345
- "models": ["seedance-1.5-pro", "seedance-2-mini", "seedance-2.0", "seedance-2.0-fast", "seedance-v1-pro", "seedance-v1-pro-fast"],
1363
+ "models": ["seedance-1.5-pro", "seedance-2-mini", "seedance-2.0", "seedance-2.0-fast", "seedance-2.5", "seedance-v1-pro", "seedance-v1-pro-fast"],
1346
1364
  "fields_by_model": {
1347
1365
  "seedance-1.5-pro": {
1348
1366
  "aspect_ratio": {
@@ -1376,6 +1394,20 @@ CONTRACT = {
1376
1394
  "enum": ["480p", "720p"]
1377
1395
  }
1378
1396
  },
1397
+ "seedance-2.5": {
1398
+ "aspect_ratio": {
1399
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"]
1400
+ },
1401
+ "duration_seconds": {
1402
+ "enum": [-1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
1403
+ },
1404
+ "output_format": {
1405
+ "enum": ["mp4", "mov"]
1406
+ },
1407
+ "output_resolution": {
1408
+ "enum": ["480p", "720p"]
1409
+ }
1410
+ },
1379
1411
  "seedance-v1-pro": {
1380
1412
  "aspect_ratio": {
1381
1413
  "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
@@ -1397,6 +1429,19 @@ CONTRACT = {
1397
1429
  }
1398
1430
  }
1399
1431
  },
1432
+ "seedream/decompose-layers": {
1433
+ "models": ["seedream-5-pro-layer-decomposition"],
1434
+ "fields_by_model": {
1435
+ "seedream-5-pro-layer-decomposition": {
1436
+ "output_format": {
1437
+ "enum": ["png", "jpeg"]
1438
+ },
1439
+ "size": {
1440
+ "enum": ["auto", "1K", "1.5K", "2K"]
1441
+ }
1442
+ }
1443
+ }
1444
+ },
1400
1445
  "seedream/edit-image": {
1401
1446
  "models": ["seedream-4.5-edit", "seedream-5-lite-edit", "seedream-5-pro-edit", "seedream-v4-edit"],
1402
1447
  "fields_by_model": {
@@ -270,11 +270,6 @@ def _extract_message(body: Any) -> Optional[str]:
270
270
  if message:
271
271
  return message
272
272
 
273
- errors = body.get("errors")
274
- if isinstance(errors, list) and errors:
275
- first = errors[0]
276
- return first.get("message") if isinstance(first, dict) else first
277
-
278
273
  return body.get("message") or body.get("detail") or body.get("errorMessage") or body.get("msg")
279
274
 
280
275
 
@@ -1,8 +1,4 @@
1
- """File upload client.
2
-
3
- Shared infrastructure for every model line that takes file inputs: upload a
4
- local file or register a remote URL and receive a usable file reference.
5
- """
1
+ """Persistent File lifecycle and temporary URL upload client."""
6
2
 
7
3
  from __future__ import annotations
8
4
 
@@ -10,10 +6,12 @@ import base64
10
6
  import hashlib
11
7
  import os
12
8
  from typing import Any, Mapping, Optional, Union
9
+ from urllib.parse import quote, urlencode
13
10
 
14
11
  from . import auth
15
12
  from .http_client import HttpClient
16
13
  from .models import BaseModel, optional, required
14
+ from .multipart import MultipartBody, MultipartFile
17
15
  from .options import ClientOptions, RequestOptions
18
16
  from .resource import Resource
19
17
 
@@ -27,10 +25,35 @@ class UploadResponse(BaseModel):
27
25
  expires_at = required(str)
28
26
 
29
27
 
28
+ class FileObject(BaseModel):
29
+ id = required(str)
30
+ object = required(str)
31
+ bytes = required(int)
32
+ created_at = required(int)
33
+ expires_at = optional(int)
34
+ filename = required(str)
35
+ purpose = required(str)
36
+
37
+
38
+ class FileList(BaseModel):
39
+ object = required(str)
40
+ data = required([FileObject])
41
+ first_id = optional(str)
42
+ last_id = optional(str)
43
+ has_more = required(bool)
44
+
45
+
46
+ class DeletedFile(BaseModel):
47
+ id = required(str)
48
+ object = required(str)
49
+ deleted = required(bool)
50
+
51
+
30
52
  class FilesClient(Resource):
31
53
  ENDPOINT = "/api/v1/files"
32
54
  PREPARE_ENDPOINT = f"{ENDPOINT}/prepare"
33
55
  CONFIRM_ENDPOINT = f"{ENDPOINT}/confirm"
56
+ PROTOCOL_ENDPOINT = "/v1/files"
34
57
 
35
58
  RESPONSE_CLASS = UploadResponse
36
59
 
@@ -74,6 +97,52 @@ class FilesClient(Resource):
74
97
  )
75
98
  return self._request("post", self.ENDPOINT, body=body, options=options)
76
99
 
100
+ def create_file(
101
+ self,
102
+ file: Any,
103
+ purpose: str = "user_data",
104
+ file_name: Optional[str] = None,
105
+ options: Optional[RequestOptions] = None,
106
+ ) -> FileObject:
107
+ path = self._file_path(file)
108
+ body = MultipartBody(
109
+ fields={"purpose": purpose},
110
+ files={"file": MultipartFile(path=path, filename=file_name or os.path.basename(path))},
111
+ )
112
+ return self._request(
113
+ "post", self.PROTOCOL_ENDPOINT, body=body, options=options, response_class=FileObject
114
+ )
115
+
116
+ def list(
117
+ self,
118
+ *,
119
+ after: Optional[str] = None,
120
+ limit: Optional[int] = None,
121
+ order: Optional[str] = None,
122
+ purpose: Optional[str] = None,
123
+ options: Optional[RequestOptions] = None,
124
+ ) -> FileList:
125
+ query = urlencode(self._compact_params({
126
+ "after": after, "limit": limit, "order": order, "purpose": purpose
127
+ }))
128
+ path = self.PROTOCOL_ENDPOINT + (f"?{query}" if query else "")
129
+ return self._request("get", path, options=options, response_class=FileList)
130
+
131
+ def retrieve(self, file_id: str, options: Optional[RequestOptions] = None) -> FileObject:
132
+ return self._request(
133
+ "get", self._protocol_file_path(file_id), options=options, response_class=FileObject
134
+ )
135
+
136
+ def content(self, file_id: str, options: Optional[RequestOptions] = None) -> bytes:
137
+ return self._http.request_bytes(
138
+ "get", f"{self._protocol_file_path(file_id)}/content", options=options
139
+ )
140
+
141
+ def delete_file(self, file_id: str, options: Optional[RequestOptions] = None) -> DeletedFile:
142
+ return self._request(
143
+ "delete", self._protocol_file_path(file_id), options=options, response_class=DeletedFile
144
+ )
145
+
77
146
  def _upload_direct(self, file: Any, file_name: Optional[str], options: Optional[RequestOptions]) -> Any:
78
147
  """Local files upload straight to storage: ask for a pre-authorized target,
79
148
  PUT the bytes there (never through the API), then confirm. The caller still
@@ -139,3 +208,9 @@ class FilesClient(Resource):
139
208
  if name:
140
209
  return name
141
210
  raise ValueError("file must be a file path or a file-like object with a name")
211
+
212
+ @classmethod
213
+ def _protocol_file_path(cls, file_id: str) -> str:
214
+ if not file_id.strip():
215
+ raise ValueError("file_id is required")
216
+ return f"{cls.PROTOCOL_ENDPOINT}/{quote(file_id, safe='')}"
@@ -115,6 +115,37 @@ class HttpClient:
115
115
  if not response.is_success:
116
116
  raise error_from_response(response)
117
117
 
118
+ def request_bytes(
119
+ self,
120
+ method: str,
121
+ path: str,
122
+ options: Optional[RequestOptions] = None,
123
+ ) -> bytes:
124
+ """Return a successful response body without text decoding."""
125
+ max_retries = self._options.max_retries
126
+ if options is not None and options.max_retries is not None:
127
+ max_retries = options.max_retries
128
+ headers = {str(key): str(value) for key, value in (options.headers or {}).items()} if options else {}
129
+ timeout = options.timeout if (options and options.timeout is not None) else httpx.USE_CLIENT_DEFAULT
130
+ method = method.upper()
131
+ retries = 0
132
+
133
+ while True:
134
+ try:
135
+ response = self._client.request(method, path, headers=headers, timeout=timeout)
136
+ except httpx.TimeoutException as exc:
137
+ raise TimeoutError(str(exc))
138
+ except httpx.TransportError as exc:
139
+ raise NetworkError(str(exc))
140
+ if response.is_success:
141
+ return response.content
142
+ error = error_from_response(response)
143
+ if self._retryable(method, response.status_code) and retries < max_retries:
144
+ retries += 1
145
+ time.sleep(self._retry_delay(retries, error))
146
+ continue
147
+ raise error
148
+
118
149
  def close(self) -> None:
119
150
  self._client.close()
120
151
  self._upload_client.close()
@@ -10,6 +10,7 @@ from .files import FilesClient
10
10
  from .http_client import HttpClient
11
11
  from .options import ClientOptions
12
12
  from .pricing import Pricing
13
+ from .uploads import Uploads
13
14
 
14
15
 
15
16
  class ProviderClient:
@@ -22,3 +23,4 @@ class ProviderClient:
22
23
  self.files = FilesClient(http=self._http)
23
24
  self.account = Account(self._http)
24
25
  self.pricing = Pricing(self._http)
26
+ self.uploads = Uploads(self._http)
@@ -0,0 +1,91 @@
1
+ """Multipart Upload lifecycle client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Optional, Sequence
7
+ from urllib.parse import quote
8
+
9
+ from .files import FileObject, FilesClient
10
+ from .models import BaseModel, optional, required
11
+ from .multipart import MultipartBody, MultipartFile
12
+ from .options import RequestOptions
13
+ from .resource import Resource
14
+
15
+
16
+ class UploadObject(BaseModel):
17
+ id = required(str)
18
+ object = required(str)
19
+ bytes = required(int)
20
+ created_at = required(int)
21
+ filename = required(str)
22
+ purpose = required(str)
23
+ status = required(str)
24
+ expires_at = required(int)
25
+ file = optional(FileObject)
26
+
27
+
28
+ class UploadPart(BaseModel):
29
+ id = required(str)
30
+ object = required(str)
31
+ created_at = required(int)
32
+ upload_id = required(str)
33
+
34
+
35
+ class Uploads(Resource):
36
+ ENDPOINT = "/v1/uploads"
37
+ RESPONSE_CLASS = UploadObject
38
+
39
+ def create(
40
+ self,
41
+ *,
42
+ bytes: int,
43
+ filename: str,
44
+ mime_type: str,
45
+ purpose: str = "user_data",
46
+ options: Optional[RequestOptions] = None,
47
+ ) -> UploadObject:
48
+ return self._request("post", self.ENDPOINT, body={
49
+ "bytes": bytes,
50
+ "filename": filename,
51
+ "mime_type": mime_type,
52
+ "purpose": purpose,
53
+ }, options=options)
54
+
55
+ def add_part(
56
+ self,
57
+ upload_id: str,
58
+ data: Any,
59
+ file_name: Optional[str] = None,
60
+ options: Optional[RequestOptions] = None,
61
+ ) -> UploadPart:
62
+ path = FilesClient._file_path(data)
63
+ body = MultipartBody(files={
64
+ "data": MultipartFile(path=path, filename=file_name or os.path.basename(path))
65
+ })
66
+ return self._request(
67
+ "post", f"{self._upload_path(upload_id)}/parts", body=body,
68
+ options=options, response_class=UploadPart,
69
+ )
70
+
71
+ def complete(
72
+ self,
73
+ upload_id: str,
74
+ part_ids: Sequence[str],
75
+ options: Optional[RequestOptions] = None,
76
+ ) -> UploadObject:
77
+ return self._request(
78
+ "post", f"{self._upload_path(upload_id)}/complete",
79
+ body={"part_ids": list(part_ids)}, options=options,
80
+ )
81
+
82
+ def cancel(self, upload_id: str, options: Optional[RequestOptions] = None) -> UploadObject:
83
+ return self._request(
84
+ "post", f"{self._upload_path(upload_id)}/cancel", body={}, options=options
85
+ )
86
+
87
+ @classmethod
88
+ def _upload_path(cls, upload_id: str) -> str:
89
+ if not upload_id.strip():
90
+ raise ValueError("upload_id is required")
91
+ return f"{cls.ENDPOINT}/{quote(upload_id, safe='')}"
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -100,9 +100,9 @@ def test_continuation_errors_preserve_codes_and_status_classification():
100
100
  assert error.code == code
101
101
 
102
102
 
103
- def test_extracts_message_from_errors_array():
103
+ def test_does_not_extract_message_from_legacy_errors_array():
104
104
  error = errors.error_from_response(response(400, body='{"errors":["First error"]}'))
105
- assert error.message == "First error"
105
+ assert error.message == "Bad request"
106
106
 
107
107
 
108
108
  def test_handles_html_error_page():
@@ -144,10 +144,9 @@ def test_service_unavailable_defaults_to_503():
144
144
  assert errors.ServiceUnavailableError().status == 503
145
145
 
146
146
 
147
- def test_extracts_message_from_errors_array_of_dicts():
148
- # Regression: an errors-array whose elements are dicts must yield the dict's
149
- # message string, not the raw dict.
147
+ def test_resource_validation_uses_summary_and_preserves_field_errors():
150
148
  error = errors.error_from_response(
151
- response(400, body='{"errors":[{"field":"prompt","message":"too long"}]}')
149
+ response(422, body='{"error":"Validation failed","errors":{"prompt":["too long"]}}')
152
150
  )
153
- assert error.message == "too long"
151
+ assert error.message == "Validation failed"
152
+ assert error.details["errors"] == {"prompt": ["too long"]}
@@ -176,3 +176,45 @@ def test_create_rejects_blank_source():
176
176
  client.create(source="")
177
177
  with pytest.raises(ValueError):
178
178
  client.create(source=" ")
179
+
180
+
181
+ def test_protocol_file_lifecycle_keeps_binary_content(tmp_path):
182
+ path = tmp_path / "input.bin"
183
+ path.write_bytes(b"\x00\xff\x01")
184
+ responses = iter([
185
+ {"id": "file_123", "object": "file", "bytes": 3, "created_at": 1,
186
+ "filename": "input.bin", "purpose": "user_data"},
187
+ {"object": "list", "data": [], "has_more": False},
188
+ {"id": "file_123", "object": "file", "bytes": 3, "created_at": 1,
189
+ "filename": "input.bin", "purpose": "user_data"},
190
+ {"id": "file_123", "object": "file", "deleted": True},
191
+ ])
192
+
193
+ class ProtocolHttp:
194
+ def __init__(self):
195
+ self.calls = []
196
+
197
+ def request(self, method, request_path, body=None, options=None):
198
+ self.calls.append((method, request_path, body))
199
+ return next(responses)
200
+
201
+ def request_bytes(self, method, request_path, options=None):
202
+ self.calls.append((method, request_path, None))
203
+ return b"\x00\xff\x01"
204
+
205
+ fake = ProtocolHttp()
206
+ files = FilesClient(http=fake)
207
+
208
+ created = files.create_file(path)
209
+ listed = files.list(limit=1, order="asc")
210
+ retrieved = files.retrieve("file_123")
211
+ content = files.content("file_123")
212
+ deleted = files.delete_file("file_123")
213
+
214
+ assert created.id == retrieved.id == deleted.id == "file_123"
215
+ assert listed.has_more is False
216
+ assert fake.calls[0][1] == "/v1/files"
217
+ assert fake.calls[1][1] == "/v1/files?limit=1&order=asc"
218
+ assert fake.calls[3][1] == "/v1/files/file_123/content"
219
+ assert fake.calls[4][0] == "delete"
220
+ assert content == b"\x00\xff\x01"
@@ -0,0 +1,36 @@
1
+ from runapi.core.multipart import MultipartBody
2
+ from runapi.core.uploads import Uploads
3
+
4
+
5
+ class FakeHttp:
6
+ def __init__(self):
7
+ self.calls = []
8
+
9
+ def request(self, method, path, body=None, options=None):
10
+ self.calls.append((method, path, body))
11
+ if path.endswith("/parts"):
12
+ return {"id": "part_123", "object": "upload.part", "created_at": 1, "upload_id": "upload_123"}
13
+ return {
14
+ "id": "upload_123", "object": "upload", "bytes": 3, "created_at": 1,
15
+ "filename": "data.bin", "purpose": "user_data", "status": "pending", "expires_at": 2,
16
+ }
17
+
18
+
19
+ def test_upload_lifecycle(tmp_path):
20
+ part = tmp_path / "part.bin"
21
+ part.write_bytes(b"abc")
22
+ fake = FakeHttp()
23
+ uploads = Uploads(fake)
24
+
25
+ uploads.create(bytes=3, filename="data.bin", mime_type="application/octet-stream")
26
+ uploads.add_part("upload_123", part)
27
+ uploads.complete("upload_123", ["part_123"])
28
+ uploads.cancel("upload_123")
29
+
30
+ assert fake.calls[0][1:] == ("/v1/uploads", {
31
+ "bytes": 3, "filename": "data.bin", "mime_type": "application/octet-stream", "purpose": "user_data"
32
+ })
33
+ assert fake.calls[1][1] == "/v1/uploads/upload_123/parts"
34
+ assert isinstance(fake.calls[1][2], MultipartBody)
35
+ assert fake.calls[2][2] == {"part_ids": ["part_123"]}
36
+ assert fake.calls[3][1] == "/v1/uploads/upload_123/cancel"
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"