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.
Files changed (42) hide show
  1. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CHANGELOG.md +17 -0
  2. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/PKG-INFO +28 -1
  3. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/README.md +27 -0
  4. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/pyproject.toml +1 -1
  5. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/scripts/gen-types.py +39 -16
  6. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/__init__.py +21 -2
  7. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_operations.py +11 -0
  8. codai_sdk-0.4.0/src/codai/_resolve_types.py +174 -0
  9. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_types.py +49 -5
  10. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/__init__.py +5 -0
  11. codai_sdk-0.4.0/src/codai/resources/resolve.py +148 -0
  12. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/triggers.py +8 -0
  13. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/test_parity.py +4 -3
  14. codai_sdk-0.4.0/tests/test_resolve.py +95 -0
  15. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/test_resources.py +126 -2
  16. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/dependabot.yml +0 -0
  19. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/pull_request_template.md +0 -0
  20. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.github/workflows/publish.yml +0 -0
  21. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/.gitignore +0 -0
  22. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  23. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/CONTRIBUTING.md +0 -0
  24. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/LICENSE +0 -0
  25. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/SECURITY.md +0 -0
  26. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/agent.py +0 -0
  27. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/chat.py +0 -0
  28. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/feedback.py +0 -0
  29. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/examples/streaming.py +0 -0
  30. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/scripts/smoke_local.py +0 -0
  31. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/_http.py +0 -0
  32. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/_base.py +0 -0
  33. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/agents.py +0 -0
  34. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/chat.py +0 -0
  35. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/core.py +0 -0
  36. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/environments.py +0 -0
  37. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/messages.py +0 -0
  38. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/platform.py +0 -0
  39. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/responses.py +0 -0
  40. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/src/codai/resources/sessions.py +0 -0
  41. {codai_sdk-0.3.0 → codai_sdk-0.4.0}/tests/conftest.py +0 -0
  42. {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.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.3.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 # write
10
- .\\.venv\\Scripts\\python scripts/gen-types.py --check # exit 1 when stale
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
- SPEC = PKG.parents[1] / "apps" / "docs" / "openapi" / "en" / "gateway.yaml"
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 gateway API.
44
+ HEADER = '''"""Typed dictionaries for the codai {api} API.
34
45
 
35
- generated — do not edit. Source: apps/docs/openapi/en/gateway.yaml.
36
- Regenerate with ``python scripts/gen-types.py`` (see that script's docstring).
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__(self, spec: Dict[str, Any]) -> None:
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 = [HEADER]
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
- with SPEC.open("r", encoding="utf-8") as fh:
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 = OUT.read_text(encoding="utf-8") if OUT.exists() else ""
337
+ current = out.read_text(encoding="utf-8") if out.exists() else ""
315
338
  if current != text:
316
- print(f"STALE: {OUT} differs from a fresh generation", file=sys.stderr)
339
+ print(f"STALE: {out} differs from a fresh generation", file=sys.stderr)
317
340
  return 1
318
- print(f"OK: {OUT} is up to date")
341
+ print(f"OK: {out} is up to date")
319
342
  return 0
320
- OUT.write_text(text, encoding="utf-8", newline="\n")
321
- print(f"wrote {OUT} ({text.count(chr(10))} lines, {len(spec['components']['schemas'])} schemas)")
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.3.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 Project(TypedDict):
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 CreateEnvironmentResponse(TypedDict):
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
- assert set(ts_map) == set(OPERATION_METHODS)
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()} == OPERATION_METHODS
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.3.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