ciralgo 1.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.
ciralgo-1.1.0/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Ciralgo BV
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
18
+
19
+ The full Apache License 2.0 text is available at the URL above.
ciralgo-1.1.0/NOTICE ADDED
@@ -0,0 +1,17 @@
1
+ Ciralgo Python SDK
2
+ Copyright 2026 Ciralgo B.V.
3
+
4
+ This product is the official Python client for the Ciralgo Platform API
5
+ (https://www.ciralgo.com).
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License"); you may
8
+ not use this file except in compliance with the License. You may obtain
9
+ a copy of the License at:
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
ciralgo-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciralgo
3
+ Version: 1.1.0
4
+ Summary: Official Python SDK for the Ciralgo Platform API
5
+ Project-URL: Homepage, https://www.ciralgo.com
6
+ Project-URL: Documentation, https://docs.ciralgo.com/sdk-python
7
+ Project-URL: Repository, https://github.com/Ciralgo/ciralgo-python
8
+ Project-URL: Changelog, https://github.com/Ciralgo/ciralgo-python/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/Ciralgo/ciralgo-python/issues
10
+ Author-email: Ciralgo <support@ciralgo.com>
11
+ License-Expression: Apache-2.0
12
+ License-File: LICENSE
13
+ License-File: NOTICE
14
+ Keywords: ai,anthropic,ciralgo,compliance,eu-sovereignty,llm,openai
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: Apache Software License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<1.0,>=0.27
26
+ Requires-Dist: typing-extensions>=4.5; python_version < '3.11'
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: respx>=0.21; extra == 'dev'
32
+ Requires-Dist: ruff>=0.5; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Ciralgo Python SDK
36
+
37
+ Official Python SDK for the [Ciralgo Platform API](https://www.ciralgo.com).
38
+
39
+ Version: `1.1.0` (mirrors `openapi.json info.version`).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install ciralgo
45
+ ```
46
+
47
+ Requires Python 3.9+.
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ from ciralgo import Client
53
+
54
+ client = Client(api_key="sk-cg-...") # or set CIRALGO_API_KEY in your env
55
+
56
+ response = client.chat.completions.create(
57
+ model="openai/gpt-4o-mini",
58
+ messages=[{"role": "user", "content": "Say hello."}],
59
+ )
60
+ print(response["choices"][0]["message"]["content"])
61
+ ```
62
+
63
+ ## Authentication
64
+
65
+ The SDK reads the API key from (in order):
66
+
67
+ 1. The `api_key=` argument to `Client(...)` or `AsyncClient(...)`.
68
+ 2. The `CIRALGO_API_KEY` environment variable.
69
+
70
+ If neither is set, `AuthenticationError` is raised at construction time.
71
+
72
+ The optional `CIRALGO_BASE_URL` env var overrides the default `https://api.ciralgo.com`. This is useful for staging or for self-hosted Ciralgo deployments.
73
+
74
+ ## Endpoints
75
+
76
+ The four public proxy operations from the Ciralgo OpenAPI spec (v1.1.0):
77
+
78
+ ### Chat completions
79
+
80
+ ```python
81
+ response = client.chat.completions.create(
82
+ model="anthropic/claude-sonnet-4-6",
83
+ messages=[
84
+ {"role": "system", "content": "You are a finance compliance assistant."},
85
+ {"role": "user", "content": "Summarise EU AI Act Article 15."},
86
+ ],
87
+ temperature=0.2,
88
+ max_tokens=400,
89
+ tags={"project": "ai-act-summariser", "env": "prod"},
90
+ )
91
+ ```
92
+
93
+ ### Streaming chat completions
94
+
95
+ ```python
96
+ for chunk in client.chat.completions.create(
97
+ model="openai/gpt-4o-mini",
98
+ messages=[{"role": "user", "content": "Stream me a haiku."}],
99
+ stream=True,
100
+ ):
101
+ delta = chunk["choices"][0]["delta"].get("content", "")
102
+ print(delta, end="", flush=True)
103
+ ```
104
+
105
+ ### Embeddings
106
+
107
+ ```python
108
+ embedding = client.embeddings.create(
109
+ model="openai/text-embedding-3-small",
110
+ input="EU AI Act Article 15 covers accuracy, robustness and cybersecurity.",
111
+ )
112
+ print(len(embedding["data"][0]["embedding"])) # → vector dimension
113
+ ```
114
+
115
+ ### Usage
116
+
117
+ ```python
118
+ usage = client.usage.get(from_date="2026-06-01", to_date="2026-06-30")
119
+ print(usage["total_cost_usd"], usage["calls"])
120
+ ```
121
+
122
+ ### Anthropic Messages
123
+
124
+ ```python
125
+ response = client.anthropic.messages_create(
126
+ model="anthropic/claude-sonnet-4-6",
127
+ max_tokens=400,
128
+ system="You are an EU compliance assistant.",
129
+ messages=[{"role": "user", "content": "What is GDPR Article 32?"}],
130
+ )
131
+ print(response["content"][0]["text"])
132
+ ```
133
+
134
+ ## Error handling
135
+
136
+ The SDK maps HTTP status codes to typed exceptions. Catch the specific class:
137
+
138
+ ```python
139
+ from ciralgo import Client
140
+ from ciralgo.errors import RateLimitError, UpstreamError, AuthenticationError
141
+
142
+ try:
143
+ response = client.chat.completions.create(...)
144
+ except RateLimitError as e:
145
+ time.sleep(e.retry_after or 5)
146
+ # retry
147
+ except UpstreamError as e:
148
+ # upstream LLM provider 5xx, pick a different model
149
+ ...
150
+ except AuthenticationError:
151
+ # rotate / re-issue the key
152
+ raise
153
+ ```
154
+
155
+ Every exception carries:
156
+
157
+ - `code`: stable string error code from the API envelope (e.g. `rate_limit_exceeded`)
158
+ - `message`: human-readable
159
+ - `trace_id`: pass this to Ciralgo support to look up the request server-side
160
+ - `status_code`: HTTP status
161
+ - `retry_after`: only set on 429
162
+
163
+ ## Async
164
+
165
+ ```python
166
+ import asyncio
167
+ from ciralgo import AsyncClient
168
+
169
+ async def main():
170
+ async with AsyncClient() as client:
171
+ # NOTE: async surface in v1.1.0 is the client construction +
172
+ # close lifecycle. Full async parity for chat / embeddings ships
173
+ # in v1.2.0.
174
+ pass
175
+
176
+ asyncio.run(main())
177
+ ```
178
+
179
+ ## Migration to a codegen client
180
+
181
+ The current client is hand-written. A codegen-based replacement is
182
+ viable using [openapi-python-client](https://github.com/openapi-generators/openapi-python-client)
183
+ against the published Ciralgo OpenAPI spec. The hand-written client
184
+ gives us control over:
185
+
186
+ - Streaming semantics (`stream=True` returning an iterator).
187
+ - The `X-Ciralgo-Tags` header marshalling.
188
+ - The typed exception hierarchy (codegen produces a single error class).
189
+
190
+ A future major bump (`v2.0.0`) can switch to codegen if the trade-offs
191
+ change.
192
+
193
+ ## Development
194
+
195
+ From the SDK repo root:
196
+
197
+ ```bash
198
+ pip install -e ".[dev]"
199
+ pytest
200
+ ruff check src
201
+ mypy src
202
+ ```
203
+
204
+ ## Publishing
205
+
206
+ Publishing to PyPI is handled by a GitHub Actions workflow triggered on
207
+ tags of the form `sdk-py-v*`. The workflow uses PyPI Trusted Publishing
208
+ (OIDC). No long-lived API token is stored in CI secrets.
209
+
210
+ The engineer-facing release runbook (version bump, tag push, environment
211
+ approval, troubleshooting) is at [docs/publish-runbook.md](docs/publish-runbook.md).
212
+
213
+ ## License
214
+
215
+ Apache-2.0
@@ -0,0 +1,181 @@
1
+ # Ciralgo Python SDK
2
+
3
+ Official Python SDK for the [Ciralgo Platform API](https://www.ciralgo.com).
4
+
5
+ Version: `1.1.0` (mirrors `openapi.json info.version`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install ciralgo
11
+ ```
12
+
13
+ Requires Python 3.9+.
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from ciralgo import Client
19
+
20
+ client = Client(api_key="sk-cg-...") # or set CIRALGO_API_KEY in your env
21
+
22
+ response = client.chat.completions.create(
23
+ model="openai/gpt-4o-mini",
24
+ messages=[{"role": "user", "content": "Say hello."}],
25
+ )
26
+ print(response["choices"][0]["message"]["content"])
27
+ ```
28
+
29
+ ## Authentication
30
+
31
+ The SDK reads the API key from (in order):
32
+
33
+ 1. The `api_key=` argument to `Client(...)` or `AsyncClient(...)`.
34
+ 2. The `CIRALGO_API_KEY` environment variable.
35
+
36
+ If neither is set, `AuthenticationError` is raised at construction time.
37
+
38
+ The optional `CIRALGO_BASE_URL` env var overrides the default `https://api.ciralgo.com`. This is useful for staging or for self-hosted Ciralgo deployments.
39
+
40
+ ## Endpoints
41
+
42
+ The four public proxy operations from the Ciralgo OpenAPI spec (v1.1.0):
43
+
44
+ ### Chat completions
45
+
46
+ ```python
47
+ response = client.chat.completions.create(
48
+ model="anthropic/claude-sonnet-4-6",
49
+ messages=[
50
+ {"role": "system", "content": "You are a finance compliance assistant."},
51
+ {"role": "user", "content": "Summarise EU AI Act Article 15."},
52
+ ],
53
+ temperature=0.2,
54
+ max_tokens=400,
55
+ tags={"project": "ai-act-summariser", "env": "prod"},
56
+ )
57
+ ```
58
+
59
+ ### Streaming chat completions
60
+
61
+ ```python
62
+ for chunk in client.chat.completions.create(
63
+ model="openai/gpt-4o-mini",
64
+ messages=[{"role": "user", "content": "Stream me a haiku."}],
65
+ stream=True,
66
+ ):
67
+ delta = chunk["choices"][0]["delta"].get("content", "")
68
+ print(delta, end="", flush=True)
69
+ ```
70
+
71
+ ### Embeddings
72
+
73
+ ```python
74
+ embedding = client.embeddings.create(
75
+ model="openai/text-embedding-3-small",
76
+ input="EU AI Act Article 15 covers accuracy, robustness and cybersecurity.",
77
+ )
78
+ print(len(embedding["data"][0]["embedding"])) # → vector dimension
79
+ ```
80
+
81
+ ### Usage
82
+
83
+ ```python
84
+ usage = client.usage.get(from_date="2026-06-01", to_date="2026-06-30")
85
+ print(usage["total_cost_usd"], usage["calls"])
86
+ ```
87
+
88
+ ### Anthropic Messages
89
+
90
+ ```python
91
+ response = client.anthropic.messages_create(
92
+ model="anthropic/claude-sonnet-4-6",
93
+ max_tokens=400,
94
+ system="You are an EU compliance assistant.",
95
+ messages=[{"role": "user", "content": "What is GDPR Article 32?"}],
96
+ )
97
+ print(response["content"][0]["text"])
98
+ ```
99
+
100
+ ## Error handling
101
+
102
+ The SDK maps HTTP status codes to typed exceptions. Catch the specific class:
103
+
104
+ ```python
105
+ from ciralgo import Client
106
+ from ciralgo.errors import RateLimitError, UpstreamError, AuthenticationError
107
+
108
+ try:
109
+ response = client.chat.completions.create(...)
110
+ except RateLimitError as e:
111
+ time.sleep(e.retry_after or 5)
112
+ # retry
113
+ except UpstreamError as e:
114
+ # upstream LLM provider 5xx, pick a different model
115
+ ...
116
+ except AuthenticationError:
117
+ # rotate / re-issue the key
118
+ raise
119
+ ```
120
+
121
+ Every exception carries:
122
+
123
+ - `code`: stable string error code from the API envelope (e.g. `rate_limit_exceeded`)
124
+ - `message`: human-readable
125
+ - `trace_id`: pass this to Ciralgo support to look up the request server-side
126
+ - `status_code`: HTTP status
127
+ - `retry_after`: only set on 429
128
+
129
+ ## Async
130
+
131
+ ```python
132
+ import asyncio
133
+ from ciralgo import AsyncClient
134
+
135
+ async def main():
136
+ async with AsyncClient() as client:
137
+ # NOTE: async surface in v1.1.0 is the client construction +
138
+ # close lifecycle. Full async parity for chat / embeddings ships
139
+ # in v1.2.0.
140
+ pass
141
+
142
+ asyncio.run(main())
143
+ ```
144
+
145
+ ## Migration to a codegen client
146
+
147
+ The current client is hand-written. A codegen-based replacement is
148
+ viable using [openapi-python-client](https://github.com/openapi-generators/openapi-python-client)
149
+ against the published Ciralgo OpenAPI spec. The hand-written client
150
+ gives us control over:
151
+
152
+ - Streaming semantics (`stream=True` returning an iterator).
153
+ - The `X-Ciralgo-Tags` header marshalling.
154
+ - The typed exception hierarchy (codegen produces a single error class).
155
+
156
+ A future major bump (`v2.0.0`) can switch to codegen if the trade-offs
157
+ change.
158
+
159
+ ## Development
160
+
161
+ From the SDK repo root:
162
+
163
+ ```bash
164
+ pip install -e ".[dev]"
165
+ pytest
166
+ ruff check src
167
+ mypy src
168
+ ```
169
+
170
+ ## Publishing
171
+
172
+ Publishing to PyPI is handled by a GitHub Actions workflow triggered on
173
+ tags of the form `sdk-py-v*`. The workflow uses PyPI Trusted Publishing
174
+ (OIDC). No long-lived API token is stored in CI secrets.
175
+
176
+ The engineer-facing release runbook (version bump, tag push, environment
177
+ approval, troubleshooting) is at [docs/publish-runbook.md](docs/publish-runbook.md).
178
+
179
+ ## License
180
+
181
+ Apache-2.0
@@ -0,0 +1,85 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ciralgo"
7
+ version = "1.1.0" # mirrors openapi.json info.version
8
+ description = "Official Python SDK for the Ciralgo Platform API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE", "NOTICE"]
13
+ authors = [
14
+ { name = "Ciralgo", email = "support@ciralgo.com" }
15
+ ]
16
+ keywords = [
17
+ "ciralgo",
18
+ "openai",
19
+ "anthropic",
20
+ "llm",
21
+ "ai",
22
+ "compliance",
23
+ "eu-sovereignty"
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: Apache Software License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ ]
36
+ dependencies = [
37
+ # httpx is the standard async-first HTTP client for Python SDKs in
38
+ # 2026. Mature, sync + async surfaces, types built in. We pin a wide
39
+ # range to be a polite dep. Customers may already have httpx pinned
40
+ # tighter in their own apps.
41
+ "httpx>=0.27,<1.0",
42
+ # typing-extensions for Python 3.9 / 3.10 to handle older TypedDict,
43
+ # Required, NotRequired that 3.11+ ships natively.
44
+ "typing-extensions>=4.5; python_version < '3.11'"
45
+ ]
46
+
47
+ [project.urls]
48
+ Homepage = "https://www.ciralgo.com"
49
+ Documentation = "https://docs.ciralgo.com/sdk-python"
50
+ Repository = "https://github.com/Ciralgo/ciralgo-python"
51
+ Changelog = "https://github.com/Ciralgo/ciralgo-python/blob/main/CHANGELOG.md"
52
+ Issues = "https://github.com/Ciralgo/ciralgo-python/issues"
53
+
54
+ [project.optional-dependencies]
55
+ dev = [
56
+ "pytest>=8.0",
57
+ "pytest-asyncio>=0.23",
58
+ "respx>=0.21", # httpx mocking, no live network in unit tests
59
+ "mypy>=1.10",
60
+ "ruff>=0.5",
61
+ ]
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["src/ciralgo"]
65
+
66
+ [tool.hatch.build.targets.sdist]
67
+ include = [
68
+ "src/ciralgo",
69
+ "README.md",
70
+ "LICENSE",
71
+ "NOTICE",
72
+ "CHANGELOG.md",
73
+ ]
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
77
+ asyncio_mode = "auto"
78
+
79
+ [tool.mypy]
80
+ python_version = "3.9"
81
+ strict = true
82
+
83
+ [tool.ruff]
84
+ line-length = 100
85
+ target-version = "py39"
@@ -0,0 +1,49 @@
1
+ """Official Python SDK for the Ciralgo Platform API.
2
+
3
+ Quick start:
4
+
5
+ from ciralgo import Client
6
+
7
+ client = Client(api_key="sk-cg-...")
8
+ response = client.chat.completions.create(
9
+ model="openai/gpt-4o-mini",
10
+ messages=[{"role": "user", "content": "Hello!"}],
11
+ )
12
+ print(response["choices"][0]["message"]["content"])
13
+
14
+ The SDK is a thin, typed wrapper over the public proxy surface documented
15
+ at https://docs.ciralgo.com. Same authentication, same error envelope.
16
+ See the examples/ directory for chat, embeddings, usage, and Anthropic
17
+ Messages examples.
18
+
19
+ The package version mirrors `openapi.json info.version`. Bumping the API
20
+ spec triggers a coordinated SDK release.
21
+ """
22
+
23
+ from ciralgo.client import Client, AsyncClient
24
+ from ciralgo.errors import (
25
+ CiralgoError,
26
+ AuthenticationError,
27
+ PermissionError,
28
+ NotFoundError,
29
+ RateLimitError,
30
+ ValidationError,
31
+ UpstreamError,
32
+ InternalError,
33
+ )
34
+
35
+ __version__ = "1.1.0"
36
+
37
+ __all__ = [
38
+ "Client",
39
+ "AsyncClient",
40
+ "CiralgoError",
41
+ "AuthenticationError",
42
+ "PermissionError",
43
+ "NotFoundError",
44
+ "RateLimitError",
45
+ "ValidationError",
46
+ "UpstreamError",
47
+ "InternalError",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,374 @@
1
+ """Client implementation for the Ciralgo Platform API.
2
+
3
+ This file ships hand-written wrappers over the four public proxy
4
+ operations documented in the Ciralgo OpenAPI spec v1.1.0:
5
+
6
+ POST /v1/chat/completions
7
+ POST /v1/embeddings
8
+ GET /v1/usage
9
+ POST /anthropic/v1/messages
10
+
11
+ A codegen approach using `openapi-python-client` is a viable replacement
12
+ that produces fully typed dataclasses from the spec; the README documents
13
+ the migration path. The hand-written client gives us full control over
14
+ streaming semantics, error mapping, and retries without taking the
15
+ codegen toolchain as a hard dependency for every release.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import Any, Dict, Iterator, List, Optional, Union
22
+
23
+ import httpx
24
+
25
+ from ciralgo.errors import (
26
+ AuthenticationError,
27
+ CiralgoError,
28
+ InternalError,
29
+ NotFoundError,
30
+ PermissionError,
31
+ RateLimitError,
32
+ UpstreamError,
33
+ ValidationError,
34
+ )
35
+
36
+ DEFAULT_BASE_URL = "https://api.ciralgo.com"
37
+ DEFAULT_TIMEOUT_SEC = 120
38
+ USER_AGENT = "ciralgo-python/1.1.0"
39
+
40
+
41
+ class _ChatCompletions:
42
+ """Sub-resource for /v1/chat/completions.
43
+
44
+ Accessed via `client.chat.completions.create(...)`.
45
+ """
46
+
47
+ def __init__(self, client: "Client") -> None:
48
+ self._client = client
49
+
50
+ def create(
51
+ self,
52
+ *,
53
+ model: str,
54
+ messages: List[Dict[str, Any]],
55
+ temperature: Optional[float] = None,
56
+ max_tokens: Optional[int] = None,
57
+ stream: bool = False,
58
+ stream_options: Optional[Dict[str, Any]] = None,
59
+ tools: Optional[List[Dict[str, Any]]] = None,
60
+ tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
61
+ response_format: Optional[Dict[str, Any]] = None,
62
+ idempotency_key: Optional[str] = None,
63
+ tags: Optional[Dict[str, str]] = None,
64
+ ) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
65
+ """Create a chat completion.
66
+
67
+ Returns the JSON dict when stream=False. When stream=True, returns
68
+ an iterator yielding the SSE chunks as decoded dicts. The final
69
+ `data: [DONE]` sentinel is consumed by the iterator and ends it.
70
+ """
71
+ body: Dict[str, Any] = {"model": model, "messages": messages}
72
+ if temperature is not None:
73
+ body["temperature"] = temperature
74
+ if max_tokens is not None:
75
+ body["max_tokens"] = max_tokens
76
+ if stream:
77
+ body["stream"] = True
78
+ if stream_options is not None:
79
+ body["stream_options"] = stream_options
80
+ if tools is not None:
81
+ body["tools"] = tools
82
+ if tool_choice is not None:
83
+ body["tool_choice"] = tool_choice
84
+ if response_format is not None:
85
+ body["response_format"] = response_format
86
+
87
+ headers: Dict[str, str] = {}
88
+ if idempotency_key:
89
+ headers["Idempotency-Key"] = idempotency_key
90
+ if tags:
91
+ headers["X-Ciralgo-Tags"] = ",".join(f"{k}={v}" for k, v in tags.items())
92
+
93
+ if stream:
94
+ return self._client._stream("POST", "/v1/chat/completions", body, headers)
95
+ return self._client._request("POST", "/v1/chat/completions", body, headers)
96
+
97
+
98
+ class _Embeddings:
99
+ """Sub-resource for /v1/embeddings."""
100
+
101
+ def __init__(self, client: "Client") -> None:
102
+ self._client = client
103
+
104
+ def create(
105
+ self,
106
+ *,
107
+ model: str,
108
+ input: Union[str, List[str]],
109
+ encoding_format: Optional[str] = None,
110
+ ) -> Dict[str, Any]:
111
+ body: Dict[str, Any] = {"model": model, "input": input}
112
+ if encoding_format is not None:
113
+ body["encoding_format"] = encoding_format
114
+ return self._client._request("POST", "/v1/embeddings", body)
115
+
116
+
117
+ class _Usage:
118
+ """Sub-resource for /v1/usage."""
119
+
120
+ def __init__(self, client: "Client") -> None:
121
+ self._client = client
122
+
123
+ def get(
124
+ self,
125
+ *,
126
+ from_date: Optional[str] = None,
127
+ to_date: Optional[str] = None,
128
+ group_by: Optional[str] = None,
129
+ ) -> Dict[str, Any]:
130
+ params: Dict[str, str] = {}
131
+ if from_date:
132
+ params["from"] = from_date
133
+ if to_date:
134
+ params["to"] = to_date
135
+ if group_by:
136
+ params["group_by"] = group_by
137
+ return self._client._request("GET", "/v1/usage", params=params)
138
+
139
+
140
+ class _Anthropic:
141
+ """Sub-resource for /anthropic/v1/messages."""
142
+
143
+ def __init__(self, client: "Client") -> None:
144
+ self._client = client
145
+
146
+ def messages_create(
147
+ self,
148
+ *,
149
+ model: str,
150
+ messages: List[Dict[str, Any]],
151
+ max_tokens: int,
152
+ system: Optional[str] = None,
153
+ tools: Optional[List[Dict[str, Any]]] = None,
154
+ stream: bool = False,
155
+ ) -> Union[Dict[str, Any], Iterator[Dict[str, Any]]]:
156
+ body: Dict[str, Any] = {
157
+ "model": model,
158
+ "messages": messages,
159
+ "max_tokens": max_tokens,
160
+ }
161
+ if system is not None:
162
+ body["system"] = system
163
+ if tools is not None:
164
+ body["tools"] = tools
165
+ if stream:
166
+ body["stream"] = True
167
+ return self._client._stream("POST", "/anthropic/v1/messages", body)
168
+ return self._client._request("POST", "/anthropic/v1/messages", body)
169
+
170
+
171
+ class _ChatNamespace:
172
+ """`client.chat.completions.create(...)` plumbing."""
173
+
174
+ def __init__(self, client: "Client") -> None:
175
+ self.completions = _ChatCompletions(client)
176
+
177
+
178
+ class Client:
179
+ """Synchronous Ciralgo client.
180
+
181
+ Usage:
182
+
183
+ from ciralgo import Client
184
+ client = Client(api_key="sk-cg-...")
185
+ r = client.chat.completions.create(
186
+ model="openai/gpt-4o-mini",
187
+ messages=[{"role": "user", "content": "Hello"}],
188
+ )
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ *,
194
+ api_key: Optional[str] = None,
195
+ base_url: Optional[str] = None,
196
+ timeout: float = DEFAULT_TIMEOUT_SEC,
197
+ ) -> None:
198
+ self.api_key = api_key or os.environ.get("CIRALGO_API_KEY")
199
+ if not self.api_key:
200
+ raise AuthenticationError(
201
+ code="missing_api_key",
202
+ message=(
203
+ "No API key found. Pass api_key=... or set CIRALGO_API_KEY in the environment."
204
+ ),
205
+ )
206
+ self.base_url = (base_url or os.environ.get("CIRALGO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
207
+ self._http = httpx.Client(timeout=timeout, headers=self._default_headers())
208
+ self.chat = _ChatNamespace(self)
209
+ self.embeddings = _Embeddings(self)
210
+ self.usage = _Usage(self)
211
+ self.anthropic = _Anthropic(self)
212
+
213
+ # Sentinel close method for `with Client(...) as client:` patterns.
214
+ def __enter__(self) -> "Client":
215
+ return self
216
+
217
+ def __exit__(self, *_: Any) -> None:
218
+ self.close()
219
+
220
+ def close(self) -> None:
221
+ self._http.close()
222
+
223
+ def _default_headers(self) -> Dict[str, str]:
224
+ return {
225
+ "Authorization": f"Bearer {self.api_key}",
226
+ "User-Agent": USER_AGENT,
227
+ "Accept": "application/json",
228
+ }
229
+
230
+ def _request(
231
+ self,
232
+ method: str,
233
+ path: str,
234
+ json_body: Optional[Dict[str, Any]] = None,
235
+ extra_headers: Optional[Dict[str, str]] = None,
236
+ params: Optional[Dict[str, str]] = None,
237
+ ) -> Dict[str, Any]:
238
+ url = f"{self.base_url}{path}"
239
+ try:
240
+ resp = self._http.request(
241
+ method,
242
+ url,
243
+ json=json_body,
244
+ params=params,
245
+ headers=extra_headers or None,
246
+ )
247
+ except httpx.TimeoutException as e:
248
+ raise UpstreamError(
249
+ code="upstream_timeout",
250
+ message=f"Request timed out: {e}",
251
+ ) from e
252
+ return self._handle(resp)
253
+
254
+ def _stream(
255
+ self,
256
+ method: str,
257
+ path: str,
258
+ json_body: Dict[str, Any],
259
+ extra_headers: Optional[Dict[str, str]] = None,
260
+ ) -> Iterator[Dict[str, Any]]:
261
+ """Stream SSE chunks from /v1/chat/completions or /anthropic/v1/messages.
262
+
263
+ Yields decoded JSON chunks. Consumes and discards the `[DONE]`
264
+ sentinel so the iterator ends naturally.
265
+ """
266
+ import json as _json
267
+
268
+ url = f"{self.base_url}{path}"
269
+ headers = dict(self._default_headers())
270
+ headers["Accept"] = "text/event-stream"
271
+ if extra_headers:
272
+ headers.update(extra_headers)
273
+
274
+ with self._http.stream(method, url, json=json_body, headers=headers) as resp:
275
+ if resp.status_code >= 400:
276
+ # Read the body, route through _handle for a typed error.
277
+ body = resp.read()
278
+ resp_for_handle = httpx.Response(
279
+ status_code=resp.status_code,
280
+ headers=resp.headers,
281
+ content=body,
282
+ )
283
+ self._handle(resp_for_handle)
284
+ return # _handle always raises
285
+
286
+ for line in resp.iter_lines():
287
+ if not line.startswith("data:"):
288
+ continue
289
+ payload = line[len("data:"):].strip()
290
+ if payload == "[DONE]":
291
+ return
292
+ if not payload:
293
+ continue
294
+ try:
295
+ yield _json.loads(payload)
296
+ except Exception:
297
+ # Skip malformed chunks rather than crash the stream.
298
+ continue
299
+
300
+ def _handle(self, resp: httpx.Response) -> Dict[str, Any]:
301
+ """Map a finished httpx.Response to a body dict or a typed exception."""
302
+ if 200 <= resp.status_code < 300:
303
+ if not resp.content:
304
+ return {}
305
+ return resp.json()
306
+
307
+ # Error path. Pull what we can from the standard envelope.
308
+ try:
309
+ body = resp.json()
310
+ except Exception:
311
+ body = {}
312
+
313
+ err_obj = body.get("error") if isinstance(body, dict) else None
314
+ code = (err_obj or {}).get("code", "unknown_error")
315
+ message = (err_obj or {}).get("message", f"HTTP {resp.status_code}")
316
+ trace_id = (err_obj or {}).get("trace_id") or resp.headers.get("X-Trace-Id")
317
+ retry_after_raw = (err_obj or {}).get("retry_after") or resp.headers.get("Retry-After")
318
+ try:
319
+ retry_after = int(retry_after_raw) if retry_after_raw is not None else None
320
+ except (TypeError, ValueError):
321
+ retry_after = None
322
+
323
+ kwargs = dict(code=code, message=message, trace_id=trace_id, status_code=resp.status_code, retry_after=retry_after)
324
+ if resp.status_code == 400:
325
+ raise ValidationError(**kwargs)
326
+ if resp.status_code == 401:
327
+ raise AuthenticationError(**kwargs)
328
+ if resp.status_code == 403:
329
+ raise PermissionError(**kwargs)
330
+ if resp.status_code == 404:
331
+ raise NotFoundError(**kwargs)
332
+ if resp.status_code == 429:
333
+ raise RateLimitError(**kwargs)
334
+ if resp.status_code == 502:
335
+ raise UpstreamError(**kwargs)
336
+ if resp.status_code >= 500:
337
+ raise InternalError(**kwargs)
338
+ raise CiralgoError(**kwargs)
339
+
340
+
341
+ class AsyncClient:
342
+ """Async sibling of Client. Same surface, async methods.
343
+
344
+ Intentionally minimal in v1.1.0. Covers the same four operations. A
345
+ follow-up release adds parity for streaming + retries.
346
+ """
347
+
348
+ def __init__(
349
+ self,
350
+ *,
351
+ api_key: Optional[str] = None,
352
+ base_url: Optional[str] = None,
353
+ timeout: float = DEFAULT_TIMEOUT_SEC,
354
+ ) -> None:
355
+ # Reuse the sync client's auth + URL resolution by constructing a
356
+ # bare Client and copying its attributes. Avoids duplicating the
357
+ # config logic until the async surface diverges.
358
+ sync = Client(api_key=api_key, base_url=base_url, timeout=timeout)
359
+ sync.close()
360
+ self.api_key = sync.api_key
361
+ self.base_url = sync.base_url
362
+ self._http = httpx.AsyncClient(
363
+ timeout=timeout,
364
+ headers=sync._default_headers(),
365
+ )
366
+
367
+ async def close(self) -> None:
368
+ await self._http.aclose()
369
+
370
+ async def __aenter__(self) -> "AsyncClient":
371
+ return self
372
+
373
+ async def __aexit__(self, *_: Any) -> None:
374
+ await self.close()
@@ -0,0 +1,86 @@
1
+ """Typed error hierarchy mirroring the OpenAPI ErrorResponse envelope.
2
+
3
+ Every API error from Ciralgo carries the shape:
4
+
5
+ {
6
+ "ok": false,
7
+ "error": {
8
+ "code": "<stable_code>",
9
+ "message": "<human readable>",
10
+ "trace_id": "<request id>",
11
+ "retry_after": <seconds, only on 429>
12
+ }
13
+ }
14
+
15
+ The client maps HTTP status codes to one of these exception classes so
16
+ customer code can catch the specific class rather than parsing the
17
+ envelope by hand:
18
+
19
+ try:
20
+ client.chat.completions.create(...)
21
+ except RateLimitError as e:
22
+ time.sleep(e.retry_after or 5)
23
+ # retry
24
+ except UpstreamError as e:
25
+ # upstream LLM provider returned 5xx, try a different model
26
+ ...
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Optional
32
+
33
+
34
+ class CiralgoError(Exception):
35
+ """Base class for every Ciralgo SDK error.
36
+
37
+ Carries the API-level error code, the human message, the trace_id for
38
+ support correlation, and the raw HTTP status code.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ code: str,
45
+ message: str,
46
+ trace_id: Optional[str] = None,
47
+ status_code: Optional[int] = None,
48
+ retry_after: Optional[int] = None,
49
+ ) -> None:
50
+ super().__init__(message)
51
+ self.code = code
52
+ self.message = message
53
+ self.trace_id = trace_id
54
+ self.status_code = status_code
55
+ self.retry_after = retry_after
56
+
57
+
58
+ class ValidationError(CiralgoError):
59
+ """4xx: request shape rejected by the server (400)."""
60
+
61
+
62
+ class AuthenticationError(CiralgoError):
63
+ """401: invalid or missing API key."""
64
+
65
+
66
+ class PermissionError(CiralgoError):
67
+ """403: caller does not have permission (tenant policy, admin MFA, etc.)."""
68
+
69
+
70
+ class NotFoundError(CiralgoError):
71
+ """404: resource does not exist or is not visible to this caller."""
72
+
73
+
74
+ class RateLimitError(CiralgoError):
75
+ """429: per-key / per-org RPM or TPM limit exceeded.
76
+
77
+ The `retry_after` attribute is set when the server provided one.
78
+ """
79
+
80
+
81
+ class UpstreamError(CiralgoError):
82
+ """502: upstream LLM provider returned an error or timed out."""
83
+
84
+
85
+ class InternalError(CiralgoError):
86
+ """5xx: unexpected server-side error. Retry with exponential backoff."""