codai-sdk 0.3.0__tar.gz → 0.4.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.
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CHANGELOG.md +17 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/PKG-INFO +28 -1
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/README.md +27 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/pyproject.toml +1 -1
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/scripts/gen-types.py +39 -16
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/__init__.py +21 -2
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_operations.py +11 -0
- codai_sdk-0.4.0/src/codai/_resolve_types.py +174 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_types.py +49 -5
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/__init__.py +5 -0
- codai_sdk-0.4.0/src/codai/resources/resolve.py +148 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/triggers.py +8 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/test_parity.py +4 -3
- codai_sdk-0.4.0/tests/test_resolve.py +95 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/test_resources.py +126 -2
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/dependabot.yml +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/pull_request_template.md +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/workflows/publish.yml +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.gitignore +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CONTRIBUTING.md +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/LICENSE +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/SECURITY.md +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/agent.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/chat.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/feedback.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/streaming.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/scripts/smoke_local.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_http.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/_base.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/agents.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/chat.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/core.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/environments.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/messages.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/platform.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/responses.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/sessions.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/conftest.py +0 -0
- {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/test_http.py +0 -0
|
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-09-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `client.resolve` — codai Resolve (pay per verified fix, `https://resolve.codai.ro`,
|
|
14
|
+
override with `resolve_base_url=`): `submit`, `get`, `accept`, `attestation`,
|
|
15
|
+
`health`, `wait_for`. `ResolveError` / `ResolveInsufficientFundsError` (402 carries
|
|
16
|
+
`topup_url`, `balance`, `required`). `RESOLVE_OPERATION_METHODS` maps every
|
|
17
|
+
`resolve.yaml` operationId; `_resolve_types.py` is generated from it.
|
|
18
|
+
|
|
19
|
+
## [0.3.1] - 2026-09-26
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Regenerated `_types.py`: project prebuild pool fields, environment `prebuild`
|
|
24
|
+
and `prebuild_claimed` on create (CE-0072).
|
|
25
|
+
|
|
9
26
|
## [0.3.0] - 2026-09-25
|
|
10
27
|
|
|
11
28
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codai-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python SDK for the codai AI gateway — chat, messages, responses, streaming, agents, sessions, hosts, tasks, account.
|
|
5
5
|
Project-URL: Homepage, https://codai.ro
|
|
6
6
|
Project-URL: Repository, https://github.com/codai-ro/codai-sdk-python
|
|
@@ -147,6 +147,33 @@ Typed request/response shapes live in `codai._types` (generated from the
|
|
|
147
147
|
OpenAPI spec — `TypedDict`s such as `ChatCompletionRequest`, `Task`,
|
|
148
148
|
`SessionEvent`, `CreateSessionShareResponse`).
|
|
149
149
|
|
|
150
|
+
## codai Resolve (0.4.0)
|
|
151
|
+
|
|
152
|
+
Pay per verified fix: submit a public GitHub issue, get a flat quote, accept it
|
|
153
|
+
(held on your wallet), and pay only when the fix passes your tests. First 3 fixes
|
|
154
|
+
are free. Lives on `https://resolve.codai.ro` (same key; `resolve_base_url=` to override).
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from codai import Codai, ResolveInsufficientFundsError
|
|
158
|
+
|
|
159
|
+
client = Codai(api_key="codai_...")
|
|
160
|
+
job = client.resolve.submit({
|
|
161
|
+
"repo_url": "https://github.com/owner/repo",
|
|
162
|
+
"issue_url": "https://github.com/owner/repo/issues/42",
|
|
163
|
+
})
|
|
164
|
+
if job["status"] == "quoted":
|
|
165
|
+
try:
|
|
166
|
+
client.resolve.accept(job["id"])
|
|
167
|
+
except ResolveInsufficientFundsError as e:
|
|
168
|
+
print("top up first:", e.topup_url)
|
|
169
|
+
raise
|
|
170
|
+
done = client.resolve.wait_for(job["id"])
|
|
171
|
+
print(done["status"], done.get("attestation_url"))
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Failed attempts and infrastructure errors refund the hold in full. Types:
|
|
175
|
+
`codai._resolve_types` (generated from `resolve.yaml`).
|
|
176
|
+
|
|
150
177
|
## Development
|
|
151
178
|
|
|
152
179
|
```bash
|
|
@@ -123,6 +123,33 @@ Typed request/response shapes live in `codai._types` (generated from the
|
|
|
123
123
|
OpenAPI spec — `TypedDict`s such as `ChatCompletionRequest`, `Task`,
|
|
124
124
|
`SessionEvent`, `CreateSessionShareResponse`).
|
|
125
125
|
|
|
126
|
+
## codai Resolve (0.4.0)
|
|
127
|
+
|
|
128
|
+
Pay per verified fix: submit a public GitHub issue, get a flat quote, accept it
|
|
129
|
+
(held on your wallet), and pay only when the fix passes your tests. First 3 fixes
|
|
130
|
+
are free. Lives on `https://resolve.codai.ro` (same key; `resolve_base_url=` to override).
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from codai import Codai, ResolveInsufficientFundsError
|
|
134
|
+
|
|
135
|
+
client = Codai(api_key="codai_...")
|
|
136
|
+
job = client.resolve.submit({
|
|
137
|
+
"repo_url": "https://github.com/owner/repo",
|
|
138
|
+
"issue_url": "https://github.com/owner/repo/issues/42",
|
|
139
|
+
})
|
|
140
|
+
if job["status"] == "quoted":
|
|
141
|
+
try:
|
|
142
|
+
client.resolve.accept(job["id"])
|
|
143
|
+
except ResolveInsufficientFundsError as e:
|
|
144
|
+
print("top up first:", e.topup_url)
|
|
145
|
+
raise
|
|
146
|
+
done = client.resolve.wait_for(job["id"])
|
|
147
|
+
print(done["status"], done.get("attestation_url"))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Failed attempts and infrastructure errors refund the hold in full. Types:
|
|
151
|
+
`codai._resolve_types` (generated from `resolve.yaml`).
|
|
152
|
+
|
|
126
153
|
## Development
|
|
127
154
|
|
|
128
155
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codai-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Official Python SDK for the codai AI gateway — chat, messages, responses, streaming, agents, sessions, hosts, tasks, account."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate ``src/codai/_types.py`` from ``apps/docs/openapi/en/gateway.yaml
|
|
2
|
+
"""Generate ``src/codai/_types.py`` from ``apps/docs/openapi/en/gateway.yaml``
|
|
3
|
+
(and ``src/codai/_resolve_types.py`` from ``resolve.yaml`` with ``--spec resolve``).
|
|
3
4
|
|
|
4
5
|
TypedDicts for every ``components.schemas`` entry plus a ``<operationId>Body`` /
|
|
5
6
|
``<operationId>Response`` alias for each operation's JSON request / 2xx body.
|
|
6
7
|
Deterministic: the same spec always produces byte-identical output, so a second
|
|
7
8
|
run is a no-op (the parity CI step diffs the committed file against a fresh run).
|
|
8
9
|
|
|
9
|
-
.\\.venv\\Scripts\\python scripts/gen-types.py
|
|
10
|
-
.\\.venv\\Scripts\\python scripts/gen-types.py --check
|
|
10
|
+
.\\.venv\\Scripts\\python scripts/gen-types.py # write
|
|
11
|
+
.\\.venv\\Scripts\\python scripts/gen-types.py --check # exit 1 when stale
|
|
12
|
+
.\\.venv\\Scripts\\python scripts/gen-types.py --spec resolve # Resolve API types
|
|
11
13
|
|
|
12
14
|
Dev-only dependency: pyyaml (``pip install -e ".[dev]"``). The runtime SDK stays
|
|
13
15
|
zero-dependency — this script is never imported by the package.
|
|
@@ -25,15 +27,24 @@ import yaml
|
|
|
25
27
|
|
|
26
28
|
HERE = Path(__file__).resolve().parent
|
|
27
29
|
PKG = HERE.parent
|
|
28
|
-
|
|
30
|
+
OPENAPI = PKG.parents[1] / "apps" / "docs" / "openapi" / "en"
|
|
31
|
+
SPEC = OPENAPI / "gateway.yaml"
|
|
29
32
|
OUT = PKG / "src" / "codai" / "_types.py"
|
|
33
|
+
RESOLVE_SPEC = OPENAPI / "resolve.yaml"
|
|
34
|
+
RESOLVE_OUT = PKG / "src" / "codai" / "_resolve_types.py"
|
|
35
|
+
|
|
36
|
+
# --spec name → (spec file, output file, API label, regenerate command)
|
|
37
|
+
TARGETS: Dict[str, Tuple[Path, Path, str, str]] = {
|
|
38
|
+
"gateway": (SPEC, OUT, "gateway", "python scripts/gen-types.py"),
|
|
39
|
+
"resolve": (RESOLVE_SPEC, RESOLVE_OUT, "Resolve", "python scripts/gen-types.py --spec resolve"),
|
|
40
|
+
}
|
|
30
41
|
|
|
31
42
|
HTTP_METHODS = ("get", "post", "put", "patch", "delete")
|
|
32
43
|
|
|
33
|
-
HEADER = '''"""Typed dictionaries for the codai
|
|
44
|
+
HEADER = '''"""Typed dictionaries for the codai {api} API.
|
|
34
45
|
|
|
35
|
-
generated — do not edit. Source: apps/docs/openapi/en/
|
|
36
|
-
Regenerate with ``
|
|
46
|
+
generated — do not edit. Source: apps/docs/openapi/en/{source}.
|
|
47
|
+
Regenerate with ``{regen}`` (see that script's docstring).
|
|
37
48
|
|
|
38
49
|
Every ``components.schemas`` entry is a ``TypedDict`` (``total=False`` when the
|
|
39
50
|
schema has optional fields; a ``_<Name>Required`` base carries the required
|
|
@@ -76,8 +87,15 @@ def union(parts: List[str]) -> str:
|
|
|
76
87
|
|
|
77
88
|
|
|
78
89
|
class Generator:
|
|
79
|
-
def __init__(
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
spec: Dict[str, Any],
|
|
93
|
+
api: str = "gateway",
|
|
94
|
+
source: str = "gateway.yaml",
|
|
95
|
+
regen: str = "python scripts/gen-types.py",
|
|
96
|
+
) -> None:
|
|
80
97
|
self.spec = spec
|
|
98
|
+
self.header = HEADER.format(api=api, source=source, regen=regen)
|
|
81
99
|
self.schemas: Dict[str, Any] = spec["components"]["schemas"]
|
|
82
100
|
self.blocks: List[Tuple[str, str]] = []
|
|
83
101
|
self.emitted: Dict[str, str] = {}
|
|
@@ -295,7 +313,7 @@ class Generator:
|
|
|
295
313
|
for name in self.schemas: # YAML order
|
|
296
314
|
self.ensure_schema(name)
|
|
297
315
|
self.emit_operations()
|
|
298
|
-
out = [
|
|
316
|
+
out = [self.header]
|
|
299
317
|
for _, code in self.blocks:
|
|
300
318
|
out.append(code)
|
|
301
319
|
out.append("\n\n")
|
|
@@ -307,18 +325,23 @@ class Generator:
|
|
|
307
325
|
|
|
308
326
|
|
|
309
327
|
def main(argv: List[str]) -> int:
|
|
310
|
-
|
|
328
|
+
name = argv[argv.index("--spec") + 1] if "--spec" in argv else "gateway"
|
|
329
|
+
if name not in TARGETS:
|
|
330
|
+
print(f"unknown --spec {name!r}; one of {', '.join(TARGETS)}", file=sys.stderr)
|
|
331
|
+
return 2
|
|
332
|
+
spec_path, out, api, regen = TARGETS[name]
|
|
333
|
+
with spec_path.open("r", encoding="utf-8") as fh:
|
|
311
334
|
spec = yaml.safe_load(fh)
|
|
312
|
-
text = Generator(spec).render()
|
|
335
|
+
text = Generator(spec, api=api, source=spec_path.name, regen=regen).render()
|
|
313
336
|
if "--check" in argv:
|
|
314
|
-
current =
|
|
337
|
+
current = out.read_text(encoding="utf-8") if out.exists() else ""
|
|
315
338
|
if current != text:
|
|
316
|
-
print(f"STALE: {
|
|
339
|
+
print(f"STALE: {out} differs from a fresh generation", file=sys.stderr)
|
|
317
340
|
return 1
|
|
318
|
-
print(f"OK: {
|
|
341
|
+
print(f"OK: {out} is up to date")
|
|
319
342
|
return 0
|
|
320
|
-
|
|
321
|
-
print(f"wrote {
|
|
343
|
+
out.write_text(text, encoding="utf-8", newline="\n")
|
|
344
|
+
print(f"wrote {out} ({text.count(chr(10))} lines, {len(spec['components']['schemas'])} schemas)")
|
|
322
345
|
return 0
|
|
323
346
|
|
|
324
347
|
|
|
@@ -32,8 +32,9 @@ from __future__ import annotations
|
|
|
32
32
|
from typing import Any, Dict, Generator, List, Optional
|
|
33
33
|
|
|
34
34
|
from ._http import CodaiError, HttpClient, RequestExtensions, SseFrame, extension_headers, frame_json, read_sse
|
|
35
|
-
from ._operations import OPERATION_METHODS, STREAMING_OPERATIONS
|
|
35
|
+
from ._operations import OPERATION_METHODS, RESOLVE_OPERATION_METHODS, STREAMING_OPERATIONS
|
|
36
36
|
from .resources import (
|
|
37
|
+
RESOLVE_TERMINAL_STATUSES,
|
|
37
38
|
Account,
|
|
38
39
|
AgentRunResult,
|
|
39
40
|
Agents,
|
|
@@ -55,6 +56,9 @@ from .resources import (
|
|
|
55
56
|
PhoneModels,
|
|
56
57
|
Projects,
|
|
57
58
|
Receipts,
|
|
59
|
+
Resolve,
|
|
60
|
+
ResolveError,
|
|
61
|
+
ResolveInsufficientFundsError,
|
|
58
62
|
Responses,
|
|
59
63
|
Sessions,
|
|
60
64
|
SystemOne,
|
|
@@ -75,12 +79,16 @@ __all__ = [
|
|
|
75
79
|
"RequestExtensions",
|
|
76
80
|
"SseFrame",
|
|
77
81
|
"OPERATION_METHODS",
|
|
82
|
+
"RESOLVE_OPERATION_METHODS",
|
|
83
|
+
"RESOLVE_TERMINAL_STATUSES",
|
|
84
|
+
"ResolveError",
|
|
85
|
+
"ResolveInsufficientFundsError",
|
|
78
86
|
"STREAMING_OPERATIONS",
|
|
79
87
|
"extension_headers",
|
|
80
88
|
"frame_json",
|
|
81
89
|
"read_sse",
|
|
82
90
|
]
|
|
83
|
-
__version__ = "0.
|
|
91
|
+
__version__ = "0.4.0"
|
|
84
92
|
|
|
85
93
|
|
|
86
94
|
class _CallableGroup:
|
|
@@ -113,6 +121,7 @@ class Codai:
|
|
|
113
121
|
client: Optional[str] = None,
|
|
114
122
|
defaults: Optional[RequestExtensions] = None,
|
|
115
123
|
opener: Any = None,
|
|
124
|
+
resolve_base_url: str = "https://resolve.codai.ro",
|
|
116
125
|
) -> None:
|
|
117
126
|
if not api_key:
|
|
118
127
|
raise ValueError("Codai: api_key is required")
|
|
@@ -156,6 +165,16 @@ class Codai:
|
|
|
156
165
|
self.phone_models = PhoneModels(http)
|
|
157
166
|
self.triggers = Triggers(http)
|
|
158
167
|
self.notifications = Notifications(http)
|
|
168
|
+
# codai Resolve lives on its own host; same key, no X-Codai-* defaults.
|
|
169
|
+
self.resolve = Resolve(
|
|
170
|
+
HttpClient(
|
|
171
|
+
api_key=api_key,
|
|
172
|
+
base_url=resolve_base_url,
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
max_retries=max_retries,
|
|
175
|
+
opener=opener,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
159
178
|
|
|
160
179
|
self.chat: Any = _CallableGroup(Chat(http), self._legacy_chat)
|
|
161
180
|
self.embeddings: Any = _CallableGroup(Embeddings(http), self._legacy_embeddings)
|
|
@@ -123,6 +123,8 @@ OPERATION_METHODS: Dict[str, str] = {
|
|
|
123
123
|
"receiveTriggerHook": "triggers.fire_hook",
|
|
124
124
|
"listNotifications": "notifications.list",
|
|
125
125
|
"markNotificationsRead": "notifications.mark_read",
|
|
126
|
+
"deleteNotification": "notifications.delete",
|
|
127
|
+
"deleteNotifications": "notifications.delete_many",
|
|
126
128
|
# Account
|
|
127
129
|
"getAccount": "account.get",
|
|
128
130
|
"patchAccount": "account.update",
|
|
@@ -133,3 +135,12 @@ OPERATION_METHODS: Dict[str, str] = {
|
|
|
133
135
|
|
|
134
136
|
# Operations whose SDK method is a generator (SSE-only endpoints).
|
|
135
137
|
STREAMING_OPERATIONS: FrozenSet[str] = frozenset({"streamAgentRun", "streamSession", "streamHost"})
|
|
138
|
+
|
|
139
|
+
# codai Resolve (``apps/docs/openapi/en/resolve.yaml``) → ``client.resolve.*``.
|
|
140
|
+
RESOLVE_OPERATION_METHODS: Dict[str, str] = {
|
|
141
|
+
"resolveHealth": "resolve.health",
|
|
142
|
+
"createResolveJob": "resolve.submit",
|
|
143
|
+
"getResolveJob": "resolve.get",
|
|
144
|
+
"acceptResolveJob": "resolve.accept",
|
|
145
|
+
"getResolveAttestation": "resolve.attestation",
|
|
146
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Typed dictionaries for the codai Resolve API.
|
|
2
|
+
|
|
3
|
+
generated — do not edit. Source: apps/docs/openapi/en/resolve.yaml.
|
|
4
|
+
Regenerate with ``python scripts/gen-types.py --spec resolve`` (see that script's docstring).
|
|
5
|
+
|
|
6
|
+
Every ``components.schemas`` entry is a ``TypedDict`` (``total=False`` when the
|
|
7
|
+
schema has optional fields; a ``_<Name>Required`` base carries the required
|
|
8
|
+
keys of mixed schemas). Non-object schemas (enums, unions, arrays) are type
|
|
9
|
+
aliases. ``<operationId>Body`` / ``<operationId>Response`` name each
|
|
10
|
+
operation's JSON request body and first 2xx JSON response.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
16
|
+
|
|
17
|
+
class ResolveHealth(TypedDict):
|
|
18
|
+
ok: bool
|
|
19
|
+
service: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ResolveValidationDetails(TypedDict):
|
|
23
|
+
"""Zod `flatten()` output returned when the intake body fails schema validation."""
|
|
24
|
+
formErrors: List[str]
|
|
25
|
+
fieldErrors: Dict[str, List[str]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResolveError(TypedDict):
|
|
29
|
+
"""Flat error envelope used by every Resolve endpoint. Not the gateway `{ error: { code, message } }` shape."""
|
|
30
|
+
error: Union[str, ResolveValidationDetails]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ResolveIntakeRequired(TypedDict):
|
|
34
|
+
repo_url: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResolveIntake(_ResolveIntakeRequired, total=False):
|
|
38
|
+
"""Body of `POST /v1/resolve/jobs`. Provide `issue_url` or `issue_text` (at least one)."""
|
|
39
|
+
issue_url: str
|
|
40
|
+
issue_text: str
|
|
41
|
+
test_command: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# `free` (first 3 resolves per user), `t7` ($7), `t19` ($19), `t49` ($49). Prices are in USD micros on the wire.
|
|
45
|
+
ResolveTier = Literal['free', 't7', 't19', 't49']
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Lifecycle: `triage → quoted → accepted → running → resolved | failed`, plus `declined`
|
|
49
|
+
ResolveJobStatus = Literal['triage', 'quoted', 'declined', 'accepted', 'running', 'resolved', 'failed', 'error']
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ResolveQuote(TypedDict):
|
|
53
|
+
"""`201` body — the job was quoted (`quoted`) or auto-accepted on the free tier (`accepted`)."""
|
|
54
|
+
id: str
|
|
55
|
+
status: Literal['quoted', 'accepted']
|
|
56
|
+
tier: ResolveTier
|
|
57
|
+
quote_micro_usd: int
|
|
58
|
+
triage_reason: str
|
|
59
|
+
free_tier_remaining: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResolveDeclined(TypedDict):
|
|
63
|
+
"""`200` body — triage declined the issue. The job is persisted with `status: declined` and is never billed."""
|
|
64
|
+
id: str
|
|
65
|
+
status: str
|
|
66
|
+
decline_reason: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResolveAccepted(TypedDict):
|
|
70
|
+
"""Accepted"""
|
|
71
|
+
id: str
|
|
72
|
+
status: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ResolveInsufficientFunds(TypedDict):
|
|
76
|
+
"""`402` body of `POST /v1/resolve/jobs/{id}/accept` when the wallet cannot hold the quote."""
|
|
77
|
+
error: str
|
|
78
|
+
balance: int
|
|
79
|
+
required: int
|
|
80
|
+
currency: str
|
|
81
|
+
topup_url: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ResolveJob(TypedDict):
|
|
85
|
+
"""Full job view returned by `GET /v1/resolve/jobs/{id}`. Nullable fields are `null` until the corresponding stag…"""
|
|
86
|
+
id: str
|
|
87
|
+
status: ResolveJobStatus
|
|
88
|
+
repo_url: str
|
|
89
|
+
tier: Optional[ResolveTier]
|
|
90
|
+
quote_micro_usd: Optional[int]
|
|
91
|
+
triage_reason: Optional[str]
|
|
92
|
+
decline_reason: Optional[str]
|
|
93
|
+
fail_reason: Optional[str]
|
|
94
|
+
cost_micro_usd: int
|
|
95
|
+
attestation_id: Optional[str]
|
|
96
|
+
attestation_url: Optional[str]
|
|
97
|
+
created_at: str
|
|
98
|
+
completed_at: Optional[str]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ResolveReproTest(TypedDict):
|
|
102
|
+
"""Repro test written by the agent when you did not supply `test_command`."""
|
|
103
|
+
path: str
|
|
104
|
+
content: Optional[str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ResolveAttestation(TypedDict):
|
|
108
|
+
"""Public execution proof. Everything needed to reproduce the verification locally."""
|
|
109
|
+
id: str
|
|
110
|
+
kind: str
|
|
111
|
+
repo_url: str
|
|
112
|
+
base_commit: str
|
|
113
|
+
patch: str
|
|
114
|
+
repro_test: Optional[ResolveReproTest]
|
|
115
|
+
test_command: str
|
|
116
|
+
regression_command: Optional[str]
|
|
117
|
+
test_output_before: str
|
|
118
|
+
test_output_after: str
|
|
119
|
+
regression_output: Optional[str]
|
|
120
|
+
runner_image_digest: str
|
|
121
|
+
started_at: str
|
|
122
|
+
verified_at: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# GET /health — 200 response
|
|
126
|
+
ResolveHealthResponse = ResolveHealth
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# POST /v1/resolve/jobs — request body
|
|
130
|
+
CreateResolveJobBody = ResolveIntake
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# POST /v1/resolve/jobs — 200 response
|
|
134
|
+
CreateResolveJobResponse = ResolveDeclined
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# POST /v1/resolve/jobs — 201 response
|
|
138
|
+
CreateResolveJobResponse201 = ResolveQuote
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# GET /v1/resolve/jobs/{id} — 200 response
|
|
142
|
+
GetResolveJobResponse = ResolveJob
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# POST /v1/resolve/jobs/{id}/accept — 200 response
|
|
146
|
+
AcceptResolveJobResponse = ResolveAccepted
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# GET /v1/resolve/attestations/{id} — 200 response
|
|
150
|
+
GetResolveAttestationResponse = ResolveAttestation
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
'AcceptResolveJobResponse',
|
|
155
|
+
'CreateResolveJobBody',
|
|
156
|
+
'CreateResolveJobResponse',
|
|
157
|
+
'CreateResolveJobResponse201',
|
|
158
|
+
'GetResolveAttestationResponse',
|
|
159
|
+
'GetResolveJobResponse',
|
|
160
|
+
'ResolveAccepted',
|
|
161
|
+
'ResolveAttestation',
|
|
162
|
+
'ResolveDeclined',
|
|
163
|
+
'ResolveError',
|
|
164
|
+
'ResolveHealth',
|
|
165
|
+
'ResolveHealthResponse',
|
|
166
|
+
'ResolveInsufficientFunds',
|
|
167
|
+
'ResolveIntake',
|
|
168
|
+
'ResolveJob',
|
|
169
|
+
'ResolveJobStatus',
|
|
170
|
+
'ResolveQuote',
|
|
171
|
+
'ResolveReproTest',
|
|
172
|
+
'ResolveTier',
|
|
173
|
+
'ResolveValidationDetails',
|
|
174
|
+
]
|
|
@@ -17,7 +17,7 @@ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
|
17
17
|
class _ErrorErrorRequired(TypedDict):
|
|
18
18
|
message: str
|
|
19
19
|
type: Literal['invalid_request_error', 'subscription_required', 'rate_limit_error', 'upstream_error', 'server_error']
|
|
20
|
-
code: Literal['bad_request', 'invalid_api_key', 'subscription_inactive', 'forbidden', 'not_found', 'payload_too_large', 'rate_limit_exceeded', 'quota_exceeded', 'upstream_error', 'internal_error', 'not_a_member', 'lease_held', 'seq_conflict', 'host_offline', 'host_timeout', 'rules_refused', 'search_unavailable', 'task_not_confirmable', 'slug_taken', 'project_has_environments', 'already_has_env', 'not_enrollable', 'not_byo', 'invalid_transition', 'already_member', 'vm_per_dev_single_member', 'last_owner']
|
|
20
|
+
code: Literal['bad_request', 'invalid_api_key', 'subscription_inactive', 'forbidden', 'not_found', 'payload_too_large', 'rate_limit_exceeded', 'quota_exceeded', 'upstream_error', 'internal_error', 'not_a_member', 'lease_held', 'seq_conflict', 'host_offline', 'host_timeout', 'rules_refused', 'search_unavailable', 'task_not_confirmable', 'slug_taken', 'project_has_environments', 'already_has_env', 'not_enrollable', 'not_byo', 'invalid_transition', 'already_member', 'vm_per_dev_single_member', 'last_owner', 'insufficient_balance', 'key_scope_forbidden']
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class ErrorError(_ErrorErrorRequired, total=False):
|
|
@@ -1362,7 +1362,7 @@ ProjectIsolation = Literal['shared_vm', 'container', 'vm_per_dev']
|
|
|
1362
1362
|
|
|
1363
1363
|
|
|
1364
1364
|
# `byo` (bring your own machine; the daemon enrols with a token) is open to every key. `openstack` / `hetzner` a…
|
|
1365
|
-
EnvironmentProvider = Literal['byo', 'openstack', 'hetzner', 'local']
|
|
1365
|
+
EnvironmentProvider = Literal['byo', 'openstack', 'hetzner', 'local', 'gce']
|
|
1366
1366
|
|
|
1367
1367
|
|
|
1368
1368
|
# Lifecycle state. `byo` starts in `pending` and reaches `enrolling` when the daemon enrols; managed providers s…
|
|
@@ -1380,8 +1380,7 @@ PortVisibility = Literal['owner', 'org', 'authenticated', 'public']
|
|
|
1380
1380
|
PortProtocol = Literal['http', 'https', 'tcp']
|
|
1381
1381
|
|
|
1382
1382
|
|
|
1383
|
-
class
|
|
1384
|
-
"""The stored project row (camelCase, as persisted)."""
|
|
1383
|
+
class _ProjectRequired(TypedDict):
|
|
1385
1384
|
id: str
|
|
1386
1385
|
ownerUserId: str
|
|
1387
1386
|
orgId: Optional[str]
|
|
@@ -1396,6 +1395,13 @@ class Project(TypedDict):
|
|
|
1396
1395
|
updatedAt: str
|
|
1397
1396
|
|
|
1398
1397
|
|
|
1398
|
+
class Project(_ProjectRequired, total=False):
|
|
1399
|
+
"""The stored project row (camelCase, as persisted)."""
|
|
1400
|
+
prebuildPoolSize: int
|
|
1401
|
+
prebuildProvider: Literal['openstack', 'hetzner']
|
|
1402
|
+
prebuildSize: str
|
|
1403
|
+
|
|
1404
|
+
|
|
1399
1405
|
class _ProjectCreateBodyRequired(TypedDict):
|
|
1400
1406
|
name: str
|
|
1401
1407
|
|
|
@@ -1417,6 +1423,9 @@ class ProjectPatchBody(TypedDict, total=False):
|
|
|
1417
1423
|
default_branch: str
|
|
1418
1424
|
devcontainer_path: Optional[str]
|
|
1419
1425
|
isolation: ProjectIsolation
|
|
1426
|
+
prebuild_pool_size: int
|
|
1427
|
+
prebuild_provider: Literal['openstack', 'hetzner']
|
|
1428
|
+
prebuild_size: Literal['small', 'medium', 'large']
|
|
1420
1429
|
|
|
1421
1430
|
|
|
1422
1431
|
class _EnvironmentRequired(TypedDict):
|
|
@@ -1450,6 +1459,7 @@ class _EnvironmentRequired(TypedDict):
|
|
|
1450
1459
|
class Environment(_EnvironmentRequired, total=False):
|
|
1451
1460
|
"""The stored environment row (camelCase, as persisted) plus snake_case `for_user_id` and, when known, the projec…"""
|
|
1452
1461
|
isolation: ProjectIsolation
|
|
1462
|
+
prebuild: bool
|
|
1453
1463
|
|
|
1454
1464
|
|
|
1455
1465
|
class _EnvironmentCreateBodyRequired(TypedDict):
|
|
@@ -1485,6 +1495,19 @@ class AlreadyHasEnvError(TypedDict):
|
|
|
1485
1495
|
environment_id: str
|
|
1486
1496
|
|
|
1487
1497
|
|
|
1498
|
+
class InsufficientBalanceErrorError(TypedDict):
|
|
1499
|
+
message: str
|
|
1500
|
+
type: Literal['invalid_request_error']
|
|
1501
|
+
code: Literal['insufficient_balance']
|
|
1502
|
+
required_micro_eur: int
|
|
1503
|
+
balance_micro_eur: int
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
class InsufficientBalanceError(TypedDict):
|
|
1507
|
+
"""The `Error` envelope plus the one-hour amount the wallet must hold and what it holds now."""
|
|
1508
|
+
error: InsufficientBalanceErrorError
|
|
1509
|
+
|
|
1510
|
+
|
|
1488
1511
|
# Environment transition
|
|
1489
1512
|
EnvironmentTransition = TypedDict(
|
|
1490
1513
|
'EnvironmentTransition',
|
|
@@ -2476,10 +2499,14 @@ class ListEnvironmentsResponse(TypedDict):
|
|
|
2476
2499
|
CreateEnvironmentBody = EnvironmentCreateBody
|
|
2477
2500
|
|
|
2478
2501
|
|
|
2479
|
-
class
|
|
2502
|
+
class _CreateEnvironmentResponseRequired(TypedDict):
|
|
2480
2503
|
environment: Environment
|
|
2481
2504
|
|
|
2482
2505
|
|
|
2506
|
+
class CreateEnvironmentResponse(_CreateEnvironmentResponseRequired, total=False):
|
|
2507
|
+
prebuild_claimed: bool
|
|
2508
|
+
|
|
2509
|
+
|
|
2483
2510
|
# POST /v1/environments/enroll — request body
|
|
2484
2511
|
EnrollEnvironmentBody = EnvironmentEnrollBody
|
|
2485
2512
|
|
|
@@ -2721,6 +2748,18 @@ class MarkNotificationsReadResponse(TypedDict):
|
|
|
2721
2748
|
read: int
|
|
2722
2749
|
|
|
2723
2750
|
|
|
2751
|
+
class DeleteNotificationResponse(TypedDict):
|
|
2752
|
+
deleted: int
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
class DeleteNotificationsBody(TypedDict):
|
|
2756
|
+
ids: List[int]
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
class DeleteNotificationsResponse(TypedDict):
|
|
2760
|
+
deleted: int
|
|
2761
|
+
|
|
2762
|
+
|
|
2724
2763
|
# POST /v1/hooks/{id} — request body
|
|
2725
2764
|
ReceiveTriggerHookBody = Dict[str, Any]
|
|
2726
2765
|
|
|
@@ -2858,6 +2897,9 @@ __all__ = [
|
|
|
2858
2897
|
'CreateTriggerBody',
|
|
2859
2898
|
'CreateTriggerResponse',
|
|
2860
2899
|
'DeleteDeviceResponse',
|
|
2900
|
+
'DeleteNotificationResponse',
|
|
2901
|
+
'DeleteNotificationsBody',
|
|
2902
|
+
'DeleteNotificationsResponse',
|
|
2861
2903
|
'DeleteProjectResponse',
|
|
2862
2904
|
'DeleteSessionResponse',
|
|
2863
2905
|
'DeleteSessionShareResponse',
|
|
@@ -2926,6 +2968,8 @@ __all__ = [
|
|
|
2926
2968
|
'HostExecResultBody',
|
|
2927
2969
|
'HostOp',
|
|
2928
2970
|
'HostPresence',
|
|
2971
|
+
'InsufficientBalanceError',
|
|
2972
|
+
'InsufficientBalanceErrorError',
|
|
2929
2973
|
'IssueMyEnvironmentSshCertBody',
|
|
2930
2974
|
'IssueMyEnvironmentSshCertResponse',
|
|
2931
2975
|
'ListAgentRunStepsResponse',
|
|
@@ -36,11 +36,16 @@ from .platform import (
|
|
|
36
36
|
Tools,
|
|
37
37
|
)
|
|
38
38
|
from .responses import Responses, ResponsesResult, ResponsesStream, ResponsesStreamResult
|
|
39
|
+
from .resolve import RESOLVE_TERMINAL_STATUSES, Resolve, ResolveError, ResolveInsufficientFundsError
|
|
39
40
|
from .sessions import SessionControls, SessionEvents, SessionLease, Sessions, SessionShares
|
|
40
41
|
from .triggers import Notifications, Triggers
|
|
41
42
|
|
|
42
43
|
__all__ = [
|
|
43
44
|
"Account",
|
|
45
|
+
"RESOLVE_TERMINAL_STATUSES",
|
|
46
|
+
"Resolve",
|
|
47
|
+
"ResolveError",
|
|
48
|
+
"ResolveInsufficientFundsError",
|
|
44
49
|
"AgentRunResult",
|
|
45
50
|
"AgentRuns",
|
|
46
51
|
"Agents",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""codai Resolve — execution-verified bug fixes, no fix no fee.
|
|
2
|
+
|
|
3
|
+
Separate service (``https://resolve.codai.ro``, spec ``apps/docs/openapi/en/resolve.yaml``)
|
|
4
|
+
with a flat ``{"error": ...}`` body, so it runs on its own ``HttpClient`` and maps
|
|
5
|
+
failures to ``ResolveError`` / ``ResolveInsufficientFundsError``. Mirrors
|
|
6
|
+
``packages/sdk/src/resources/resolve.ts``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Dict, Iterable, Optional, cast
|
|
14
|
+
|
|
15
|
+
from .._http import CodaiError, HttpClient
|
|
16
|
+
from .._resolve_types import (
|
|
17
|
+
ResolveAccepted,
|
|
18
|
+
ResolveAttestation,
|
|
19
|
+
ResolveHealth,
|
|
20
|
+
ResolveIntake,
|
|
21
|
+
ResolveJob,
|
|
22
|
+
)
|
|
23
|
+
from ._base import enc
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"RESOLVE_TERMINAL_STATUSES",
|
|
27
|
+
"Resolve",
|
|
28
|
+
"ResolveError",
|
|
29
|
+
"ResolveInsufficientFundsError",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
RESOLVE_TERMINAL_STATUSES = frozenset({"resolved", "failed", "declined", "error"})
|
|
33
|
+
_ATTESTATION_PREFIX = re.compile(r"^.*/v1/resolve/attestations/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResolveError(CodaiError):
|
|
37
|
+
"""Any non-2xx answer from the Resolve service. ``detail`` is the body's ``error``
|
|
38
|
+
field: a message, or Zod ``flatten()`` details on a 400."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, status: int, body: Any, request_id: Optional[str] = None,
|
|
41
|
+
retry_after: Optional[int] = None) -> None:
|
|
42
|
+
super().__init__(message, status, body, code=_code(body), request_id=request_id, retry_after=retry_after)
|
|
43
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
44
|
+
self.detail: Any = err if isinstance(err, (str, dict)) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ResolveInsufficientFundsError(ResolveError):
|
|
48
|
+
"""``402`` on ``accept()`` — the wallet cannot hold the quote; the job stays ``quoted``.
|
|
49
|
+
Open ``topup_url`` (pay.codai.ro checkout for the shortfall), then retry ``accept()``."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, body: Dict[str, Any], request_id: Optional[str] = None) -> None:
|
|
52
|
+
super().__init__(message, 402, body, request_id=request_id)
|
|
53
|
+
self.balance: int = int(body["balance"])
|
|
54
|
+
self.required: int = int(body["required"])
|
|
55
|
+
self.currency: str = str(body["currency"])
|
|
56
|
+
self.topup_url: str = str(body["topup_url"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _code(body: Any) -> Optional[str]:
|
|
60
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
61
|
+
return err if isinstance(err, str) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_resolve_error(path: str, err: CodaiError) -> CodaiError:
|
|
65
|
+
if isinstance(err, ResolveError) or err.status == 0:
|
|
66
|
+
return err
|
|
67
|
+
body = err.body
|
|
68
|
+
if (
|
|
69
|
+
err.status == 402
|
|
70
|
+
and isinstance(body, dict)
|
|
71
|
+
and body.get("error") == "insufficient_funds"
|
|
72
|
+
and isinstance(body.get("topup_url"), str)
|
|
73
|
+
):
|
|
74
|
+
return ResolveInsufficientFundsError(
|
|
75
|
+
f"codai-resolve {path} → 402: insufficient funds (balance {body.get('balance')}, "
|
|
76
|
+
f"required {body.get('required')} {body.get('currency')}); top up at {body['topup_url']}",
|
|
77
|
+
body,
|
|
78
|
+
request_id=err.request_id,
|
|
79
|
+
)
|
|
80
|
+
detail = _code(body) or "request failed"
|
|
81
|
+
return ResolveError(
|
|
82
|
+
f"codai-resolve {path} → {err.status}: {detail}", err.status, body,
|
|
83
|
+
request_id=err.request_id, retry_after=err.retry_after,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Resolve:
|
|
88
|
+
"""``client.resolve`` — submit, accept, poll and verify Resolve jobs."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, http: HttpClient) -> None:
|
|
91
|
+
self._http = http
|
|
92
|
+
|
|
93
|
+
def _call(self, path: str, **kwargs: Any) -> Any:
|
|
94
|
+
try:
|
|
95
|
+
return self._http.json(path, **kwargs)
|
|
96
|
+
except CodaiError as e:
|
|
97
|
+
raise _to_resolve_error(path, e) from e
|
|
98
|
+
|
|
99
|
+
def submit(self, body: ResolveIntake) -> Dict[str, Any]:
|
|
100
|
+
"""``POST /v1/resolve/jobs`` — returns a quote (``status: quoted``), a free-tier
|
|
101
|
+
auto-accept (``accepted``) or a decline (``declined``). Never retried."""
|
|
102
|
+
if not body.get("issue_url") and not body.get("issue_text"):
|
|
103
|
+
raise TypeError("resolve.submit: one of issue_url or issue_text is required")
|
|
104
|
+
return cast(Dict[str, Any], self._call("/v1/resolve/jobs", method="POST", body=dict(body), no_retry=True))
|
|
105
|
+
|
|
106
|
+
def get(self, job_id: str) -> ResolveJob:
|
|
107
|
+
"""``GET /v1/resolve/jobs/{id}`` — status, quote, reasons, cost, attestation pointer."""
|
|
108
|
+
return cast(ResolveJob, self._call(f"/v1/resolve/jobs/{enc(job_id)}"))
|
|
109
|
+
|
|
110
|
+
def accept(self, job_id: str) -> ResolveAccepted:
|
|
111
|
+
"""``POST /v1/resolve/jobs/{id}/accept`` — holds the quote on the wallet and starts
|
|
112
|
+
the job. Raises ``ResolveInsufficientFundsError`` (402) or ``ResolveError`` 409."""
|
|
113
|
+
return cast(
|
|
114
|
+
ResolveAccepted,
|
|
115
|
+
self._call(f"/v1/resolve/jobs/{enc(job_id)}/accept", method="POST", raw_body=b"", no_retry=True),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def attestation(self, id_or_url: str) -> ResolveAttestation:
|
|
119
|
+
"""``GET /v1/resolve/attestations/{id}`` — accepts the id or the job's ``attestation_url``."""
|
|
120
|
+
att_id = _ATTESTATION_PREFIX.sub("", id_or_url)
|
|
121
|
+
return cast(ResolveAttestation, self._call(f"/v1/resolve/attestations/{enc(att_id)}"))
|
|
122
|
+
|
|
123
|
+
def health(self) -> ResolveHealth:
|
|
124
|
+
"""``GET /health`` — liveness of the Resolve service."""
|
|
125
|
+
return cast(ResolveHealth, self._call("/health"))
|
|
126
|
+
|
|
127
|
+
def wait_for(
|
|
128
|
+
self,
|
|
129
|
+
job_id: str,
|
|
130
|
+
interval: float = 5.0,
|
|
131
|
+
timeout: float = 1800.0,
|
|
132
|
+
until: Optional[Iterable[str]] = None,
|
|
133
|
+
on_poll: Optional[Callable[[ResolveJob], None]] = None,
|
|
134
|
+
) -> ResolveJob:
|
|
135
|
+
"""Poll ``get()`` until a terminal status (or one of ``until``). A ``quoted`` job
|
|
136
|
+
does not move until you ``accept()`` it. Raises ``ResolveError`` (status 0) on timeout."""
|
|
137
|
+
stop = frozenset(until) if until is not None else RESOLVE_TERMINAL_STATUSES
|
|
138
|
+
deadline = time.monotonic() + timeout
|
|
139
|
+
while True:
|
|
140
|
+
job = self.get(job_id)
|
|
141
|
+
if on_poll is not None:
|
|
142
|
+
on_poll(job)
|
|
143
|
+
if job["status"] in stop:
|
|
144
|
+
return job
|
|
145
|
+
left = deadline - time.monotonic()
|
|
146
|
+
if left <= 0:
|
|
147
|
+
raise ResolveError(f"codai-resolve job {job_id} still {job['status']} after {timeout}s", 0, job)
|
|
148
|
+
time.sleep(min(interval, left))
|
|
@@ -105,3 +105,11 @@ class Notifications(Resource):
|
|
|
105
105
|
return self._json(
|
|
106
106
|
"/v1/notifications/read", ext=ext, method="POST", body={"ids": ids} if ids is not None else {}
|
|
107
107
|
)
|
|
108
|
+
|
|
109
|
+
def delete(self, id: int, ext: Optional[RequestExtensions] = None) -> Dict[str, Any]:
|
|
110
|
+
"""``DELETE /v1/notifications/{id}`` — ``{"deleted": 0}`` when the id is missing or not yours."""
|
|
111
|
+
return self._json(f"/v1/notifications/{enc(str(id))}", ext=ext, method="DELETE")
|
|
112
|
+
|
|
113
|
+
def delete_many(self, ids: List[int], ext: Optional[RequestExtensions] = None) -> Dict[str, Any]:
|
|
114
|
+
"""``POST /v1/notifications/delete`` — 1..200 ids; ids of other users are skipped."""
|
|
115
|
+
return self._json("/v1/notifications/delete", ext=ext, method="POST", body={"ids": ids})
|
|
@@ -13,7 +13,7 @@ from typing import Any, Dict, List
|
|
|
13
13
|
import pytest
|
|
14
14
|
import yaml
|
|
15
15
|
|
|
16
|
-
from codai import OPERATION_METHODS, STREAMING_OPERATIONS, Codai
|
|
16
|
+
from codai import OPERATION_METHODS, RESOLVE_OPERATION_METHODS, STREAMING_OPERATIONS, Codai
|
|
17
17
|
|
|
18
18
|
PKG = Path(__file__).resolve().parents[1]
|
|
19
19
|
SPEC = PKG.parents[1] / "apps" / "docs" / "openapi" / "en" / "gateway.yaml"
|
|
@@ -94,9 +94,10 @@ def test_mapping_mirrors_typescript_sdk_when_present():
|
|
|
94
94
|
|
|
95
95
|
pairs = re.findall(r"^\s*(\w+):\s*'([\w.]+)',", ts.read_text(encoding="utf-8"), re.M)
|
|
96
96
|
ts_map = {k: v for k, v in pairs}
|
|
97
|
-
|
|
97
|
+
py_map = {**OPERATION_METHODS, **RESOLVE_OPERATION_METHODS}
|
|
98
|
+
assert set(ts_map) == set(py_map)
|
|
98
99
|
snake = lambda s: re.sub(r"(?<!^)(?<!\.)(?=[A-Z])", "_", s).lower() # noqa: E731
|
|
99
|
-
assert {k: snake(v) for k, v in ts_map.items()} ==
|
|
100
|
+
assert {k: snake(v) for k, v in ts_map.items()} == py_map
|
|
100
101
|
|
|
101
102
|
|
|
102
103
|
def test_generated_types_are_up_to_date():
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``client.resolve`` against the stub, plus parity with resolve.yaml operationIds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from codai import (
|
|
11
|
+
RESOLVE_OPERATION_METHODS,
|
|
12
|
+
Codai,
|
|
13
|
+
ResolveError,
|
|
14
|
+
ResolveInsufficientFundsError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
SPEC = Path(__file__).resolve().parents[3] / "apps" / "docs" / "openapi" / "en" / "resolve.yaml"
|
|
18
|
+
JOB = "11111111-2222-4333-8444-555555555555"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def rclient(stub) -> Codai:
|
|
23
|
+
return Codai(api_key="codai_test", resolve_base_url=stub.url, max_retries=0, timeout=5)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_every_resolve_operation_is_mapped_and_callable(rclient):
|
|
27
|
+
spec = yaml.safe_load(SPEC.read_text(encoding="utf-8"))
|
|
28
|
+
ids = {op["operationId"] for item in spec["paths"].values() for op in item.values() if isinstance(op, dict) and "operationId" in op}
|
|
29
|
+
assert set(RESOLVE_OPERATION_METHODS) == ids
|
|
30
|
+
for dotted in RESOLVE_OPERATION_METHODS.values():
|
|
31
|
+
node = rclient
|
|
32
|
+
for part in dotted.split("."):
|
|
33
|
+
node = getattr(node, part)
|
|
34
|
+
assert callable(node)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_submit_posts_intake_with_bearer(rclient, stub):
|
|
38
|
+
stub.reply(201, {"id": JOB, "status": "quoted", "tier": "t7"})
|
|
39
|
+
r = rclient.resolve.submit({"repo_url": "https://github.com/o/r", "issue_url": "https://github.com/o/r/issues/1"})
|
|
40
|
+
assert r["status"] == "quoted"
|
|
41
|
+
assert stub.last.method == "POST" and stub.last.path == "/v1/resolve/jobs"
|
|
42
|
+
assert stub.last.headers["authorization"] == "Bearer codai_test"
|
|
43
|
+
assert stub.last.json["repo_url"] == "https://github.com/o/r"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_submit_requires_issue(rclient):
|
|
47
|
+
with pytest.raises(TypeError):
|
|
48
|
+
rclient.resolve.submit({"repo_url": "https://github.com/o/r"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_accept_sends_empty_body(rclient, stub):
|
|
52
|
+
stub.reply(202, {"id": JOB, "status": "accepted"})
|
|
53
|
+
r = rclient.resolve.accept(JOB)
|
|
54
|
+
assert r["status"] == "accepted"
|
|
55
|
+
assert stub.last.method == "POST"
|
|
56
|
+
assert stub.last.path == f"/v1/resolve/jobs/{JOB}/accept"
|
|
57
|
+
assert stub.last.body == b""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_accept_402_maps_to_insufficient_funds(rclient, stub):
|
|
61
|
+
body = {
|
|
62
|
+
"error": "insufficient_funds",
|
|
63
|
+
"balance": 0,
|
|
64
|
+
"required": 7_000_000,
|
|
65
|
+
"currency": "micro_eur",
|
|
66
|
+
"topup_url": "https://pay.codai.ro/checkout?topup=10",
|
|
67
|
+
}
|
|
68
|
+
stub.reply(402, body)
|
|
69
|
+
with pytest.raises(ResolveInsufficientFundsError) as ei:
|
|
70
|
+
rclient.resolve.accept(JOB)
|
|
71
|
+
assert ei.value.status == 402
|
|
72
|
+
assert ei.value.required == 7_000_000
|
|
73
|
+
assert ei.value.topup_url.endswith("topup=10")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_409_maps_to_resolve_error(rclient, stub):
|
|
77
|
+
stub.reply(409, {"error": "job is not quoted"})
|
|
78
|
+
with pytest.raises(ResolveError) as ei:
|
|
79
|
+
rclient.resolve.accept(JOB)
|
|
80
|
+
assert ei.value.status == 409 and ei.value.detail == "job is not quoted"
|
|
81
|
+
assert not isinstance(ei.value, ResolveInsufficientFundsError)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_attestation_accepts_url(rclient, stub):
|
|
85
|
+
stub.reply(200, {"id": "att-1"})
|
|
86
|
+
rclient.resolve.attestation("https://resolve.codai.ro/v1/resolve/attestations/att-1")
|
|
87
|
+
assert stub.last.path == "/v1/resolve/attestations/att-1"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_wait_for_polls_until_terminal(rclient, stub):
|
|
91
|
+
stub.reply(200, {"id": JOB, "status": "running"})
|
|
92
|
+
stub.reply(200, {"id": JOB, "status": "resolved"})
|
|
93
|
+
seen = []
|
|
94
|
+
job = rclient.resolve.wait_for(JOB, interval=0.01, on_poll=lambda j: seen.append(j["status"]))
|
|
95
|
+
assert job["status"] == "resolved" and seen == ["running", "resolved"]
|
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
import codai
|
|
8
|
-
from codai import Codai, ChatResult
|
|
10
|
+
from codai import Codai, ChatResult, CodaiError
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _chat_response(content="PONG", tool_calls=None):
|
|
@@ -22,7 +24,7 @@ def _chat_response(content="PONG", tool_calls=None):
|
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def test_version():
|
|
25
|
-
assert codai.__version__ == "0.
|
|
27
|
+
assert codai.__version__ == "0.4.0"
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def test_chat_completions_create(client, stub):
|
|
@@ -329,3 +331,125 @@ def test_projects_and_environments_groups(client, stub):
|
|
|
329
331
|
stub.reply(200, {"deleted": True, "name": "API_KEY"})
|
|
330
332
|
client.environments.secrets.remove("e1", "API_KEY")
|
|
331
333
|
assert stub.last.method == "DELETE" and stub.last.path == "/v1/environments/e1/secrets/API_KEY"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_projects_get_returns_full_reply(client, stub):
|
|
337
|
+
reply = {"project": {"id": "p/1", "name": "brivio"}, "environments": [{"id": "e1"}]}
|
|
338
|
+
stub.reply(200, reply)
|
|
339
|
+
assert client.projects.get("p/1") == reply
|
|
340
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/projects/p%2F1"
|
|
341
|
+
assert stub.last.json is None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_environments_get_and_update(client, stub):
|
|
345
|
+
detail = {"environment": {"id": "e1"}, "role": "owner", "members": [], "ports": [], "events": []}
|
|
346
|
+
stub.reply(200, detail)
|
|
347
|
+
assert client.environments.get("e 1") == detail
|
|
348
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e%201"
|
|
349
|
+
stub.reply(200, {"environment": {"id": "e1", "idle_timeout_minutes": 30}})
|
|
350
|
+
out = client.environments.update("e1", {"name": "dev", "idle_timeout_minutes": 30})
|
|
351
|
+
assert out == {"id": "e1", "idle_timeout_minutes": 30}
|
|
352
|
+
assert stub.last.method == "PATCH" and stub.last.path == "/v1/environments/e1"
|
|
353
|
+
assert stub.last.json == {"name": "dev", "idle_timeout_minutes": 30}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_environment_members_list_add_remove(client, stub):
|
|
357
|
+
stub.reply(200, {"members": [{"user_id": "u1", "role": "owner"}]})
|
|
358
|
+
assert client.environments.members.list("e1") == [{"user_id": "u1", "role": "owner"}]
|
|
359
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e1/members"
|
|
360
|
+
stub.reply(200, {})
|
|
361
|
+
assert client.environments.members.list("e1") == []
|
|
362
|
+
stub.reply(201, {"member": {"user_id": "u2", "role": "developer"}})
|
|
363
|
+
out = client.environments.members.add("e1", {"email": "a@b.c", "role": "developer"})
|
|
364
|
+
assert out == {"user_id": "u2", "role": "developer"}
|
|
365
|
+
assert stub.last.method == "POST" and stub.last.path == "/v1/environments/e1/members"
|
|
366
|
+
assert stub.last.json == {"email": "a@b.c", "role": "developer"}
|
|
367
|
+
stub.reply(200, {"removed": True, "user_id": "u/2"})
|
|
368
|
+
assert client.environments.members.remove("e1", "u/2") == {"removed": True, "user_id": "u/2"}
|
|
369
|
+
assert stub.last.method == "DELETE" and stub.last.path == "/v1/environments/e1/members/u%2F2"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_environment_members_ssh_cert(client, stub):
|
|
373
|
+
cert = {
|
|
374
|
+
"certificate": "ssh-ed25519-cert-v01@openssh.com AAAA u-1",
|
|
375
|
+
"host": "203.0.113.7",
|
|
376
|
+
"principal": "u-0f1d2c3e",
|
|
377
|
+
"valid_before": "2026-09-27T00:00:00.000Z",
|
|
378
|
+
}
|
|
379
|
+
stub.reply(200, cert)
|
|
380
|
+
out = client.environments.members.ssh_cert("env/1", {"public_key": "ssh-ed25519 AAAA ana@laptop"})
|
|
381
|
+
assert out == cert
|
|
382
|
+
assert stub.last.method == "POST"
|
|
383
|
+
assert stub.last.path == "/v1/environments/env%2F1/members/me/ssh-cert"
|
|
384
|
+
assert stub.last.json == {"public_key": "ssh-ed25519 AAAA ana@laptop"}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_environment_members_ssh_cert_is_not_retried(stub):
|
|
388
|
+
c = Codai(api_key="codai_test", base_url=stub.url, max_retries=2, timeout=5)
|
|
389
|
+
stub.reply(503, {"error": {"message": "busy"}})
|
|
390
|
+
with pytest.raises(CodaiError) as ei:
|
|
391
|
+
c.environments.members.ssh_cert("e1", {"public_key": "ssh-ed25519 AAAA"})
|
|
392
|
+
assert ei.value.status == 503
|
|
393
|
+
assert len(stub.requests) == 1
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_environments_ssh_ca(client, stub):
|
|
397
|
+
keys = {"public_keys": ["ssh-ed25519 AAAA ca-current", "ssh-ed25519 BBBB ca-next"]}
|
|
398
|
+
stub.reply(200, keys)
|
|
399
|
+
assert client.environments.ssh_ca("e 1") == keys
|
|
400
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e%201/ssh-ca"
|
|
401
|
+
assert stub.last.json is None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_environment_tasks_list_create_get(client, stub):
|
|
405
|
+
listing = {"tasks": [{"id": "t1", "status": "queued"}]}
|
|
406
|
+
stub.reply(200, listing)
|
|
407
|
+
assert client.environments.tasks.list("e1") == listing
|
|
408
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e1/tasks"
|
|
409
|
+
|
|
410
|
+
queued = {"task_id": "t1", "session_id": "s1", "status": "queued"}
|
|
411
|
+
stub.reply(202, queued)
|
|
412
|
+
out = client.environments.tasks.create("env/1", {"prompt": "add a /health route", "branch": "feat/health-route"})
|
|
413
|
+
assert out == queued
|
|
414
|
+
assert stub.last.method == "POST" and stub.last.path == "/v1/environments/env%2F1/tasks"
|
|
415
|
+
assert stub.last.json == {"prompt": "add a /health route", "branch": "feat/health-route"}
|
|
416
|
+
stub.reply(202, queued)
|
|
417
|
+
client.environments.tasks.create("e1", {"prompt": "no branch"})
|
|
418
|
+
assert stub.last.json == {"prompt": "no branch"}
|
|
419
|
+
|
|
420
|
+
detail = {"task": {"id": "t/1", "status": "done"}}
|
|
421
|
+
stub.reply(200, detail)
|
|
422
|
+
assert client.environments.tasks.get("e 1", "t/1") == detail
|
|
423
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e%201/tasks/t%2F1"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_environment_tasks_create_is_not_retried(stub):
|
|
427
|
+
c = Codai(api_key="codai_test", base_url=stub.url, max_retries=2, timeout=5)
|
|
428
|
+
stub.reply(503, {"error": {"message": "busy"}})
|
|
429
|
+
with pytest.raises(CodaiError):
|
|
430
|
+
c.environments.tasks.create("e1", {"prompt": "x"})
|
|
431
|
+
assert len(stub.requests) == 1
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_environment_ports_list_remove_and_secrets_list(client, stub):
|
|
435
|
+
stub.reply(200, {"ports": [{"port": 3000, "visibility": "org"}]})
|
|
436
|
+
assert client.environments.ports.list("e1") == [{"port": 3000, "visibility": "org"}]
|
|
437
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e1/ports"
|
|
438
|
+
stub.reply(200, {"deleted": True, "port": 3000})
|
|
439
|
+
assert client.environments.ports.remove("e1", 3000) == {"deleted": True, "port": 3000}
|
|
440
|
+
assert stub.last.method == "DELETE" and stub.last.path == "/v1/environments/e1/ports/3000"
|
|
441
|
+
stub.reply(200, {"secrets": [{"name": "API_KEY", "updated_at": "2026-09-26T00:00:00Z"}]})
|
|
442
|
+
assert client.environments.secrets.list("e1") == [{"name": "API_KEY", "updated_at": "2026-09-26T00:00:00Z"}]
|
|
443
|
+
assert stub.last.method == "GET" and stub.last.path == "/v1/environments/e1/secrets"
|
|
444
|
+
stub.reply(200, {"secrets": None})
|
|
445
|
+
assert client.environments.secrets.list("e1") == []
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_notifications_delete_and_delete_many(client, stub):
|
|
449
|
+
stub.reply(200, {"deleted": 1})
|
|
450
|
+
assert client.notifications.delete(7) == {"deleted": 1}
|
|
451
|
+
assert stub.last.method == "DELETE" and stub.last.path == "/v1/notifications/7"
|
|
452
|
+
stub.reply(200, {"deleted": 2})
|
|
453
|
+
assert client.notifications.delete_many([3, 4]) == {"deleted": 2}
|
|
454
|
+
assert stub.last.method == "POST" and stub.last.path == "/v1/notifications/delete"
|
|
455
|
+
assert stub.last.json == {"ids": [3, 4]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|