codai-sdk 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.
codai/__init__.py ADDED
@@ -0,0 +1,338 @@
1
+ """codai — official Python SDK for the codai AI gateway.
2
+
3
+ Zero-dependency (stdlib urllib) client mirroring @codai/sdk:
4
+
5
+ from codai import Codai
6
+
7
+ client = Codai(api_key="ck-...", session_id="my-session")
8
+ result = client.chat([{"role": "user", "content": "hello"}])
9
+ print(result.content, result.routed_to)
10
+
11
+ for delta in client.chat_stream([{"role": "user", "content": "hi"}]):
12
+ print(delta, end="")
13
+
14
+ run = client.agents_run("summarize the repo structure")
15
+ client.feedback(result.request_id, 1)
16
+
17
+ Memory tools (memory_recall/memory_remember) and skills are GATEWAY-NATIVE:
18
+ pass a stable session_id and the model uses them server-side.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, Generator, List, Optional
29
+
30
+ __all__ = ["Codai", "CodaiError", "ChatResult", "AgentRunResult"]
31
+ __version__ = "0.1.0"
32
+
33
+ _RETRYABLE = {429, 500, 502, 503, 504}
34
+
35
+
36
+ class CodaiError(Exception):
37
+ def __init__(self, message: str, status: int = 0, body: Any = None) -> None:
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.body = body
41
+
42
+
43
+ @dataclass
44
+ class ChatResult:
45
+ content: str
46
+ raw: Dict[str, Any]
47
+ request_id: Optional[str]
48
+ routed_to: Optional[str]
49
+ exec_verify: Optional[str]
50
+ usage: Optional[Dict[str, int]]
51
+
52
+
53
+ @dataclass
54
+ class AgentRunResult:
55
+ result: str
56
+ model: str
57
+ event_id: Optional[str]
58
+ usage: Optional[Dict[str, Any]] = field(default=None)
59
+
60
+
61
+ class Codai:
62
+ def __init__(
63
+ self,
64
+ api_key: str,
65
+ base_url: str = "https://ai.codai.ro",
66
+ session_id: Optional[str] = None,
67
+ timeout: float = 120.0,
68
+ max_retries: int = 2,
69
+ ) -> None:
70
+ if not api_key:
71
+ raise ValueError("Codai: api_key is required")
72
+ self.api_key = api_key
73
+ self.base_url = base_url.rstrip("/")
74
+ self.session_id = session_id
75
+ self.timeout = timeout
76
+ self.max_retries = max_retries
77
+
78
+ # ------------------------------------------------------------- chat
79
+ def chat(
80
+ self,
81
+ messages: List[Dict[str, Any]],
82
+ model: str = "codai",
83
+ temperature: Optional[float] = None,
84
+ max_tokens: Optional[int] = None,
85
+ tools: Optional[List[Dict[str, Any]]] = None,
86
+ agent_mode: bool = False,
87
+ compact: Optional[str] = None,
88
+ best_of: Optional[int] = None,
89
+ session_id: Optional[str] = None,
90
+ ) -> ChatResult:
91
+ body: Dict[str, Any] = {"model": model, "messages": messages, "stream": False}
92
+ if temperature is not None:
93
+ body["temperature"] = temperature
94
+ if max_tokens is not None:
95
+ body["max_tokens"] = max_tokens
96
+ if tools:
97
+ body["tools"] = tools
98
+ headers = self._chat_headers(agent_mode, compact, best_of, session_id)
99
+ status, resp_headers, data = self._request(
100
+ "/v1/chat/completions", body, extra_headers=headers
101
+ )
102
+ raw = json.loads(data)
103
+ choices = raw.get("choices") or []
104
+ usage_raw = raw.get("usage") or {}
105
+ return ChatResult(
106
+ content=str((choices[0].get("message") or {}).get("content") or "")
107
+ if choices
108
+ else "",
109
+ raw=raw,
110
+ request_id=resp_headers.get("x-request-id"),
111
+ routed_to=resp_headers.get("x-codai-routed-to"),
112
+ exec_verify=resp_headers.get("x-codai-exec-verify"),
113
+ usage=(
114
+ {
115
+ "prompt_tokens": int(usage_raw.get("prompt_tokens") or 0),
116
+ "completion_tokens": int(usage_raw.get("completion_tokens") or 0),
117
+ }
118
+ if usage_raw
119
+ else None
120
+ ),
121
+ )
122
+
123
+ def chat_stream(
124
+ self,
125
+ messages: List[Dict[str, Any]],
126
+ model: str = "codai",
127
+ temperature: Optional[float] = None,
128
+ max_tokens: Optional[int] = None,
129
+ agent_mode: bool = False,
130
+ session_id: Optional[str] = None,
131
+ ) -> Generator[str, None, None]:
132
+ body: Dict[str, Any] = {"model": model, "messages": messages, "stream": True}
133
+ if temperature is not None:
134
+ body["temperature"] = temperature
135
+ if max_tokens is not None:
136
+ body["max_tokens"] = max_tokens
137
+ headers = self._chat_headers(agent_mode, None, None, session_id)
138
+ req = self._build_request("/v1/chat/completions", body, headers)
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
141
+ buffer = b""
142
+ for chunk in iter(lambda: resp.read(1024), b""):
143
+ buffer += chunk
144
+ while b"\n\n" in buffer:
145
+ frame, buffer = buffer.split(b"\n\n", 1)
146
+ for line in frame.split(b"\n"):
147
+ if not line.startswith(b"data:"):
148
+ continue
149
+ payload = line[5:].strip()
150
+ if payload == b"[DONE]":
151
+ return
152
+ try:
153
+ parsed = json.loads(payload)
154
+ delta = (
155
+ (parsed.get("choices") or [{}])[0]
156
+ .get("delta", {})
157
+ .get("content")
158
+ )
159
+ if delta:
160
+ yield delta
161
+ except json.JSONDecodeError:
162
+ continue
163
+ except urllib.error.HTTPError as e: # pragma: no cover
164
+ raise CodaiError(f"codai stream → {e.code}", e.code, e.read()) from e
165
+
166
+ # ------------------------------------------------------------ agents
167
+ def agents_run(
168
+ self,
169
+ task: str,
170
+ context: Optional[str] = None,
171
+ system: Optional[str] = None,
172
+ model: Optional[str] = None,
173
+ ) -> AgentRunResult:
174
+ body: Dict[str, Any] = {"task": task}
175
+ if context:
176
+ body["context"] = context
177
+ if system:
178
+ body["system"] = system
179
+ if model:
180
+ body["model"] = model
181
+ _, _, data = self._request("/v1/agents/run", body)
182
+ raw = json.loads(data)
183
+ return AgentRunResult(
184
+ result=str(raw.get("result") or ""),
185
+ model=str(raw.get("model") or ""),
186
+ event_id=raw.get("event_id"),
187
+ usage=raw.get("usage"),
188
+ )
189
+
190
+ # ---------------------------------------------------------- feedback
191
+ def feedback(self, request_id: str, rating: int, comment: Optional[str] = None) -> None:
192
+ body: Dict[str, Any] = {"event_id": request_id, "rating": rating}
193
+ if comment:
194
+ body["comment"] = comment
195
+ self._request("/v1/feedback", body)
196
+
197
+ # ------------------------------------------------------------ models
198
+ def models(self) -> List[Dict[str, Any]]:
199
+ _, _, data = self._request("/v1/models", None, method="GET")
200
+ return json.loads(data).get("data") or []
201
+
202
+ # ------------------------------------------------------------ tokens
203
+ def mint_token(self, scope: str, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
204
+ """Mint an ephemeral scoped token ('realtime'|'audio'|'embeddings').
205
+
206
+ Returns {"token", "expires_at", "scope"}. Hand the token to a
207
+ browser/webview client; it expires in 60-3600s and only works on
208
+ its scope's surface.
209
+ """
210
+ body: Dict[str, Any] = {"scope": scope}
211
+ if ttl_seconds is not None:
212
+ body["ttl_seconds"] = ttl_seconds
213
+ _, _, data = self._request("/v1/tokens", body)
214
+ return json.loads(data)
215
+
216
+ # -------------------------------------------------------- embeddings
217
+ def embeddings(
218
+ self,
219
+ input: Any,
220
+ model: str = "codai-embed",
221
+ dimensions: Optional[int] = None,
222
+ ) -> List[List[float]]:
223
+ body: Dict[str, Any] = {"model": model, "input": input}
224
+ if dimensions is not None:
225
+ body["dimensions"] = dimensions
226
+ _, _, data = self._request("/v1/embeddings", body)
227
+ return [d["embedding"] for d in json.loads(data).get("data") or []]
228
+
229
+ # ------------------------------------------------------------- audio
230
+ def transcribe(
231
+ self,
232
+ file_bytes: bytes,
233
+ filename: str = "audio.webm",
234
+ model: str = "codai-transcribe",
235
+ ) -> str:
236
+ """Speech-to-text via multipart upload (max 25 MB). Returns transcript."""
237
+ boundary = f"----codai{int(time.time() * 1000)}"
238
+ parts = []
239
+ parts.append(
240
+ f"--{boundary}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n{model}\r\n".encode()
241
+ )
242
+ parts.append(
243
+ (
244
+ f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
245
+ f"filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
246
+ ).encode()
247
+ )
248
+ parts.append(file_bytes)
249
+ parts.append(f"\r\n--{boundary}--\r\n".encode())
250
+ payload = b"".join(parts)
251
+ req = urllib.request.Request(
252
+ f"{self.base_url}/v1/audio/transcriptions",
253
+ data=payload,
254
+ headers={
255
+ "Authorization": f"Bearer {self.api_key}",
256
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
257
+ },
258
+ method="POST",
259
+ )
260
+ try:
261
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
262
+ return str(json.loads(resp.read()).get("text") or "")
263
+ except urllib.error.HTTPError as e:
264
+ try:
265
+ err_body = json.loads(e.read())
266
+ except Exception: # noqa: BLE001
267
+ err_body = None
268
+ raise CodaiError(f"codai /v1/audio/transcriptions → {e.code}", e.code, err_body) from e
269
+
270
+ def speech(
271
+ self,
272
+ input: str,
273
+ model: str = "codai-tts",
274
+ voice: str = "alloy",
275
+ ) -> bytes:
276
+ """Text-to-speech. Returns audio bytes (default mp3)."""
277
+ _, _, data = self._request(
278
+ "/v1/audio/speech", {"model": model, "input": input, "voice": voice}
279
+ )
280
+ return data
281
+
282
+ # ---------------------------------------------------------- internal
283
+ def _chat_headers(
284
+ self,
285
+ agent_mode: bool,
286
+ compact: Optional[str],
287
+ best_of: Optional[int],
288
+ session_id: Optional[str],
289
+ ) -> Dict[str, str]:
290
+ h: Dict[str, str] = {}
291
+ session = session_id or self.session_id
292
+ if session:
293
+ h["x-codai-session-id"] = session
294
+ if agent_mode:
295
+ h["x-codai-mode"] = "agent"
296
+ if compact:
297
+ h["x-codai-compact"] = compact
298
+ if best_of is not None:
299
+ h["x-codai-best-of"] = str(best_of)
300
+ return h
301
+
302
+ def _build_request(
303
+ self, path: str, body: Any, extra_headers: Optional[Dict[str, str]] = None, method: str = "POST"
304
+ ) -> urllib.request.Request:
305
+ headers = {"Authorization": f"Bearer {self.api_key}"}
306
+ data = None
307
+ if body is not None:
308
+ headers["Content-Type"] = "application/json"
309
+ data = json.dumps(body).encode()
310
+ headers.update(extra_headers or {})
311
+ return urllib.request.Request(
312
+ f"{self.base_url}{path}", data=data, headers=headers, method=method
313
+ )
314
+
315
+ def _request(
316
+ self, path: str, body: Any, extra_headers: Optional[Dict[str, str]] = None, method: str = "POST"
317
+ ) -> tuple:
318
+ last_err: Optional[Exception] = None
319
+ for attempt in range(self.max_retries + 1):
320
+ req = self._build_request(path, body, extra_headers, method)
321
+ try:
322
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
323
+ return resp.status, dict(resp.headers), resp.read()
324
+ except urllib.error.HTTPError as e:
325
+ if e.code in _RETRYABLE and attempt < self.max_retries:
326
+ time.sleep(0.5 * 2**attempt)
327
+ continue
328
+ try:
329
+ err_body = json.loads(e.read())
330
+ except Exception: # noqa: BLE001
331
+ err_body = None
332
+ raise CodaiError(f"codai {path} → {e.code}", e.code, err_body) from e
333
+ except Exception as e: # noqa: BLE001 — network-level
334
+ last_err = e
335
+ if attempt < self.max_retries:
336
+ time.sleep(0.5 * 2**attempt)
337
+ continue
338
+ raise CodaiError(f"codai {path} failed after retries: {last_err}")
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: codai-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the codai AI gateway — chat, streaming, agents, feedback, models.
5
+ Project-URL: Homepage, https://codai.ro
6
+ Project-URL: Repository, https://github.com/dragoscv/codai-sdk-python
7
+ Project-URL: Issues, https://github.com/dragoscv/codai-sdk-python/issues
8
+ Author-email: codai <support@codai.ro>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,codai,gateway,llm,openai-compatible
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+
22
+ # codai-sdk — Python SDK
23
+
24
+ [![PyPI version](https://img.shields.io/pypi/v/codai-sdk.svg)](https://pypi.org/project/codai-sdk/)
25
+ [![Python versions](https://img.shields.io/pypi/pyversions/codai-sdk.svg)](https://pypi.org/project/codai-sdk/)
26
+ [![license](https://img.shields.io/pypi/l/codai-sdk.svg)](./LICENSE)
27
+
28
+ Official Python client for the [codai](https://codai.ro) AI gateway.
29
+ Zero dependencies (stdlib only), Python 3.9+.
30
+
31
+ ```bash
32
+ pip install codai-sdk
33
+ ```
34
+
35
+ > You need a codai API key. Get one at **[codai.ro](https://codai.ro)**.
36
+ > The import name stays `codai`:
37
+
38
+ ```python
39
+ from codai import Codai
40
+
41
+ client = Codai(api_key="ck-...", session_id="my-project")
42
+
43
+ # Chat (OpenAI-compatible, smart-routed)
44
+ result = client.chat([{"role": "user", "content": "Explain asyncio.gather"}])
45
+ print(result.content)
46
+ print(result.routed_to) # which model actually served
47
+
48
+ # Streaming
49
+ for delta in client.chat_stream([{"role": "user", "content": "hi"}]):
50
+ print(delta, end="", flush=True)
51
+
52
+ # Server-side agent loop
53
+ run = client.agents_run("Find and summarize the TODOs in this codebase")
54
+ print(run.result)
55
+
56
+ # Feedback (improves routing for everyone)
57
+ client.feedback(result.request_id, 1)
58
+ ```
59
+
60
+ ## codai extensions
61
+
62
+ | Option | Effect |
63
+ | ----------------- | ------------------------------------ |
64
+ | `session_id` | Session memory + routing stickiness |
65
+ | `agent_mode=True` | Plan-and-execute loop (Pro+) |
66
+ | `compact="auto"` | Server-side context compaction |
67
+ | `best_of=0/3` | Disable / force best-of-N ensembling |
68
+
69
+ MIT © codai
@@ -0,0 +1,5 @@
1
+ codai/__init__.py,sha256=UxrukVprtpHNc_I-4ys6ZjsM9JlO2xMGbd-MazQ1mmw,12778
2
+ codai_sdk-0.1.0.dist-info/METADATA,sha256=UY8teAnt3GknAM2VvYVM2VxPEq06Q3pRMW5pt4-lgws,2464
3
+ codai_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
4
+ codai_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=hS2xIQEKlVIu-hRrMxe4NTasTiucytr6_IZv9SBJH6g,1090
5
+ codai_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dragos Catalin Vladulescu (codai)
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.