Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint-and-test:
name: Lint & Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]

steps:
- name: Check out source
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Lint with ruff
run: ruff check .

- name: Type check with mypy
run: mypy runcycles

- name: Run tests
run: pytest
169 changes: 169 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Cycles Protocol v0.1.23 — Client (Python) Audit

**Date:** 2026-03-14
**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.23)
**Client:** `runcycles` (Python 3.10+ / httpx / Pydantic v2)
**Server audit:** See `cycles-server/AUDIT.md` (all passing)

---

## Summary

| Category | Pass | Issues |
|----------|------|--------|
| Endpoints & HTTP Methods | 9/9 | 0 |
| Request Schemas (field names & JSON keys) | 6/6 | 0 |
| Response Schemas (field names & JSON keys) | 10/10 | 0 |
| Enum Values | 5/5 | 0 |
| Nested Object Schemas | 8/8 | 0 |
| Auth Header (X-Cycles-API-Key) | — | 0 |
| Idempotency (header ↔ body sync) | — | 0 |
| Subject Validation | — | 0 |
| Response Header Capture | — | 0 |
| Client-Side Spec Constraint Validation | — | 0 |
| Lifecycle Orchestration | — | 0 |

**Overall: Client is protocol-conformant.** All endpoints, schemas, field names, JSON keys, and enum values match the OpenAPI spec. No open issues.

---

## Audit Scope

Compared the following across spec YAML and client Python source:
- All 9 endpoint paths, HTTP methods, and path/query parameters
- All 6 request body serializations vs spec schemas
- All 10 response model deserializations vs spec schemas
- All 5 enum types and their values
- Nested object schemas (Subject, Action, Amount, SignedAmount, Caps, CyclesMetrics, Balance, ErrorResponse)
- Auth and idempotency header handling
- Subject constraint validation (`anyOf` / at least one standard field)
- Pydantic Field constraints vs spec min/max bounds
- Lifecycle orchestration (reserve → execute → commit/release)

---

## PASS — Correctly Implemented

### Endpoints (all 9 match spec)

| Spec Endpoint | Client Method | HTTP Method | Match |
|---|---|---|---|
| `/v1/decide` | `client.decide()` | POST | PASS |
| `/v1/reservations` (create) | `client.create_reservation()` | POST | PASS |
| `/v1/reservations` (list) | `client.list_reservations()` | GET | PASS |
| `/v1/reservations/{reservation_id}` | `client.get_reservation()` | GET | PASS |
| `/v1/reservations/{reservation_id}/commit` | `client.commit_reservation()` | POST | PASS |
| `/v1/reservations/{reservation_id}/release` | `client.release_reservation()` | POST | PASS |
| `/v1/reservations/{reservation_id}/extend` | `client.extend_reservation()` | POST | PASS |
| `/v1/balances` | `client.get_balances()` | GET | PASS |
| `/v1/events` | `client.create_event()` | POST | PASS |

### Request Schemas (all match spec JSON keys)

**ReservationCreateRequest** — spec required: `[idempotency_key, subject, action, estimate]`
- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `ttl_ms`, `grace_period_ms`, `overage_policy`, `dry_run`, `metadata` — all snake_case, all match spec

**CommitRequest** — spec required: `[idempotency_key, actual]`
- Pydantic fields: `idempotency_key`, `actual`, `metrics`, `metadata` — all match spec

**ReleaseRequest** — spec required: `[idempotency_key]`
- Pydantic fields: `idempotency_key`, `reason` — all match spec

**DecisionRequest** — spec required: `[idempotency_key, subject, action, estimate]`
- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `metadata` — all match spec

**EventCreateRequest** — spec required: `[idempotency_key, subject, action, actual]`
- Pydantic fields: `idempotency_key`, `subject`, `action`, `actual`, `overage_policy`, `metrics`, `client_time_ms`, `metadata` — all match spec

**ReservationExtendRequest** — spec required: `[idempotency_key, extend_by_ms]`
- Pydantic fields: `idempotency_key`, `extend_by_ms`, `metadata` — all match spec

### Response Schemas (all match spec JSON keys)

| Spec Schema | Client Class | JSON Keys | Match |
|---|---|---|---|
| `ReservationCreateResponse` | `ReservationCreateResponse` | `decision`, `reservation_id`, `affected_scopes`, `expires_at_ms`, `scope_path`, `reserved`, `caps`, `reason_code`, `retry_after_ms`, `balances` | PASS |
| `CommitResponse` | `CommitResponse` | `status`, `charged`, `released`, `balances` | PASS |
| `ReleaseResponse` | `ReleaseResponse` | `status`, `released`, `balances` | PASS |
| `DecisionResponse` | `DecisionResponse` | `decision`, `caps`, `reason_code`, `retry_after_ms`, `affected_scopes` | PASS |
| `EventCreateResponse` | `EventCreateResponse` | `status`, `event_id`, `balances` | PASS |
| `ReservationExtendResponse` | `ReservationExtendResponse` | `status`, `expires_at_ms`, `balances` | PASS |
| `BalanceResponse` | `BalanceResponse` | `balances`, `has_more`, `next_cursor` | PASS |
| `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 |
| `ReservationSummary` | `ReservationSummary` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `created_at_ms`, `expires_at_ms`, `scope_path`, `affected_scopes` | PASS |
| `ReservationListResponse` | `ReservationListResponse` | `reservations`, `has_more`, `next_cursor` | PASS |

### Nested Object Schemas (all match)

| Spec Schema | Client Class | JSON Keys | Match |
|---|---|---|---|
| `Subject` | `Subject` | `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `dimensions` | PASS |
| `Action` | `Action` | `kind`, `name`, `tags` | PASS |
| `Amount` | `Amount` | `unit`, `amount` | PASS |
| `SignedAmount` | `SignedAmount` | `unit`, `amount` | PASS |
| `Caps` | `Caps` | `max_tokens`, `max_steps_remaining`, `tool_allowlist`, `tool_denylist`, `cooldown_ms` | PASS |
| `StandardMetrics` | `CyclesMetrics` | `tokens_input`, `tokens_output`, `latency_ms`, `model_version`, `custom` | PASS |
| `Balance` | `Balance` | `scope`, `scope_path`, `remaining`, `reserved`, `spent`, `allocated`, `debt`, `overdraft_limit`, `is_over_limit` | PASS |
| `ErrorResponse` | `ErrorResponse` | `error`, `message`, `request_id`, `details` | PASS |

### Enum Values (all match spec)

| Spec Enum | Client Enum | Values | Match |
|---|---|---|---|
| `DecisionEnum` | `Decision` | `ALLOW`, `ALLOW_WITH_CAPS`, `DENY` | PASS |
| `UnitEnum` | `Unit` | `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS` | PASS |
| `CommitOveragePolicy` | `CommitOveragePolicy` | `REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT` | PASS |
| `ReservationStatus` | `ReservationStatus` | `ACTIVE`, `COMMITTED`, `RELEASED`, `EXPIRED` | PASS |
| `ErrorCode` | `ErrorCode` | All 12 spec values + `UNKNOWN` (client fallback) | PASS |

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.

### Auth & Idempotency (correct)

- **X-Cycles-API-Key**: Set on all requests via `httpx.Client` base headers in `CyclesClient.__init__()` (`client.py`)
- **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."

### Subject Validation (correct)

- `validate_subject()` in `_validation.py` calls `Subject.has_at_least_one_standard_field()` which checks all 6 standard fields — matches spec `anyOf` constraint
- Pydantic Field constraints enforce `maxLength: 128` on all Subject fields and `maxLength: 256` on dimension values

### Response Header Capture (correct)

- `_extract_response_headers()` in `client.py` captures `x-request-id`, `x-ratelimit-remaining`, `x-ratelimit-reset`, `x-cycles-tenant`
- Exposed via `CyclesResponse` properties: `request_id`, `rate_limit_remaining`, `rate_limit_reset`, `cycles_tenant`

### Client-Side Spec Constraint Validation (correct)

All spec constraints are validated both via Pydantic Field validators (on typed request models) and via explicit validation functions (on dict-based lifecycle path):

- `validate_non_negative()`: `Amount.amount >= 0` (spec `minimum: 0`)
- `validate_ttl_ms()`: 1000–86400000 (spec `minimum: 1000, maximum: 86400000`)
- `validate_grace_period_ms()`: 0–60000 (spec `minimum: 0, maximum: 60000`)
- `validate_extend_by_ms()`: 1–86400000 (spec `minimum: 1, maximum: 86400000`)
- Pydantic `Field(ge=1, le=86_400_000)` on `ReservationExtendRequest.extend_by_ms`
- Pydantic `Field(max_length=64)` on `Action.kind`, `Field(max_length=256)` on `Action.name`
- Pydantic `Field(min_length=1, max_length=256)` on all `idempotency_key` fields

### Lifecycle Orchestration (correct)

- Reserve → Execute → Commit flow with proper cleanup (release on failure)
- Heartbeat-based TTL extension at `max(ttl_ms / 2, 1000)` ms interval using `extend` endpoint
- Commit retry engine for transient failures (transport errors, 5xx) with exponential backoff
- Dry-run handling returns `DryRunResult` without executing guarded function
- `DENY` decision correctly raises typed `CyclesProtocolError`
- `ALLOW_WITH_CAPS` correctly propagates `Caps` via `CyclesContext`
- Lifecycle instance cached at decoration time (deferred client resolution on first call)
- `ContextVar`-based context propagation (safe for both sync threads and async tasks)

### HTTP Status Code Handling (correct)

- `is_success` correctly handles 2xx range (200 for most endpoints, 201 for events)
- Error responses parsed via `ErrorResponse.model_validate()` with `ErrorCode` mapping
- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError`

---

## Verdict

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.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,23 @@ def overdraft_func() -> str:
- **Response metadata**: Access `request_id`, `rate_limit_remaining`, and `rate_limit_reset` on every response
- **Environment config**: `CyclesConfig.from_env()` for 12-factor apps

## Development

```bash
pip install -e ".[dev]"

# Lint
ruff check .

# Type check (strict mode)
mypy runcycles

# Run tests
pytest
```

CI runs all three checks on Python 3.10 and 3.12 for every push and pull request.

## Requirements

- Python 3.10+
Expand Down
1 change: 0 additions & 1 deletion examples/async_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
get_cycles_context,
)


config = CyclesConfig(
base_url="http://localhost:7878",
api_key="your-api-key",
Expand Down
5 changes: 2 additions & 3 deletions examples/basic_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
CyclesConfig,
CyclesMetrics,
ReservationCreateRequest,
ReleaseRequest,
Subject,
Unit,
)
Expand Down Expand Up @@ -40,8 +39,8 @@ def main() -> None:
reservation_id = response.get_body_attribute("reservation_id")
print(f"Reserved: {reservation_id}")

# Simulate work
result = "Generated response text"
# Simulate work (result would be used in a real application)
_ = "Generated response text"

# Commit actual usage
commit_response = client.commit_reservation(reservation_id, CommitRequest(
Expand Down
1 change: 0 additions & 1 deletion examples/decorator_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
get_cycles_context,
)


config = CyclesConfig(
base_url="http://localhost:7878",
api_key="your-api-key",
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ dev = [
target-version = "py310"
line-length = 120

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

[tool.mypy]
python_version = "3.10"
strict = true
Expand Down
1 change: 1 addition & 0 deletions runcycles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
EventCreateRequest,
EventCreateResponse,
EventStatus,
ExtendStatus,
ReleaseRequest,
ReleaseResponse,
ReleaseStatus,
Expand Down
11 changes: 10 additions & 1 deletion runcycles/_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
def validate_subject(subject: Subject | None) -> None:
"""Validate that a subject has at least one standard field."""
if subject is not None and not subject.has_at_least_one_standard_field():
raise ValueError("Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)")
raise ValueError(
"Subject must have at least one standard field"
" (tenant, workspace, app, workflow, agent, or toolset)"
)


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


def validate_extend_by_ms(extend_by_ms: int) -> None:
"""Validate extend_by_ms is within allowed range (1ms to 24h)."""
if extend_by_ms < 1 or extend_by_ms > 86_400_000:
raise ValueError(f"extend_by_ms must be between 1 and 86400000, got {extend_by_ms}")


def validate_grace_period_ms(grace_period_ms: int | None) -> None:
"""Validate grace period is within allowed range (0 to 60s)."""
if grace_period_ms is not None and (grace_period_ms < 0 or grace_period_ms > 60_000):
Expand Down
13 changes: 10 additions & 3 deletions runcycles/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ def _extract_response_headers(resp: httpx.Response) -> dict[str, str]:
def _validate_balance_filters(params: dict[str, str]) -> None:
"""Validate that at least one subject filter is provided for balance queries."""
if not any(k in _BALANCE_FILTER_PARAMS for k in params):
raise ValueError("get_balances requires at least one subject filter (tenant, workspace, app, workflow, agent, or toolset)")
raise ValueError(
"get_balances requires at least one subject filter"
" (tenant, workspace, app, workflow, agent, or toolset)"
)


class CyclesClient:
Expand Down Expand Up @@ -148,7 +151,9 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse:
error_msg = None
if body and isinstance(body, dict):
error_msg = body.get("message") or body.get("error")
return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers)
return CyclesResponse.http_error(
resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers,
)


class AsyncCyclesClient:
Expand Down Expand Up @@ -242,4 +247,6 @@ def _handle_response(resp: httpx.Response) -> CyclesResponse:
error_msg = None
if body and isinstance(body, dict):
error_msg = body.get("message") or body.get("error")
return CyclesResponse.http_error(resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers)
return CyclesResponse.http_error(
resp.status_code, error_msg or resp.reason_phrase or "Unknown error", body, headers=headers,
)
2 changes: 1 addition & 1 deletion runcycles/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import os
from dataclasses import dataclass, field
from dataclasses import dataclass


@dataclass
Expand Down
2 changes: 1 addition & 1 deletion runcycles/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from contextvars import ContextVar
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any

from runcycles.models import Amount, Balance, Caps, CyclesMetrics, Decision
Expand Down
Loading
Loading