From b2bbbe3cc85ec523df8e89c33339349e34f92d56 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:10:00 +0800 Subject: [PATCH] feat(forward): support state and session decisions --- .env.live.example | 6 + CHANGELOG.md | 4 + CONTRIBUTING.md | 23 + README.md | 50 ++ docs/api/reference.md | 609 ++++++++++++++++++ examples/forward/decisions.py | 43 ++ src/qca/forward/_client.py | 9 + src/qca/forward/resources/__init__.py | 2 + src/qca/forward/resources/decisions.py | 196 ++++++ src/qca/forward/types/__init__.py | 14 + src/qca/forward/types/decision.py | 77 +++ .../forward/types/decision_create_params.py | 47 ++ src/qca/forward/types/decision_list_params.py | 10 + tests/fixtures/api-contracts.json | 29 + tests/fixtures/decisions.json | 106 +++ tests/integration/test_decisions.py | 60 ++ tests/test_decisions.py | 194 ++++++ tests/test_resource_surface.py | 2 +- 18 files changed, 1480 insertions(+), 1 deletion(-) create mode 100644 examples/forward/decisions.py create mode 100644 src/qca/forward/resources/decisions.py create mode 100644 src/qca/forward/types/decision.py create mode 100644 src/qca/forward/types/decision_create_params.py create mode 100644 src/qca/forward/types/decision_list_params.py create mode 100644 tests/fixtures/decisions.json create mode 100644 tests/integration/test_decisions.py create mode 100644 tests/test_decisions.py diff --git a/.env.live.example b/.env.live.example index db1bda3..27dde2f 100644 --- a/.env.live.example +++ b/.env.live.example @@ -56,3 +56,9 @@ QODER_REALTIME_GLOBAL_SAT= # Uses QODER_FORWARD_CN_PAT, QODER_FORWARD_GLOBAL_PAT, QODER_SA_CN_KEY, # and QODER_SA_GLOBAL_KEY. Never point clear at a shared Identity. QODER_DRIVE_LIVE_ALLOW_WRITE=false + +# Forward Decisions consume model usage and retain records until server expiry. +# Also requires QODER_FORWARD_LIVE_ALLOW_WRITE/EXECUTION=true. +QODER_FORWARD_LIVE_ALLOW_DECISIONS=false +# Optional existing Session for the Python/TypeScript live suite and all examples. +QODER_DECISION_SESSION_ID= diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c705f..bb86d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Add Forward Decisions for synchronous evaluation of explicit state or accessible Session history, with choice and score questions, idempotent creation, retrieval, cursor pagination, and typed context truncation/token-estimate metadata. + ## [0.9.0] - 2026-10-26 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a7a373..040e4bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -179,3 +179,26 @@ those tests do not replace SDK integration coverage. ## Realtime validation Realtime REST and local WebSocket protocol/lifecycle tests run offline with the default suite. `uv run make test-live-realtime` is a separate opt-in CN/Global entry, controlled by the dedicated `QODER_REALTIME_*` configuration and both write/execution flags. It creates persistent Conversations using already-enabled Templates; transport close is not resource cleanup. Record the printed Conversation/request IDs and agree on retention with the service owner. Basic ping/history success does not replace text, PCM, playback, and background Work acceptance. Realtime auth headers, SSL context, and proxy settings use the WebSocket adapter; arbitrary httpx middleware is not applied to it. + +## Decision live tests + +Decision evaluation is opt-in because it consumes model usage and creates +records without a delete endpoint. Configure a dedicated account using +`QODER_FORWARD_PAT` and `QODER_FORWARD_BASE_URL` in `.env.live`, then set +`QODER_FORWARD_LIVE_ALLOW_WRITE=true`, +`QODER_FORWARD_LIVE_ALLOW_EXECUTION=true`, and +`QODER_FORWARD_LIVE_ALLOW_DECISIONS=true`. These live tests use PAT; offline +contracts cover both PAT and SAT behavior. Normal unit/contract runs are offline. + +```bash +QODER_RUN_LIVE=1 uv run pytest tests/integration/test_decisions.py -m integration -v +``` + +This checks state evaluation, idempotent replay, retrieval, and cursor pages. +Set `QODER_DECISION_SESSION_ID` to an accessible Session with existing history +to also test Session input; otherwise that case is explicitly skipped. The +suite does not modify the supplied Session. + +Decision records expire according to server retention. Retain only decision +IDs, HTTP status, and request IDs in shared diagnostics; do not log credentials +or unrelated Session contents. diff --git a/README.md b/README.md index 0275478..1f410bc 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,56 @@ credential = client.vaults.credentials.retrieve("credential-id", vault_id="vault memory = client.memory_stores.memories.retrieve("memory-id", memory_store_id="store-id") ``` +## Forward Decisions (next release) + +Evaluate a nonempty `state` object or an accessible `session_id` (exactly one) +with 1–32 choice or score questions. Choice values are strings or booleans; +score questions select from your numeric levels. Creation waits for the result. +The service selects the model; no `model` parameter is accepted. Normal PAT or +SAT authentication determines the caller's access. + +```python +from uuid import uuid4 +from qca import Forward + +with Forward() as client: # Or use the PAT/SA configuration above. + decision = client.decisions.create( + state={"message": "My issue is resolved."}, + questions=[{ + "type": "choice", "name": "resolved", + "instructions": "Has the user confirmed resolution?", + "choices": [{"value": True}, {"value": False}], + }], + idempotency_key=str(uuid4()), + ) + stored = client.decisions.retrieve(decision.id) + print(stored.answers) + page = client.decisions.list(limit=20) + print(page.data) # Iterate over page directly to follow all pages. +``` + +For Session input, replace `state=...` with `session_id=session_id`. +For scores, use `{"type": "score", "instructions": "Rate risk", "levels": +[{"label": "low", "score": 0}, {"label": "high", "score": 10}]}`. +`AsyncForward` offers the same methods with `await` and asynchronous pagination. + +Session context is selected by the server. Inspect `context.truncated`, +`context.truncation_reasons`, and the input-token estimate to understand the +selected history. Explicit state and questions are never shortened; oversized +input returns HTTP 422. Context estimates are separate from `usage`, which can +be null. Answers can also have type `refusal`. + +Reuse an idempotency key only for identical request bytes. A changed request or +a still-running request with that key returns HTTP 409; the SDK does not retry +that conflict automatically. Creation errors can contain `decision_id` in the +standard API error body. Retrieve and list can return running or failed records. +Lists preserve server order and support `after_id` / `before_id` cursors (one at +a time), optional `session_id`, and a limit of 1–100. There is no delete endpoint; +Decision records expire according to server retention. + +See the [runnable example](examples/forward/decisions.py) and the +[opt-in live test instructions](CONTRIBUTING.md#decision-live-tests). + ## Forward Drive (Beta) Drive is a Beta API. It uses `entries` / `next_page_token`, not the ordinary diff --git a/docs/api/reference.md b/docs/api/reference.md index 48e2d3a..9dbc2e1 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### decisions + +```python +@cached_property +def decisions() -> Decisions +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### webhooks @@ -2285,6 +2296,17 @@ class AsyncForward(AsyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### decisions + +```python +@cached_property +def decisions() -> AsyncDecisions +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### webhooks @@ -3353,6 +3375,182 @@ async def retrieve( GET /qr_sessions/{session_key}. + + +# qca.forward.resources.decisions + + + +## Decisions + +```python +class Decisions(SyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + + + +#### create + +```python +def create( + *, + questions: List[DecisionQuestionParam], + session_id: Union[str, NotGiven] = NOT_GIVEN, + state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> Decision +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +POST /decisions. + +Synchronous evaluation using normal PAT/SAT authentication. Provide exactly +one of session_id and nonempty state; state.session_context is reserved. +Questions are choice (string/boolean options) or score (numeric levels). +Reusing an idempotency key with identical bytes replays the stored result; +different bytes or an in-progress request return HTTP 409. +Explicit state/questions are never shortened; over-budget input is HTTP 422. + + + +#### retrieve + +```python +def retrieve( + decision_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> Decision +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +GET /decisions/{decision_id}. + +Retrieve a stored Decision, including running or failed records. + + + +#### list + +```python +def list( + *, + session_id: Union[str, NotGiven] = NOT_GIVEN, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + before_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> SyncPage[Decision] +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +GET /decisions. + +List in server order, optionally filtered by an accessible Session. +Limit is 1-100 (default 20). after_id and before_id are mutually exclusive. +Identity scope comes from authentication, not a query parameter. + + + +## AsyncDecisions + +```python +class AsyncDecisions(AsyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + + + +#### create + +```python +async def create( + *, + questions: List[DecisionQuestionParam], + session_id: Union[str, NotGiven] = NOT_GIVEN, + state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> Decision +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +POST /decisions. + +Synchronous evaluation using normal PAT/SAT authentication. Provide exactly +one of session_id and nonempty state; state.session_context is reserved. +Questions are choice (string/boolean options) or score (numeric levels). +Reusing an idempotency key with identical bytes replays the stored result; +different bytes or an in-progress request return HTTP 409. +Explicit state/questions are never shortened; over-budget input is HTTP 422. + + + +#### retrieve + +```python +async def retrieve( + decision_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> Decision +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +GET /decisions/{decision_id}. + +Retrieve a stored Decision, including running or failed records. + + + +#### list + +```python +def list( + *, + session_id: Union[str, NotGiven] = NOT_GIVEN, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + before_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> AsyncPaginator[Decision] +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py) + +GET /decisions. + +List in server order, optionally filtered by an accessible Session. +Limit is 1-100 (default 20). after_id and before_id are mutually exclusive. +Identity scope comes from authentication, not a query parameter. + # qca.forward.resources.drives @@ -11085,6 +11283,417 @@ class ContentBlockParam(TypedDict) #### source + + +# qca.forward.types.decision + + + +## DecisionChoice + +```python +class DecisionChoice(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + + + +#### value + + + +#### description + + + +## DecisionLevel + +```python +class DecisionLevel(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + + + +#### label + + + +#### score + + + +#### description + + + +## DecisionQuestion + +```python +class DecisionQuestion(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + + + +#### type + + + +#### instructions + + + +#### name + + + +#### choices + + + +#### levels + + + +## DecisionAnswer + +```python +class DecisionAnswer(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + + + +#### type + + + +#### name + + + +#### value + + + +#### score + + + +#### refusal + + + +## DecisionContext + +```python +class DecisionContext(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + +Server-selected input metadata. Estimates are not billed model usage. + +Session-only fields and preparation metadata may be absent, especially on +running or failed records. Unknown fields remain accessible as model extras. + + + +#### mode + + + +#### policy\_version + + + +#### token\_estimator + + + +#### estimated\_input\_tokens + + + +#### max\_input\_tokens + + + +#### snapshot\_at + + + +#### anchor\_event\_id + + + +#### first\_event\_id + + + +#### last\_event\_id + + + +#### included\_segment\_count + + + +#### included\_event\_count + + + +#### scan\_limited + + + +#### truncated + + + +#### truncation\_reasons + + + +## DecisionError + +```python +class DecisionError(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + + + +#### code + + + +#### message + + + +## Decision + +```python +class Decision(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py) + +A stored evaluation. Retrieval and listing also expose running/failed records. + + + +#### id + + + +#### session\_id + + + +#### source + + + +#### status + + + +#### questions + + + +#### answers + + + +#### context + + + +#### usage + + + +#### error + + + +#### created\_at + + + +#### completed\_at + + + +# qca.forward.types.decision\_create\_params + + + +## DecisionChoiceParam + +```python +class DecisionChoiceParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py) + + + +#### value + + + +#### description + + + +## DecisionLevelParam + +```python +class DecisionLevelParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py) + + + +#### label + + + +#### score + + + +#### description + + + +## DecisionChoiceQuestionParam + +```python +class DecisionChoiceQuestionParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py) + + + +#### type + + + +#### instructions + + + +#### name + + + +#### choices + + + +## DecisionScoreQuestionParam + +```python +class DecisionScoreQuestionParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py) + + + +#### type + + + +#### instructions + + + +#### name + + + +#### levels + + + +#### DecisionQuestionParam + + + +## DecisionCreateParams + +```python +class DecisionCreateParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py) + +Provide exactly one of session_id and state, plus 1-32 questions. + +State must be a nonempty JSON object; state.session_context is reserved. +Choice questions require 2-32 distinct string/boolean options; score +questions require 2-32 finite numeric levels. No model parameter is accepted. + + + +#### questions + + + +#### session\_id + + + +#### state + + + +#### idempotency\_key + + + +# qca.forward.types.decision\_list\_params + + + +## DecisionListParams + +```python +class DecisionListParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_list_params.py) + + + +#### session\_id + + + +#### limit + + + +#### after\_id + + + +#### before\_id + # qca.forward.types.deleted\_channel diff --git a/examples/forward/decisions.py b/examples/forward/decisions.py new file mode 100644 index 0000000..027b43b --- /dev/null +++ b/examples/forward/decisions.py @@ -0,0 +1,43 @@ +"""Create a Decision using shared PAT/SA configuration. + +Run: python -m examples.forward.decisions --env .env.live +Optionally set QODER_DECISION_SESSION_ID to evaluate an accessible Session. +""" + +from __future__ import annotations + +import os +from uuid import uuid4 + +from examples.common.live import Run, run_cli +from qca import Forward +from qca.forward.types import DecisionCreateParams + + +def run(client: Forward, context: Run) -> None: + params: DecisionCreateParams = { + "state": {"message": "The issue is resolved; everything works now."}, + "questions": [ + { + "type": "choice", + "name": "resolved", + "instructions": "Has the user explicitly confirmed that their issue is resolved?", + "choices": [{"value": True}, {"value": False}], + } + ], + "idempotency_key": str(uuid4()), + } + session_id = os.environ.get("QODER_DECISION_SESSION_ID") + if session_id: + del params["state"] + params["session_id"] = session_id + decision = client.decisions.create(**params) + stored = client.decisions.retrieve(decision.id) + context.output("decision", stored.to_dict(mode="json")) + page = client.decisions.list(limit=5, **({"session_id": session_id} if session_id else {})) + context.output("page", [{"id": item.id, "status": item.status} for item in page.data]) + # Decision records expire according to server retention; there is no delete API. + + +if __name__ == "__main__": + run_cli("forward", Forward, {"decisions": run}) diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py index f2259b7..32654e7 100644 --- a/src/qca/forward/_client.py +++ b/src/qca/forward/_client.py @@ -7,6 +7,7 @@ from .resources.batches.batches import AsyncBatches, Batches from .resources.channel_pairings import AsyncChannelPairings, ChannelPairings from .resources.channels.channels import AsyncChannels, Channels +from .resources.decisions import AsyncDecisions, Decisions from .resources.drives import AsyncDrives, Drives from .resources.environments import AsyncEnvironments, Environments from .resources.files import AsyncFiles, Files @@ -26,6 +27,10 @@ class Forward(SyncAPIClient): + @cached_property + def decisions(self) -> Decisions: + return Decisions(self) + @cached_property def webhooks(self) -> Webhooks: return Webhooks(self) @@ -107,6 +112,10 @@ def models(self) -> Models: class AsyncForward(AsyncAPIClient): + @cached_property + def decisions(self) -> AsyncDecisions: + return AsyncDecisions(self) + @cached_property def webhooks(self) -> AsyncWebhooks: return AsyncWebhooks(self) diff --git a/src/qca/forward/resources/__init__.py b/src/qca/forward/resources/__init__.py index cc2234f..c32de00 100644 --- a/src/qca/forward/resources/__init__.py +++ b/src/qca/forward/resources/__init__.py @@ -1,5 +1,7 @@ from .channel_pairings import AsyncChannelPairings as AsyncChannelPairings from .channel_pairings import ChannelPairings as ChannelPairings +from .decisions import AsyncDecisions as AsyncDecisions +from .decisions import Decisions as Decisions from .drives import AsyncDrives as AsyncDrives from .drives import Drives as Drives from .environments import AsyncEnvironments as AsyncEnvironments diff --git a/src/qca/forward/resources/decisions.py b/src/qca/forward/resources/decisions.py new file mode 100644 index 0000000..ce076d9 --- /dev/null +++ b/src/qca/forward/resources/decisions.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Union + +import httpx + +from qca.common._resource import AsyncAPIResource, SyncAPIResource +from qca.common._types import NOT_GIVEN, NotGiven +from qca.common._utils import make_request_options, path_template +from qca.common.pagination import AsyncPaginator, SyncPage +from qca.forward.types.decision import Decision +from qca.forward.types.decision_create_params import DecisionQuestionParam + +__all__ = ["Decisions", "AsyncDecisions"] + + +class Decisions(SyncAPIResource): + def create( + self, + *, + questions: List[DecisionQuestionParam], + session_id: Union[str, NotGiven] = NOT_GIVEN, + state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Decision: + """POST /decisions. + + Synchronous evaluation using normal PAT/SAT authentication. Provide exactly + one of session_id and nonempty state; state.session_context is reserved. + Questions are choice (string/boolean options) or score (numeric levels). + Reusing an idempotency key with identical bytes replays the stored result; + different bytes or an in-progress request return HTTP 409. + Explicit state/questions are never shortened; over-budget input is HTTP 422. + """ + _path = path_template("/decisions") + options = make_request_options( + body={"questions": questions, "session_id": session_id, "state": state}, + query={}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("POST", _path, cast_to=Decision, options=options) + + def retrieve( + self, + decision_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Decision: + """GET /decisions/{decision_id}. + + Retrieve a stored Decision, including running or failed records. + """ + _path = path_template("/decisions/{decision_id}", decision_id=decision_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("GET", _path, cast_to=Decision, options=options) + + def list( + self, + *, + session_id: Union[str, NotGiven] = NOT_GIVEN, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + before_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> SyncPage[Decision]: + """GET /decisions. + + List in server order, optionally filtered by an accessible Session. + Limit is 1-100 (default 20). after_id and before_id are mutually exclusive. + Identity scope comes from authentication, not a query parameter. + """ + _path = path_template("/decisions") + options = make_request_options( + body={}, + query={"session_id": session_id, "limit": limit, "after_id": after_id, "before_id": before_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("GET", _path, cast_to=Decision, options=options, page_style="Page") + + +class AsyncDecisions(AsyncAPIResource): + async def create( + self, + *, + questions: List[DecisionQuestionParam], + session_id: Union[str, NotGiven] = NOT_GIVEN, + state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN, + idempotency_key: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Decision: + """POST /decisions. + + Synchronous evaluation using normal PAT/SAT authentication. Provide exactly + one of session_id and nonempty state; state.session_context is reserved. + Questions are choice (string/boolean options) or score (numeric levels). + Reusing an idempotency key with identical bytes replays the stored result; + different bytes or an in-progress request return HTTP 409. + Explicit state/questions are never shortened; over-budget input is HTTP 422. + """ + _path = path_template("/decisions") + options = make_request_options( + body={"questions": questions, "session_id": session_id, "state": state}, + query={}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("POST", _path, cast_to=Decision, options=options) + + async def retrieve( + self, + decision_id: str, + *, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> Decision: + """GET /decisions/{decision_id}. + + Retrieve a stored Decision, including running or failed records. + """ + _path = path_template("/decisions/{decision_id}", decision_id=decision_id) + options = make_request_options( + body={}, + query={}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("GET", _path, cast_to=Decision, options=options) + + def list( + self, + *, + session_id: Union[str, NotGiven] = NOT_GIVEN, + limit: Union[int, NotGiven] = NOT_GIVEN, + after_id: Union[str, NotGiven] = NOT_GIVEN, + before_id: Union[str, NotGiven] = NOT_GIVEN, + extra_headers: Dict[str, str] | None = None, + extra_query: Dict[str, Any] | None = None, + extra_body: Dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> AsyncPaginator[Decision]: + """GET /decisions. + + List in server order, optionally filtered by an accessible Session. + Limit is 1-100 (default 20). after_id and before_id are mutually exclusive. + Identity scope comes from authentication, not a query parameter. + """ + _path = path_template("/decisions") + options = make_request_options( + body={}, + query={"session_id": session_id, "limit": limit, "after_id": after_id, "before_id": before_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return AsyncPaginator( + lambda: self._client.request("GET", _path, cast_to=Decision, options=options, page_style="Page") + ) diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py index 934732e..d4ef21e 100644 --- a/src/qca/forward/types/__init__.py +++ b/src/qca/forward/types/__init__.py @@ -30,6 +30,20 @@ from .channel_qr_session_create_params import ChannelQRSessionCreateParams as ChannelQRSessionCreateParams from .channel_update_params import ChannelUpdateParams as ChannelUpdateParams from .content_block_param import ContentBlockParam as ContentBlockParam +from .decision import Decision as Decision +from .decision import DecisionAnswer as DecisionAnswer +from .decision import DecisionChoice as DecisionChoice +from .decision import DecisionContext as DecisionContext +from .decision import DecisionError as DecisionError +from .decision import DecisionLevel as DecisionLevel +from .decision import DecisionQuestion as DecisionQuestion +from .decision_create_params import DecisionChoiceParam as DecisionChoiceParam +from .decision_create_params import DecisionChoiceQuestionParam as DecisionChoiceQuestionParam +from .decision_create_params import DecisionCreateParams as DecisionCreateParams +from .decision_create_params import DecisionLevelParam as DecisionLevelParam +from .decision_create_params import DecisionQuestionParam as DecisionQuestionParam +from .decision_create_params import DecisionScoreQuestionParam as DecisionScoreQuestionParam +from .decision_list_params import DecisionListParams as DecisionListParams from .deleted_channel import DeletedChannel as DeletedChannel from .deleted_channel_pairing import DeletedChannelPairing as DeletedChannelPairing from .deleted_identity import DeletedIdentity as DeletedIdentity diff --git a/src/qca/forward/types/decision.py b/src/qca/forward/types/decision.py new file mode 100644 index 0000000..9ac115e --- /dev/null +++ b/src/qca/forward/types/decision.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from qca.common._models import BaseModel + + +class DecisionChoice(BaseModel): + value: str | bool + description: str | None = None + + +class DecisionLevel(BaseModel): + label: str + score: float + description: str | None = None + + +class DecisionQuestion(BaseModel): + type: Literal["choice", "score"] + instructions: str + name: str | None = None + choices: list[DecisionChoice] | None = None + levels: list[DecisionLevel] | None = None + + +class DecisionAnswer(BaseModel): + type: Literal["choice", "score", "refusal"] + name: str | None = None + value: str | bool | None = None + score: float | None = None + refusal: str | None = None + + +class DecisionContext(BaseModel): + """Server-selected input metadata. Estimates are not billed model usage. + + Session-only fields and preparation metadata may be absent, especially on + running or failed records. Unknown fields remain accessible as model extras. + """ + + mode: Literal["state", "session"] + policy_version: str | None = None + token_estimator: str | None = None + estimated_input_tokens: int | None = None + max_input_tokens: int | None = None + snapshot_at: datetime | None = None + anchor_event_id: str | None = None + first_event_id: str | None = None + last_event_id: str | None = None + included_segment_count: int | None = None + included_event_count: int | None = None + scan_limited: bool | None = None + truncated: bool | None = None + truncation_reasons: list[str] | None = None + + +class DecisionError(BaseModel): + code: str + message: str + + +class Decision(BaseModel): + """A stored evaluation. Retrieval and listing also expose running/failed records.""" + + id: str + session_id: str | None + source: str + status: Literal["running", "completed", "failed"] + questions: list[DecisionQuestion] + answers: list[DecisionAnswer] + context: DecisionContext + usage: dict[str, Any] | None + error: DecisionError | None + created_at: datetime + completed_at: datetime | None diff --git a/src/qca/forward/types/decision_create_params.py b/src/qca/forward/types/decision_create_params.py new file mode 100644 index 0000000..b059555 --- /dev/null +++ b/src/qca/forward/types/decision_create_params.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any, Literal, Union + +from typing_extensions import Required, TypedDict + + +class DecisionChoiceParam(TypedDict, total=False): + value: Required[Union[str, bool]] + description: str + + +class DecisionLevelParam(TypedDict, total=False): + label: Required[str] + score: Required[float] + description: str + + +class DecisionChoiceQuestionParam(TypedDict, total=False): + type: Required[Literal["choice"]] + instructions: Required[str] + name: str + choices: Required[list[DecisionChoiceParam]] + + +class DecisionScoreQuestionParam(TypedDict, total=False): + type: Required[Literal["score"]] + instructions: Required[str] + name: str + levels: Required[list[DecisionLevelParam]] + + +DecisionQuestionParam = Union[DecisionChoiceQuestionParam, DecisionScoreQuestionParam] + + +class DecisionCreateParams(TypedDict, total=False): + """Provide exactly one of session_id and state, plus 1-32 questions. + + State must be a nonempty JSON object; state.session_context is reserved. + Choice questions require 2-32 distinct string/boolean options; score + questions require 2-32 finite numeric levels. No model parameter is accepted. + """ + + questions: Required[list[DecisionQuestionParam]] + session_id: str + state: dict[str, Any] + idempotency_key: str diff --git a/src/qca/forward/types/decision_list_params.py b/src/qca/forward/types/decision_list_params.py new file mode 100644 index 0000000..1ee5f69 --- /dev/null +++ b/src/qca/forward/types/decision_list_params.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing_extensions import TypedDict + + +class DecisionListParams(TypedDict, total=False): + session_id: str + limit: int + after_id: str + before_id: str diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json index cd17ff7..5e75a8e 100644 --- a/tests/fixtures/api-contracts.json +++ b/tests/fixtures/api-contracts.json @@ -2107,6 +2107,31 @@ "endpoint_id" ], "kind": "model" + }, + { + "id": "forward.decisions.create", + "method": "POST", + "path": "/decisions", + "required": [ + "questions" + ], + "kind": "model" + }, + { + "id": "forward.decisions.retrieve", + "method": "GET", + "path": "/decisions/{decision_id}", + "required": [ + "decision_id" + ], + "kind": "model" + }, + { + "id": "forward.decisions.list", + "method": "GET", + "path": "/decisions", + "required": [], + "kind": "page" } ], "extensions": [ @@ -2117,6 +2142,10 @@ { "source": "https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview (2026-10-22); Forward webhook handler and DTO cross-check", "operations": 8 + }, + { + "source": "cloud-agents-forward-service@8b9474c6: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go", + "operations": 3 } ] } diff --git a/tests/fixtures/decisions.json b/tests/fixtures/decisions.json new file mode 100644 index 0000000..30036ba --- /dev/null +++ b/tests/fixtures/decisions.json @@ -0,0 +1,106 @@ +{ + "source": "cloud-agents-forward-service@8b9474c6: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go", + "request": { + "state": { + "resolved": false, + "risk": "low" + }, + "questions": [ + { + "type": "choice", + "name": "resolved", + "instructions": "Has the user confirmed resolution?", + "choices": [ + { + "value": true, + "description": "Confirmed" + }, + { + "value": false, + "description": "Not confirmed" + } + ] + }, + { + "type": "score", + "name": "risk", + "instructions": "Select the risk level.", + "levels": [ + { + "label": "low", + "score": 0, + "description": "Routine" + }, + { + "label": "high", + "score": 10, + "description": "Destructive" + } + ] + } + ] + }, + "response": { + "id": "dec_contract", + "session_id": null, + "source": "api", + "status": "completed", + "questions": [ + { + "type": "choice", + "name": "resolved", + "instructions": "Has the user confirmed resolution?", + "choices": [ + { + "value": true, + "description": "Confirmed" + }, + { + "value": false, + "description": "Not confirmed" + } + ] + }, + { + "type": "score", + "name": "risk", + "instructions": "Select the risk level.", + "levels": [ + { + "label": "low", + "score": 0, + "description": "Routine" + }, + { + "label": "high", + "score": 10, + "description": "Destructive" + } + ] + } + ], + "answers": [ + { + "type": "choice", + "name": "resolved", + "value": false + }, + { + "type": "score", + "name": "risk", + "score": 0 + } + ], + "context": { + "mode": "state", + "policy_version": "decision-context-v3", + "token_estimator": "caw-heuristic-v1", + "estimated_input_tokens": 234, + "max_input_tokens": 30000 + }, + "usage": null, + "error": null, + "created_at": "2026-10-23T05:00:00.123456Z", + "completed_at": "2026-10-23T05:00:01.123456Z" + } +} diff --git a/tests/integration/test_decisions.py b/tests/integration/test_decisions.py new file mode 100644 index 0000000..4237f8c --- /dev/null +++ b/tests/integration/test_decisions.py @@ -0,0 +1,60 @@ +"""Opt-in Decision evaluation; records are retained by the server (no delete API).""" + +import os +from uuid import uuid4 + +import pytest + +from tests.support.harness import read_env + +pytestmark = pytest.mark.integration + + +@pytest.mark.parametrize("live_example", ["forward"], indirect=True) +@pytest.mark.parametrize("mode", ["state", "session"]) +def test_decisions(live_example, mode): + values = {**read_env(os.environ.get("QODER_LIVE_ENV_FILE", ".env.live")), **os.environ} + for gate in ("WRITE", "EXECUTION", "DECISIONS"): + if values.get(f"QODER_FORWARD_LIVE_ALLOW_{gate}") != "true": + pytest.skip(f"QODER_FORWARD_LIVE_ALLOW_{gate}=true is required") + session_id = values.get("QODER_DECISION_SESSION_ID") + if mode == "session" and not session_id: + pytest.skip("Set QODER_DECISION_SESSION_ID to an accessible Session with history") + client, context = live_example + params = { + "questions": [ + { + "type": "choice", + "instructions": "Return true only if RESOLVED_SDK_TEST appears in the input.", + "choices": [{"value": True}, {"value": False}], + }, + { + "type": "score", + "instructions": "Select high if RISK_LEVEL=high appears, otherwise select low.", + "levels": [{"label": "low", "score": 0}, {"label": "high", "score": 10}], + }, + ], + "idempotency_key": str(uuid4()), + **({"session_id": session_id} if mode == "session" else {"state": {"message": "RISK_LEVEL=low"}}), + } + decision = client.decisions.create(**params, timeout=context.remaining()) + assert decision.id and decision.status == "completed" + assert decision.context.mode == mode and len(decision.answers) == 2 + assert 0 < decision.context.estimated_input_tokens <= decision.context.max_input_tokens + if mode == "state": + assert decision.session_id is None + assert decision.answers[0].value is False and decision.answers[1].score == 0 + else: + assert decision.session_id == session_id and decision.context.included_event_count > 0 + replay = client.decisions.create(**params, timeout=context.remaining()) + assert replay.id == decision.id and replay.completed_at == decision.completed_at + stored = client.decisions.retrieve(decision.id) + assert stored.to_dict() == decision.to_dict() + page = client.decisions.list(limit=1, **({"session_id": session_id} if mode == "session" else {})) + assert len(page.data) == 1 + if mode == "session": + assert page.data[0].session_id == session_id + if page.has_next_page(): + following = page.get_next_page() + assert following.data and following.data[0].id != page.data[0].id + context.output("decision_id", decision.id) diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..3d550d8 --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,194 @@ +import copy +import inspect +import json +from pathlib import Path + +import httpx +import pytest + +from qca import APIStatusError, AsyncForward, Forward +from qca.forward.types import Decision + +FIXTURE = json.loads((Path(__file__).parent / "fixtures/decisions.json").read_text()) + + +async def resolve(value): + return await value if inspect.isawaitable(value) else value + + +def client_for(async_, handle, **options): + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + return (AsyncForward if async_ else Forward)(http_client=http, **options) + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("auth", [{"pat": "token"}, {"auth_mode": "sa", "access_token": "token"}]) +async def test_decision_state_session_and_response_metadata(async_, auth): + calls = [] + + def handle(request): + calls.append(request) + assert request.headers["authorization"] == "Bearer token" + assert "x-qoder-beta" not in request.headers + assert not request.url.query + result = copy.deepcopy(FIXTURE["response"]) + result["future_field"] = 1 + if request.method == "POST": + assert request.url.path == "/api/v1/forward/decisions" + body = json.loads(request.content) + assert body["questions"] == FIXTURE["request"]["questions"] + if len(calls) == 1: + assert body == FIXTURE["request"] + assert request.headers["Idempotency-Key"] == "header-wins" + else: + assert body == {"session_id": "sess_own", "questions": FIXTURE["request"]["questions"]} + result["session_id"] = "sess_own" + result["context"].update( + mode="session", + snapshot_at="2026-10-26T00:00:00.123456789Z", + truncated=True, + truncation_reasons=["segment_budget"], + included_segment_count=12, + scan_limited=False, + future_context=True, + ) + result["answers"] = [{"type": "refusal", "name": "private", "refusal": "Cannot assess"}] + else: + assert request.url.raw_path.endswith(b"/decisions/opaque%20%2F%3F%25%23") + assert not request.content + return httpx.Response(200, json=result, headers={"x-request-id": "req_decision"}) + + client = client_for(async_, handle, _strict_response_validation=True, **auth) + try: + result = await resolve( + client.decisions.create( + **FIXTURE["request"], idempotency_key="parameter-key", extra_headers={"Idempotency-Key": "header-wins"} + ) + ) + assert isinstance(result, Decision) + assert result.answers[0].value is False and result.answers[1].score == 0 + assert "value" not in result.answers[1].model_fields_set + assert result.session_id is None and result.usage is None and result.error is None + assert result.created_at.microsecond == 123456 + assert result._request_id == "req_decision" and result.model_extra["future_field"] == 1 + result = await resolve( + client.decisions.create(session_id="sess_own", questions=FIXTURE["request"]["questions"]) + ) + assert result.context.truncated and result.context.included_segment_count == 12 + assert result.context.scan_limited is False and result.context.model_extra["future_context"] is True + assert result.answers[0].refusal == "Cannot assess" and "value" not in result.answers[0].model_fields_set + assert (await resolve(client.decisions.retrieve("opaque /?%#"))).id == "dec_contract" + with pytest.raises(ValueError): + await resolve(client.decisions.retrieve("")) + assert len(calls) == 3 + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("cursor", ["after_id", "before_id"]) +async def test_decision_pagination_keeps_filter_options_and_order(async_, cursor): + calls = [] + + def handle(request): + calls.append(request) + assert request.url.params["session_id"] == "sess_own" + assert request.url.params["limit"] == "1" and "page" not in request.url.params + assert request.headers["X-Page"] == "kept" + assert not request.content + other = "before_id" if cursor == "after_id" else "after_id" + assert other not in request.url.params + assert request.url.params[cursor] == ("dec_start" if len(calls) == 1 else "dec_z") + if len(calls) == 1: + return httpx.Response( + 200, + json={ + "data": [ + {"id": "dec_z", "status": "running", "context": {"mode": "session"}, "completed_at": None} + ], + "first_id": "dec_z", + "last_id": "dec_z", + "has_more": True, + }, + ) + return httpx.Response( + 200, + json={ + "data": [ + { + "id": "dec_a", + "status": "failed", + "error": {"code": "decision_upstream_timeout", "message": "Timed out"}, + } + ], + "first_id": "dec_a", + "last_id": "dec_a", + "has_more": False, + }, + ) + + client = client_for(async_, handle, pat="token") + try: + page = client.decisions.list( + session_id="sess_own", limit=1, extra_headers={"X-Page": "kept"}, **{cursor: "dec_start"} + ) + items = [d async for d in page] if async_ else list(page) + assert [d.id for d in items] == ["dec_z", "dec_a"] and len(calls) == 2 + assert items[0].completed_at is None and items[1].error.code == "decision_upstream_timeout" + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("keyed", [False, True]) +async def test_decision_retry_requires_key_and_preserves_exact_body(async_, keyed): + calls = [] + + def handle(request): + calls.append(request) + assert request.headers.get("Idempotency-Key") == ("decision-once" if keyed else None) + if len(calls) == 1: + return httpx.Response(503, json={"error": {"message": "unavailable"}}, headers={"retry-after-ms": "1"}) + return httpx.Response(200, json=FIXTURE["response"]) + + client = client_for(async_, handle, pat="token", max_retries=1) + try: + params = {**FIXTURE["request"], **({"idempotency_key": "decision-once"} if keyed else {})} + if keyed: + assert (await resolve(client.decisions.create(**params))).id == "dec_contract" + assert len(calls) == 2 and calls[0].content == calls[1].content + else: + with pytest.raises(APIStatusError): + await resolve(client.decisions.create(**params)) + assert len(calls) == 1 + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 504]) +async def test_decision_error_keeps_correlation_and_server_validation(async_, status): + calls = [] + + def handle(request): + calls.append(request) + assert json.loads(request.content)["session_id"] == "sess_own" + assert json.loads(request.content)["state"] == FIXTURE["request"]["state"] + return httpx.Response( + status, + json={ + "request_id": "req_decision", + "decision_id": "dec_failed", + "error": {"type": "api_error", "code": "decision_test_error", "message": "Decision failed"}, + }, + ) + + client = client_for(async_, handle, pat="token", max_retries=1 if status == 409 else 0) + try: + with pytest.raises(APIStatusError) as error: + await resolve(client.decisions.create(**FIXTURE["request"], session_id="sess_own", idempotency_key="key")) + assert error.value.status_code == status and error.value.body["decision_id"] == "dec_failed" + assert error.value.request_id == "req_decision" and error.value.code == "decision_test_error" + assert len(calls) == 1 + finally: + await resolve(client.close()) diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py index 78b03b2..92d246e 100644 --- a/tests/test_resource_surface.py +++ b/tests/test_resource_surface.py @@ -39,7 +39,7 @@ def test_contract_fixture_has_pinned_provenance(): "commit": "bc5bc655d70b67fcd88623320ac01896a7360d8a", "captured_at": "2026-09-22", } - assert len(CONTRACT_FIXTURE["contracts"]) == 229 + assert len(CONTRACT_FIXTURE["contracts"]) == 232 @pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)