corent 0.1.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.
- corent-0.1.0/.gitignore +25 -0
- corent-0.1.0/PKG-INFO +59 -0
- corent-0.1.0/README.md +42 -0
- corent-0.1.0/corent/__init__.py +31 -0
- corent-0.1.0/corent/client.py +236 -0
- corent-0.1.0/corent/errors.py +29 -0
- corent-0.1.0/corent/types.py +45 -0
- corent-0.1.0/pyproject.toml +27 -0
- corent-0.1.0/tests/test_client.py +87 -0
corent-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.local
|
|
3
|
+
.env.*.local
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
backend/venv/
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.pyc
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
|
|
11
|
+
# Node / Next.js
|
|
12
|
+
frontend/node_modules/
|
|
13
|
+
.next/
|
|
14
|
+
frontend/out/
|
|
15
|
+
|
|
16
|
+
# OS
|
|
17
|
+
.DS_Store
|
|
18
|
+
|
|
19
|
+
# MCP package
|
|
20
|
+
mcp/node_modules/
|
|
21
|
+
mcp/dist/
|
|
22
|
+
.vercel
|
|
23
|
+
|
|
24
|
+
# fund-agent local state (balance history)
|
|
25
|
+
fund-agent/state/
|
corent-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: corent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Corent — one API for AI image, video, and voice generation with built-in routing, failover, and exact receipts.
|
|
5
|
+
Project-URL: Homepage, https://corent.tech
|
|
6
|
+
Project-URL: Documentation, https://corent.tech/docs
|
|
7
|
+
Author-email: Corent <krrocicrypto@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: ai,api,corent,image-generation,text-to-speech,video-generation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: httpx>=0.24
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Corent Python SDK
|
|
19
|
+
|
|
20
|
+
One API for AI **image, video, and voice** generation. You pick a quality tier; Corent's router picks the best live model, reroutes failures, verifies the output, and returns the exact charge on every response. Failed generations are never billed.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install corent
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from corent import Corent
|
|
28
|
+
|
|
29
|
+
client = Corent("co_live_...") # get a key at https://corent.tech
|
|
30
|
+
|
|
31
|
+
image = client.images.generate("a lighthouse at dusk", tier="premium", aspect_ratio="9:16")
|
|
32
|
+
print(image.url, image.width, image.height, image.cost_cents)
|
|
33
|
+
|
|
34
|
+
video = client.videos.generate("a paper boat drifting across a puddle",
|
|
35
|
+
tier="premium", duration_s=5, resolution="1080p")
|
|
36
|
+
print(video.url, video.resolution, video.cost_cents)
|
|
37
|
+
|
|
38
|
+
speech = client.speech.generate("Welcome to Corent.")
|
|
39
|
+
print(speech.url, speech.cost_cents)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What the SDK handles for you
|
|
43
|
+
|
|
44
|
+
- **Timeout-safe renders** — images submit as background jobs and are polled; a network hiccup can never lose a finished (and billed) result.
|
|
45
|
+
- **Safe retries** — every generate call carries an auto idempotency key; retries can never double-charge.
|
|
46
|
+
- **Backoff** — 429/5xx are retried with `Retry-After` respected.
|
|
47
|
+
- **Honest receipts** — `width`/`height` are the *measured* pixels of the delivered file, and `cost_cents` is the exact charge.
|
|
48
|
+
|
|
49
|
+
## Fine-grained control
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
job = client.images.generate("...", tier="max_pro", wait=False) # returns immediately
|
|
53
|
+
job = client.jobs.wait(job.id) # resume any time
|
|
54
|
+
|
|
55
|
+
client.tiers() # live catalog with honest min-max price ranges
|
|
56
|
+
client.balance_cents() # prepaid balance
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Tiers: `air` | `lite` | `premium` | `pro` | `max_pro` — see [corent.tech/pricing](https://corent.tech/pricing). Docs: [corent.tech/docs](https://corent.tech/docs).
|
corent-0.1.0/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Corent Python SDK
|
|
2
|
+
|
|
3
|
+
One API for AI **image, video, and voice** generation. You pick a quality tier; Corent's router picks the best live model, reroutes failures, verifies the output, and returns the exact charge on every response. Failed generations are never billed.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install corent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from corent import Corent
|
|
11
|
+
|
|
12
|
+
client = Corent("co_live_...") # get a key at https://corent.tech
|
|
13
|
+
|
|
14
|
+
image = client.images.generate("a lighthouse at dusk", tier="premium", aspect_ratio="9:16")
|
|
15
|
+
print(image.url, image.width, image.height, image.cost_cents)
|
|
16
|
+
|
|
17
|
+
video = client.videos.generate("a paper boat drifting across a puddle",
|
|
18
|
+
tier="premium", duration_s=5, resolution="1080p")
|
|
19
|
+
print(video.url, video.resolution, video.cost_cents)
|
|
20
|
+
|
|
21
|
+
speech = client.speech.generate("Welcome to Corent.")
|
|
22
|
+
print(speech.url, speech.cost_cents)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What the SDK handles for you
|
|
26
|
+
|
|
27
|
+
- **Timeout-safe renders** — images submit as background jobs and are polled; a network hiccup can never lose a finished (and billed) result.
|
|
28
|
+
- **Safe retries** — every generate call carries an auto idempotency key; retries can never double-charge.
|
|
29
|
+
- **Backoff** — 429/5xx are retried with `Retry-After` respected.
|
|
30
|
+
- **Honest receipts** — `width`/`height` are the *measured* pixels of the delivered file, and `cost_cents` is the exact charge.
|
|
31
|
+
|
|
32
|
+
## Fine-grained control
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
job = client.images.generate("...", tier="max_pro", wait=False) # returns immediately
|
|
36
|
+
job = client.jobs.wait(job.id) # resume any time
|
|
37
|
+
|
|
38
|
+
client.tiers() # live catalog with honest min-max price ranges
|
|
39
|
+
client.balance_cents() # prepaid balance
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Tiers: `air` | `lite` | `premium` | `pro` | `max_pro` — see [corent.tech/pricing](https://corent.tech/pricing). Docs: [corent.tech/docs](https://corent.tech/docs).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Corent Python SDK — one API for AI image, video, and voice.
|
|
2
|
+
|
|
3
|
+
from corent import Corent
|
|
4
|
+
|
|
5
|
+
client = Corent("co_live_...")
|
|
6
|
+
image = client.images.generate("a lighthouse at dusk", tier="premium")
|
|
7
|
+
print(image.url, image.width, image.height, image.cost_cents)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import Corent
|
|
11
|
+
from .errors import (
|
|
12
|
+
CorentError,
|
|
13
|
+
InsufficientBalanceError,
|
|
14
|
+
InvalidRequestError,
|
|
15
|
+
RateLimitedError,
|
|
16
|
+
)
|
|
17
|
+
from .types import GeneratedImage, GeneratedSpeech, GeneratedVideo, Job
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Corent",
|
|
21
|
+
"CorentError",
|
|
22
|
+
"InsufficientBalanceError",
|
|
23
|
+
"InvalidRequestError",
|
|
24
|
+
"RateLimitedError",
|
|
25
|
+
"GeneratedImage",
|
|
26
|
+
"GeneratedVideo",
|
|
27
|
+
"GeneratedSpeech",
|
|
28
|
+
"Job",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""The Corent client. Hides everything the raw HTTP caller has to hand-roll:
|
|
4
|
+
auth headers, idempotency keys, 429/5xx retries with backoff, and job polling.
|
|
5
|
+
|
|
6
|
+
Design choices worth knowing:
|
|
7
|
+
|
|
8
|
+
- Image generation submits with async=true and polls the job. A finished
|
|
9
|
+
render can then never be lost to a client-side timeout — the job id exists
|
|
10
|
+
from the first response, and the SDK just resumes polling.
|
|
11
|
+
- Every generate call sends an auto-generated Idempotency-Key, so the SDK's
|
|
12
|
+
own retries (and yours) can never double-charge.
|
|
13
|
+
- Failed generations are never billed by the API; the SDK surfaces them as
|
|
14
|
+
GenerationFailedError with the API's sanitized error code.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import (
|
|
23
|
+
CorentError,
|
|
24
|
+
GenerationFailedError,
|
|
25
|
+
InsufficientBalanceError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
RateLimitedError,
|
|
28
|
+
)
|
|
29
|
+
from .types import GeneratedImage, GeneratedSpeech, GeneratedVideo, Job
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.corent.tech"
|
|
32
|
+
MAX_RETRIES = 3
|
|
33
|
+
POLL_INTERVAL_S = 3.0
|
|
34
|
+
DEFAULT_WAIT_TIMEOUT_S = 900 # videos can take minutes; images far less
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Corent:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
42
|
+
timeout: float = 60.0,
|
|
43
|
+
):
|
|
44
|
+
self._http = httpx.Client(
|
|
45
|
+
base_url=base_url,
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
headers={
|
|
48
|
+
"Authorization": f"Bearer {api_key}",
|
|
49
|
+
"User-Agent": "corent-python/0.1.0",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
self.images = _Images(self)
|
|
53
|
+
self.videos = _Videos(self)
|
|
54
|
+
self.speech = _Speech(self)
|
|
55
|
+
self.jobs = _Jobs(self)
|
|
56
|
+
|
|
57
|
+
# -- account & discovery -------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def balance_cents(self) -> int:
|
|
60
|
+
return self._request("GET", "/v1/account/balance")["balance_cents"]
|
|
61
|
+
|
|
62
|
+
def tiers(self) -> list[dict]:
|
|
63
|
+
"""The live tier catalog with honest price ranges. Public endpoint."""
|
|
64
|
+
return self._request("GET", "/v1/tiers")["tiers"]
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._http.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *exc):
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
# -- plumbing ------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _request(self, method: str, path: str, json: dict | None = None,
|
|
78
|
+
idempotent: bool = False) -> dict:
|
|
79
|
+
headers = {}
|
|
80
|
+
if idempotent:
|
|
81
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
82
|
+
last_exc: Exception | None = None
|
|
83
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
84
|
+
try:
|
|
85
|
+
resp = self._http.request(method, path, json=json, headers=headers)
|
|
86
|
+
except httpx.TransportError as exc:
|
|
87
|
+
last_exc = exc
|
|
88
|
+
time.sleep(min(2**attempt, 8))
|
|
89
|
+
continue
|
|
90
|
+
if resp.status_code < 400:
|
|
91
|
+
return resp.json()
|
|
92
|
+
if resp.status_code == 429:
|
|
93
|
+
retry_after = float(resp.headers.get("Retry-After", 2 ** (attempt + 1)))
|
|
94
|
+
if attempt < MAX_RETRIES:
|
|
95
|
+
time.sleep(min(retry_after, 30))
|
|
96
|
+
continue
|
|
97
|
+
raise RateLimitedError("rate limited", retry_after_s=retry_after)
|
|
98
|
+
if resp.status_code in (502, 503) and attempt < MAX_RETRIES:
|
|
99
|
+
time.sleep(min(2 ** (attempt + 1), 8))
|
|
100
|
+
continue
|
|
101
|
+
self._raise_for(resp)
|
|
102
|
+
raise CorentError(f"request failed after {MAX_RETRIES} retries: {last_exc}")
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _raise_for(resp: httpx.Response) -> None:
|
|
106
|
+
try:
|
|
107
|
+
detail = resp.json().get("detail", resp.text)
|
|
108
|
+
except Exception:
|
|
109
|
+
detail = resp.text
|
|
110
|
+
message = detail if isinstance(detail, str) else str(detail)
|
|
111
|
+
if resp.status_code == 402:
|
|
112
|
+
raise InsufficientBalanceError(message, 402)
|
|
113
|
+
if resp.status_code in (400, 422):
|
|
114
|
+
raise InvalidRequestError(message, resp.status_code)
|
|
115
|
+
raise CorentError(message, resp.status_code)
|
|
116
|
+
|
|
117
|
+
def _wait(self, job_id: str, timeout_s: float) -> dict:
|
|
118
|
+
deadline = time.monotonic() + timeout_s
|
|
119
|
+
while time.monotonic() < deadline:
|
|
120
|
+
job = self._request("GET", f"/v1/jobs/{job_id}")
|
|
121
|
+
if job["status"] == "completed":
|
|
122
|
+
return job
|
|
123
|
+
if job["status"] == "failed":
|
|
124
|
+
raise GenerationFailedError(job.get("error") or "generation_failed")
|
|
125
|
+
time.sleep(POLL_INTERVAL_S)
|
|
126
|
+
raise CorentError(f"job {job_id} still processing after {timeout_s:.0f}s; "
|
|
127
|
+
f"poll client.jobs.get('{job_id}') to resume")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Images:
|
|
131
|
+
def __init__(self, client: Corent):
|
|
132
|
+
self._c = client
|
|
133
|
+
|
|
134
|
+
def generate(
|
|
135
|
+
self,
|
|
136
|
+
prompt: str,
|
|
137
|
+
tier: str | None = None,
|
|
138
|
+
style: str | None = None,
|
|
139
|
+
aspect_ratio: str = "1:1",
|
|
140
|
+
wait: bool = True,
|
|
141
|
+
timeout_s: float = DEFAULT_WAIT_TIMEOUT_S,
|
|
142
|
+
) -> "GeneratedImage | Job":
|
|
143
|
+
"""Render an image. tier: air | lite | premium | pro | max_pro.
|
|
144
|
+
wait=False returns the Job immediately; poll with client.jobs."""
|
|
145
|
+
body = {"prompt": prompt, "aspect_ratio": aspect_ratio, "async": True}
|
|
146
|
+
if tier:
|
|
147
|
+
body["tier"] = tier
|
|
148
|
+
if style:
|
|
149
|
+
body["style"] = style
|
|
150
|
+
submitted = self._c._request("POST", "/v1/images/generate", json=body, idempotent=True)
|
|
151
|
+
if submitted["status"] == "completed": # idempotent replay of a finished job
|
|
152
|
+
return _image_from_job(submitted)
|
|
153
|
+
if not wait:
|
|
154
|
+
return Job(id=submitted["id"], status=submitted["status"], raw=submitted)
|
|
155
|
+
return _image_from_job(self._c._wait(submitted["id"], timeout_s))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _Videos:
|
|
159
|
+
def __init__(self, client: Corent):
|
|
160
|
+
self._c = client
|
|
161
|
+
|
|
162
|
+
def generate(
|
|
163
|
+
self,
|
|
164
|
+
prompt: str,
|
|
165
|
+
tier: str | None = None,
|
|
166
|
+
aspect_ratio: str = "16:9",
|
|
167
|
+
duration_s: int | None = None,
|
|
168
|
+
resolution: str | None = None,
|
|
169
|
+
image_url: str | None = None,
|
|
170
|
+
wait: bool = True,
|
|
171
|
+
timeout_s: float = DEFAULT_WAIT_TIMEOUT_S,
|
|
172
|
+
) -> "GeneratedVideo | Job":
|
|
173
|
+
"""Render a video (1-5 minutes typical). resolution: 720p | 1080p | 4k
|
|
174
|
+
(tier-capped; clamped down, never rejected). image_url animates an
|
|
175
|
+
existing image instead of starting from text."""
|
|
176
|
+
body: dict = {"prompt": prompt, "aspect_ratio": aspect_ratio}
|
|
177
|
+
if tier:
|
|
178
|
+
body["tier"] = tier
|
|
179
|
+
if duration_s is not None:
|
|
180
|
+
body["duration_s"] = duration_s
|
|
181
|
+
if resolution:
|
|
182
|
+
body["resolution"] = resolution
|
|
183
|
+
if image_url:
|
|
184
|
+
body["image_url"] = image_url
|
|
185
|
+
submitted = self._c._request("POST", "/v1/videos/generate", json=body, idempotent=True)
|
|
186
|
+
if not wait:
|
|
187
|
+
return Job(id=submitted["id"], status=submitted["status"], raw=submitted)
|
|
188
|
+
return _video_from_job(self._c._wait(submitted["id"], timeout_s))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Speech:
|
|
192
|
+
def __init__(self, client: Corent):
|
|
193
|
+
self._c = client
|
|
194
|
+
|
|
195
|
+
def generate(self, text: str, voice_id: str | None = None) -> GeneratedSpeech:
|
|
196
|
+
"""Text to speech; synchronous, returns the finished audio."""
|
|
197
|
+
body: dict = {"text": text}
|
|
198
|
+
if voice_id:
|
|
199
|
+
body["voice_id"] = voice_id
|
|
200
|
+
r = self._c._request("POST", "/v1/audio/speech", json=body, idempotent=True)
|
|
201
|
+
meta = r.get("meta") or {}
|
|
202
|
+
return GeneratedSpeech(id=r["id"], url=r["audio_url"],
|
|
203
|
+
model=meta.get("model"), cost_cents=meta.get("cost_cents"))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _Jobs:
|
|
207
|
+
def __init__(self, client: Corent):
|
|
208
|
+
self._c = client
|
|
209
|
+
|
|
210
|
+
def get(self, job_id: str) -> Job:
|
|
211
|
+
raw = self._c._request("GET", f"/v1/jobs/{job_id}")
|
|
212
|
+
return Job(id=raw["id"], status=raw["status"], raw=raw)
|
|
213
|
+
|
|
214
|
+
def wait(self, job_id: str, timeout_s: float = DEFAULT_WAIT_TIMEOUT_S) -> Job:
|
|
215
|
+
raw = self._c._wait(job_id, timeout_s)
|
|
216
|
+
return Job(id=raw["id"], status=raw["status"], raw=raw)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _image_from_job(job: dict) -> GeneratedImage:
|
|
220
|
+
image = (job.get("images") or [{}])[0]
|
|
221
|
+
meta = job.get("meta") or {}
|
|
222
|
+
return GeneratedImage(
|
|
223
|
+
id=job["id"], url=image.get("url"), width=image.get("width"),
|
|
224
|
+
height=image.get("height"), model=meta.get("model"), cost_cents=meta.get("cost_cents"),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _video_from_job(job: dict) -> GeneratedVideo:
|
|
229
|
+
video = (job.get("videos") or [{}])[0]
|
|
230
|
+
meta = job.get("meta") or {}
|
|
231
|
+
return GeneratedVideo(
|
|
232
|
+
id=job["id"], url=video.get("url"), width=video.get("width"),
|
|
233
|
+
height=video.get("height"), resolution=video.get("resolution"),
|
|
234
|
+
duration_s=video.get("duration_s"), model=meta.get("model"),
|
|
235
|
+
cost_cents=meta.get("cost_cents"),
|
|
236
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CorentError(Exception):
|
|
5
|
+
"""Base error: carries the HTTP status and the API's error detail."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidRequestError(CorentError):
|
|
13
|
+
"""400/422: fix the highlighted field and resend."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InsufficientBalanceError(CorentError):
|
|
17
|
+
"""402: add funds at https://corent.tech/dashboard."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitedError(CorentError):
|
|
21
|
+
"""429 after retries were exhausted: slow down or raise your key limits."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, retry_after_s: float | None = None):
|
|
24
|
+
self.retry_after_s = retry_after_s
|
|
25
|
+
super().__init__(message, 429)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GenerationFailedError(CorentError):
|
|
29
|
+
"""The job reached a terminal failed state. You were not charged."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class GeneratedImage:
|
|
8
|
+
"""A finished image. width/height are the MEASURED pixels of the delivered
|
|
9
|
+
file (Corent parses the file itself, never echoes your request)."""
|
|
10
|
+
|
|
11
|
+
id: str
|
|
12
|
+
url: str
|
|
13
|
+
width: int | None
|
|
14
|
+
height: int | None
|
|
15
|
+
model: str | None # Corent tier that served it, e.g. "image-premium"
|
|
16
|
+
cost_cents: int | None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GeneratedVideo:
|
|
21
|
+
id: str
|
|
22
|
+
url: str
|
|
23
|
+
width: int | None
|
|
24
|
+
height: int | None
|
|
25
|
+
resolution: str | None
|
|
26
|
+
duration_s: int | None
|
|
27
|
+
model: str | None
|
|
28
|
+
cost_cents: int | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GeneratedSpeech:
|
|
33
|
+
id: str
|
|
34
|
+
url: str
|
|
35
|
+
model: str | None
|
|
36
|
+
cost_cents: int | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Job:
|
|
41
|
+
"""Raw job state for callers driving their own polling."""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
status: str # "processing" | "completed" | "failed"
|
|
45
|
+
raw: dict
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "corent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for Corent — one API for AI image, video, and voice generation with built-in routing, failover, and exact receipts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Corent", email = "krrocicrypto@gmail.com" }]
|
|
13
|
+
keywords = ["ai", "image-generation", "video-generation", "text-to-speech", "api", "corent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.24"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://corent.tech"
|
|
24
|
+
Documentation = "https://corent.tech/docs"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["corent"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""SDK behavior tests against a mock transport: no network, no spend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import corent.client as client_mod
|
|
9
|
+
from corent import Corent, InsufficientBalanceError
|
|
10
|
+
from corent.errors import GenerationFailedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_client(handler) -> Corent:
|
|
14
|
+
c = Corent("co_live_test", base_url="https://api.test")
|
|
15
|
+
c._http = httpx.Client(
|
|
16
|
+
base_url="https://api.test",
|
|
17
|
+
transport=httpx.MockTransport(handler),
|
|
18
|
+
headers={"Authorization": "Bearer co_live_test"},
|
|
19
|
+
)
|
|
20
|
+
return c
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_image_generate_submits_async_and_polls(monkeypatch):
|
|
24
|
+
monkeypatch.setattr(client_mod, "POLL_INTERVAL_S", 0)
|
|
25
|
+
calls = []
|
|
26
|
+
|
|
27
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
28
|
+
calls.append((request.method, request.url.path))
|
|
29
|
+
if request.url.path == "/v1/images/generate":
|
|
30
|
+
body = json.loads(request.content)
|
|
31
|
+
assert body["async"] is True # timeout-safety by default
|
|
32
|
+
assert request.headers.get("Idempotency-Key") # auto retry-safety
|
|
33
|
+
return httpx.Response(202, json={"id": "j1", "status": "processing"})
|
|
34
|
+
if len(calls) < 3:
|
|
35
|
+
return httpx.Response(200, json={"id": "j1", "status": "processing"})
|
|
36
|
+
return httpx.Response(200, json={
|
|
37
|
+
"id": "j1", "status": "completed",
|
|
38
|
+
"images": [{"url": "https://cdn/x.jpg", "width": 608, "height": 1088}],
|
|
39
|
+
"meta": {"model": "image-air", "cost_cents": 2},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
image = make_client(handler).images.generate("a lighthouse", tier="air", aspect_ratio="9:16")
|
|
43
|
+
assert (image.width, image.height, image.cost_cents) == (608, 1088, 2)
|
|
44
|
+
assert calls[0] == ("POST", "/v1/images/generate")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_failed_job_raises_and_names_the_error(monkeypatch):
|
|
48
|
+
monkeypatch.setattr(client_mod, "POLL_INTERVAL_S", 0)
|
|
49
|
+
|
|
50
|
+
def handler(request):
|
|
51
|
+
if request.url.path == "/v1/images/generate":
|
|
52
|
+
return httpx.Response(202, json={"id": "j2", "status": "processing"})
|
|
53
|
+
return httpx.Response(200, json={"id": "j2", "status": "failed", "error": "all_providers_failed"})
|
|
54
|
+
|
|
55
|
+
with pytest.raises(GenerationFailedError, match="all_providers_failed"):
|
|
56
|
+
make_client(handler).images.generate("x")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_402_maps_to_insufficient_balance():
|
|
60
|
+
def handler(request):
|
|
61
|
+
return httpx.Response(402, json={"detail": "Insufficient balance"})
|
|
62
|
+
|
|
63
|
+
with pytest.raises(InsufficientBalanceError):
|
|
64
|
+
make_client(handler).images.generate("x")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_429_retries_then_succeeds(monkeypatch):
|
|
68
|
+
monkeypatch.setattr(client_mod.time, "sleep", lambda s: None)
|
|
69
|
+
attempts = []
|
|
70
|
+
|
|
71
|
+
def handler(request):
|
|
72
|
+
attempts.append(1)
|
|
73
|
+
if len(attempts) == 1:
|
|
74
|
+
return httpx.Response(429, headers={"Retry-After": "0"})
|
|
75
|
+
return httpx.Response(200, json={"balance_cents": 845})
|
|
76
|
+
|
|
77
|
+
assert make_client(handler).balance_cents() == 845
|
|
78
|
+
assert len(attempts) == 2
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_wait_false_returns_job_handle():
|
|
82
|
+
def handler(request):
|
|
83
|
+
return httpx.Response(202, json={"id": "j3", "status": "processing"})
|
|
84
|
+
|
|
85
|
+
job = make_client(handler).images.generate("x", wait=False)
|
|
86
|
+
assert job.id == "j3"
|
|
87
|
+
assert job.status == "processing"
|