codai-sdk 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.
@@ -0,0 +1,33 @@
1
+ name: Bug report
2
+ description: Report a problem with codai-sdk
3
+ labels: [bug]
4
+ body:
5
+ - type: textarea
6
+ id: what
7
+ attributes:
8
+ label: What happened?
9
+ description: A clear description of the bug and what you expected instead.
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: repro
14
+ attributes:
15
+ label: Minimal reproduction
16
+ description: Smallest code snippet that reproduces the issue. Do NOT include your API key.
17
+ render: python
18
+ validations:
19
+ required: true
20
+ - type: input
21
+ id: version
22
+ attributes:
23
+ label: SDK version
24
+ placeholder: "0.1.0"
25
+ validations:
26
+ required: true
27
+ - type: input
28
+ id: python
29
+ attributes:
30
+ label: Python version
31
+ placeholder: "3.12.2"
32
+ validations:
33
+ required: true
@@ -0,0 +1,17 @@
1
+ name: Feature request
2
+ description: Suggest an idea for codai-sdk
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: problem
7
+ attributes:
8
+ label: Problem
9
+ description: What are you trying to do that the SDK doesn't support today?
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: proposal
14
+ attributes:
15
+ label: Proposed solution
16
+ validations:
17
+ required: false
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 5
8
+ - package-ecosystem: github-actions
9
+ directory: "/"
10
+ schedule:
11
+ interval: weekly
@@ -0,0 +1,10 @@
1
+ ## Summary
2
+
3
+ <!-- What does this change and why? -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] No credentials/secrets included
8
+ - [ ] SDK remains dependency-free (stdlib only)
9
+ - [ ] `python -m build` succeeds
10
+ - [ ] CHANGELOG updated (Unreleased)
@@ -0,0 +1,42 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ id-token: write # required for PyPI trusted publishing (OIDC)
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ environment: pypi
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Verify tag matches pyproject version
25
+ if: github.event_name == 'push'
26
+ run: |
27
+ PKG_VERSION="$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
28
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
29
+ if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
30
+ echo "::error::tag $TAG_VERSION != pyproject $PKG_VERSION"
31
+ exit 1
32
+ fi
33
+
34
+ - name: Build
35
+ run: |
36
+ python -m pip install --upgrade build
37
+ python -m build
38
+
39
+ - name: Publish to PyPI
40
+ uses: pypa/gh-action-pypi-publish@release/v1
41
+ # Uses PyPI Trusted Publishing (OIDC) — no API token needed once the
42
+ # trusted publisher is configured on pypi.org for this repo+workflow.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .DS_Store
8
+ .pytest_cache/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to `codai-sdk` are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-23
10
+
11
+ ### Added
12
+
13
+ - Initial public release.
14
+ - `Codai.chat()` — OpenAI-compatible completions with codai extensions
15
+ (`session_id`, `agent_mode`, `compact`, `best_of`).
16
+ - `Codai.chat_stream()` — streaming via generator.
17
+ - `Codai.agents_run()` — server-side agent loop.
18
+ - `Codai.feedback()` — thumbs rating on a completed request.
19
+ - `Codai.models()` — list models available to the key.
20
+ - Zero dependencies (stdlib only), Python 3.9+.
21
+
22
+ [Unreleased]: https://github.com/dragoscv/codai-sdk-python/compare/v0.1.0...HEAD
23
+ [0.1.0]: https://github.com/dragoscv/codai-sdk-python/releases/tag/v0.1.0
@@ -0,0 +1,31 @@
1
+ # Contributor Code of Conduct
2
+
3
+ We as members, contributors, and leaders pledge to make participation in our
4
+ community a harassment-free experience for everyone, regardless of age, body
5
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
6
+ identity and expression, level of experience, education, socio-economic status,
7
+ nationality, personal appearance, race, religion, or sexual identity and
8
+ orientation.
9
+
10
+ ## Our Standards
11
+
12
+ Examples of behavior that contributes to a positive environment:
13
+
14
+ - Demonstrating empathy and kindness toward other people
15
+ - Being respectful of differing opinions, viewpoints, and experiences
16
+ - Giving and gracefully accepting constructive feedback
17
+
18
+ Examples of unacceptable behavior:
19
+
20
+ - Trolling, insulting or derogatory comments, and personal attacks
21
+ - Public or private harassment
22
+ - Publishing others' private information without explicit permission
23
+
24
+ ## Enforcement
25
+
26
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
27
+ reported to the maintainers at **conduct@codai.ro**. All complaints will be
28
+ reviewed and investigated promptly and fairly.
29
+
30
+ This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
31
+ version 2.1.
@@ -0,0 +1,30 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in `codai-sdk`!
4
+
5
+ > **Note:** This repository is a published mirror of the SDK developed inside
6
+ > the codai monorepo. Source changes are made upstream and synced here on
7
+ > release. Issues and PRs are welcome and will be triaged upstream.
8
+
9
+ ## Reporting issues
10
+
11
+ Open an issue with a minimal reproduction, the SDK version, and your Python
12
+ version.
13
+
14
+ ## Pull requests
15
+
16
+ - Keep changes focused and small.
17
+ - Keep the SDK **dependency-free** (stdlib only).
18
+ - Add or update tests where it makes sense.
19
+ - Do not include any credentials in code, tests, or examples.
20
+
21
+ ## Local development
22
+
23
+ ```bash
24
+ python -m pip install --upgrade build
25
+ python -m build # produces wheel + sdist in dist/
26
+ ```
27
+
28
+ ## Code of Conduct
29
+
30
+ By participating you agree to abide by the [Code of Conduct](./CODE_OF_CONDUCT.md).
@@ -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.
@@ -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,48 @@
1
+ # codai-sdk — Python SDK
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/codai-sdk.svg)](https://pypi.org/project/codai-sdk/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/codai-sdk.svg)](https://pypi.org/project/codai-sdk/)
5
+ [![license](https://img.shields.io/pypi/l/codai-sdk.svg)](./LICENSE)
6
+
7
+ Official Python client for the [codai](https://codai.ro) AI gateway.
8
+ Zero dependencies (stdlib only), Python 3.9+.
9
+
10
+ ```bash
11
+ pip install codai-sdk
12
+ ```
13
+
14
+ > You need a codai API key. Get one at **[codai.ro](https://codai.ro)**.
15
+ > The import name stays `codai`:
16
+
17
+ ```python
18
+ from codai import Codai
19
+
20
+ client = Codai(api_key="ck-...", session_id="my-project")
21
+
22
+ # Chat (OpenAI-compatible, smart-routed)
23
+ result = client.chat([{"role": "user", "content": "Explain asyncio.gather"}])
24
+ print(result.content)
25
+ print(result.routed_to) # which model actually served
26
+
27
+ # Streaming
28
+ for delta in client.chat_stream([{"role": "user", "content": "hi"}]):
29
+ print(delta, end="", flush=True)
30
+
31
+ # Server-side agent loop
32
+ run = client.agents_run("Find and summarize the TODOs in this codebase")
33
+ print(run.result)
34
+
35
+ # Feedback (improves routing for everyone)
36
+ client.feedback(result.request_id, 1)
37
+ ```
38
+
39
+ ## codai extensions
40
+
41
+ | Option | Effect |
42
+ | ----------------- | ------------------------------------ |
43
+ | `session_id` | Session memory + routing stickiness |
44
+ | `agent_mode=True` | Plan-and-execute loop (Pro+) |
45
+ | `compact="auto"` | Server-side context compaction |
46
+ | `best_of=0/3` | Disable / force best-of-N ensembling |
47
+
48
+ MIT © codai
@@ -0,0 +1,14 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Please **do not** open public issues for security vulnerabilities.
6
+
7
+ Email **security@codai.ro** with details and reproduction steps. We aim to
8
+ acknowledge reports within 72 hours.
9
+
10
+ ## Scope
11
+
12
+ This repository contains the public client SDK only. It holds no credentials
13
+ and no server-side logic. Never commit your codai API key — load it from an
14
+ environment variable (e.g. `CODAI_API_KEY`).
@@ -0,0 +1,16 @@
1
+ """Server-side agent run.
2
+
3
+ CODAI_API_KEY=sk_... python examples/agent.py
4
+ """
5
+ import os
6
+
7
+ from codai import Codai
8
+
9
+ client = Codai(api_key=os.environ["CODAI_API_KEY"])
10
+
11
+ run = client.agents_run(
12
+ "Summarize the following text in 3 bullet points.",
13
+ context="codai is an OpenAI-compatible AI gateway with smart routing.",
14
+ )
15
+
16
+ print(run.result)
@@ -0,0 +1,14 @@
1
+ """Basic chat completion.
2
+
3
+ CODAI_API_KEY=sk_... python examples/chat.py
4
+ """
5
+ import os
6
+
7
+ from codai import Codai
8
+
9
+ client = Codai(api_key=os.environ["CODAI_API_KEY"])
10
+
11
+ result = client.chat([{"role": "user", "content": "Give me one tip for clean Python."}])
12
+
13
+ print(result.content)
14
+ print("routed to:", result.routed_to)
@@ -0,0 +1,15 @@
1
+ """Submit feedback on a completion.
2
+
3
+ CODAI_API_KEY=sk_... python examples/feedback.py
4
+ """
5
+ import os
6
+
7
+ from codai import Codai
8
+
9
+ client = Codai(api_key=os.environ["CODAI_API_KEY"])
10
+
11
+ result = client.chat([{"role": "user", "content": "Hello!"}])
12
+
13
+ if result.request_id:
14
+ client.feedback(result.request_id, 1) # 1 = up, -1 = down
15
+ print("feedback submitted for", result.request_id)
@@ -0,0 +1,15 @@
1
+ """Streaming chat completion.
2
+
3
+ CODAI_API_KEY=sk_... python examples/streaming.py
4
+ """
5
+ import os
6
+ import sys
7
+
8
+ from codai import Codai
9
+
10
+ client = Codai(api_key=os.environ["CODAI_API_KEY"])
11
+
12
+ for delta in client.chat_stream([{"role": "user", "content": "Write a haiku about Python."}]):
13
+ sys.stdout.write(delta)
14
+ sys.stdout.flush()
15
+ print()
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codai-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the codai AI gateway — chat, streaming, agents, feedback, models."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "codai", email = "support@codai.ro" }]
13
+ keywords = ["codai", "ai", "llm", "openai-compatible", "gateway", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://codai.ro"
27
+ Repository = "https://github.com/dragoscv/codai-sdk-python"
28
+ Issues = "https://github.com/dragoscv/codai-sdk-python/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/codai"]
@@ -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}")