Skip to content

Commit 8f8084c

Browse files
authored
Merge pull request #46 from runcycles/feat/45-dynamic-subject-action-fields
feat(decorator): support callables on subject and action fields (#45)
2 parents 059c88f + 31aeab7 commit 8f8084c

10 files changed

Lines changed: 438 additions & 53 deletions

File tree

AUDIT.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,22 @@ Added `StreamReservation` and `AsyncStreamReservation` context managers that aut
205205
- **Error handling:** `RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`, and `IDEMPOTENCY_MISMATCH` do not trigger release; other 4xx client errors do trigger release — matches lifecycle.py behavior exactly
206206

207207
Protocol conformance: No new endpoints or protocol changes. All reservation, commit, release, and extend calls use the same client methods and body formats as the decorator path. Verified by 64 unit tests covering success, deny, error, retry, heartbeat, cost resolution, context propagation, spec validation, and all commit error-code branches.
208+
209+
---
210+
211+
## Dynamic Subject & Action Fields on `@cycles` (added 2026-04-27)
212+
213+
**Issue:** [#45](https://github.com/runcycles/cycles-client-python/issues/45)
214+
**Files:** `runcycles/lifecycle.py`, `runcycles/decorator.py`
215+
**Test files:** `tests/test_lifecycle.py`, `tests/test_decorator.py`
216+
**Version:** 0.4.0
217+
218+
Widened the `@cycles` decorator to accept callables — in addition to constants — for every field that previously had to be static at decoration time. Mirrors the existing `estimate` / `actual` callable contract and re-aligns the Python client with the Java client's `@Cycles(workspace = "#workspaceId")` SpEL behavior shipped in `cycles-spring-boot-starter` 0.2.1 ([java#50](https://github.com/runcycles/cycles-spring-boot-starter/pull/50)).
219+
220+
- **Newly callable fields:** `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `action_kind`, `action_name`, `action_tags`, `dimensions`. Each accepts `T | Callable[..., T | None] | None`.
221+
- **Resolution:** new `_resolve_value(val, args, kwargs)` helper in `lifecycle.py` invokes the callable with the decorated function's `*args, **kwargs` at reservation time; constants pass through untouched.
222+
- **Fallback semantics preserved:** subject callables returning `None` fall through to `default_subject_fields` (client config); `action_kind` / `action_name` returning `None` fall through to `"unknown"`; `action_tags` / `dimensions` returning `None` are omitted. Constants behave identically to today (regression-tested).
223+
- **Fail-fast:** exceptions raised inside a user callable propagate to the decorator caller without creating a reservation.
224+
- **Signature change:** `_build_reservation_body` now takes `args` and `kwargs` parameters; both `CyclesLifecycle.execute` and `AsyncCyclesLifecycle.execute` thread them through.
225+
226+
Protocol conformance: No protocol or wire-format changes. The reservation request body shape is unchanged — only the source of each field's value is widened. Verified by new unit tests in `TestCallableSubjectFields`, `TestCallableActionFields`, `TestCallableDimensions` plus an end-to-end decorator test asserting the captured request body.

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.0] - 2026-04-27
9+
10+
Dynamic subject and action fields on the `@cycles` decorator.
11+
12+
### Added
13+
14+
- Subject fields (`tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`), action fields (`action_kind`, `action_name`, `action_tags`), and `dimensions` now accept callables in addition to constants. Callables are invoked with the decorated function's `*args, **kwargs` at reservation time, enabling per-call budget routing and dynamic action labeling. Mirrors the Java client's SpEL behavior. (#45)
15+
16+
### Changed
17+
18+
- `_build_reservation_body` signature widened to thread `args` / `kwargs` through to the new `_resolve_value` helper. Internal API only; no protocol or wire-format changes.
19+
820
## [0.3.0] - 2026-04-08
921

1022
Add streaming support.
@@ -108,6 +120,7 @@ Initial public release.
108120

109121
- Comprehensive error handling and improved API model validation (#1)
110122

123+
[0.4.0]: https://github.com/runcycles/cycles-client-python/compare/v0.3.0...v0.4.0
111124
[0.3.0]: https://github.com/runcycles/cycles-client-python/compare/v0.2.0...v0.3.0
112125
[0.2.0]: https://github.com/runcycles/cycles-client-python/compare/v0.1.3...v0.2.0
113126
[0.1.3]: https://github.com/runcycles/cycles-client-python/compare/v0.1.2...v0.1.3

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@ result = call_llm("Hello", tokens=100)
6161
> ```
6262
> The key (e.g. `cyc_live_abc123...`) is shown only once — save it immediately. For key rotation and lifecycle details, see [API Key Management](https://runcycles.io/how-to/api-key-management-in-cycles).
6363
64+
### Dynamic subject and action fields
65+
66+
Subject fields (`tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`), action fields (`action_kind`, `action_name`, `action_tags`), and `dimensions` all accept either a constant or a callable. When given a callable, it is invoked with the decorated function's `*args, **kwargs` at reservation time — useful for routing per-call to different budget scopes or labeling actions dynamically:
67+
68+
```python
69+
@cycles(
70+
estimate=lambda req, workspace_id: req.tokens * 10,
71+
workspace=lambda req, workspace_id: workspace_id, # per-call budget routing
72+
action_kind=lambda req, *_: f"llm.{req.provider}", # dynamic action label
73+
action_name=lambda req, *_: req.model,
74+
dimensions=lambda req, *_: {"region": req.region},
75+
client=client,
76+
)
77+
def run_request(req: ResponseRequest, workspace_id: str) -> Response:
78+
...
79+
```
80+
81+
Fallback semantics mirror the constant case:
82+
83+
- Subject callables returning `None` fall through to the client-config default (`CyclesConfig(workspace=...)`).
84+
- `action_kind` / `action_name` returning `None` fall through to `"unknown"`.
85+
- `action_tags` / `dimensions` returning `None` are omitted from the request.
86+
- A callable that raises propagates the exception — fail-fast — without creating a reservation.
87+
6488
### Budget lifecycle
6589
6690
The `@cycles` decorator wraps your function in a reserve → execute → commit/release lifecycle:

examples/decorator_usage.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ def call_llm(prompt: str, tokens: int) -> str:
4949
return "Generated response for: " + prompt
5050

5151

52+
# Per-call subject / action routing via callables — resolved at reservation time
53+
# against the wrapped function's *args, **kwargs
54+
@cycles(
55+
estimate=1000,
56+
workspace=lambda req, workspace_id: workspace_id,
57+
action_kind=lambda req, *_: f"llm.{req['provider']}",
58+
action_name=lambda req, *_: req["model"],
59+
client=client,
60+
)
61+
def run_request(req: dict[str, str], workspace_id: str) -> str:
62+
return f"Routed {req['model']} to {workspace_id}"
63+
64+
5265
def main() -> None:
5366
print("Simple call:")
5467
result1 = simple_call()
@@ -58,6 +71,10 @@ def main() -> None:
5871
result2 = call_llm("Tell me a joke", tokens=200)
5972
print(f" Result: {result2}")
6073

74+
print("\nPer-call subject/action routing:")
75+
result3 = run_request({"provider": "openai", "model": "gpt-4"}, workspace_id="ws-42")
76+
print(f" Result: {result3}")
77+
6178

6279
if __name__ == "__main__":
6380
main()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "runcycles"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Python client for the Cycles budget-management protocol"
99
readme = "README.md"
1010
license = "Apache-2.0"

runcycles/decorator.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,46 +56,57 @@ def cycles(
5656
estimate: int | Callable[..., int],
5757
*,
5858
actual: int | Callable[..., int] | None = None,
59-
action_kind: str | None = None,
60-
action_name: str | None = None,
61-
action_tags: list[str] | None = None,
59+
action_kind: str | Callable[..., str | None] | None = None,
60+
action_name: str | Callable[..., str | None] | None = None,
61+
action_tags: list[str] | Callable[..., list[str] | None] | None = None,
6262
unit: Unit | str = Unit.USD_MICROCENTS,
6363
ttl_ms: int = 60_000,
6464
grace_period_ms: int | None = None,
6565
overage_policy: str = "ALLOW_IF_AVAILABLE",
6666
dry_run: bool = False,
67-
tenant: str | None = None,
68-
workspace: str | None = None,
69-
app: str | None = None,
70-
workflow: str | None = None,
71-
agent: str | None = None,
72-
toolset: str | None = None,
73-
dimensions: dict[str, str] | None = None,
67+
tenant: str | Callable[..., str | None] | None = None,
68+
workspace: str | Callable[..., str | None] | None = None,
69+
app: str | Callable[..., str | None] | None = None,
70+
workflow: str | Callable[..., str | None] | None = None,
71+
agent: str | Callable[..., str | None] | None = None,
72+
toolset: str | Callable[..., str | None] | None = None,
73+
dimensions: dict[str, str] | Callable[..., dict[str, str] | None] | None = None,
7474
client: CyclesClient | AsyncCyclesClient | None = None,
7575
use_estimate_if_actual_not_provided: bool = True,
7676
) -> Callable[[F], F]:
7777
"""Decorator that wraps a function with the Cycles reserve/execute/commit lifecycle.
7878
79+
Subject and action fields accept either a constant or a callable. When given a
80+
callable, it is invoked with the decorated function's ``*args, **kwargs`` at
81+
reservation time. Subject callables returning ``None`` fall through to the
82+
client-config default; ``action_kind`` / ``action_name`` returning ``None`` fall
83+
through to ``"unknown"``; ``action_tags`` / ``dimensions`` returning ``None`` are
84+
omitted.
85+
7986
Args:
8087
estimate: Estimated cost. Either an int constant or a callable that receives
8188
the decorated function's ``*args, **kwargs`` and returns an int.
8289
actual: Actual cost. Either an int constant or a callable that receives
8390
the function's return value and returns an int. Defaults to the estimate.
84-
action_kind: Action category (e.g. "llm.completion").
85-
action_name: Action identifier (e.g. "gpt-4").
86-
action_tags: Optional tags for filtering/reporting.
91+
action_kind: Action category (e.g. "llm.completion"). Constant or callable
92+
receiving ``*args, **kwargs``.
93+
action_name: Action identifier (e.g. "gpt-4"). Constant or callable
94+
receiving ``*args, **kwargs``.
95+
action_tags: Optional tags for filtering/reporting. Constant list or callable
96+
receiving ``*args, **kwargs`` returning a list.
8797
unit: Cost unit. Default: USD_MICROCENTS.
8898
ttl_ms: Reservation TTL in milliseconds. Default: 60000.
8999
grace_period_ms: Grace period after TTL expiry in milliseconds.
90100
overage_policy: REJECT, ALLOW_IF_AVAILABLE (default), or ALLOW_WITH_OVERDRAFT.
91101
dry_run: If True, evaluate without persisting (method won't execute).
92-
tenant: Subject tenant override.
93-
workspace: Subject workspace override.
94-
app: Subject app override.
95-
workflow: Subject workflow override.
96-
agent: Subject agent override.
97-
toolset: Subject toolset override.
98-
dimensions: Custom dimensions for the subject.
102+
tenant: Subject tenant override. Constant or callable receiving ``*args, **kwargs``.
103+
workspace: Subject workspace override. Constant or callable receiving ``*args, **kwargs``.
104+
app: Subject app override. Constant or callable receiving ``*args, **kwargs``.
105+
workflow: Subject workflow override. Constant or callable receiving ``*args, **kwargs``.
106+
agent: Subject agent override. Constant or callable receiving ``*args, **kwargs``.
107+
toolset: Subject toolset override. Constant or callable receiving ``*args, **kwargs``.
108+
dimensions: Custom dimensions for the subject. Constant dict or callable
109+
receiving ``*args, **kwargs`` returning a dict.
99110
client: Explicit Cycles client to use. Falls back to module-level default.
100111
use_estimate_if_actual_not_provided: If True and actual is None, use estimate as actual.
101112
@@ -116,6 +127,17 @@ def call_llm(prompt: str) -> str:
116127
)
117128
def call_llm(prompt: str, tokens: int) -> str:
118129
return openai.complete(prompt, max_tokens=tokens)
130+
131+
# Per-call subject/action routing via callables
132+
@cycles(
133+
estimate=1000,
134+
workspace=lambda req, workspace_id: workspace_id,
135+
action_kind=lambda req, *_: f"llm.{req.provider}",
136+
action_name=lambda req, *_: req.model,
137+
client=my_client,
138+
)
139+
def run_request(req: Request, workspace_id: str) -> Response:
140+
...
119141
"""
120142
unit_str = unit.value if isinstance(unit, Unit) else str(unit)
121143

runcycles/lifecycle.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,21 @@ class DecoratorConfig:
4747

4848
estimate: int | Callable[..., int]
4949
actual: int | Callable[..., int] | None = None
50-
action_kind: str | None = None
51-
action_name: str | None = None
52-
action_tags: list[str] | None = None
50+
action_kind: str | Callable[..., str | None] | None = None
51+
action_name: str | Callable[..., str | None] | None = None
52+
action_tags: list[str] | Callable[..., list[str] | None] | None = None
5353
unit: str = "USD_MICROCENTS"
5454
ttl_ms: int = 60_000
5555
grace_period_ms: int | None = None
5656
overage_policy: str = "ALLOW_IF_AVAILABLE"
5757
dry_run: bool = False
58-
tenant: str | None = None
59-
workspace: str | None = None
60-
app: str | None = None
61-
workflow: str | None = None
62-
agent: str | None = None
63-
toolset: str | None = None
64-
dimensions: dict[str, str] | None = None
58+
tenant: str | Callable[..., str | None] | None = None
59+
workspace: str | Callable[..., str | None] | None = None
60+
app: str | Callable[..., str | None] | None = None
61+
workflow: str | Callable[..., str | None] | None = None
62+
agent: str | Callable[..., str | None] | None = None
63+
toolset: str | Callable[..., str | None] | None = None
64+
dimensions: dict[str, str] | Callable[..., dict[str, str] | None] | None = None
6565
use_estimate_if_actual_not_provided: bool = True
6666

6767

@@ -72,6 +72,13 @@ def _evaluate_amount(expr: int | Callable[..., int], args: tuple[Any, ...], kwar
7272
return int(expr)
7373

7474

75+
def _resolve_value(val: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
76+
"""Resolve a decorator value: invoke if callable, else return as-is."""
77+
if callable(val):
78+
return val(*args, **kwargs)
79+
return val
80+
81+
7582
def _evaluate_actual(
7683
expr: int | Callable[..., int] | None,
7784
result: Any,
@@ -89,29 +96,39 @@ def _evaluate_actual(
8996

9097

9198
def _build_reservation_body(
92-
cfg: DecoratorConfig, estimate: int, default_subject_fields: dict[str, str | None],
99+
cfg: DecoratorConfig,
100+
estimate: int,
101+
default_subject_fields: dict[str, str | None],
102+
args: tuple[Any, ...],
103+
kwargs: dict[str, Any],
93104
) -> dict[str, Any]:
94105
"""Build the reservation create request body."""
95106
validate_non_negative(estimate, "estimate")
96107
validate_ttl_ms(cfg.ttl_ms)
97108

98109
subject: dict[str, Any] = {}
99110
for field_name in ("tenant", "workspace", "app", "workflow", "agent", "toolset"):
100-
val = getattr(cfg, field_name, None) or default_subject_fields.get(field_name)
111+
val = _resolve_value(getattr(cfg, field_name, None), args, kwargs)
112+
if not val:
113+
val = default_subject_fields.get(field_name)
101114
if val:
102115
subject[field_name] = val
103-
if cfg.dimensions:
104-
subject["dimensions"] = cfg.dimensions
116+
dims = _resolve_value(cfg.dimensions, args, kwargs)
117+
if dims:
118+
subject["dimensions"] = dims
105119

106120
subject_model = Subject(**subject)
107121
validate_subject(subject_model)
108122

123+
kind = _resolve_value(cfg.action_kind, args, kwargs)
124+
name = _resolve_value(cfg.action_name, args, kwargs)
125+
tags = _resolve_value(cfg.action_tags, args, kwargs)
109126
action: dict[str, Any] = {
110-
"kind": cfg.action_kind or "unknown",
111-
"name": cfg.action_name or "unknown",
127+
"kind": kind or "unknown",
128+
"name": name or "unknown",
112129
}
113-
if cfg.action_tags:
114-
action["tags"] = cfg.action_tags
130+
if tags:
131+
action["tags"] = tags
115132

116133
body: dict[str, Any] = {
117134
"idempotency_key": str(uuid.uuid4()),
@@ -234,7 +251,7 @@ def execute(
234251
logger.debug("Estimated usage: estimate=%d", estimate)
235252

236253
# Create reservation
237-
create_body = _build_reservation_body(cfg, estimate, self._default_subject)
254+
create_body = _build_reservation_body(cfg, estimate, self._default_subject, args, kwargs)
238255
logger.debug("Creating reservation: body=%s", create_body)
239256

240257
res_t1 = time.monotonic()
@@ -416,7 +433,7 @@ async def execute(
416433
estimate = _evaluate_amount(cfg.estimate, args, kwargs)
417434
logger.debug("Estimated usage: estimate=%d", estimate)
418435

419-
create_body = _build_reservation_body(cfg, estimate, self._default_subject)
436+
create_body = _build_reservation_body(cfg, estimate, self._default_subject, args, kwargs)
420437
res_response = await self._client.create_reservation(create_body)
421438

422439
if not res_response.is_success:

tests/test_decorator.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,49 @@ def compute(x: int) -> str:
8282
assert result == "hello"
8383
client.close()
8484

85+
def test_callable_subject_and_action_fields(self, config: CyclesConfig, httpx_mock) -> None: # type: ignore[no-untyped-def]
86+
"""Per-call subject/action callables resolve against function args at reservation time."""
87+
import json
88+
89+
httpx_mock.add_response(
90+
method="POST",
91+
url="http://localhost:7878/v1/reservations",
92+
json={
93+
"decision": "ALLOW", "reservation_id": "res_dec_dyn",
94+
"expires_at_ms": 9999999999, "affected_scopes": ["tenant:acme"],
95+
},
96+
status_code=200,
97+
)
98+
httpx_mock.add_response(
99+
method="POST",
100+
url="http://localhost:7878/v1/reservations/res_dec_dyn/commit",
101+
json={"status": "COMMITTED", "charged": {"unit": "USD_MICROCENTS", "amount": 1000}},
102+
status_code=200,
103+
)
104+
105+
client = CyclesClient(config)
106+
107+
@cycles(
108+
estimate=1000,
109+
workspace=lambda req, workspace_id: workspace_id,
110+
action_kind=lambda req, workspace_id: f"llm.{req['provider']}",
111+
action_name=lambda req, workspace_id: req["model"],
112+
client=client,
113+
)
114+
def run_request(req: dict[str, str], workspace_id: str) -> str:
115+
return "ok"
116+
117+
result = run_request({"provider": "openai", "model": "gpt-4"}, workspace_id="ws-42")
118+
assert result == "ok"
119+
120+
sent = httpx_mock.get_request(method="POST", url="http://localhost:7878/v1/reservations")
121+
assert sent is not None
122+
body = json.loads(sent.content)
123+
assert body["subject"]["workspace"] == "ws-42"
124+
assert body["action"]["kind"] == "llm.openai"
125+
assert body["action"]["name"] == "gpt-4"
126+
client.close()
127+
85128
def test_denied_raises(self, config: CyclesConfig, httpx_mock) -> None: # type: ignore[no-untyped-def]
86129
httpx_mock.add_response(
87130
method="POST",

0 commit comments

Comments
 (0)