spicyapi 0.1.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.
- spicyapi/__init__.py +42 -0
- spicyapi/_client.py +1046 -0
- spicyapi/py.typed +0 -0
- spicyapi-0.1.0.dist-info/METADATA +159 -0
- spicyapi-0.1.0.dist-info/RECORD +7 -0
- spicyapi-0.1.0.dist-info/WHEEL +4 -0
- spicyapi-0.1.0.dist-info/licenses/LICENSE +21 -0
spicyapi/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Official SpicyAPI client for Python.
|
|
2
|
+
|
|
3
|
+
The public surface is re-exported here, so ``from spicyapi import SpicyClient``
|
|
4
|
+
is the only import most programs need.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._client import (
|
|
8
|
+
ACTIVE_STATES,
|
|
9
|
+
API_BASE_URL,
|
|
10
|
+
TERMINAL_STATES,
|
|
11
|
+
SpicyApiError,
|
|
12
|
+
SpicyClient,
|
|
13
|
+
SpicyTimeoutError,
|
|
14
|
+
SpicyUploadError,
|
|
15
|
+
SpicyWebhookError,
|
|
16
|
+
compute_webhook_signature,
|
|
17
|
+
is_terminal,
|
|
18
|
+
output_assets,
|
|
19
|
+
output_text,
|
|
20
|
+
verify_webhook,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ACTIVE_STATES",
|
|
25
|
+
"API_BASE_URL",
|
|
26
|
+
"TERMINAL_STATES",
|
|
27
|
+
"SpicyApiError",
|
|
28
|
+
"SpicyClient",
|
|
29
|
+
"SpicyTimeoutError",
|
|
30
|
+
"compute_webhook_signature",
|
|
31
|
+
"SpicyUploadError",
|
|
32
|
+
"SpicyWebhookError",
|
|
33
|
+
"is_terminal",
|
|
34
|
+
"output_assets",
|
|
35
|
+
"output_text",
|
|
36
|
+
"verify_webhook",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# 版本号的唯一出处是 _client.py,这里只是把它再导出一次,pyproject 也从那里读。
|
|
40
|
+
# 写两处必然漂移,而漂移的表现是 PyPI 上的版本与 __version__ 对不上——排查时
|
|
41
|
+
# 最误导人的那种。
|
|
42
|
+
from ._client import __version__ as __version__
|
spicyapi/_client.py
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
"""Official SpicyAPI client for Python 3.11+.
|
|
2
|
+
|
|
3
|
+
SpicyAPI serves image and video generation models behind one API. Text models are
|
|
4
|
+
not covered on purpose: they speak the OpenAI, Anthropic and Gemini wire formats,
|
|
5
|
+
so the official libraries for those already work against this service.
|
|
6
|
+
|
|
7
|
+
Depends on the standard library only. The ten-minute polling deadline is a local
|
|
8
|
+
safety bound, not a service guarantee.
|
|
9
|
+
|
|
10
|
+
It covers the whole media workflow: discover a model, upload a reference file,
|
|
11
|
+
confirm the price, create the task, wait for a terminal state, read the result,
|
|
12
|
+
refresh an expired output link, and destroy the stored content afterwards.
|
|
13
|
+
Webhook signature verification is a module-level function so a web handler can
|
|
14
|
+
use it without constructing a client.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
API_BASE_URL = "https://api.spicyapi.ai/api/v1"
|
|
33
|
+
REQUEST_TIMEOUT_SECONDS = 30.0
|
|
34
|
+
# Sending bytes is not an API call. 90 MiB in 30 seconds would demand a
|
|
35
|
+
# sustained 25 Mbit/s uplink, and the failure reads as a network fault when it
|
|
36
|
+
# is really this client hanging up on itself.
|
|
37
|
+
UPLOAD_TIMEOUT_SECONDS = 10.0 * 60.0
|
|
38
|
+
WAIT_TIMEOUT_SECONDS = 10.0 * 60.0
|
|
39
|
+
TERMINAL_STATES = frozenset({"succeeded", "failed", "canceled", "expired"})
|
|
40
|
+
ACTIVE_STATES = frozenset({"queued", "running"})
|
|
41
|
+
# 版本号的唯一出处。放在这里而不是 __init__.py,是因为 __init__ 要 import 本模块,
|
|
42
|
+
# 反过来读它就成了循环导入;而测试把本文件当成一个无父包的独立模块加载,相对导入
|
|
43
|
+
# 在那里必然失败。__init__.py 与 pyproject 都从这里读。
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
# 响应体的本地上限。见 _read_capped 的说明:这个数不是契约规定的。
|
|
47
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
48
|
+
_READ_CHUNK = 64 * 1024
|
|
49
|
+
|
|
50
|
+
RETRYABLE_HTTP = frozenset({408, 429, 500, 502, 503, 504})
|
|
51
|
+
RETRYABLE_CODES = frozenset({429, 500, 50301})
|
|
52
|
+
# 50302 挂在 503 上,只看 HTTP 状态就会把它当成一次普通的上游不可用重发。契约说
|
|
53
|
+
# 这把幂等键已经把失败记下来了,沿用它只会把那次失败原样重放回来——重试四轮、
|
|
54
|
+
# 四轮都注定失败,而最后那句错误看着像「重试过了还是不行」,把真正的处置点
|
|
55
|
+
#(换一把新的幂等键再发)盖掉了。这个集合先于状态码判定生效。
|
|
56
|
+
NON_RETRYABLE_CODES = frozenset({50302})
|
|
57
|
+
|
|
58
|
+
IMAGE_CONTENT_TYPES = frozenset({"image/jpeg", "image/png", "image/webp", "image/gif"})
|
|
59
|
+
AUDIO_VIDEO_CONTENT_TYPES = frozenset({"video/mp4", "video/webm", "audio/mpeg", "audio/wav"})
|
|
60
|
+
UPLOAD_CONTENT_TYPES = IMAGE_CONTENT_TYPES | AUDIO_VIDEO_CONTENT_TYPES
|
|
61
|
+
MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024
|
|
62
|
+
MAX_AUDIO_VIDEO_UPLOAD_BYTES = 90 * 1024 * 1024
|
|
63
|
+
CONTENT_TYPE_BY_SUFFIX = {
|
|
64
|
+
".gif": "image/gif",
|
|
65
|
+
".jpeg": "image/jpeg",
|
|
66
|
+
".jpg": "image/jpeg",
|
|
67
|
+
".mp3": "audio/mpeg",
|
|
68
|
+
".mp4": "video/mp4",
|
|
69
|
+
".png": "image/png",
|
|
70
|
+
".wav": "audio/wav",
|
|
71
|
+
".webm": "video/webm",
|
|
72
|
+
".webp": "image/webp",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
WEBHOOK_TOLERANCE_SECONDS = 300
|
|
76
|
+
WEBHOOK_MAX_BODY_BYTES = 1024 * 1024
|
|
77
|
+
|
|
78
|
+
# What to do about a business code, straight from the published contract. These
|
|
79
|
+
# are the cases where blind retrying is either useless or wrong.
|
|
80
|
+
RECOVERY_BY_CODE: Mapping[int, str] = {
|
|
81
|
+
40003: (
|
|
82
|
+
"the stored bytes do not match the upload ticket (size, media type or signature): "
|
|
83
|
+
"request a new ticket with the exact contentType and byte count, PUT the file again, "
|
|
84
|
+
"then commit. Retrying the commit alone cannot change the stored object"
|
|
85
|
+
),
|
|
86
|
+
40004: (
|
|
87
|
+
"the request is valid but no deployment serves this parameter combination: change the "
|
|
88
|
+
"parameter named in the message, or pick another model. The identical request fails again"
|
|
89
|
+
),
|
|
90
|
+
40901: (
|
|
91
|
+
"the quote expired or the price changed before the funds were reserved: quote again and "
|
|
92
|
+
"resend with the new quoteId and expectedCost. Keep the original Idempotency-Key so an "
|
|
93
|
+
"already accepted task is recovered instead of created twice"
|
|
94
|
+
),
|
|
95
|
+
50301: (
|
|
96
|
+
"the model has no usable deployment or effective price right now: retry later with "
|
|
97
|
+
"backoff, or choose another model"
|
|
98
|
+
),
|
|
99
|
+
50302: (
|
|
100
|
+
"a synchronous generation failed upstream and the charge was refunded: the request can be "
|
|
101
|
+
"sent again, with a new Idempotency-Key so it is treated as a fresh submission"
|
|
102
|
+
),
|
|
103
|
+
503: (
|
|
104
|
+
"a dependency is temporarily unavailable: wait for retry_after_seconds when the server "
|
|
105
|
+
"sent one, then retry"
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
TransportResult = tuple[int, Mapping[str, str], bytes]
|
|
110
|
+
Transport = Callable[[str, str, Mapping[str, str], bytes | None, float], TransportResult]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SpicyApiError(RuntimeError):
|
|
114
|
+
"""HTTP, envelope, or network failure with support correlation fields."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
message: str,
|
|
119
|
+
*,
|
|
120
|
+
status: int = 0,
|
|
121
|
+
code: int | None = None,
|
|
122
|
+
request_id: str = "",
|
|
123
|
+
retry_after_seconds: float | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__(message)
|
|
126
|
+
self.status = status
|
|
127
|
+
self.code = code
|
|
128
|
+
self.request_id = request_id
|
|
129
|
+
self.retry_after_seconds = retry_after_seconds
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def recovery(self) -> str:
|
|
133
|
+
"""Contract-defined recovery step, or an empty string when there is none.
|
|
134
|
+
|
|
135
|
+
Branch on ``code``, never on the message: the message is prose and is
|
|
136
|
+
translated according to the account's API error language.
|
|
137
|
+
"""
|
|
138
|
+
return RECOVERY_BY_CODE.get(self.code, "") if self.code is not None else ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SpicyUploadError(SpicyApiError):
|
|
142
|
+
"""The presigned PUT to object storage failed.
|
|
143
|
+
|
|
144
|
+
Storage answers with its own status and an XML body rather than the
|
|
145
|
+
SpicyAPI envelope, so there is no business ``code`` to branch on. A 403
|
|
146
|
+
here usually means a ticket header was altered or dropped: both
|
|
147
|
+
``Content-Type`` and ``Content-Length`` are part of the signature.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SpicyTimeoutError(TimeoutError):
|
|
152
|
+
"""A local request or polling deadline elapsed; remote state is unknown."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, message: str, *, task_id: str | None = None) -> None:
|
|
155
|
+
super().__init__(message)
|
|
156
|
+
self.task_id = task_id
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SpicyWebhookError(ValueError):
|
|
160
|
+
"""A webhook delivery failed verification and must not be acted on."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, reason: str, message: str) -> None:
|
|
163
|
+
super().__init__(message)
|
|
164
|
+
self.reason = reason
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_capped(stream: Any, limit: int = MAX_RESPONSE_BYTES) -> bytes:
|
|
168
|
+
"""至多读 limit 字节,超了就报错,而不是一直读下去。
|
|
169
|
+
|
|
170
|
+
`read()` 读到的是对端想给多少就给多少。一个坏掉的中间层、或者一条被劫持的连接,
|
|
171
|
+
可以一直吐字节直到进程被 OOM 杀掉——而这中间没有任何一步会报错。
|
|
172
|
+
|
|
173
|
+
上限**不是契约规定的**,是我们自己取的工程值:2026-09-20 实测公开目录 121 个端点
|
|
174
|
+
共 273 KiB,带 inputSchema 的内部目录估算约 1.33 MiB,8 MiB 留着几倍的增长余量。
|
|
175
|
+
取 8 是为了和 Go 那份一致(TypeScript 那份目前是 4 MiB)。
|
|
176
|
+
"""
|
|
177
|
+
chunks: list[bytes] = []
|
|
178
|
+
read = 0
|
|
179
|
+
while True:
|
|
180
|
+
chunk = stream.read(_READ_CHUNK)
|
|
181
|
+
if not chunk:
|
|
182
|
+
break
|
|
183
|
+
read += len(chunk)
|
|
184
|
+
if read > limit:
|
|
185
|
+
raise SpicyApiError(
|
|
186
|
+
f"response body exceeded the local {limit}-byte ceiling; "
|
|
187
|
+
"refusing to buffer an unbounded response"
|
|
188
|
+
)
|
|
189
|
+
chunks.append(chunk)
|
|
190
|
+
return b"".join(chunks)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _urllib_transport(
|
|
194
|
+
method: str,
|
|
195
|
+
url: str,
|
|
196
|
+
headers: Mapping[str, str],
|
|
197
|
+
body: bytes | None,
|
|
198
|
+
timeout: float,
|
|
199
|
+
) -> TransportResult:
|
|
200
|
+
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
|
201
|
+
try:
|
|
202
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
203
|
+
return response.status, dict(response.headers.items()), _read_capped(response)
|
|
204
|
+
except urllib.error.HTTPError as error:
|
|
205
|
+
return error.code, dict(error.headers.items()), _read_capped(error)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_terminal(task: Mapping[str, Any]) -> bool:
|
|
209
|
+
"""这条任务是否已经到终态,不会再变了。
|
|
210
|
+
|
|
211
|
+
带 wait_seconds 建任务时必须用它分支:拿到的**可能**是终态记录,也可能是
|
|
212
|
+
等待预算用尽后的受理响应。两者形状一样,只有 state 不同,所以「我传了
|
|
213
|
+
wait 所以它一定完成了」这个假设会静默地拿着一条还在跑的任务往下走。
|
|
214
|
+
"""
|
|
215
|
+
return task.get("state") in TERMINAL_STATES
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
219
|
+
"""大小写无关地取一个响应头。
|
|
220
|
+
|
|
221
|
+
HTTP 头名不分大小写,而 urllib 把服务端发来的大小写原样保留。按字面取的话,
|
|
222
|
+
服务端发 `retry-after` 就取不到——退避会悄悄改用本地的指数退避,不再听服务端
|
|
223
|
+
给的那个窗口。它不报错,只是不听话。
|
|
224
|
+
"""
|
|
225
|
+
lowered = name.lower()
|
|
226
|
+
for key, value in headers.items():
|
|
227
|
+
if key.lower() == lowered:
|
|
228
|
+
return value
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _normalise_base_url(value: str, label: str = "base_url") -> str:
|
|
233
|
+
"""校验并规范化 base URL。
|
|
234
|
+
|
|
235
|
+
不校验的代价是把 Bearer 密钥明文发出去:传一个 http:// 的地址,密钥就躺在
|
|
236
|
+
网络上,而调用方那边什么异常都看不到。TypeScript / Go / PHP / Java 四份都
|
|
237
|
+
校验,只有这里没有。回环地址上的 http 放行,本地联调要用。
|
|
238
|
+
"""
|
|
239
|
+
parsed = urllib.parse.urlparse(value)
|
|
240
|
+
if not parsed.scheme or not parsed.netloc:
|
|
241
|
+
raise ValueError(f"{label} must be an absolute URL")
|
|
242
|
+
host = parsed.hostname or ""
|
|
243
|
+
loopback = host in {"localhost", "::1"} or host == "127.0.0.1" or host.startswith("127.")
|
|
244
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"{label} must use HTTPS; HTTP is allowed only for loopback development"
|
|
247
|
+
)
|
|
248
|
+
if parsed.username or parsed.password:
|
|
249
|
+
raise ValueError(f"{label} must not contain credentials")
|
|
250
|
+
if parsed.query or parsed.fragment:
|
|
251
|
+
raise ValueError(f"{label} must not contain a query or fragment")
|
|
252
|
+
return value.rstrip("/")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _user_agent() -> str:
|
|
256
|
+
"""`spicyapi-python/<version>`,版本号只有一个来源。
|
|
257
|
+
|
|
258
|
+
不发这个头的话 urllib 会发它自己的默认值 `Python-urllib/3.x`。那不只是少了
|
|
259
|
+
版本遥测:本仓 scripts/check_contract.py 里就记着,文档站的边缘防护会把 urllib
|
|
260
|
+
的默认 UA 拒成 403。同一条规则哪天挂到 api 站上,所有 Python 用户一起 403,
|
|
261
|
+
而排查的人不会想到是 UA。
|
|
262
|
+
"""
|
|
263
|
+
return f"spicyapi-python/{__version__}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
267
|
+
"""Seconds from a Retry-After header, or None when it is absent or unusable."""
|
|
268
|
+
if value is None:
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
return max(0.0, float(value))
|
|
272
|
+
except ValueError:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def output_text(task: Mapping[str, Any]) -> str | None:
|
|
277
|
+
"""The text answer of a finished task, or None when it produced files.
|
|
278
|
+
|
|
279
|
+
Some endpoints answer in ``output.text`` and carry no ``assets`` key at
|
|
280
|
+
all, so reading ``task["output"]["assets"]`` directly raises KeyError on a
|
|
281
|
+
perfectly successful task.
|
|
282
|
+
"""
|
|
283
|
+
output = task.get("output")
|
|
284
|
+
if not isinstance(output, Mapping):
|
|
285
|
+
return None
|
|
286
|
+
text = output.get("text")
|
|
287
|
+
return text if isinstance(text, str) else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def output_assets(task: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
291
|
+
"""Generated files of a finished task, or an empty list.
|
|
292
|
+
|
|
293
|
+
Returns [] both for a text answer and for a failed task, so callers never
|
|
294
|
+
have to distinguish "no assets key" from "empty assets list".
|
|
295
|
+
"""
|
|
296
|
+
output = task.get("output")
|
|
297
|
+
if not isinstance(output, Mapping):
|
|
298
|
+
return []
|
|
299
|
+
assets = output.get("assets")
|
|
300
|
+
if not isinstance(assets, list):
|
|
301
|
+
return []
|
|
302
|
+
return [dict(asset) for asset in assets if isinstance(asset, Mapping)]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def content_type_for_path(path: str) -> str:
|
|
306
|
+
"""Upload content type inferred from a file name extension."""
|
|
307
|
+
suffix = os.path.splitext(path)[1].lower()
|
|
308
|
+
content_type = CONTENT_TYPE_BY_SUFFIX.get(suffix)
|
|
309
|
+
if content_type is None:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"cannot infer an upload content type from {path!r}; "
|
|
312
|
+
f"pass content_type explicitly, one of {sorted(UPLOAD_CONTENT_TYPES)}"
|
|
313
|
+
)
|
|
314
|
+
return content_type
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def max_upload_bytes(content_type: str) -> int:
|
|
318
|
+
"""Platform ceiling for this media type. A model's schema may allow less."""
|
|
319
|
+
return (
|
|
320
|
+
MAX_IMAGE_UPLOAD_BYTES
|
|
321
|
+
if content_type in IMAGE_CONTENT_TYPES
|
|
322
|
+
else MAX_AUDIO_VIDEO_UPLOAD_BYTES
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def compute_webhook_signature(
|
|
327
|
+
task_id: str,
|
|
328
|
+
timestamp: str | int,
|
|
329
|
+
raw_body: bytes,
|
|
330
|
+
secret: str,
|
|
331
|
+
) -> str:
|
|
332
|
+
"""Base64 HMAC-SHA256 over ``taskId.timestamp.hex(sha256(raw_body))``.
|
|
333
|
+
|
|
334
|
+
``raw_body`` must be the exact bytes received. Re-serializing the parsed
|
|
335
|
+
JSON changes key order and whitespace, and the signature no longer matches.
|
|
336
|
+
"""
|
|
337
|
+
digest = hashlib.sha256(raw_body).hexdigest()
|
|
338
|
+
message = f"{task_id}.{timestamp}.{digest}".encode()
|
|
339
|
+
mac = hmac.new(secret.encode(), message, hashlib.sha256)
|
|
340
|
+
return base64.b64encode(mac.digest()).decode()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def verify_webhook(
|
|
344
|
+
*,
|
|
345
|
+
raw_body: bytes,
|
|
346
|
+
timestamp: str,
|
|
347
|
+
signature: str,
|
|
348
|
+
payload_version: int | str,
|
|
349
|
+
secret: str,
|
|
350
|
+
tolerance_seconds: int = WEBHOOK_TOLERANCE_SECONDS,
|
|
351
|
+
now: Callable[[], float] = time.time,
|
|
352
|
+
max_body_bytes: int = WEBHOOK_MAX_BODY_BYTES,
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
"""Verify one ``callBackUrl`` delivery and return its parsed payload.
|
|
355
|
+
|
|
356
|
+
This verifies the signature scheme used by the per-task ``callBackUrl``:
|
|
357
|
+
``base64(HMAC-SHA256(secret, "taskId.timestamp.hex(sha256(body))"))``, carried
|
|
358
|
+
in ``X-Webhook-Signature``. Naming it precisely matters because a second,
|
|
359
|
+
account-level delivery scheme is planned; when that ships it gets its own
|
|
360
|
+
verifier rather than extra arguments here, and code written today keeps working.
|
|
361
|
+
|
|
362
|
+
``timestamp``, ``signature`` and ``payload_version`` are the
|
|
363
|
+
``X-Webhook-Timestamp``, ``X-Webhook-Signature`` and
|
|
364
|
+
``X-Webhook-Payload-Version`` headers. Raises SpicyWebhookError with a
|
|
365
|
+
machine-readable ``reason`` when the delivery must be rejected.
|
|
366
|
+
|
|
367
|
+
Deliveries are retried, so a receiver must be idempotent: for payload
|
|
368
|
+
version 2 the ``request_id`` field is the stable delivery identifier.
|
|
369
|
+
"""
|
|
370
|
+
if len(raw_body) > max_body_bytes:
|
|
371
|
+
raise SpicyWebhookError("body_too_large", f"webhook body exceeds {max_body_bytes} bytes")
|
|
372
|
+
if not secret.strip():
|
|
373
|
+
raise SpicyWebhookError("invalid_secret", "webhook signing secret is required")
|
|
374
|
+
version = str(payload_version)
|
|
375
|
+
if version not in {"1", "2"}:
|
|
376
|
+
raise SpicyWebhookError("invalid_version", "webhook payload version must be 1 or 2")
|
|
377
|
+
if not timestamp.isdigit():
|
|
378
|
+
raise SpicyWebhookError("invalid_timestamp", "webhook timestamp must be Unix seconds")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
payload = json.loads(raw_body)
|
|
382
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
383
|
+
raise SpicyWebhookError("invalid_json", "webhook body is not valid JSON") from error
|
|
384
|
+
if not isinstance(payload, dict):
|
|
385
|
+
raise SpicyWebhookError("invalid_payload", "webhook body is not a JSON object")
|
|
386
|
+
|
|
387
|
+
# The task ID is part of the signed string, and the two payload versions
|
|
388
|
+
# keep it in different places.
|
|
389
|
+
if version == "1":
|
|
390
|
+
task_id = payload.get("task_id")
|
|
391
|
+
else:
|
|
392
|
+
data = payload.get("data")
|
|
393
|
+
task_id = data.get("taskId") if isinstance(data, dict) else None
|
|
394
|
+
if not isinstance(task_id, str) or not task_id:
|
|
395
|
+
raise SpicyWebhookError(
|
|
396
|
+
"invalid_payload",
|
|
397
|
+
f"webhook payload version {version} does not contain a task ID",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
expected = compute_webhook_signature(task_id, timestamp, raw_body, secret)
|
|
401
|
+
# Constant time: a byte-by-byte comparison leaks how much of a forged
|
|
402
|
+
# signature was correct, which is enough to construct a valid one.
|
|
403
|
+
if not hmac.compare_digest(expected.encode(), signature.encode()):
|
|
404
|
+
raise SpicyWebhookError("invalid_signature", "webhook signature does not match")
|
|
405
|
+
|
|
406
|
+
# Freshness is checked only after the signature: an unauthenticated body
|
|
407
|
+
# should never decide anything, including whether it is too old.
|
|
408
|
+
seconds = int(timestamp)
|
|
409
|
+
if abs(int(now()) - seconds) > tolerance_seconds:
|
|
410
|
+
raise SpicyWebhookError(
|
|
411
|
+
"stale_timestamp",
|
|
412
|
+
f"webhook timestamp is more than {tolerance_seconds}s from now",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
delivery_id = payload.get("request_id") if version == "2" else None
|
|
416
|
+
return {
|
|
417
|
+
"payload": payload,
|
|
418
|
+
"payload_version": int(version),
|
|
419
|
+
"task_id": task_id,
|
|
420
|
+
"delivery_id": delivery_id if isinstance(delivery_id, str) else None,
|
|
421
|
+
"timestamp": seconds,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class SpicyClient:
|
|
426
|
+
def __init__(
|
|
427
|
+
self,
|
|
428
|
+
api_key: str | None = None,
|
|
429
|
+
*,
|
|
430
|
+
base_url: str = API_BASE_URL,
|
|
431
|
+
transport: Transport = _urllib_transport,
|
|
432
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
433
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
434
|
+
random_value: Callable[[], float] = random.random,
|
|
435
|
+
request_timeout_seconds: float = REQUEST_TIMEOUT_SECONDS,
|
|
436
|
+
upload_timeout_seconds: float = UPLOAD_TIMEOUT_SECONDS,
|
|
437
|
+
wait_timeout_seconds: float = WAIT_TIMEOUT_SECONDS,
|
|
438
|
+
max_retries: int = 3,
|
|
439
|
+
) -> None:
|
|
440
|
+
self.api_key = api_key or os.environ.get("SPICY_API_KEY", "")
|
|
441
|
+
if not self.api_key:
|
|
442
|
+
raise ValueError("SPICY_API_KEY is required")
|
|
443
|
+
self.base_url = _normalise_base_url(base_url)
|
|
444
|
+
self.transport = transport
|
|
445
|
+
self.sleep = sleep
|
|
446
|
+
self.monotonic = monotonic
|
|
447
|
+
self.random_value = random_value
|
|
448
|
+
self.request_timeout_seconds = request_timeout_seconds
|
|
449
|
+
self.upload_timeout_seconds = upload_timeout_seconds
|
|
450
|
+
self.wait_timeout_seconds = wait_timeout_seconds
|
|
451
|
+
self.max_retries = max_retries
|
|
452
|
+
|
|
453
|
+
# ── Models ────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
def list_models(
|
|
456
|
+
self,
|
|
457
|
+
*,
|
|
458
|
+
modality: str | None = None,
|
|
459
|
+
provider: str | None = None,
|
|
460
|
+
task: str | None = None,
|
|
461
|
+
search: str | None = None,
|
|
462
|
+
include_schema: bool | None = None,
|
|
463
|
+
include_examples: bool | None = None,
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
values: dict[str, str] = {}
|
|
466
|
+
for key, value in {
|
|
467
|
+
"modality": modality,
|
|
468
|
+
"provider": provider,
|
|
469
|
+
"task": task,
|
|
470
|
+
"search": search,
|
|
471
|
+
"includeSchema": None if include_schema is None else str(int(include_schema)),
|
|
472
|
+
"includeExamples": None if include_examples is None else str(int(include_examples)),
|
|
473
|
+
}.items():
|
|
474
|
+
if value is not None:
|
|
475
|
+
values[key] = value
|
|
476
|
+
suffix = f"?{urllib.parse.urlencode(values)}" if values else ""
|
|
477
|
+
return self._request("GET", f"/models{suffix}")
|
|
478
|
+
|
|
479
|
+
def get_model(self, model: str) -> dict[str, Any]:
|
|
480
|
+
if not model:
|
|
481
|
+
raise ValueError("model is required")
|
|
482
|
+
encoded = urllib.parse.quote(model, safe="")
|
|
483
|
+
return self._request("GET", f"/models/{encoded}")
|
|
484
|
+
|
|
485
|
+
# ── Tasks ─────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
def quote_task(
|
|
488
|
+
self,
|
|
489
|
+
*,
|
|
490
|
+
model: str,
|
|
491
|
+
input_data: Mapping[str, Any],
|
|
492
|
+
callback_url: str | None = None,
|
|
493
|
+
) -> dict[str, Any]:
|
|
494
|
+
"""Price a task without creating it or reserving any funds.
|
|
495
|
+
|
|
496
|
+
Returns quoteId, estimatedCost, maxCharge and expiresAt. The quote is
|
|
497
|
+
bound to this account, API key and request and lives five minutes; pass
|
|
498
|
+
its quoteId together with estimatedCost as expectedCost to create_task
|
|
499
|
+
to be rejected rather than charged if the price moved in between.
|
|
500
|
+
|
|
501
|
+
A quote reserves nothing, so it never guarantees later availability.
|
|
502
|
+
"""
|
|
503
|
+
body: dict[str, Any] = {"model": model, "input": dict(input_data)}
|
|
504
|
+
if callback_url is not None:
|
|
505
|
+
body["callBackUrl"] = callback_url
|
|
506
|
+
return self._request("POST", "/jobs/quote", body=body)
|
|
507
|
+
|
|
508
|
+
def create_task(
|
|
509
|
+
self,
|
|
510
|
+
*,
|
|
511
|
+
model: str,
|
|
512
|
+
input_data: Mapping[str, Any],
|
|
513
|
+
idempotency_key: str,
|
|
514
|
+
callback_url: str | None = None,
|
|
515
|
+
quote_id: str | None = None,
|
|
516
|
+
expected_cost: str | None = None,
|
|
517
|
+
wait_seconds: int | None = None,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
"""Submit one asynchronous generation task.
|
|
520
|
+
|
|
521
|
+
HTTP 202 means accepted, not finished. The estimated cost is held, not
|
|
522
|
+
charged; the final charge is capped at the hold.
|
|
523
|
+
|
|
524
|
+
Keep one Idempotency-Key per logical submission and reuse it for every
|
|
525
|
+
resend of that submission, including after a timeout or a dropped
|
|
526
|
+
connection. A lost response does not prove the task was not created,
|
|
527
|
+
and a fresh key turns an unknown outcome into a second paid task. The
|
|
528
|
+
same key with different request semantics returns 409 instead.
|
|
529
|
+
|
|
530
|
+
Pass quote_id and expected_cost from quote_task to make a price change
|
|
531
|
+
fail with business code 40901 before any funds are reserved.
|
|
532
|
+
|
|
533
|
+
There is deliberately no content-mode flag. What a model will produce is
|
|
534
|
+
decided by the model you pick, not by a per-request declaration, and the
|
|
535
|
+
request schema has no such field. Older clients may still send one; the
|
|
536
|
+
service accepts and ignores it.
|
|
537
|
+
|
|
538
|
+
wait_seconds holds the connection open for up to that many seconds so the
|
|
539
|
+
first poll is unnecessary. **It does not guarantee a finished task**: if
|
|
540
|
+
the budget runs out you get the ordinary accepted response and poll from
|
|
541
|
+
there, exactly as if you had not asked. Branch on the state, never on the
|
|
542
|
+
fact that you passed the parameter::
|
|
543
|
+
|
|
544
|
+
task = client.create_task(..., wait_seconds=30)
|
|
545
|
+
if not is_terminal(task):
|
|
546
|
+
task = client.wait_for_terminal(task["taskId"])
|
|
547
|
+
|
|
548
|
+
The server clamps anything above 60 and ignores anything that is not a
|
|
549
|
+
positive integer, so nothing is clamped here; only a negative value is
|
|
550
|
+
refused, since that is a caller mistake rather than a platform limit that
|
|
551
|
+
could be relaxed later. Disconnecting stops the wait and nothing else —
|
|
552
|
+
the task keeps running and is billed as usual.
|
|
553
|
+
"""
|
|
554
|
+
if not idempotency_key.strip():
|
|
555
|
+
raise ValueError("Idempotency-Key is required")
|
|
556
|
+
body: dict[str, Any] = {"model": model, "input": dict(input_data)}
|
|
557
|
+
if callback_url is not None:
|
|
558
|
+
body["callBackUrl"] = callback_url
|
|
559
|
+
if quote_id is not None:
|
|
560
|
+
body["quoteId"] = quote_id
|
|
561
|
+
if expected_cost is not None:
|
|
562
|
+
body["expectedCost"] = expected_cost
|
|
563
|
+
path = "/jobs/createTask"
|
|
564
|
+
timeout_seconds = None
|
|
565
|
+
if wait_seconds is not None:
|
|
566
|
+
if wait_seconds < 0:
|
|
567
|
+
raise ValueError("wait_seconds must not be negative")
|
|
568
|
+
path = f"{path}?{urllib.parse.urlencode({'wait': wait_seconds})}"
|
|
569
|
+
# 本地请求超时必须容得下服务端那段等待,否则这一发会在服务端还在等的时候
|
|
570
|
+
# 就从本地断掉——功能看起来「不生效」,而真正的原因是两个超时撞在一起。
|
|
571
|
+
# 留 10 秒余量给建任务本身和网络往返。
|
|
572
|
+
timeout_seconds = max(self.request_timeout_seconds, wait_seconds + 10)
|
|
573
|
+
return self._request(
|
|
574
|
+
"POST",
|
|
575
|
+
path,
|
|
576
|
+
body=body,
|
|
577
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
578
|
+
timeout_seconds=timeout_seconds,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def get_task(
|
|
582
|
+
self,
|
|
583
|
+
task_id: str,
|
|
584
|
+
*,
|
|
585
|
+
timeout_seconds: float | None = None,
|
|
586
|
+
) -> dict[str, Any]:
|
|
587
|
+
if not task_id:
|
|
588
|
+
raise ValueError("task_id is required")
|
|
589
|
+
query = urllib.parse.urlencode({"taskId": task_id})
|
|
590
|
+
return self._request(
|
|
591
|
+
"GET",
|
|
592
|
+
f"/jobs/recordInfo?{query}",
|
|
593
|
+
timeout_seconds=timeout_seconds,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
def list_tasks(
|
|
597
|
+
self,
|
|
598
|
+
*,
|
|
599
|
+
from_date: str | None = None,
|
|
600
|
+
to_date: str | None = None,
|
|
601
|
+
state: str | None = None,
|
|
602
|
+
model: str | None = None,
|
|
603
|
+
limit: int | None = None,
|
|
604
|
+
cursor: str | None = None,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""List this API key's tasks, newest first; metadata only.
|
|
607
|
+
|
|
608
|
+
Dates are a UTC half-open interval [from, to) of at most 92 days.
|
|
609
|
+
Scope is this API key: other keys on the account and console
|
|
610
|
+
generations are not included.
|
|
611
|
+
"""
|
|
612
|
+
# 只拒非正数——那是明确的调用方错误。**上限不在本地判**:平台哪天把它
|
|
613
|
+
# 从 100 放宽到 200,这里会替用户拒绝一个已经能用的值,而这种失效没有
|
|
614
|
+
# 任何信号——用户只看到 SDK 说不行,去查服务端却发现明明可以。同样的
|
|
615
|
+
# 理由适用于 X-Spicy-Retention 的上限,两处口径一致。
|
|
616
|
+
if limit is not None and limit < 1:
|
|
617
|
+
raise ValueError("limit must be a positive integer")
|
|
618
|
+
values: dict[str, str] = {}
|
|
619
|
+
for key, value in {
|
|
620
|
+
"from": from_date,
|
|
621
|
+
"to": to_date,
|
|
622
|
+
"state": state,
|
|
623
|
+
"model": model,
|
|
624
|
+
"limit": None if limit is None else str(limit),
|
|
625
|
+
"cursor": cursor,
|
|
626
|
+
}.items():
|
|
627
|
+
if value is None:
|
|
628
|
+
continue
|
|
629
|
+
if value == "":
|
|
630
|
+
# 空串不是「没设」:调用方算出了一个筛选条件,而结果是空的。
|
|
631
|
+
#
|
|
632
|
+
# 静默丢掉它,他会拿到**没筛过**的整张列表,而没有任何东西告诉他
|
|
633
|
+
# 筛选没生效;原样发出去则撞一个服务端的 400,而那句话里不会说是
|
|
634
|
+
# 哪个参数。两条路都不好,所以在这里点名拒掉。
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"{key} must not be an empty string; omit it to leave that filter off"
|
|
637
|
+
)
|
|
638
|
+
values[key] = value
|
|
639
|
+
suffix = f"?{urllib.parse.urlencode(values)}" if values else ""
|
|
640
|
+
return self._request("GET", f"/jobs{suffix}")
|
|
641
|
+
|
|
642
|
+
def iter_tasks(
|
|
643
|
+
self,
|
|
644
|
+
*,
|
|
645
|
+
from_date: str | None = None,
|
|
646
|
+
to_date: str | None = None,
|
|
647
|
+
state: str | None = None,
|
|
648
|
+
model: str | None = None,
|
|
649
|
+
limit: int | None = None,
|
|
650
|
+
) -> Iterator[dict[str, Any]]:
|
|
651
|
+
"""Walk every page of list_tasks, yielding one task at a time.
|
|
652
|
+
|
|
653
|
+
Every filter is repeated unchanged on each page, as the contract
|
|
654
|
+
requires; changing one mid-walk makes the cursor meaningless. Pages
|
|
655
|
+
read live state rather than a frozen snapshot, so a task may move
|
|
656
|
+
between states while you paginate.
|
|
657
|
+
"""
|
|
658
|
+
cursor: str | None = None
|
|
659
|
+
while True:
|
|
660
|
+
page = self.list_tasks(
|
|
661
|
+
from_date=from_date,
|
|
662
|
+
to_date=to_date,
|
|
663
|
+
state=state,
|
|
664
|
+
model=model,
|
|
665
|
+
limit=limit,
|
|
666
|
+
cursor=cursor,
|
|
667
|
+
)
|
|
668
|
+
yield from page.get("items") or []
|
|
669
|
+
cursor = page.get("nextCursor")
|
|
670
|
+
if not page.get("hasMore") or not cursor:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
def retry_task(self, task_id: str, idempotency_key: str) -> dict[str, Any]:
|
|
674
|
+
if not task_id:
|
|
675
|
+
raise ValueError("task_id is required")
|
|
676
|
+
if not idempotency_key.strip():
|
|
677
|
+
raise ValueError("Idempotency-Key is required")
|
|
678
|
+
return self._request(
|
|
679
|
+
"POST",
|
|
680
|
+
"/jobs/retry",
|
|
681
|
+
body={"taskId": task_id},
|
|
682
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def purge_task(self, task_id: str) -> dict[str, Any]:
|
|
686
|
+
"""Destroy a finished task's stored content: media, result and prompt.
|
|
687
|
+
|
|
688
|
+
Billing evidence is never touched. The ledger, the charged amount, the
|
|
689
|
+
model, the state, the timestamps and the request_id all remain, and the
|
|
690
|
+
response repeats that as billingRetained.
|
|
691
|
+
|
|
692
|
+
Only a terminal task can be destroyed; queued or running returns 400.
|
|
693
|
+
No Idempotency-Key is sent: this endpoint does not read one, the taskId
|
|
694
|
+
is the idempotency key, and a repeat returns the original purgedAt. So
|
|
695
|
+
a timed-out call is safe to send again.
|
|
696
|
+
"""
|
|
697
|
+
if not task_id:
|
|
698
|
+
raise ValueError("task_id is required")
|
|
699
|
+
return self._request("POST", "/jobs/purge", body={"taskId": task_id})
|
|
700
|
+
|
|
701
|
+
def wait_for_terminal(
|
|
702
|
+
self,
|
|
703
|
+
task_id: str,
|
|
704
|
+
*,
|
|
705
|
+
timeout_seconds: float | None = None,
|
|
706
|
+
) -> dict[str, Any]:
|
|
707
|
+
total_timeout = self.wait_timeout_seconds if timeout_seconds is None else timeout_seconds
|
|
708
|
+
deadline = self.monotonic() + total_timeout
|
|
709
|
+
interval = 2.0
|
|
710
|
+
|
|
711
|
+
while self.monotonic() < deadline:
|
|
712
|
+
remaining = deadline - self.monotonic()
|
|
713
|
+
# 剩余预算不够发一次有意义的请求时,直接跳出去抛下面那个**带 task_id**
|
|
714
|
+
# 的超时。
|
|
715
|
+
#
|
|
716
|
+
# 不设这道门槛的话,最后一圈会用一个近乎零的超时发请求,那个请求几乎
|
|
717
|
+
# 必然超时——而它抛的是「请求超时」,不带 task_id。于是调用方在最该
|
|
718
|
+
# 知道任务编号的时刻失去了它:任务还在跑、还在计费,而他手上只有一句
|
|
719
|
+
# 「请求超时了」。丢的不是一次轮询,是找回那条任务的唯一线索。
|
|
720
|
+
#
|
|
721
|
+
# 门槛取 1 秒:小于它的一次往返本来也拿不到有用的结果。
|
|
722
|
+
if remaining < 1.0:
|
|
723
|
+
break
|
|
724
|
+
try:
|
|
725
|
+
task = self.get_task(
|
|
726
|
+
task_id,
|
|
727
|
+
timeout_seconds=min(self.request_timeout_seconds, remaining),
|
|
728
|
+
)
|
|
729
|
+
except SpicyTimeoutError:
|
|
730
|
+
# 单发轮询超时不等于这次等待失败——预算还剩着,就接着轮。
|
|
731
|
+
#
|
|
732
|
+
# 让它原样抛出去会同时犯两个错。一是丢掉 task_id:这个超时来自
|
|
733
|
+
# _request,而 _request 不知道自己正在为哪条任务轮询。二是把一次
|
|
734
|
+
# 网络抖动升级成整次等待的终结——默认 600 秒的预算会因为第一发卡
|
|
735
|
+
# 满 30 秒就放弃,而任务还在跑、还在计费。
|
|
736
|
+
#
|
|
737
|
+
# 预算真的耗尽时,循环顶上那道门槛会跳出去,抛下面那个带 task_id
|
|
738
|
+
# 的超时。TypeScript 那份实现不需要这段:它整段等待共用一个 abort
|
|
739
|
+
# signal,而那个 signal 的 reason 本身就带着 taskId。这里没有
|
|
740
|
+
# signal,只能显式接住。
|
|
741
|
+
pass
|
|
742
|
+
else:
|
|
743
|
+
state = task.get("state")
|
|
744
|
+
if state in TERMINAL_STATES and not self._has_pending_assets(task):
|
|
745
|
+
return task
|
|
746
|
+
if state not in TERMINAL_STATES and state not in ACTIVE_STATES:
|
|
747
|
+
raise SpicyApiError(f"unknown task state: {state!r}", status=200, code=200)
|
|
748
|
+
|
|
749
|
+
delay = min(self._jitter(interval), max(0.0, deadline - self.monotonic()))
|
|
750
|
+
if delay > 0:
|
|
751
|
+
self.sleep(delay)
|
|
752
|
+
interval = min(interval * 1.5, 15.0)
|
|
753
|
+
|
|
754
|
+
raise SpicyTimeoutError(
|
|
755
|
+
f"task {task_id} exceeded the local {total_timeout}s polling deadline; "
|
|
756
|
+
"its remote state is unknown",
|
|
757
|
+
task_id=task_id,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _has_pending_assets(task: Mapping[str, Any]) -> bool:
|
|
762
|
+
"""True while a succeeded task still has assets without a URL.
|
|
763
|
+
|
|
764
|
+
A task can reach succeeded before every output object has landed. Those
|
|
765
|
+
assets carry pending: true and no url, so returning here would hand the
|
|
766
|
+
caller a result it cannot download.
|
|
767
|
+
"""
|
|
768
|
+
if task.get("state") != "succeeded":
|
|
769
|
+
return False
|
|
770
|
+
return any(
|
|
771
|
+
asset.get("pending") and not asset.get("unavailable")
|
|
772
|
+
for asset in output_assets(task)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# ── Media ─────────────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
def create_upload_url(self, *, content_type: str, byte_count: int) -> dict[str, Any]:
|
|
778
|
+
"""Request a presigned upload ticket for an exact number of bytes.
|
|
779
|
+
|
|
780
|
+
The ticket is not retried on a transient failure: each one is a signed
|
|
781
|
+
write authorization against a tight per-account fuse, and a second
|
|
782
|
+
ticket does not make the first one usable.
|
|
783
|
+
"""
|
|
784
|
+
if content_type not in UPLOAD_CONTENT_TYPES:
|
|
785
|
+
raise ValueError(
|
|
786
|
+
f"content_type must be one of {sorted(UPLOAD_CONTENT_TYPES)}, got {content_type!r}"
|
|
787
|
+
)
|
|
788
|
+
limit = max_upload_bytes(content_type)
|
|
789
|
+
if byte_count < 1 or byte_count > limit:
|
|
790
|
+
raise ValueError(
|
|
791
|
+
f"{content_type} uploads must be between 1 and {limit} bytes, got {byte_count}"
|
|
792
|
+
)
|
|
793
|
+
return self._request(
|
|
794
|
+
"POST",
|
|
795
|
+
"/common/upload-url",
|
|
796
|
+
body={"contentType": content_type, "bytes": byte_count},
|
|
797
|
+
retryable=False,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def commit_file(self, file_id: str) -> dict[str, Any]:
|
|
801
|
+
"""Verify and freeze an uploaded object; returns the spicy:// URI.
|
|
802
|
+
|
|
803
|
+
Only data["uri"] may be used in task input — never the upload URL, the
|
|
804
|
+
temporary object key, or the bare file ID. Repeating a successful
|
|
805
|
+
commit is idempotent, so this call is safe to retry.
|
|
806
|
+
"""
|
|
807
|
+
if not file_id:
|
|
808
|
+
raise ValueError("file_id is required")
|
|
809
|
+
encoded = urllib.parse.quote(file_id, safe="")
|
|
810
|
+
return self._request("POST", f"/files/{encoded}/commit")
|
|
811
|
+
|
|
812
|
+
def upload_bytes(self, data: bytes, *, content_type: str) -> dict[str, Any]:
|
|
813
|
+
"""Ticket, PUT and commit in one call; returns the commit record.
|
|
814
|
+
|
|
815
|
+
Put the returned ``uri`` into the model input field whose x-ui.widget
|
|
816
|
+
is "upload" (or inside the list for "multi-upload").
|
|
817
|
+
"""
|
|
818
|
+
ticket = self.create_upload_url(content_type=content_type, byte_count=len(data))
|
|
819
|
+
max_bytes = ticket.get("maxBytes")
|
|
820
|
+
if isinstance(max_bytes, int) and len(data) > max_bytes:
|
|
821
|
+
raise SpicyUploadError(
|
|
822
|
+
f"file of {len(data)} bytes exceeds the ticket limit of {max_bytes} bytes",
|
|
823
|
+
status=413,
|
|
824
|
+
)
|
|
825
|
+
self._put_bytes(
|
|
826
|
+
str(ticket["uploadUrl"]),
|
|
827
|
+
# Every ticket header goes out exactly as received. Content-Type and
|
|
828
|
+
# Content-Length are both signed, so altering or dropping one makes
|
|
829
|
+
# storage answer 403 with an error that does not come from us.
|
|
830
|
+
dict(ticket.get("headers") or {}),
|
|
831
|
+
data,
|
|
832
|
+
method=str(ticket.get("method") or "PUT"),
|
|
833
|
+
)
|
|
834
|
+
return self.commit_file(str(ticket["fileId"]))
|
|
835
|
+
|
|
836
|
+
def upload_file(self, path: str, *, content_type: str | None = None) -> dict[str, Any]:
|
|
837
|
+
"""Upload a local file. The content type is inferred from its name.
|
|
838
|
+
|
|
839
|
+
The type and the size are settled before the file is read: rejecting an
|
|
840
|
+
unsupported or oversized file should not cost 90 MiB of memory first.
|
|
841
|
+
"""
|
|
842
|
+
resolved = content_type or content_type_for_path(path)
|
|
843
|
+
limit = max_upload_bytes(resolved)
|
|
844
|
+
size = os.path.getsize(path)
|
|
845
|
+
if size < 1 or size > limit:
|
|
846
|
+
raise ValueError(
|
|
847
|
+
f"{resolved} uploads must be between 1 and {limit} bytes, got {size}"
|
|
848
|
+
)
|
|
849
|
+
with open(path, "rb") as handle:
|
|
850
|
+
data = handle.read()
|
|
851
|
+
return self.upload_bytes(data, content_type=resolved)
|
|
852
|
+
|
|
853
|
+
def create_download_url(self, task_id: str, key: str | None = None) -> dict[str, Any]:
|
|
854
|
+
"""Mint a fresh short-lived link to one of a task's outputs.
|
|
855
|
+
|
|
856
|
+
Ready results already carry a usable output.assets[].url, so this is
|
|
857
|
+
only needed once that link has expired. Polling the task again works
|
|
858
|
+
just as well. Fetch the returned URL without any Authorization header:
|
|
859
|
+
it is itself the credential.
|
|
860
|
+
"""
|
|
861
|
+
if not task_id:
|
|
862
|
+
raise ValueError("task_id is required")
|
|
863
|
+
body: dict[str, Any] = {"taskId": task_id}
|
|
864
|
+
if key is not None:
|
|
865
|
+
body["key"] = key
|
|
866
|
+
return self._request("POST", "/common/download-url", body=body, retryable=False)
|
|
867
|
+
|
|
868
|
+
# ── Account ───────────────────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
def get_balance(self) -> dict[str, Any]:
|
|
871
|
+
"""Net available, held and total balance in USD decimal strings."""
|
|
872
|
+
return self._request("GET", "/chat/credit")
|
|
873
|
+
|
|
874
|
+
def get_usage(
|
|
875
|
+
self,
|
|
876
|
+
*,
|
|
877
|
+
from_date: str | None = None,
|
|
878
|
+
to_date: str | None = None,
|
|
879
|
+
) -> dict[str, Any]:
|
|
880
|
+
"""Settled spend and call counts for this API key over [from, to).
|
|
881
|
+
|
|
882
|
+
For reconciliation, not for progress: this endpoint has its own
|
|
883
|
+
account-wide budget of 30 requests per minute shared by every key, and
|
|
884
|
+
that limiter fails closed, so polling it can lock every key on the
|
|
885
|
+
account out of its own reporting. Follow a task with get_task instead.
|
|
886
|
+
|
|
887
|
+
Late settlement can change a previous day's total, so a figure read
|
|
888
|
+
today is not final.
|
|
889
|
+
"""
|
|
890
|
+
values: dict[str, str] = {}
|
|
891
|
+
if from_date is not None:
|
|
892
|
+
values["from"] = from_date
|
|
893
|
+
if to_date is not None:
|
|
894
|
+
values["to"] = to_date
|
|
895
|
+
suffix = f"?{urllib.parse.urlencode(values)}" if values else ""
|
|
896
|
+
return self._request("GET", f"/usage{suffix}")
|
|
897
|
+
|
|
898
|
+
# ── Transport ─────────────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
def _put_bytes(
|
|
901
|
+
self,
|
|
902
|
+
url: str,
|
|
903
|
+
headers: Mapping[str, str],
|
|
904
|
+
data: bytes,
|
|
905
|
+
*,
|
|
906
|
+
method: str = "PUT",
|
|
907
|
+
) -> None:
|
|
908
|
+
"""Send the bytes to object storage. Not a SpicyAPI call.
|
|
909
|
+
|
|
910
|
+
No Authorization header: the presigned URL carries its own credential
|
|
911
|
+
and some storage implementations reject a request that has both. No
|
|
912
|
+
envelope either — a storage response body is XML, not our JSON.
|
|
913
|
+
|
|
914
|
+
One attempt only, on the upload timeout rather than the API timeout.
|
|
915
|
+
"""
|
|
916
|
+
try:
|
|
917
|
+
status, _, _ = self.transport(method, url, headers, data, self.upload_timeout_seconds)
|
|
918
|
+
except TimeoutError as error:
|
|
919
|
+
raise SpicyTimeoutError(
|
|
920
|
+
f"upload exceeded the local {self.upload_timeout_seconds}s timeout"
|
|
921
|
+
) from error
|
|
922
|
+
except (urllib.error.URLError, OSError) as error:
|
|
923
|
+
raise SpicyUploadError(f"presigned upload failed: {error}") from error
|
|
924
|
+
if not 200 <= status < 300:
|
|
925
|
+
raise SpicyUploadError(
|
|
926
|
+
f"presigned upload failed with HTTP {status}; the ticket headers must be sent "
|
|
927
|
+
"unchanged and the body must be exactly the declared number of bytes",
|
|
928
|
+
status=status,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
def _request(
|
|
932
|
+
self,
|
|
933
|
+
method: str,
|
|
934
|
+
path: str,
|
|
935
|
+
*,
|
|
936
|
+
body: Mapping[str, Any] | None = None,
|
|
937
|
+
headers: Mapping[str, str] | None = None,
|
|
938
|
+
timeout_seconds: float | None = None,
|
|
939
|
+
retryable: bool = True,
|
|
940
|
+
) -> Any:
|
|
941
|
+
request_headers = {
|
|
942
|
+
"Accept": "application/json",
|
|
943
|
+
"User-Agent": _user_agent(),
|
|
944
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
945
|
+
**({"Content-Type": "application/json"} if body is not None else {}),
|
|
946
|
+
**dict(headers or {}),
|
|
947
|
+
}
|
|
948
|
+
encoded_body = None if body is None else json.dumps(body, separators=(",", ":")).encode()
|
|
949
|
+
timeout = self.request_timeout_seconds if timeout_seconds is None else timeout_seconds
|
|
950
|
+
# 单发超时:显式传了预算就以它为准,没传才用客户端的默认请求超时。
|
|
951
|
+
#
|
|
952
|
+
# 写死用 self.request_timeout_seconds 会让「带 wait 建任务」静默失效——
|
|
953
|
+
# 服务端还在等,本地 30 秒就把这一发断掉了,表现是这个功能看起来没生效,
|
|
954
|
+
# 而真正的原因是两个超时撞在一起。
|
|
955
|
+
per_attempt = self.request_timeout_seconds if timeout_seconds is None else timeout
|
|
956
|
+
request_deadline = self.monotonic() + timeout
|
|
957
|
+
|
|
958
|
+
for attempt in range(self.max_retries + 1):
|
|
959
|
+
remaining = request_deadline - self.monotonic()
|
|
960
|
+
if remaining <= 0:
|
|
961
|
+
raise SpicyTimeoutError(
|
|
962
|
+
f"request exceeded the local {timeout}s timeout"
|
|
963
|
+
)
|
|
964
|
+
try:
|
|
965
|
+
status, response_headers, raw = self.transport(
|
|
966
|
+
method,
|
|
967
|
+
f"{self.base_url}{path}",
|
|
968
|
+
request_headers,
|
|
969
|
+
encoded_body,
|
|
970
|
+
min(per_attempt, remaining),
|
|
971
|
+
)
|
|
972
|
+
except TimeoutError as error:
|
|
973
|
+
if retryable and attempt < self.max_retries:
|
|
974
|
+
self.sleep(min(
|
|
975
|
+
self._retry_delay(attempt),
|
|
976
|
+
max(0.0, request_deadline - self.monotonic()),
|
|
977
|
+
))
|
|
978
|
+
continue
|
|
979
|
+
raise SpicyTimeoutError(
|
|
980
|
+
f"request exceeded the local {timeout}s timeout"
|
|
981
|
+
) from error
|
|
982
|
+
except (urllib.error.URLError, OSError) as error:
|
|
983
|
+
if retryable and attempt < self.max_retries:
|
|
984
|
+
self.sleep(min(
|
|
985
|
+
self._retry_delay(attempt),
|
|
986
|
+
max(0.0, request_deadline - self.monotonic()),
|
|
987
|
+
))
|
|
988
|
+
continue
|
|
989
|
+
raise SpicyApiError(f"network request failed: {error}") from error
|
|
990
|
+
|
|
991
|
+
retry_after = _parse_retry_after(_header(response_headers, "Retry-After"))
|
|
992
|
+
try:
|
|
993
|
+
envelope = json.loads(raw)
|
|
994
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
995
|
+
if retryable and attempt < self.max_retries and status in RETRYABLE_HTTP:
|
|
996
|
+
self.sleep(min(
|
|
997
|
+
self._retry_delay(attempt, _header(response_headers, "Retry-After")),
|
|
998
|
+
max(0.0, request_deadline - self.monotonic()),
|
|
999
|
+
))
|
|
1000
|
+
continue
|
|
1001
|
+
raise SpicyApiError(
|
|
1002
|
+
"response was not valid JSON", status=status
|
|
1003
|
+
) from error
|
|
1004
|
+
|
|
1005
|
+
code = envelope.get("code") if isinstance(envelope, dict) else None
|
|
1006
|
+
message = envelope.get("msg") if isinstance(envelope, dict) else None
|
|
1007
|
+
request_id = envelope.get("request_id", "") if isinstance(envelope, dict) else ""
|
|
1008
|
+
failed = not 200 <= status < 300 or code != 200
|
|
1009
|
+
# 40004 and 40901 never enter this set: the identical request gets
|
|
1010
|
+
# the identical answer, and only the caller can change a parameter
|
|
1011
|
+
# or accept a new price.
|
|
1012
|
+
resend_helps = code not in NON_RETRYABLE_CODES and (
|
|
1013
|
+
status in RETRYABLE_HTTP or code in RETRYABLE_CODES
|
|
1014
|
+
)
|
|
1015
|
+
if failed and retryable and resend_helps and attempt < self.max_retries:
|
|
1016
|
+
self.sleep(min(
|
|
1017
|
+
self._retry_delay(attempt, _header(response_headers, "Retry-After")),
|
|
1018
|
+
max(0.0, request_deadline - self.monotonic()),
|
|
1019
|
+
))
|
|
1020
|
+
continue
|
|
1021
|
+
if failed:
|
|
1022
|
+
raise SpicyApiError(
|
|
1023
|
+
message if isinstance(message, str) else f"request failed with HTTP {status}",
|
|
1024
|
+
status=status,
|
|
1025
|
+
code=code if isinstance(code, int) else None,
|
|
1026
|
+
request_id=request_id if isinstance(request_id, str) else "",
|
|
1027
|
+
retry_after_seconds=retry_after,
|
|
1028
|
+
)
|
|
1029
|
+
if "data" not in envelope:
|
|
1030
|
+
raise SpicyApiError(
|
|
1031
|
+
"successful envelope omitted data",
|
|
1032
|
+
status=status,
|
|
1033
|
+
code=code,
|
|
1034
|
+
request_id=request_id,
|
|
1035
|
+
)
|
|
1036
|
+
return envelope["data"]
|
|
1037
|
+
|
|
1038
|
+
raise AssertionError("retry loop exited unexpectedly")
|
|
1039
|
+
|
|
1040
|
+
def _jitter(self, seconds: float) -> float:
|
|
1041
|
+
return seconds * (0.8 + self.random_value() * 0.4)
|
|
1042
|
+
|
|
1043
|
+
def _retry_delay(self, attempt: int, retry_after: str | None = None) -> float:
|
|
1044
|
+
exponential = min(0.5 * (2**attempt), 8.0)
|
|
1045
|
+
server_delay = _parse_retry_after(retry_after) or 0.0
|
|
1046
|
+
return max(self._jitter(exponential), server_delay)
|
spicyapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spicyapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for SpicyAPI — image, video and text generation behind one API.
|
|
5
|
+
Project-URL: Homepage, https://spicyapi.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.spicyapi.ai
|
|
7
|
+
Project-URL: Source, https://github.com/Spicy-API/spicy-python
|
|
8
|
+
Project-URL: Issues, https://spicyapi.ai/contact
|
|
9
|
+
Author: SpicyAPI
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,api-client,image-generation,spicyapi,video-generation
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
|
|
27
|
+
# spicyapi
|
|
28
|
+
|
|
29
|
+
**Official Python SDK for [SpicyAPI](https://spicyapi.ai)** — image, video and text models behind one API.
|
|
30
|
+
|
|
31
|
+
[Get a key](https://spicyapi.ai) · [Models](https://spicyapi.ai/models) · [Docs](https://docs.spicyapi.ai) · [Status](https://status.spicyapi.ai)
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
One endpoint in front of 83 model families across 121 callable endpoints, billed in USD per request
|
|
38
|
+
rather than in credits. Media generation is asynchronous and quotable before you spend; text models
|
|
39
|
+
speak the OpenAI, Anthropic and Gemini wire formats.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install spicyapi
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.11+. **No runtime dependencies**: this package goes into your dependency tree, and
|
|
46
|
+
every constraint it adds is one more chance of a conflict.
|
|
47
|
+
|
|
48
|
+
## Generate something
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import os, uuid
|
|
52
|
+
from spicyapi import SpicyClient, output_assets
|
|
53
|
+
|
|
54
|
+
client = SpicyClient() # reads SPICY_API_KEY from the environment
|
|
55
|
+
|
|
56
|
+
model = client.get_model("MODEL_ID_FROM_CATALOG") # copy a real id from list_models()
|
|
57
|
+
quote = client.quote_task(model=model["model"], input_data={"prompt": "a lantern in fog"})
|
|
58
|
+
print(quote["estimatedCost"], quote["maxCharge"]) # decide before you spend
|
|
59
|
+
|
|
60
|
+
task = client.create_task(
|
|
61
|
+
model=model["model"],
|
|
62
|
+
input_data={"prompt": "a lantern in fog"},
|
|
63
|
+
idempotency_key=str(uuid.uuid4()),
|
|
64
|
+
quote_id=quote["quoteId"],
|
|
65
|
+
expected_cost=quote["estimatedCost"],
|
|
66
|
+
)
|
|
67
|
+
final = client.wait_for_terminal(task["taskId"])
|
|
68
|
+
for asset in output_assets(final): # module-level helper, not a method
|
|
69
|
+
print(asset["url"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Build the `input` from that model's own `inputSchema` — every model has different fields, and
|
|
73
|
+
`list_models(include_schema=True)` returns them.
|
|
74
|
+
|
|
75
|
+
## Start from a local file
|
|
76
|
+
|
|
77
|
+
Image-to-video, face swap and image editing all need your material on our side first. Upload returns
|
|
78
|
+
a `spicy://` URI; that is what goes into `input`.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
uploaded = client.upload_file("/path/to/reference.png")
|
|
82
|
+
task = client.create_task(
|
|
83
|
+
model="MODEL_ID_FROM_CATALOG",
|
|
84
|
+
input_data={"image": uploaded["uri"], "prompt": "slow dolly in"},
|
|
85
|
+
idempotency_key=str(uuid.uuid4()),
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Webhooks
|
|
90
|
+
|
|
91
|
+
`verify_webhook` is a module-level function, so a request handler can use it without building a
|
|
92
|
+
client. It compares in constant time and checks the timestamp only after the signature is valid.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from spicyapi import verify_webhook
|
|
96
|
+
|
|
97
|
+
delivery = verify_webhook(
|
|
98
|
+
raw_body=request.body, # the exact bytes, before any parsing
|
|
99
|
+
signature=request.headers["X-Webhook-Signature"],
|
|
100
|
+
timestamp=request.headers["X-Webhook-Timestamp"],
|
|
101
|
+
payload_version=request.headers["X-Webhook-Payload-Version"],
|
|
102
|
+
secret=os.environ["SPICY_WEBHOOK_SECRET"],
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Verify the raw bytes. Re-serialising the parsed JSON changes them, and the signature will never match.
|
|
107
|
+
|
|
108
|
+
## Two things that will save you money
|
|
109
|
+
|
|
110
|
+
**Keep one idempotency key per submission.** Reuse it for every resend of that submission, including
|
|
111
|
+
after a timeout or a dropped connection. A lost response does not prove the task was not created — a
|
|
112
|
+
fresh key turns an unknown outcome into a second paid task.
|
|
113
|
+
|
|
114
|
+
**A task that succeeds is charged, even if the result disappoints.** Quote first when the price
|
|
115
|
+
matters; `quote_task` reserves nothing.
|
|
116
|
+
|
|
117
|
+
## What this package does not do
|
|
118
|
+
|
|
119
|
+
**Text models.** They speak the OpenAI, Anthropic and Google Gemini wire formats, so the official
|
|
120
|
+
libraries for those already work — point them at `https://api.spicyapi.ai/v1` with the same key.
|
|
121
|
+
Wrapping them here would add nothing.
|
|
122
|
+
|
|
123
|
+
**Browser, mobile and desktop apps.** Never ship this key inside an application: a key compiled into
|
|
124
|
+
a client is a public key. Call from your server, or put
|
|
125
|
+
`@spicyapi/proxy` in front.
|
|
126
|
+
|
|
127
|
+
## Errors
|
|
128
|
+
|
|
129
|
+
Every failure raises `SpicyApiError` with `status`, `code`, `request_id` and `retry_after_seconds`.
|
|
130
|
+
Branch on `code`, never on the message text — messages are translated, codes are not.
|
|
131
|
+
|
|
132
|
+
`503` is shared by three different business codes, so reading the HTTP status alone is not enough:
|
|
133
|
+
|
|
134
|
+
| code | meaning | what to do |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `40003` | uploaded bytes do not match their ticket | upload again |
|
|
137
|
+
| `40004` | no deployment serves that parameter combination | change the parameter named in the message |
|
|
138
|
+
| `40901` | the price moved before the task was created | quote again, keep the same idempotency key |
|
|
139
|
+
| `503` | a dependency is briefly unavailable | back off by `Retry-After` |
|
|
140
|
+
| `50301` | the model has no usable deployment or price right now | do not hammer; refresh the catalogue |
|
|
141
|
+
| `50302` | a synchronous generation failed upstream and was refunded | retry with a **new** idempotency key |
|
|
142
|
+
|
|
143
|
+
`err.recovery` carries the same guidance at runtime.
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- [Documentation](https://docs.spicyapi.ai)
|
|
148
|
+
- [API reference](https://docs.spicyapi.ai/docs/api-reference)
|
|
149
|
+
- [Source](https://github.com/Spicy-API/spicy-python)
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
<div align="center">
|
|
154
|
+
<sub>
|
|
155
|
+
|
|
156
|
+
Also available in [TypeScript](https://github.com/Spicy-API/spicy-sdk) · **Python** · [Go](https://github.com/Spicy-API/spicy-go) · [PHP](https://github.com/Spicy-API/spicy-php) · [Java](https://github.com/Spicy-API/spicy-java)
|
|
157
|
+
|
|
158
|
+
</sub>
|
|
159
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
spicyapi/__init__.py,sha256=zSh9DEw365woqNfICPUyS0GsGn41WE6SVzoS3MHLHKc,1053
|
|
2
|
+
spicyapi/_client.py,sha256=8Ln6cMtLx6EVRA7UhqDe9hn_dI3A9JS1fIAfjhYCKUc,45627
|
|
3
|
+
spicyapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
spicyapi-0.1.0.dist-info/METADATA,sha256=8aAM6f46OysAaQNYqJM-UiYxZVx8-U7rSRUEP85ym-I,6156
|
|
5
|
+
spicyapi-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
6
|
+
spicyapi-0.1.0.dist-info/licenses/LICENSE,sha256=ivhd3AXs0xBDlrRWrfas7it38wcrfVJ0uVw7rQvEMO0,1065
|
|
7
|
+
spicyapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpicyAPI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|