Skip to content

Commit 64bcd51

Browse files
authored
Merge pull request #7 from runcycles/claude/review-python-client-mj54H
Add comprehensive audit report and improve code quality
2 parents 0f4e317 + 22f0ce5 commit 64bcd51

24 files changed

Lines changed: 403 additions & 87 deletions

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint-and-test:
11+
name: Lint & Test (Python ${{ matrix.python-version }})
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.10", "3.12"]
16+
17+
steps:
18+
- name: Check out source
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Python ${{ matrix.python-version }}
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install -e ".[dev]"
30+
31+
- name: Lint with ruff
32+
run: ruff check .
33+
34+
- name: Type check with mypy
35+
run: mypy runcycles
36+
37+
- name: Run tests
38+
run: pytest

AUDIT.md

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Cycles Protocol v0.1.23 — Client (Python) Audit
2+
3+
**Date:** 2026-03-14
4+
**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.23)
5+
**Client:** `runcycles` (Python 3.10+ / httpx / Pydantic v2)
6+
**Server audit:** See `cycles-server/AUDIT.md` (all passing)
7+
8+
---
9+
10+
## Summary
11+
12+
| Category | Pass | Issues |
13+
|----------|------|--------|
14+
| Endpoints & HTTP Methods | 9/9 | 0 |
15+
| Request Schemas (field names & JSON keys) | 6/6 | 0 |
16+
| Response Schemas (field names & JSON keys) | 10/10 | 0 |
17+
| Enum Values | 5/5 | 0 |
18+
| Nested Object Schemas | 8/8 | 0 |
19+
| Auth Header (X-Cycles-API-Key) || 0 |
20+
| Idempotency (header ↔ body sync) || 0 |
21+
| Subject Validation || 0 |
22+
| Response Header Capture || 0 |
23+
| Client-Side Spec Constraint Validation || 0 |
24+
| Lifecycle Orchestration || 0 |
25+
26+
**Overall: Client is protocol-conformant.** All endpoints, schemas, field names, JSON keys, and enum values match the OpenAPI spec. No open issues.
27+
28+
---
29+
30+
## Audit Scope
31+
32+
Compared the following across spec YAML and client Python source:
33+
- All 9 endpoint paths, HTTP methods, and path/query parameters
34+
- All 6 request body serializations vs spec schemas
35+
- All 10 response model deserializations vs spec schemas
36+
- All 5 enum types and their values
37+
- Nested object schemas (Subject, Action, Amount, SignedAmount, Caps, CyclesMetrics, Balance, ErrorResponse)
38+
- Auth and idempotency header handling
39+
- Subject constraint validation (`anyOf` / at least one standard field)
40+
- Pydantic Field constraints vs spec min/max bounds
41+
- Lifecycle orchestration (reserve → execute → commit/release)
42+
43+
---
44+
45+
## PASS — Correctly Implemented
46+
47+
### Endpoints (all 9 match spec)
48+
49+
| Spec Endpoint | Client Method | HTTP Method | Match |
50+
|---|---|---|---|
51+
| `/v1/decide` | `client.decide()` | POST | PASS |
52+
| `/v1/reservations` (create) | `client.create_reservation()` | POST | PASS |
53+
| `/v1/reservations` (list) | `client.list_reservations()` | GET | PASS |
54+
| `/v1/reservations/{reservation_id}` | `client.get_reservation()` | GET | PASS |
55+
| `/v1/reservations/{reservation_id}/commit` | `client.commit_reservation()` | POST | PASS |
56+
| `/v1/reservations/{reservation_id}/release` | `client.release_reservation()` | POST | PASS |
57+
| `/v1/reservations/{reservation_id}/extend` | `client.extend_reservation()` | POST | PASS |
58+
| `/v1/balances` | `client.get_balances()` | GET | PASS |
59+
| `/v1/events` | `client.create_event()` | POST | PASS |
60+
61+
### Request Schemas (all match spec JSON keys)
62+
63+
**ReservationCreateRequest** — spec required: `[idempotency_key, subject, action, estimate]`
64+
- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `ttl_ms`, `grace_period_ms`, `overage_policy`, `dry_run`, `metadata` — all snake_case, all match spec
65+
66+
**CommitRequest** — spec required: `[idempotency_key, actual]`
67+
- Pydantic fields: `idempotency_key`, `actual`, `metrics`, `metadata` — all match spec
68+
69+
**ReleaseRequest** — spec required: `[idempotency_key]`
70+
- Pydantic fields: `idempotency_key`, `reason` — all match spec
71+
72+
**DecisionRequest** — spec required: `[idempotency_key, subject, action, estimate]`
73+
- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `metadata` — all match spec
74+
75+
**EventCreateRequest** — spec required: `[idempotency_key, subject, action, actual]`
76+
- Pydantic fields: `idempotency_key`, `subject`, `action`, `actual`, `overage_policy`, `metrics`, `client_time_ms`, `metadata` — all match spec
77+
78+
**ReservationExtendRequest** — spec required: `[idempotency_key, extend_by_ms]`
79+
- Pydantic fields: `idempotency_key`, `extend_by_ms`, `metadata` — all match spec
80+
81+
### Response Schemas (all match spec JSON keys)
82+
83+
| Spec Schema | Client Class | JSON Keys | Match |
84+
|---|---|---|---|
85+
| `ReservationCreateResponse` | `ReservationCreateResponse` | `decision`, `reservation_id`, `affected_scopes`, `expires_at_ms`, `scope_path`, `reserved`, `caps`, `reason_code`, `retry_after_ms`, `balances` | PASS |
86+
| `CommitResponse` | `CommitResponse` | `status`, `charged`, `released`, `balances` | PASS |
87+
| `ReleaseResponse` | `ReleaseResponse` | `status`, `released`, `balances` | PASS |
88+
| `DecisionResponse` | `DecisionResponse` | `decision`, `caps`, `reason_code`, `retry_after_ms`, `affected_scopes` | PASS |
89+
| `EventCreateResponse` | `EventCreateResponse` | `status`, `event_id`, `balances` | PASS |
90+
| `ReservationExtendResponse` | `ReservationExtendResponse` | `status`, `expires_at_ms`, `balances` | PASS |
91+
| `BalanceResponse` | `BalanceResponse` | `balances`, `has_more`, `next_cursor` | PASS |
92+
| `ReservationDetail` | `ReservationDetail` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `committed`, `created_at_ms`, `expires_at_ms`, `finalized_at_ms`, `scope_path`, `affected_scopes`, `metadata` | PASS |
93+
| `ReservationSummary` | `ReservationSummary` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `created_at_ms`, `expires_at_ms`, `scope_path`, `affected_scopes` | PASS |
94+
| `ReservationListResponse` | `ReservationListResponse` | `reservations`, `has_more`, `next_cursor` | PASS |
95+
96+
### Nested Object Schemas (all match)
97+
98+
| Spec Schema | Client Class | JSON Keys | Match |
99+
|---|---|---|---|
100+
| `Subject` | `Subject` | `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `dimensions` | PASS |
101+
| `Action` | `Action` | `kind`, `name`, `tags` | PASS |
102+
| `Amount` | `Amount` | `unit`, `amount` | PASS |
103+
| `SignedAmount` | `SignedAmount` | `unit`, `amount` | PASS |
104+
| `Caps` | `Caps` | `max_tokens`, `max_steps_remaining`, `tool_allowlist`, `tool_denylist`, `cooldown_ms` | PASS |
105+
| `StandardMetrics` | `CyclesMetrics` | `tokens_input`, `tokens_output`, `latency_ms`, `model_version`, `custom` | PASS |
106+
| `Balance` | `Balance` | `scope`, `scope_path`, `remaining`, `reserved`, `spent`, `allocated`, `debt`, `overdraft_limit`, `is_over_limit` | PASS |
107+
| `ErrorResponse` | `ErrorResponse` | `error`, `message`, `request_id`, `details` | PASS |
108+
109+
### Enum Values (all match spec)
110+
111+
| Spec Enum | Client Enum | Values | Match |
112+
|---|---|---|---|
113+
| `DecisionEnum` | `Decision` | `ALLOW`, `ALLOW_WITH_CAPS`, `DENY` | PASS |
114+
| `UnitEnum` | `Unit` | `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS` | PASS |
115+
| `CommitOveragePolicy` | `CommitOveragePolicy` | `REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT` | PASS |
116+
| `ReservationStatus` | `ReservationStatus` | `ACTIVE`, `COMMITTED`, `RELEASED`, `EXPIRED` | PASS |
117+
| `ErrorCode` | `ErrorCode` | All 12 spec values + `UNKNOWN` (client fallback) | PASS |
118+
119+
Note: Client `ErrorCode` adds `UNKNOWN` as a fallback for unrecognized server error codes. This is a client-side convenience and does not violate the spec.
120+
121+
### Auth & Idempotency (correct)
122+
123+
- **X-Cycles-API-Key**: Set on all requests via `httpx.Client` base headers in `CyclesClient.__init__()` (`client.py`)
124+
- **X-Idempotency-Key**: Extracted from request body `idempotency_key` field via `_extract_idempotency_key()` and set as header in `_post()`. Header and body values always match (copied from body to header), satisfying the spec rule: "If X-Idempotency-Key header is present and body.idempotency_key is present, they MUST match."
125+
126+
### Subject Validation (correct)
127+
128+
- `validate_subject()` in `_validation.py` calls `Subject.has_at_least_one_standard_field()` which checks all 6 standard fields — matches spec `anyOf` constraint
129+
- Pydantic Field constraints enforce `maxLength: 128` on all Subject fields and `maxLength: 256` on dimension values
130+
131+
### Response Header Capture (correct)
132+
133+
- `_extract_response_headers()` in `client.py` captures `x-request-id`, `x-ratelimit-remaining`, `x-ratelimit-reset`, `x-cycles-tenant`
134+
- Exposed via `CyclesResponse` properties: `request_id`, `rate_limit_remaining`, `rate_limit_reset`, `cycles_tenant`
135+
136+
### Client-Side Spec Constraint Validation (correct)
137+
138+
All spec constraints are validated both via Pydantic Field validators (on typed request models) and via explicit validation functions (on dict-based lifecycle path):
139+
140+
- `validate_non_negative()`: `Amount.amount >= 0` (spec `minimum: 0`)
141+
- `validate_ttl_ms()`: 1000–86400000 (spec `minimum: 1000, maximum: 86400000`)
142+
- `validate_grace_period_ms()`: 0–60000 (spec `minimum: 0, maximum: 60000`)
143+
- `validate_extend_by_ms()`: 1–86400000 (spec `minimum: 1, maximum: 86400000`)
144+
- Pydantic `Field(ge=1, le=86_400_000)` on `ReservationExtendRequest.extend_by_ms`
145+
- Pydantic `Field(max_length=64)` on `Action.kind`, `Field(max_length=256)` on `Action.name`
146+
- Pydantic `Field(min_length=1, max_length=256)` on all `idempotency_key` fields
147+
148+
### Lifecycle Orchestration (correct)
149+
150+
- Reserve → Execute → Commit flow with proper cleanup (release on failure)
151+
- Heartbeat-based TTL extension at `max(ttl_ms / 2, 1000)` ms interval using `extend` endpoint
152+
- Commit retry engine for transient failures (transport errors, 5xx) with exponential backoff
153+
- Dry-run handling returns `DryRunResult` without executing guarded function
154+
- `DENY` decision correctly raises typed `CyclesProtocolError`
155+
- `ALLOW_WITH_CAPS` correctly propagates `Caps` via `CyclesContext`
156+
- Lifecycle instance cached at decoration time (deferred client resolution on first call)
157+
- `ContextVar`-based context propagation (safe for both sync threads and async tasks)
158+
159+
### HTTP Status Code Handling (correct)
160+
161+
- `is_success` correctly handles 2xx range (200 for most endpoints, 201 for events)
162+
- Error responses parsed via `ErrorResponse.model_validate()` with `ErrorCode` mapping
163+
- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError`
164+
165+
---
166+
167+
## Verdict
168+
169+
The client is **fully protocol-conformant** with the Cycles Protocol v0.1.23 OpenAPI spec. All 9 endpoints, 6 request schemas, 10 response schemas, 5 enum types, and all nested object serializations match the spec exactly. JSON field names use correct snake_case throughout. Auth headers, idempotency handling, subject validation, response header capture, and spec constraint validation all follow spec normative rules. No open issues.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,23 @@ def overdraft_func() -> str:
301301
- **Response metadata**: Access `request_id`, `rate_limit_remaining`, and `rate_limit_reset` on every response
302302
- **Environment config**: `CyclesConfig.from_env()` for 12-factor apps
303303

304+
## Development
305+
306+
```bash
307+
pip install -e ".[dev]"
308+
309+
# Lint
310+
ruff check .
311+
312+
# Type check (strict mode)
313+
mypy runcycles
314+
315+
# Run tests
316+
pytest
317+
```
318+
319+
CI runs all three checks on Python 3.10 and 3.12 for every push and pull request.
320+
304321
## Requirements
305322

306323
- Python 3.10+

examples/async_usage.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
get_cycles_context,
1111
)
1212

13-
1413
config = CyclesConfig(
1514
base_url="http://localhost:7878",
1615
api_key="your-api-key",

examples/basic_usage.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
CyclesConfig,
99
CyclesMetrics,
1010
ReservationCreateRequest,
11-
ReleaseRequest,
1211
Subject,
1312
Unit,
1413
)
@@ -40,8 +39,8 @@ def main() -> None:
4039
reservation_id = response.get_body_attribute("reservation_id")
4140
print(f"Reserved: {reservation_id}")
4241

43-
# Simulate work
44-
result = "Generated response text"
42+
# Simulate work (result would be used in a real application)
43+
_ = "Generated response text"
4544

4645
# Commit actual usage
4746
commit_response = client.commit_reservation(reservation_id, CommitRequest(

examples/decorator_usage.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
get_cycles_context,
99
)
1010

11-
1211
config = CyclesConfig(
1312
base_url="http://localhost:7878",
1413
api_key="your-api-key",

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ dev = [
2828
target-version = "py310"
2929
line-length = 120
3030

31+
[tool.ruff.lint]
32+
select = ["E", "F", "I", "UP"]
33+
3134
[tool.mypy]
3235
python_version = "3.10"
3336
strict = true

runcycles/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
EventCreateRequest,
3535
EventCreateResponse,
3636
EventStatus,
37+
ExtendStatus,
3738
ReleaseRequest,
3839
ReleaseResponse,
3940
ReleaseStatus,

runcycles/_validation.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
def validate_subject(subject: Subject | None) -> None:
99
"""Validate that a subject has at least one standard field."""
1010
if subject is not None and not subject.has_at_least_one_standard_field():
11-
raise ValueError("Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)")
11+
raise ValueError(
12+
"Subject must have at least one standard field"
13+
" (tenant, workspace, app, workflow, agent, or toolset)"
14+
)
1215

1316

1417
def validate_reservation_id(reservation_id: str | None) -> None:
@@ -29,6 +32,12 @@ def validate_ttl_ms(ttl_ms: int) -> None:
2932
raise ValueError(f"ttl_ms must be between 1000 and 86400000, got {ttl_ms}")
3033

3134

35+
def validate_extend_by_ms(extend_by_ms: int) -> None:
36+
"""Validate extend_by_ms is within allowed range (1ms to 24h)."""
37+
if extend_by_ms < 1 or extend_by_ms > 86_400_000:
38+
raise ValueError(f"extend_by_ms must be between 1 and 86400000, got {extend_by_ms}")
39+
40+
3241
def validate_grace_period_ms(grace_period_ms: int | None) -> None:
3342
"""Validate grace period is within allowed range (0 to 60s)."""
3443
if grace_period_ms is not None and (grace_period_ms < 0 or grace_period_ms > 60_000):

runcycles/client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ def _extract_response_headers(resp: httpx.Response) -> dict[str, str]:
5454
def _validate_balance_filters(params: dict[str, str]) -> None:
5555
"""Validate that at least one subject filter is provided for balance queries."""
5656
if not any(k in _BALANCE_FILTER_PARAMS for k in params):
57-
raise ValueError("get_balances requires at least one subject filter (tenant, workspace, app, workflow, agent, or toolset)")
57+
raise ValueError(
58+
"get_balances requires at least one subject filter"
59+
" (tenant, workspace, app, workflow, agent, or toolset)"
60+
)
5861

5962

6063
class CyclesClient:
@@ -148,7 +151,9 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse:
148151
error_msg = None
149152
if body and isinstance(body, dict):
150153
error_msg = body.get("message") or body.get("error")
151-
return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers)
154+
return CyclesResponse.http_error(
155+
resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers,
156+
)
152157

153158

154159
class AsyncCyclesClient:
@@ -242,4 +247,6 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse:
242247
error_msg = None
243248
if body and isinstance(body, dict):
244249
error_msg = body.get("message") or body.get("error")
245-
return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers)
250+
return CyclesResponse.http_error(
251+
resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers,
252+
)

0 commit comments

Comments
 (0)