From d61ec14f73cdf69ec3dc92768a8bd9ae54533816 Mon Sep 17 00:00:00 2001
From: moonyue-w <300878504+moonyue-w@users.noreply.github.com>
Date: Fri, 9 Oct 2026 19:27:08 +0800
Subject: [PATCH] feat(sdk): expose stable usage and managed control APIs
Add Forward credential rotation and hourly Usage aggregation, plus Managed Session cancellation and deployment-scoped Run queries.
Include typed public exports, contract fixtures, live scenarios, generated API documentation, and changelog entries.
---
CHANGELOG.md | 6 +
CONTRIBUTING.md | 14 +
README.md | 37 +-
docs/api/reference.md | 802 +++++++++++++++++-
src/qca/common/_base_client.py | 16 +-
src/qca/forward/_client.py | 9 +
src/qca/forward/resources/__init__.py | 2 +
src/qca/forward/resources/usage.py | 194 +++++
.../forward/resources/vaults/credentials.py | 65 ++
src/qca/forward/types/__init__.py | 9 +
src/qca/forward/types/identity_usage.py | 11 +
src/qca/forward/types/template_usage.py | 12 +
src/qca/forward/types/usage_list_params.py | 17 +
src/qca/forward/types/vault_credential.py | 1 +
.../forward/types/vault_credential_auth.py | 1 +
.../types/vault_credential_update_params.py | 37 +
src/qca/managed/resources/__init__.py | 2 +
.../resources/deployment_scoped_runs.py | 143 ++++
src/qca/managed/resources/deployments.py | 10 +
.../managed/resources/sessions/sessions.py | 55 ++
src/qca/managed/types/__init__.py | 6 +
.../deployment_scoped_run_list_params.py | 15 +
.../deployment_scoped_run_retrieve_params.py | 9 +
.../managed/types/session_cancel_params.py | 8 +
.../managed/types/session_cancel_response.py | 9 +
tests/fixtures/api-contracts.json | 64 ++
tests/fixtures/api-expansion.json | 262 ++++++
tests/integration/test_api_expansion.py | 20 +
tests/support/scenarios/api_expansion.py | 128 +++
tests/support/scenarios/managed.py | 8 +
tests/support/stub.py | 67 +-
tests/test_api_expansion.py | 263 ++++++
tests/test_api_expansion_scenarios_offline.py | 74 ++
tests/test_resource_surface.py | 2 +-
34 files changed, 2354 insertions(+), 24 deletions(-)
create mode 100644 src/qca/forward/resources/usage.py
create mode 100644 src/qca/forward/types/identity_usage.py
create mode 100644 src/qca/forward/types/template_usage.py
create mode 100644 src/qca/forward/types/usage_list_params.py
create mode 100644 src/qca/forward/types/vault_credential_update_params.py
create mode 100644 src/qca/managed/resources/deployment_scoped_runs.py
create mode 100644 src/qca/managed/types/deployment_scoped_run_list_params.py
create mode 100644 src/qca/managed/types/deployment_scoped_run_retrieve_params.py
create mode 100644 src/qca/managed/types/session_cancel_params.py
create mode 100644 src/qca/managed/types/session_cancel_response.py
create mode 100644 tests/fixtures/api-expansion.json
create mode 100644 tests/integration/test_api_expansion.py
create mode 100644 tests/support/scenarios/api_expansion.py
create mode 100644 tests/test_api_expansion.py
create mode 100644 tests/test_api_expansion_scenarios_offline.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 230d878..da27d67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ existing `0.1.0` release; earlier development prereleases are not listed.
## [Unreleased]
+### Added
+
+- Forward Vault Credential updates for secret rotation and metadata merge patches, with automatic retries disabled for write-only updates.
+- Forward Usage aggregation by Identity and Template using hourly `start_at` / `end_at` windows in Asia/Shanghai, with fractional `active_seconds` and multi-ID filters. Legacy timestamp parameters are not exposed.
+- Managed Session cancellation with the lightweight acknowledgement for both active and idle sessions, plus deployment-scoped Run listing and retrieval.
+
## [0.2.0] - 2026-09-24
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dcdb87e..8f3ac57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -34,6 +34,20 @@ The default test command excludes account-backed integration tests and must not
Never commit `.env.live`, tokens, credentials, generated logs, or test output. Integration scenarios must register cleanup immediately after creating a resource. Run them explicitly with `QODER_RUN_LIVE=1`; they are not part of public pull-request CI.
+`tests/integration/test_api_expansion.py` adds Forward hourly Usage, Credential
+merge patches and secret redaction, and Managed Session cancellation before and
+after sending a turn. The existing Managed deployment scenario also checks
+scoped Run listing/retrieval. These are part of `test-live-all`; cancellation
+after sending a turn and deployment scenarios can execute models. Select a
+separate `QODER_LIVE_ENV_FILE` with matching URL and PAT for each CN/Global run.
+
+Usage queries the last 24 completed whole hours in Asia/Shanghai in both regions;
+empty pages verify only the collection. A Session may finish before cancellation
+and return HTTP 200 instead of 202; tests record which response occurred. Those
+responses do not prove active cancellation occurred. Cleanup failures remain
+failures; offline replay exercises assertions and cleanup without account
+credentials.
+
The Forward and Managed integration files also include six `strict_response_contract` checks using a separate client with `_strict_response_validation=True`: model, template/agent, and session lists. They send only GET requests, inspect the first page with `limit=1` where supported, and allow empty lists. An empty list checks the response envelope; item schemas are checked when items exist. Returned items must have a non-empty string ID, and `data` must be present even when empty. Existing business scenarios continue to use the default lenient response parsing.
To run only these read-only checks:
diff --git a/README.md b/README.md
index dc8604f..d906e36 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
The Qoder Cloud Agents Python SDK provides access to the Qoder Cloud Agents API from Python 3.10+. It ships synchronous and natively asynchronous clients, typed request parameters and response models, automatic pagination, SSE streaming, and file transfer.
-The API is exposed in two modes, and each has its own client, resources, and types. Forward is multi-tenant: sessions are created from an Identity and a Template, and it adds Schedule, Batch, and Channel. Managed is single-tenant: sessions are created from an Agent and an Environment, and it adds Deployment, Dream, and the Work API for self-hosted environments.
+The API is exposed in two modes, and each has its own client, resources, and types. Forward is multi-tenant: sessions are created from an Identity and a Template, and it adds Schedule, Batch, Channel, and Usage. Managed is single-tenant: sessions are created from an Agent and an Environment, and it adds Deployment, Dream, and the Work API for self-hosted environments.
## Installation
@@ -104,6 +104,41 @@ asyncio.run(main())
Every method shown in this document has an async counterpart with the same name and signature. Async streams are opened with `async with await client.sessions.events.stream(...)`.
+## Usage and cancellation
+
+Forward Usage requires PAT or Admin SAT.
+
+```python
+with Forward() as client:
+ for row in client.usage.list_identities(
+ start_at="2026-09-14T09:00:00", end_at="2026-09-14T12:00:00",
+ identity_ids=["idn_one", "idn_two"],
+ ):
+ print(row.identity_id, row.active_seconds, row.credits)
+
+with Managed() as client:
+ acknowledgement = client.sessions.cancel("sess_one")
+ for run in client.deployments.runs.list("dep_one", limit=20):
+ print(run.id)
+ run = client.deployments.runs.retrieve("drun_one", deployment_id="dep_one")
+```
+
+`usage.list_templates` accepts the same filters. Bounds are whole hours in
+Asia/Shanghai for CN and Global, with an inclusive start, exclusive end, and a
+maximum 744-hour span. Multi-ID filters accept lists or comma-separated strings.
+`active_seconds` preserves fractions. Legacy timestamp parameters and
+`duration_seconds` are not exposed.
+
+`client.vaults.credentials.update("cred_one", vault_id="vault_one", auth={...})`
+rotates write-only secrets; only auth and metadata are patched, and omitted fields
+are preserved. `metadata=None` clears metadata; `metadata={"key": None}` deletes a
+key. This operation never retries automatically, even if client retries are enabled.
+
+Session cancellation returns the lightweight `canceling` acknowledgement for
+active (HTTP 202) and idle (HTTP 200) sessions. The global `deployment_runs` resource
+remains available. All new methods also exist on `AsyncForward` / `AsyncManaged`;
+await requests or iterate pages with `async for`.
+
## Sessions
A session is the unit of agent execution. Forward materializes one from an Identity and a Template:
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 5b81aa2..488a45a 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -1981,6 +1981,17 @@ def memory_stores() -> MemoryStores
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### usage
+
+```python
+@cached_property
+def usage() -> Usage
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### models
@@ -2145,6 +2156,17 @@ def memory_stores() -> AsyncMemoryStores
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### usage
+
+```python
+@cached_property
+def usage() -> AsyncUsage
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### models
@@ -7454,6 +7476,146 @@ async def clone(
POST /templates/{template_id}/clone.
+
+
+# qca.forward.resources.usage
+
+
+
+## Usage
+
+```python
+class Usage(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+
+
+#### list\_identities
+
+```python
+def list_identities(
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[IdentityUsage]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+GET /usage/identities.
+
+PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+
+
+
+#### list\_templates
+
+```python
+def list_templates(
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[TemplateUsage]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+GET /usage/templates.
+
+PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+
+
+
+## AsyncUsage
+
+```python
+class AsyncUsage(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+
+
+#### list\_identities
+
+```python
+def list_identities(
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[IdentityUsage]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+GET /usage/identities.
+
+PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+
+
+
+#### list\_templates
+
+```python
+def list_templates(
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[TemplateUsage]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/usage.py)
+
+GET /usage/templates.
+
+PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+
# qca.forward.resources.vaults.credentials
@@ -7468,6 +7630,31 @@ class Credentials(SyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/vaults/credentials.py)
+
+
+#### update
+
+```python
+def update(
+ credential_id: str,
+ *,
+ vault_id: str,
+ auth: Union[VaultCredentialUpdateAuth, None, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, 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
+) -> VaultCredential
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/vaults/credentials.py)
+
+POST /vaults/{vault_id}/credentials/{credential_id}.
+
+Merge auth or metadata. Write-only secret updates are never automatically retried.
+
#### list
@@ -7564,6 +7751,31 @@ class AsyncCredentials(AsyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/vaults/credentials.py)
+
+
+#### update
+
+```python
+async def update(
+ credential_id: str,
+ *,
+ vault_id: str,
+ auth: Union[VaultCredentialUpdateAuth, None, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, 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
+) -> VaultCredential
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/vaults/credentials.py)
+
+POST /vaults/{vault_id}/credentials/{credential_id}.
+
+Merge auth or metadata. Write-only secret updates are never automatically retried.
+
#### list
@@ -10072,6 +10284,40 @@ class IdentityUpdateParams(TypedDict)
#### idempotency\_key
+
+
+# qca.forward.types.identity\_usage
+
+
+
+## IdentityUsage
+
+```python
+class IdentityUsage(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/identity_usage.py)
+
+
+
+#### type
+
+
+
+#### identity\_id
+
+
+
+#### session\_count
+
+
+
+#### active\_seconds
+
+
+
+#### credits
+
# qca.forward.types.image\_source\_param
@@ -13418,6 +13664,44 @@ class TemplateUpdateParams(TypedDict)
#### beta
+
+
+# qca.forward.types.template\_usage
+
+
+
+## TemplateUsage
+
+```python
+class TemplateUsage(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template_usage.py)
+
+
+
+#### type
+
+
+
+#### template\_id
+
+
+
+#### active\_identities
+
+
+
+#### session\_count
+
+
+
+#### active\_seconds
+
+
+
+#### credits
+
# qca.forward.types.tool
@@ -13606,6 +13890,58 @@ class ToolParam(TypedDict)
#### input\_schema
+
+
+# qca.forward.types.usage\_list\_params
+
+
+
+## UsageListParams
+
+```python
+class UsageListParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/usage_list_params.py)
+
+Hourly Asia/Shanghai window, including CN and Global. No legacy timestamp parameters.
+
+
+
+#### start\_at
+
+
+
+#### end\_at
+
+
+
+#### limit
+
+
+
+#### after\_id
+
+
+
+#### before\_id
+
+
+
+#### identity\_id
+
+
+
+#### identity\_ids
+
+
+
+#### template\_id
+
+
+
+#### template\_ids
+
# qca.forward.types.vault
@@ -13712,6 +14048,10 @@ class VaultCredential(BaseModel)
#### metadata
+
+
+#### archived\_at
+
#### created\_at
@@ -13742,6 +14082,10 @@ class VaultCredentialAuth(BaseModel)
#### mcp\_server\_url
+
+
+#### secret\_name
+
# qca.forward.types.vault\_credential\_create\_params
@@ -13780,31 +14124,147 @@ class VaultCredentialCreateParams(TypedDict)
## VaultCredentialListParams
-```python
-class VaultCredentialListParams(TypedDict)
-```
+```python
+class VaultCredentialListParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_list_params.py)
+
+
+
+#### limit
+
+
+
+#### page
+
+
+
+#### after\_id
+
+
+
+#### before\_id
+
+
+
+#### name
+
+
+
+# qca.forward.types.vault\_credential\_update\_params
+
+
+
+## StaticBearerUpdate
+
+```python
+class StaticBearerUpdate(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_update_params.py)
+
+
+
+#### type
+
+
+
+#### token
+
+
+
+## EnvironmentVariableUpdate
+
+```python
+class EnvironmentVariableUpdate(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_update_params.py)
+
+
+
+#### type
+
+
+
+#### secret\_value
+
+
+
+## MCPOAuthRefreshUpdate
+
+```python
+class MCPOAuthRefreshUpdate(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_update_params.py)
+
+
+
+#### refresh\_token
+
+
+
+#### scope
+
+
+
+#### token\_endpoint\_auth
+
+
+
+## MCPOAuthUpdate
+
+```python
+class MCPOAuthUpdate(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_update_params.py)
+
+
+
+#### type
+
+
+
+#### access\_token
+
+
+
+#### expires\_at
+
+
+
+#### refresh
+
+
+
+#### VaultCredentialUpdateAuth
-[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_list_params.py)
+
-
+## VaultCredentialUpdateParams
-#### limit
+```python
+class VaultCredentialUpdateParams(TypedDict)
+```
-
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/vault_credential_update_params.py)
-#### page
+Merge patch. At least auth or metadata is required; null clears metadata.
-
+
-#### after\_id
+#### auth
-
+
-#### before\_id
+#### metadata
-
+
-#### name
+#### identity\_id
@@ -14638,6 +15098,128 @@ def list(
GET /deployment_runs.
+
+
+# qca.managed.resources.deployment\_scoped\_runs
+
+
+
+## Runs
+
+```python
+class Runs(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+
+
+#### list
+
+```python
+def list(
+ deployment_id: str,
+ *,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ page: Union[str, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_after: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_before: Union[str, None, NotGiven] = NOT_GIVEN,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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[DeploymentRun]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+GET /deployments/{deployment_id}/runs.
+
+
+
+#### retrieve
+
+```python
+def retrieve(
+ run_id: str,
+ *,
+ deployment_id: str,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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
+) -> DeploymentRun
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+GET /deployments/{deployment_id}/runs/{run_id}.
+
+
+
+## AsyncRuns
+
+```python
+class AsyncRuns(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+
+
+#### list
+
+```python
+def list(
+ deployment_id: str,
+ *,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ page: Union[str, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_after: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_before: Union[str, None, NotGiven] = NOT_GIVEN,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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[DeploymentRun]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+GET /deployments/{deployment_id}/runs.
+
+
+
+#### retrieve
+
+```python
+async def retrieve(
+ run_id: str,
+ *,
+ deployment_id: str,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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
+) -> DeploymentRun
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployment_scoped_runs.py)
+
+GET /deployments/{deployment_id}/runs/{run_id}.
+
# qca.managed.resources.deployments
@@ -14652,6 +15234,17 @@ class Deployments(SyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployments.py)
+
+
+#### runs
+
+```python
+@cached_property
+def runs() -> Runs
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployments.py)
+
#### create
@@ -14868,6 +15461,17 @@ class AsyncDeployments(AsyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployments.py)
+
+
+#### runs
+
+```python
+@cached_property
+def runs() -> AsyncRuns
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/deployments.py)
+
#### create
@@ -17668,6 +18272,29 @@ class Sessions(SyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
+
+
+#### cancel
+
+```python
+def cancel(
+ session_id: str,
+ *,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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
+) -> SessionCancelResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
+
+POST /sessions/{session_id}/cancel.
+
+Cancel the current turn (202); an idle session is a safe no-op (200).
+
#### events
@@ -17870,6 +18497,29 @@ class AsyncSessions(AsyncAPIResource)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
+
+
+#### cancel
+
+```python
+async def cancel(
+ session_id: str,
+ *,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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
+) -> SessionCancelResponse
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/resources/sessions/sessions.py)
+
+POST /sessions/{session_id}/cancel.
+
+Cancel the current turn (202); an idle session is a safe no-op (200).
+
#### events
@@ -21736,6 +22386,82 @@ class DeploymentRunRetrieveParams(TypedDict)
#### betas
+
+
+# qca.managed.types.deployment\_scoped\_run\_list\_params
+
+
+
+## DeploymentScopedRunListParams
+
+```python
+class DeploymentScopedRunListParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/deployment_scoped_run_list_params.py)
+
+
+
+#### deployment\_id
+
+
+
+#### limit
+
+
+
+#### page
+
+
+
+#### after\_id
+
+
+
+#### before\_id
+
+
+
+#### triggered\_after
+
+
+
+#### triggered\_before
+
+
+
+#### workspace\_id
+
+
+
+#### betas
+
+
+
+# qca.managed.types.deployment\_scoped\_run\_retrieve\_params
+
+
+
+## DeploymentScopedRunRetrieveParams
+
+```python
+class DeploymentScopedRunRetrieveParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/deployment_scoped_run_retrieve_params.py)
+
+
+
+#### deployment\_id
+
+
+
+#### workspace\_id
+
+
+
+#### betas
+
# qca.managed.types.deployment\_unpause\_params
@@ -26318,6 +27044,54 @@ class SessionArchiveParams(TypedDict)
#### betas
+
+
+# qca.managed.types.session\_cancel\_params
+
+
+
+## SessionCancelParams
+
+```python
+class SessionCancelParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/session_cancel_params.py)
+
+
+
+#### workspace\_id
+
+
+
+#### betas
+
+
+
+# qca.managed.types.session\_cancel\_response
+
+
+
+## SessionCancelResponse
+
+```python
+class SessionCancelResponse(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/session_cancel_response.py)
+
+
+
+#### id
+
+
+
+#### type
+
+
+
+#### status
+
# qca.managed.types.session\_create\_params
diff --git a/src/qca/common/_base_client.py b/src/qca/common/_base_client.py
index ec848ad..fbf7008 100644
--- a/src/qca/common/_base_client.py
+++ b/src/qca/common/_base_client.py
@@ -282,8 +282,9 @@ def __init__(
self._client = http_client or httpx.Client(timeout=timeout, follow_redirects=False)
def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx.Response:
+ max_retries = 0 if request.extensions.get("qca_no_retry") else self.max_retries
track_retries = request.headers.get("X-Qoder-Retry-Count") == "0"
- for attempt in range(self.max_retries + 1):
+ for attempt in range(max_retries + 1):
if track_retries and attempt:
request.headers["X-Qoder-Retry-Count"] = str(attempt)
if not storage and self.credential and not self.pat and "Authorization" not in self.default_headers:
@@ -294,14 +295,14 @@ def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx.Respo
try:
response = self._client.send(request, stream=True, auth=None, follow_redirects=False)
except httpx.TransportError as exc:
- if attempt < self.max_retries and self._retryable(request, None):
+ if attempt < max_retries and self._retryable(request, None):
time.sleep(self._retry_delay(attempt, None))
continue
cls = APITimeoutError if isinstance(exc, httpx.TimeoutException) else APIConnectionError
raise cls(request=request) from exc
if response.is_success:
return response
- if attempt < self.max_retries and self._retryable(request, response):
+ if attempt < max_retries and self._retryable(request, response):
delay = self._retry_delay(attempt, response)
response.close()
time.sleep(delay)
@@ -335,6 +336,7 @@ def request(
)
# httpx multipart streams are single-use. Cache the encoded request once.
request.read()
+ request.extensions["qca_no_retry"] = options.get("max_retries") == 0
response = self._send(request)
if stream:
return Stream(response, cast_to, strict=self._strict_response_validation)
@@ -397,8 +399,9 @@ def __init__(
self._client = http_client or httpx.AsyncClient(timeout=timeout, follow_redirects=False)
async def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx.Response:
+ max_retries = 0 if request.extensions.get("qca_no_retry") else self.max_retries
track_retries = request.headers.get("X-Qoder-Retry-Count") == "0"
- for attempt in range(self.max_retries + 1):
+ for attempt in range(max_retries + 1):
if track_retries and attempt:
request.headers["X-Qoder-Retry-Count"] = str(attempt)
if not storage and self.credential and not self.pat and not request.extensions.get("qca_explicit_auth"):
@@ -409,14 +412,14 @@ async def _send(self, request: httpx.Request, *, storage: bool = False) -> httpx
try:
response = await self._client.send(request, stream=True, auth=None, follow_redirects=False)
except httpx.TransportError as exc:
- if attempt < self.max_retries and self._retryable(request, None):
+ if attempt < max_retries and self._retryable(request, None):
await anyio.sleep(self._retry_delay(attempt, None))
continue
cls = APITimeoutError if isinstance(exc, httpx.TimeoutException) else APIConnectionError
raise cls(request=request) from exc
if response.is_success:
return response
- if attempt < self.max_retries and self._retryable(request, response):
+ if attempt < max_retries and self._retryable(request, response):
delay = self._retry_delay(attempt, response)
await response.aclose()
await anyio.sleep(delay)
@@ -455,6 +458,7 @@ async def request(
key.lower() == "authorization" for key in (*self.default_headers, *options.get("headers", {}))
)
await request.aread()
+ request.extensions["qca_no_retry"] = options.get("max_retries") == 0
response = await self._send(request)
if stream:
return AsyncStream(response, cast_to, strict=self._strict_response_validation)
diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py
index 3757456..46e47e7 100644
--- a/src/qca/forward/_client.py
+++ b/src/qca/forward/_client.py
@@ -17,6 +17,7 @@
from .resources.sessions.sessions import AsyncSessions, Sessions
from .resources.skills.skills import AsyncSkills, Skills
from .resources.templates import AsyncTemplates, Templates
+from .resources.usage import AsyncUsage, Usage
from .resources.vaults.vaults import AsyncVaults, Vaults
@@ -76,6 +77,10 @@ def vaults(self) -> Vaults:
def memory_stores(self) -> MemoryStores:
return MemoryStores(self)
+ @cached_property
+ def usage(self) -> Usage:
+ return Usage(self)
+
@cached_property
def models(self) -> Models:
return Models(self)
@@ -137,6 +142,10 @@ def vaults(self) -> AsyncVaults:
def memory_stores(self) -> AsyncMemoryStores:
return AsyncMemoryStores(self)
+ @cached_property
+ def usage(self) -> AsyncUsage:
+ return AsyncUsage(self)
+
@cached_property
def models(self) -> AsyncModels:
return AsyncModels(self)
diff --git a/src/qca/forward/resources/__init__.py b/src/qca/forward/resources/__init__.py
index 1cc914b..6988f70 100644
--- a/src/qca/forward/resources/__init__.py
+++ b/src/qca/forward/resources/__init__.py
@@ -12,3 +12,5 @@
from .schedules import Schedules as Schedules
from .templates import AsyncTemplates as AsyncTemplates
from .templates import Templates as Templates
+from .usage import AsyncUsage as AsyncUsage
+from .usage import Usage as Usage
diff --git a/src/qca/forward/resources/usage.py b/src/qca/forward/resources/usage.py
new file mode 100644
index 0000000..3cc71a3
--- /dev/null
+++ b/src/qca/forward/resources/usage.py
@@ -0,0 +1,194 @@
+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.identity_usage import IdentityUsage
+from qca.forward.types.template_usage import TemplateUsage
+
+__all__ = ["Usage", "AsyncUsage"]
+
+
+class Usage(SyncAPIResource):
+ def list_identities(
+ self,
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[IdentityUsage]:
+ """GET /usage/identities.
+
+ PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+ """
+ _path = path_template("/usage/identities")
+ options = make_request_options(
+ body={},
+ query={
+ "start_at": start_at,
+ "end_at": end_at,
+ "limit": limit,
+ "after_id": after_id,
+ "before_id": before_id,
+ "identity_id": identity_id,
+ "identity_ids": identity_ids,
+ "template_id": template_id,
+ "template_ids": template_ids,
+ },
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=IdentityUsage, options=options, page_style="Page")
+
+ def list_templates(
+ self,
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[TemplateUsage]:
+ """GET /usage/templates.
+
+ PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+ """
+ _path = path_template("/usage/templates")
+ options = make_request_options(
+ body={},
+ query={
+ "start_at": start_at,
+ "end_at": end_at,
+ "limit": limit,
+ "after_id": after_id,
+ "before_id": before_id,
+ "identity_id": identity_id,
+ "identity_ids": identity_ids,
+ "template_id": template_id,
+ "template_ids": template_ids,
+ },
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=TemplateUsage, options=options, page_style="Page")
+
+
+class AsyncUsage(AsyncAPIResource):
+ def list_identities(
+ self,
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[IdentityUsage]:
+ """GET /usage/identities.
+
+ PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+ """
+ _path = path_template("/usage/identities")
+ options = make_request_options(
+ body={},
+ query={
+ "start_at": start_at,
+ "end_at": end_at,
+ "limit": limit,
+ "after_id": after_id,
+ "before_id": before_id,
+ "identity_id": identity_id,
+ "identity_ids": identity_ids,
+ "template_id": template_id,
+ "template_ids": template_ids,
+ },
+ 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=IdentityUsage, options=options, page_style="Page")
+ )
+
+ def list_templates(
+ self,
+ *,
+ start_at: str,
+ end_at: str,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ identity_ids: Union[str, List[str], None, NotGiven] = NOT_GIVEN,
+ template_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ template_ids: Union[str, List[str], None, 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[TemplateUsage]:
+ """GET /usage/templates.
+
+ PAT or Admin SAT. YYYY-MM-DDTHH:00:00 in Asia/Shanghai, start inclusive, end exclusive; maximum 744 hours. Only hourly parameters are supported.
+ """
+ _path = path_template("/usage/templates")
+ options = make_request_options(
+ body={},
+ query={
+ "start_at": start_at,
+ "end_at": end_at,
+ "limit": limit,
+ "after_id": after_id,
+ "before_id": before_id,
+ "identity_id": identity_id,
+ "identity_ids": identity_ids,
+ "template_id": template_id,
+ "template_ids": template_ids,
+ },
+ 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=TemplateUsage, options=options, page_style="Page")
+ )
diff --git a/src/qca/forward/resources/vaults/credentials.py b/src/qca/forward/resources/vaults/credentials.py
index be2f5da..644742a 100644
--- a/src/qca/forward/resources/vaults/credentials.py
+++ b/src/qca/forward/resources/vaults/credentials.py
@@ -9,11 +9,44 @@
from qca.common._utils import make_request_options, path_template
from qca.common.pagination import AsyncPaginator, SyncPage
from qca.forward.types.vault_credential import VaultCredential
+from qca.forward.types.vault_credential_update_params import VaultCredentialUpdateAuth
__all__ = ["Credentials", "AsyncCredentials"]
class Credentials(SyncAPIResource):
+ def update(
+ self,
+ credential_id: str,
+ *,
+ vault_id: str,
+ auth: Union[VaultCredentialUpdateAuth, None, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, 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,
+ ) -> VaultCredential:
+ """POST /vaults/{vault_id}/credentials/{credential_id}.
+
+ Merge auth or metadata. Write-only secret updates are never automatically retried.
+ """
+ _path = path_template(
+ "/vaults/{vault_id}/credentials/{credential_id}", vault_id=vault_id, credential_id=credential_id
+ )
+ options = make_request_options(
+ body={"auth": auth, "metadata": metadata},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ options["max_retries"] = 0
+ return self._client.request("POST", _path, cast_to=VaultCredential, options=options)
+
def list(
self,
vault_id: str,
@@ -119,6 +152,38 @@ def delete(
class AsyncCredentials(AsyncAPIResource):
+ async def update(
+ self,
+ credential_id: str,
+ *,
+ vault_id: str,
+ auth: Union[VaultCredentialUpdateAuth, None, NotGiven] = NOT_GIVEN,
+ metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN,
+ identity_id: Union[str, None, 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,
+ ) -> VaultCredential:
+ """POST /vaults/{vault_id}/credentials/{credential_id}.
+
+ Merge auth or metadata. Write-only secret updates are never automatically retried.
+ """
+ _path = path_template(
+ "/vaults/{vault_id}/credentials/{credential_id}", vault_id=vault_id, credential_id=credential_id
+ )
+ options = make_request_options(
+ body={"auth": auth, "metadata": metadata},
+ query={"identity_id": identity_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ options["max_retries"] = 0
+ return await self._client.request("POST", _path, cast_to=VaultCredential, options=options)
+
def list(
self,
vault_id: str,
diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py
index a14e6b3..00eef37 100644
--- a/src/qca/forward/types/__init__.py
+++ b/src/qca/forward/types/__init__.py
@@ -77,6 +77,7 @@
from .identity_stats import IdentityStats as IdentityStats
from .identity_template import IdentityTemplate as IdentityTemplate
from .identity_update_params import IdentityUpdateParams as IdentityUpdateParams
+from .identity_usage import IdentityUsage as IdentityUsage
from .image_source_param import ImageSourceParam as ImageSourceParam
from .mcp_server import MCPServer as MCPServer
from .mcp_server_override import MCPServerOverride as MCPServerOverride
@@ -174,18 +175,26 @@
from .template_create_params import TemplateCreateParams as TemplateCreateParams
from .template_list_params import TemplateListParams as TemplateListParams
from .template_update_params import TemplateUpdateParams as TemplateUpdateParams
+from .template_usage import TemplateUsage as TemplateUsage
from .tool import Tool as Tool
from .tool_config import ToolConfig as ToolConfig
from .tool_config_param import ToolConfigParam as ToolConfigParam
from .tool_override import ToolOverride as ToolOverride
from .tool_override_param import ToolOverrideParam as ToolOverrideParam
from .tool_param import ToolParam as ToolParam
+from .usage_list_params import UsageListParams as UsageListParams
from .vault import Vault as Vault
from .vault_create_params import VaultCreateParams as VaultCreateParams
from .vault_credential import VaultCredential as VaultCredential
from .vault_credential_auth import VaultCredentialAuth as VaultCredentialAuth
from .vault_credential_create_params import VaultCredentialCreateParams as VaultCredentialCreateParams
from .vault_credential_list_params import VaultCredentialListParams as VaultCredentialListParams
+from .vault_credential_update_params import EnvironmentVariableUpdate as EnvironmentVariableUpdate
+from .vault_credential_update_params import MCPOAuthRefreshUpdate as MCPOAuthRefreshUpdate
+from .vault_credential_update_params import MCPOAuthUpdate as MCPOAuthUpdate
+from .vault_credential_update_params import StaticBearerUpdate as StaticBearerUpdate
+from .vault_credential_update_params import VaultCredentialUpdateAuth as VaultCredentialUpdateAuth
+from .vault_credential_update_params import VaultCredentialUpdateParams as VaultCredentialUpdateParams
from .vault_list_params import VaultListParams as VaultListParams
_namespace = dict(
diff --git a/src/qca/forward/types/identity_usage.py b/src/qca/forward/types/identity_usage.py
new file mode 100644
index 0000000..adb1034
--- /dev/null
+++ b/src/qca/forward/types/identity_usage.py
@@ -0,0 +1,11 @@
+from typing import Literal
+
+from qca.common._models import BaseModel
+
+
+class IdentityUsage(BaseModel):
+ type: Literal["identity_usage"]
+ identity_id: str
+ session_count: int
+ active_seconds: float
+ credits: float
diff --git a/src/qca/forward/types/template_usage.py b/src/qca/forward/types/template_usage.py
new file mode 100644
index 0000000..a07fad7
--- /dev/null
+++ b/src/qca/forward/types/template_usage.py
@@ -0,0 +1,12 @@
+from typing import Literal
+
+from qca.common._models import BaseModel
+
+
+class TemplateUsage(BaseModel):
+ type: Literal["template_usage"]
+ template_id: str
+ active_identities: int
+ session_count: int
+ active_seconds: float
+ credits: float
diff --git a/src/qca/forward/types/usage_list_params.py b/src/qca/forward/types/usage_list_params.py
new file mode 100644
index 0000000..0e0d9cc
--- /dev/null
+++ b/src/qca/forward/types/usage_list_params.py
@@ -0,0 +1,17 @@
+from typing import List, Union
+
+from typing_extensions import Required, TypedDict
+
+
+class UsageListParams(TypedDict, total=False):
+ """Hourly Asia/Shanghai window, including CN and Global. No legacy timestamp parameters."""
+
+ start_at: Required[str]
+ end_at: Required[str]
+ limit: int
+ after_id: str
+ before_id: str
+ identity_id: str
+ identity_ids: Union[str, List[str]]
+ template_id: str
+ template_ids: Union[str, List[str]]
diff --git a/src/qca/forward/types/vault_credential.py b/src/qca/forward/types/vault_credential.py
index f408f35..3d20603 100644
--- a/src/qca/forward/types/vault_credential.py
+++ b/src/qca/forward/types/vault_credential.py
@@ -18,5 +18,6 @@ class VaultCredential(BaseModel):
auth: Optional[VaultCredentialAuth] = None
display_name: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
+ archived_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
diff --git a/src/qca/forward/types/vault_credential_auth.py b/src/qca/forward/types/vault_credential_auth.py
index e9761e3..f99d7ad 100644
--- a/src/qca/forward/types/vault_credential_auth.py
+++ b/src/qca/forward/types/vault_credential_auth.py
@@ -10,3 +10,4 @@
class VaultCredentialAuth(BaseModel):
type: Optional[str] = None
mcp_server_url: Optional[str] = None
+ secret_name: Optional[str] = None
diff --git a/src/qca/forward/types/vault_credential_update_params.py b/src/qca/forward/types/vault_credential_update_params.py
new file mode 100644
index 0000000..947e51d
--- /dev/null
+++ b/src/qca/forward/types/vault_credential_update_params.py
@@ -0,0 +1,37 @@
+from typing import Any, Dict, Literal, Optional, Union
+
+from typing_extensions import Required, TypedDict
+
+
+class StaticBearerUpdate(TypedDict, total=False):
+ type: Required[Literal["static_bearer"]]
+ token: str
+
+
+class EnvironmentVariableUpdate(TypedDict, total=False):
+ type: Required[Literal["environment_variable"]]
+ secret_value: str
+
+
+class MCPOAuthRefreshUpdate(TypedDict, total=False):
+ refresh_token: str
+ scope: Optional[str]
+ token_endpoint_auth: Dict[str, Any]
+
+
+class MCPOAuthUpdate(TypedDict, total=False):
+ type: Required[Literal["mcp_oauth"]]
+ access_token: str
+ expires_at: Optional[str]
+ refresh: MCPOAuthRefreshUpdate
+
+
+VaultCredentialUpdateAuth = Union[StaticBearerUpdate, EnvironmentVariableUpdate, MCPOAuthUpdate]
+
+
+class VaultCredentialUpdateParams(TypedDict, total=False):
+ """Merge patch. At least auth or metadata is required; null clears metadata."""
+
+ auth: VaultCredentialUpdateAuth
+ metadata: Optional[Dict[str, Any]]
+ identity_id: str
diff --git a/src/qca/managed/resources/__init__.py b/src/qca/managed/resources/__init__.py
index 465b4d8..f507ca6 100644
--- a/src/qca/managed/resources/__init__.py
+++ b/src/qca/managed/resources/__init__.py
@@ -1,5 +1,7 @@
from .deployment_runs import AsyncDeploymentRuns as AsyncDeploymentRuns
from .deployment_runs import DeploymentRuns as DeploymentRuns
+from .deployment_scoped_runs import AsyncRuns as AsyncRuns
+from .deployment_scoped_runs import Runs as Runs
from .deployments import AsyncDeployments as AsyncDeployments
from .deployments import Deployments as Deployments
from .dreams import AsyncDreams as AsyncDreams
diff --git a/src/qca/managed/resources/deployment_scoped_runs.py b/src/qca/managed/resources/deployment_scoped_runs.py
new file mode 100644
index 0000000..ecc7a04
--- /dev/null
+++ b/src/qca/managed/resources/deployment_scoped_runs.py
@@ -0,0 +1,143 @@
+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.managed.types.deployment_run import DeploymentRun
+
+__all__ = ["Runs", "AsyncRuns"]
+
+
+class Runs(SyncAPIResource):
+ def list(
+ self,
+ deployment_id: str,
+ *,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ page: Union[str, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_after: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_before: Union[str, None, NotGiven] = NOT_GIVEN,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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[DeploymentRun]:
+ """GET /deployments/{deployment_id}/runs."""
+ _path = path_template("/deployments/{deployment_id}/runs", deployment_id=deployment_id)
+ options = make_request_options(
+ body={},
+ query={
+ "limit": limit,
+ "page": page,
+ "after_id": after_id,
+ "before_id": before_id,
+ "triggered_after": triggered_after,
+ "triggered_before": triggered_before,
+ },
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=DeploymentRun, options=options, page_style="PageCursor")
+
+ def retrieve(
+ self,
+ run_id: str,
+ *,
+ deployment_id: str,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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,
+ ) -> DeploymentRun:
+ """GET /deployments/{deployment_id}/runs/{run_id}."""
+ _path = path_template("/deployments/{deployment_id}/runs/{run_id}", deployment_id=deployment_id, run_id=run_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=DeploymentRun, options=options)
+
+
+class AsyncRuns(AsyncAPIResource):
+ def list(
+ self,
+ deployment_id: str,
+ *,
+ limit: Union[int, None, NotGiven] = NOT_GIVEN,
+ page: Union[str, None, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_after: Union[str, None, NotGiven] = NOT_GIVEN,
+ triggered_before: Union[str, None, NotGiven] = NOT_GIVEN,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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[DeploymentRun]:
+ """GET /deployments/{deployment_id}/runs."""
+ _path = path_template("/deployments/{deployment_id}/runs", deployment_id=deployment_id)
+ options = make_request_options(
+ body={},
+ query={
+ "limit": limit,
+ "page": page,
+ "after_id": after_id,
+ "before_id": before_id,
+ "triggered_after": triggered_after,
+ "triggered_before": triggered_before,
+ },
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return AsyncPaginator(
+ lambda: self._client.request("GET", _path, cast_to=DeploymentRun, options=options, page_style="PageCursor")
+ )
+
+ async def retrieve(
+ self,
+ run_id: str,
+ *,
+ deployment_id: str,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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,
+ ) -> DeploymentRun:
+ """GET /deployments/{deployment_id}/runs/{run_id}."""
+ _path = path_template("/deployments/{deployment_id}/runs/{run_id}", deployment_id=deployment_id, run_id=run_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("GET", _path, cast_to=DeploymentRun, options=options)
diff --git a/src/qca/managed/resources/deployments.py b/src/qca/managed/resources/deployments.py
index 31a35d5..609e67b 100644
--- a/src/qca/managed/resources/deployments.py
+++ b/src/qca/managed/resources/deployments.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime
+from functools import cached_property
from typing import Any, Dict, List, Literal, Union
import httpx
@@ -9,6 +10,7 @@
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.managed.resources.deployment_scoped_runs import AsyncRuns, Runs
from qca.managed.types.agent_params import AgentParams
from qca.managed.types.budget_limit_param import BudgetLimitParam
from qca.managed.types.deployment import Deployment
@@ -25,6 +27,10 @@
class Deployments(SyncAPIResource):
+ @cached_property
+ def runs(self) -> Runs:
+ return Runs(self._client)
+
def create(
self,
*,
@@ -288,6 +294,10 @@ def unpause(
class AsyncDeployments(AsyncAPIResource):
+ @cached_property
+ def runs(self) -> AsyncRuns:
+ return AsyncRuns(self._client)
+
async def create(
self,
*,
diff --git a/src/qca/managed/resources/sessions/sessions.py b/src/qca/managed/resources/sessions/sessions.py
index 9205cca..d2c8365 100644
--- a/src/qca/managed/resources/sessions/sessions.py
+++ b/src/qca/managed/resources/sessions/sessions.py
@@ -22,6 +22,7 @@
from qca.managed.types.memory_store_resource_param import MemoryStoreResourceParam
from qca.managed.types.session import Session
from qca.managed.types.session_agent_update_param import SessionAgentUpdateParam
+from qca.managed.types.session_cancel_response import SessionCancelResponse
from qca.managed.types.user_define_outcome_event_params import UserDefineOutcomeEventParams
from qca.managed.types.user_message_event_params import UserMessageEventParams
@@ -29,6 +30,33 @@
class Sessions(SyncAPIResource):
+ def cancel(
+ self,
+ session_id: str,
+ *,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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,
+ ) -> SessionCancelResponse:
+ """POST /sessions/{session_id}/cancel.
+
+ Cancel the current turn (202); an idle session is a safe no-op (200).
+ """
+ _path = path_template("/sessions/{session_id}/cancel", session_id=session_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("POST", _path, cast_to=SessionCancelResponse, options=options)
+
@cached_property
def events(self) -> Events:
return Events(self._client)
@@ -250,6 +278,33 @@ def archive(
class AsyncSessions(AsyncAPIResource):
+ async def cancel(
+ self,
+ session_id: str,
+ *,
+ workspace_id: Union[str, None, NotGiven] = NOT_GIVEN,
+ betas: Union[List[str], None, 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,
+ ) -> SessionCancelResponse:
+ """POST /sessions/{session_id}/cancel.
+
+ Cancel the current turn (202); an idle session is a safe no-op (200).
+ """
+ _path = path_template("/sessions/{session_id}/cancel", session_id=session_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={"qoder-workspace-id": workspace_id, "x-qoder-beta": betas},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("POST", _path, cast_to=SessionCancelResponse, options=options)
+
@cached_property
def events(self) -> AsyncEvents:
return AsyncEvents(self._client)
diff --git a/src/qca/managed/types/__init__.py b/src/qca/managed/types/__init__.py
index 1765161..8368893 100644
--- a/src/qca/managed/types/__init__.py
+++ b/src/qca/managed/types/__init__.py
@@ -90,6 +90,10 @@
from .deployment_run_list_params import DeploymentRunListParams as DeploymentRunListParams
from .deployment_run_params import DeploymentRunParams as DeploymentRunParams
from .deployment_run_retrieve_params import DeploymentRunRetrieveParams as DeploymentRunRetrieveParams
+from .deployment_scoped_run_list_params import DeploymentScopedRunListParams as DeploymentScopedRunListParams
+from .deployment_scoped_run_retrieve_params import (
+ DeploymentScopedRunRetrieveParams as DeploymentScopedRunRetrieveParams,
+)
from .deployment_unpause_params import DeploymentUnpauseParams as DeploymentUnpauseParams
from .deployment_update_params import DeploymentUpdateParams as DeploymentUpdateParams
from .deployment_user_define_outcome_event_rubric_union import (
@@ -267,6 +271,8 @@
)
from .session_agent_update_param import SessionAgentUpdateParam as SessionAgentUpdateParam
from .session_archive_params import SessionArchiveParams as SessionArchiveParams
+from .session_cancel_params import SessionCancelParams as SessionCancelParams
+from .session_cancel_response import SessionCancelResponse as SessionCancelResponse
from .session_create_params import SessionCreateParams as SessionCreateParams
from .session_delete_params import SessionDeleteParams as SessionDeleteParams
from .session_error_event_error_union import SessionErrorEventErrorUnion as SessionErrorEventErrorUnion
diff --git a/src/qca/managed/types/deployment_scoped_run_list_params.py b/src/qca/managed/types/deployment_scoped_run_list_params.py
new file mode 100644
index 0000000..2582d3f
--- /dev/null
+++ b/src/qca/managed/types/deployment_scoped_run_list_params.py
@@ -0,0 +1,15 @@
+from typing import List
+
+from typing_extensions import Required, TypedDict
+
+
+class DeploymentScopedRunListParams(TypedDict, total=False):
+ deployment_id: Required[str]
+ limit: int
+ page: str
+ after_id: str
+ before_id: str
+ triggered_after: str
+ triggered_before: str
+ workspace_id: str
+ betas: List[str]
diff --git a/src/qca/managed/types/deployment_scoped_run_retrieve_params.py b/src/qca/managed/types/deployment_scoped_run_retrieve_params.py
new file mode 100644
index 0000000..b15870e
--- /dev/null
+++ b/src/qca/managed/types/deployment_scoped_run_retrieve_params.py
@@ -0,0 +1,9 @@
+from typing import List
+
+from typing_extensions import Required, TypedDict
+
+
+class DeploymentScopedRunRetrieveParams(TypedDict, total=False):
+ deployment_id: Required[str]
+ workspace_id: str
+ betas: List[str]
diff --git a/src/qca/managed/types/session_cancel_params.py b/src/qca/managed/types/session_cancel_params.py
new file mode 100644
index 0000000..a9a3d9e
--- /dev/null
+++ b/src/qca/managed/types/session_cancel_params.py
@@ -0,0 +1,8 @@
+from typing import List
+
+from typing_extensions import TypedDict
+
+
+class SessionCancelParams(TypedDict, total=False):
+ workspace_id: str
+ betas: List[str]
diff --git a/src/qca/managed/types/session_cancel_response.py b/src/qca/managed/types/session_cancel_response.py
new file mode 100644
index 0000000..13b87fc
--- /dev/null
+++ b/src/qca/managed/types/session_cancel_response.py
@@ -0,0 +1,9 @@
+from typing import Literal
+
+from qca.common._models import BaseModel
+
+
+class SessionCancelResponse(BaseModel):
+ id: str
+ type: Literal["session"]
+ status: Literal["canceling"]
diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json
index fcbb249..9d85168 100644
--- a/tests/fixtures/api-contracts.json
+++ b/tests/fixtures/api-contracts.json
@@ -1876,6 +1876,70 @@
"vault_id"
],
"kind": "model"
+ },
+ {
+ "id": "forward.vaults.credentials.update",
+ "method": "POST",
+ "path": "/vaults/{vault_id}/credentials/{credential_id}",
+ "required": [
+ "credential_id",
+ "vault_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.usage.list_identities",
+ "method": "GET",
+ "path": "/usage/identities",
+ "required": [
+ "end_at",
+ "start_at"
+ ],
+ "kind": "page"
+ },
+ {
+ "id": "forward.usage.list_templates",
+ "method": "GET",
+ "path": "/usage/templates",
+ "required": [
+ "end_at",
+ "start_at"
+ ],
+ "kind": "page"
+ },
+ {
+ "id": "managed.sessions.cancel",
+ "method": "POST",
+ "path": "/sessions/{session_id}/cancel",
+ "required": [
+ "session_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "managed.deployments.runs.list",
+ "method": "GET",
+ "path": "/deployments/{deployment_id}/runs",
+ "required": [
+ "deployment_id"
+ ],
+ "kind": "page"
+ },
+ {
+ "id": "managed.deployments.runs.retrieve",
+ "method": "GET",
+ "path": "/deployments/{deployment_id}/runs/{run_id}",
+ "required": [
+ "deployment_id",
+ "run_id"
+ ],
+ "kind": "model"
+ }
+ ],
+ "extensions": [
+ {
+ "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)",
+ "operations": 6
}
]
}
diff --git a/tests/fixtures/api-expansion.json b/tests/fixtures/api-expansion.json
new file mode 100644
index 0000000..3eedac4
--- /dev/null
+++ b/tests/fixtures/api-expansion.json
@@ -0,0 +1,262 @@
+{
+ "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)",
+ "operations": [
+ {
+ "mode": "forward",
+ "entry": "vaults.credentials",
+ "method": "update",
+ "goentry": "Vaults.Credentials",
+ "goname": "Update",
+ "http": "POST",
+ "path": "/vaults/{id}/credentials/{cred_id}",
+ "doc": "Credentials__update.md",
+ "body": {
+ "auth": {
+ "type": "static_bearer",
+ "token": "rotated-test-token"
+ },
+ "metadata": {
+ "remove": null,
+ "rotated_by": "sdk"
+ }
+ },
+ "query": {
+ "identity_id": "idn_contract"
+ },
+ "response": {
+ "id": "vcred_xxx",
+ "type": "vault_credential",
+ "vault_id": "vault_xxx",
+ "auth": {
+ "type": "static_bearer",
+ "mcp_server_url": "https://mcp.example.com"
+ },
+ "display_name": "",
+ "metadata": {
+ "rotated_by": "console"
+ },
+ "archived_at": null,
+ "created_at": "2026-07-23T10:00:00Z",
+ "updated_at": "2026-08-27T12:00:00Z"
+ },
+ "page": false,
+ "op": "updateVaultCredential",
+ "service": "VaultCredentialService",
+ "kind": "json",
+ "type": "VaultCredential",
+ "paramsType": "VaultCredentialUpdateParams"
+ },
+ {
+ "mode": "forward",
+ "entry": "usage",
+ "method": "listIdentities",
+ "goentry": "Usage",
+ "goname": "ListIdentities",
+ "http": "GET",
+ "path": "/usage/identities",
+ "doc": "Usage__ListIdentityUsage.md",
+ "body": null,
+ "query": {
+ "start_at": "2026-09-14T09:00:00",
+ "end_at": "2026-09-14T12:00:00",
+ "identity_ids": [
+ "idn_one",
+ "idn_two"
+ ],
+ "template_ids": [
+ "tmpl_one",
+ "tmpl_two"
+ ],
+ "limit": 20
+ },
+ "response": {
+ "type": "identity_usage.list",
+ "start_at": "2026-09-14T09:00:00",
+ "end_at": "2026-09-14T12:00:00",
+ "has_more": false,
+ "first_id": "idn_abc",
+ "last_id": "idn_efg",
+ "data": [
+ {
+ "type": "identity_usage",
+ "identity_id": "idn_abc",
+ "session_count": 3,
+ "active_seconds": 720.222,
+ "credits": 3.1
+ },
+ {
+ "type": "identity_usage",
+ "identity_id": "idn_efg",
+ "session_count": 2,
+ "active_seconds": 480.222,
+ "credits": 1.58
+ }
+ ]
+ },
+ "page": true,
+ "op": "listIdentityUsage",
+ "service": "UsageService",
+ "kind": "page",
+ "type": "IdentityUsage",
+ "paramsType": "UsageListParams"
+ },
+ {
+ "mode": "forward",
+ "entry": "usage",
+ "method": "listTemplates",
+ "goentry": "Usage",
+ "goname": "ListTemplates",
+ "http": "GET",
+ "path": "/usage/templates",
+ "doc": "Usage__ListTemplateUsage.md",
+ "body": null,
+ "query": {
+ "start_at": "2026-09-14T09:00:00",
+ "end_at": "2026-09-14T12:00:00",
+ "identity_ids": [
+ "idn_one",
+ "idn_two"
+ ],
+ "template_ids": [
+ "tmpl_one",
+ "tmpl_two"
+ ],
+ "limit": 20
+ },
+ "response": {
+ "type": "template_usage.list",
+ "start_at": "2026-09-14T09:00:00",
+ "end_at": "2026-09-14T12:00:00",
+ "has_more": false,
+ "first_id": "tmpl_123",
+ "last_id": "tmpl_456",
+ "data": [
+ {
+ "type": "template_usage",
+ "template_id": "tmpl_123",
+ "active_identities": 2,
+ "session_count": 3,
+ "active_seconds": 900.321,
+ "credits": 3.45
+ },
+ {
+ "type": "template_usage",
+ "template_id": "tmpl_456",
+ "active_identities": 2,
+ "session_count": 2,
+ "active_seconds": 300.123,
+ "credits": 1.23
+ }
+ ]
+ },
+ "page": true,
+ "op": "listTemplateUsage",
+ "service": "UsageService",
+ "kind": "page",
+ "type": "TemplateUsage",
+ "paramsType": "UsageListParams"
+ },
+ {
+ "mode": "managed",
+ "entry": "sessions",
+ "method": "cancel",
+ "goentry": "Sessions",
+ "goname": "Cancel",
+ "http": "POST",
+ "path": "/sessions/{session_id}/cancel",
+ "doc": "sessions__cancel.md",
+ "body": null,
+ "query": {},
+ "response": {
+ "id": "sess_019ef30a4f0275678965d496ab542ef0",
+ "type": "session",
+ "status": "canceling"
+ },
+ "page": false,
+ "op": "cancelSession",
+ "service": "SessionService",
+ "kind": "json",
+ "type": "SessionCancelResponse",
+ "paramsType": "SessionCancelParams"
+ },
+ {
+ "mode": "managed",
+ "entry": "deployments.runs",
+ "method": "list",
+ "goentry": "Deployments.Runs",
+ "goname": "List",
+ "http": "GET",
+ "path": "/deployments/{deployment_id}/runs",
+ "doc": "deployments__list-runs.md",
+ "body": null,
+ "query": {
+ "limit": 20,
+ "triggered_after": "2026-06-01T00:00:00Z"
+ },
+ "response": {
+ "data": [
+ {
+ "agent": {
+ "id": "agent_019ebb21ef8e7df6a559052c94875160",
+ "type": "agent",
+ "version": 1
+ },
+ "created_at": "2026-06-14T08:58:17Z",
+ "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a",
+ "error": null,
+ "id": "drun_019ec55a68af73028afa5b87931cb2f3",
+ "session_id": "sess_019ec55a68b37e1e8d660691af161ab4",
+ "trigger_context": {
+ "type": "manual"
+ },
+ "type": "deployment_run"
+ }
+ ],
+ "first_id": "drun_019ec55a68af73028afa5b87931cb2f3",
+ "has_more": false,
+ "last_id": "drun_019ec55a68af73028afa5b87931cb2f3",
+ "next_page": null
+ },
+ "page": true,
+ "op": "listDeploymentScopedRun",
+ "service": "DeploymentScopedRunService",
+ "kind": "page",
+ "type": "ManagedAgentsDeploymentRun",
+ "paramsType": "DeploymentScopedRunListParams"
+ },
+ {
+ "mode": "managed",
+ "entry": "deployments.runs",
+ "method": "retrieve",
+ "goentry": "Deployments.Runs",
+ "goname": "Get",
+ "http": "GET",
+ "path": "/deployments/{deployment_id}/runs/{run_id}",
+ "doc": "deployments__get-run.md",
+ "body": null,
+ "query": {},
+ "response": {
+ "agent": {
+ "id": "agent_019ebb21ef8e7df6a559052c94875160",
+ "type": "agent",
+ "version": 1
+ },
+ "created_at": "2026-06-14T08:58:17Z",
+ "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a",
+ "error": null,
+ "id": "drun_019ec55a68af73028afa5b87931cb2f3",
+ "session_id": "sess_019ec55a68b37e1e8d660691af161ab4",
+ "trigger_context": {
+ "type": "manual"
+ },
+ "type": "deployment_run"
+ },
+ "page": false,
+ "op": "getDeploymentScopedRun",
+ "service": "DeploymentScopedRunService",
+ "kind": "json",
+ "type": "ManagedAgentsDeploymentRun",
+ "paramsType": "DeploymentScopedRunRetrieveParams"
+ }
+ ]
+}
diff --git a/tests/integration/test_api_expansion.py b/tests/integration/test_api_expansion.py
new file mode 100644
index 0000000..d08cb9b
--- /dev/null
+++ b/tests/integration/test_api_expansion.py
@@ -0,0 +1,20 @@
+import pytest
+
+from tests.support.scenarios.api_expansion import SCENARIOS
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.mark.parametrize(
+ "live_example,scenario",
+ list(SCENARIOS.values()),
+ ids=list(SCENARIOS),
+ indirect=["live_example"],
+)
+def test_new_api_live(live_example, scenario):
+ client, context = live_example
+ try:
+ scenario(client, context)
+ finally:
+ for output in context.outputs:
+ print(f"{output['label']}: {output['value']}")
diff --git a/tests/support/scenarios/api_expansion.py b/tests/support/scenarios/api_expansion.py
new file mode 100644
index 0000000..04b09a6
--- /dev/null
+++ b/tests/support/scenarios/api_expansion.py
@@ -0,0 +1,128 @@
+"""Account-backed scenarios for the newly added, non-beta API operations."""
+
+import json
+from datetime import datetime, timedelta, timezone
+
+from qca import Forward, Managed
+from tests.support.cleanup import finish_session_managed
+from tests.support.harness import Run, marker, name
+
+
+def usage(client: Forward, context: Run) -> None:
+ end = datetime.now(timezone(timedelta(hours=8))).replace(minute=0, second=0, microsecond=0)
+ bounds = {
+ "start_at": (end - timedelta(hours=24)).strftime("%Y-%m-%dT%H:00:00"),
+ "end_at": end.strftime("%Y-%m-%dT%H:00:00"),
+ }
+ for method, kind, id_field in [
+ (client.usage.list_identities, "identity", "identity_id"),
+ (client.usage.list_templates, "template", "template_id"),
+ ]:
+ page = method(**bounds, limit=2)
+ if page.type != f"{kind}_usage.list" or page.start_at != bounds["start_at"] or page.end_at != bounds["end_at"]:
+ raise AssertionError("Usage collection type or hourly window changed")
+ if len(page.data) > 2:
+ raise AssertionError("Usage exceeded the requested limit")
+ for row in page.data:
+ if row.type != f"{kind}_usage" or not getattr(row, id_field):
+ raise AssertionError("Usage item has the wrong type or no identity")
+ if row.active_seconds < 0 or row.credits < 0 or row.session_count < 0:
+ raise AssertionError("Usage counters must be nonnegative")
+ if kind == "template" and row.active_identities < 0:
+ raise AssertionError("Active identity count must be nonnegative")
+ if page.has_next_page():
+ following = page.get_next_page()
+ if following.start_at != page.start_at or following.end_at != page.end_at:
+ raise AssertionError("Usage pagination lost the hourly window")
+ ids = [getattr(row, id_field) for row in page.data]
+ if ids:
+ for value in [ids, ",".join(ids)]:
+ filtered = method(**bounds, limit=2, **{id_field + "s": value})
+ if any(getattr(row, id_field) not in ids for row in filtered.data):
+ raise AssertionError("Usage ignored the multi-ID filter")
+ context.output(f"{kind}_usage_rows", len(page.data))
+
+
+def credentials(client: Forward, context: Run) -> None:
+ vault = client.vaults.create(display_name=name("vault"))
+ context.track("vault", vault.id, lambda: client.vaults.delete(vault.id))
+ original, rotated = marker(), marker()
+ url = "https://example.com/" + name("mcp")
+ credential = client.vaults.credentials.create(
+ vault.id,
+ auth={"type": "static_bearer", "mcp_server_url": url, "token": original},
+ metadata={"keep": "original", "remove": "old"},
+ )
+ context.track(
+ "credential", credential.id, lambda: client.vaults.credentials.delete(credential.id, vault_id=vault.id)
+ )
+ updated = client.vaults.credentials.update(
+ credential.id,
+ vault_id=vault.id,
+ auth={"type": "static_bearer", "token": rotated},
+ metadata={"remove": None, "added": "new"},
+ )
+ saved = client.vaults.credentials.retrieve(credential.id, vault_id=vault.id)
+ patched_saved = saved
+ for response in [updated, saved]:
+ if (
+ response.id != credential.id
+ or response.auth.type != "static_bearer"
+ or response.auth.mcp_server_url != url
+ or response.metadata.get("keep") != "original"
+ or response.metadata.get("added") != "new"
+ or "remove" in response.metadata
+ ):
+ raise AssertionError("Credential merge patch did not preserve immutable or omitted fields")
+ cleared = client.vaults.credentials.update(credential.id, vault_id=vault.id, metadata=None)
+ saved = client.vaults.credentials.retrieve(credential.id, vault_id=vault.id)
+ if cleared.metadata or saved.metadata:
+ raise AssertionError("Null metadata did not clear the credential metadata")
+ encoded = json.dumps([r.to_dict(mode="json") for r in [credential, updated, patched_saved, cleared, saved]])
+ if original in encoded or rotated in encoded:
+ raise AssertionError("Credential response disclosed a write-only secret")
+
+
+def session_cancel(client: Managed, context: Run) -> None:
+ environment = client.environments.create(name=name("env"), config={"type": "cloud"})
+ context.track("environment", environment.id, lambda: client.environments.archive(environment.id))
+ agent = client.agents.create(
+ name=name("agent"), model={"id": context.config.model or "auto"}, tools=[{"type": "agent_toolset_20260401"}]
+ )
+ context.track("agent", agent.id, lambda: client.agents.archive(agent.id))
+ session = client.sessions.create(agent=agent.id, environment_id=environment.id)
+ context.track("session", session.id, lambda: finish_session_managed(client, context, session.id))
+ for active in [False, True]:
+ if active:
+ client.sessions.events.send(
+ session.id,
+ events=[
+ {
+ "type": "user.message",
+ "content": [
+ {
+ "type": "text",
+ "text": "Use a shell command to sleep 30 seconds, then reply with SDK-LIVE.",
+ }
+ ],
+ }
+ ],
+ )
+ raw = client.sessions.with_raw_response.cancel(session.id)
+ result = raw.parse()
+ if raw.status_code not in ([200, 202] if active else [200]) or (result.id, result.type, result.status) != (
+ session.id,
+ "session",
+ "canceling",
+ ):
+ raise AssertionError("Session cancellation did not return the lightweight acknowledgement")
+ context.output("active_cancel_http_status" if active else "idle_cancel_http_status", raw.status_code)
+ while client.sessions.retrieve(session.id).status not in ("idle", "terminated"):
+ context.pause()
+
+
+SCENARIOS = {
+ "usage": ("forward", usage),
+ "credential_update": ("forward", credentials),
+ "session_cancel": ("managed", session_cancel),
+}
diff --git a/tests/support/scenarios/managed.py b/tests/support/scenarios/managed.py
index fc9b2c5..4b8b94f 100644
--- a/tests/support/scenarios/managed.py
+++ b/tests/support/scenarios/managed.py
@@ -194,6 +194,14 @@ def deployment(client: Managed, context: Run) -> None:
if saved.session_id != execution.session_id:
raise AssertionError("Deployment Run session ID changed")
wait_reply(client.sessions.events, context, execution.session_id).verify([expected])
+ scoped = client.deployments.runs.retrieve(execution.id, deployment_id=deployment.id)
+ if scoped.id != execution.id or scoped.deployment_id != deployment.id or scoped.session_id != execution.session_id:
+ raise AssertionError("Scoped Run changed its identity, deployment, or session")
+ runs = client.deployments.runs.list(deployment.id, limit=10)
+ if not any(row.id == execution.id for row in runs.data) or any(
+ row.deployment_id != deployment.id for row in runs.data
+ ):
+ raise AssertionError("Scoped Run list omitted the created Run or returned another deployment")
def dream(client: Managed, context: Run) -> None:
diff --git a/tests/support/stub.py b/tests/support/stub.py
index e886525..05ae374 100644
--- a/tests/support/stub.py
+++ b/tests/support/stub.py
@@ -106,11 +106,72 @@ def reply(data):
if path == "models":
return reply({"data": [{"id": "ultimate", "is_enabled": True}]})
+ if parts[0] == "usage":
+ assert request.url.params.get("start_at") and request.url.params.get("end_at")
+ assert "start_time" not in request.url.params and "end_time" not in request.url.params
+ kind = "identity" if parts[1] == "identities" else "template"
+ return reply(
+ {
+ "type": f"{kind}_usage.list",
+ "start_at": request.url.params["start_at"],
+ "end_at": request.url.params["end_at"],
+ "data": [
+ {
+ "type": f"{kind}_usage",
+ f"{kind}_id": kind + "-usage",
+ "active_seconds": 1.25,
+ "credits": 0.5,
+ "session_count": 1,
+ **({"active_identities": 1} if kind == "template" else {}),
+ }
+ ],
+ "has_more": False,
+ }
+ )
+ if parts[0] == "vaults" and len(parts) >= 3 and parts[2] == "credentials":
+ if len(parts) == 3 and verb == "POST":
+ auth = {key: value for key, value in body["auth"].items() if key != "token"}
+ return reply(
+ self.create(
+ "credential", {"vault_id": parts[1], "auth": auth, "metadata": body.get("metadata", {})}
+ )
+ )
+ if len(parts) == 4:
+ credential = self.objects[parts[3]]
+ assert credential["vault_id"] == parts[1]
+ if verb == "POST":
+ assert "mcp_server_url" not in body.get("auth", {})
+ if "metadata" in body:
+ if body["metadata"] is None:
+ credential["metadata"] = {}
+ else:
+ for key, value in body["metadata"].items():
+ if value is None:
+ credential["metadata"].pop(key, None)
+ else:
+ credential["metadata"][key] = value
+ if verb == "DELETE":
+ self.deleted.append(parts[3])
+ return reply(credential)
+ if parts[0] == "deployments" and len(parts) >= 3 and parts[2] == "runs":
+ runs = [item for item in self.objects.values() if item.get("deployment_id") == parts[1]]
+ if len(parts) == 3:
+ return reply({"data": runs, "has_more": False})
+ assert self.objects[parts[3]]["deployment_id"] == parts[1]
+ return reply(self.objects[parts[3]])
if parts[0] == "sessions" and len(parts) >= 3:
session_id = parts[1]
+ if parts[2] == "cancel":
+ session = self.objects[session_id]
+ status = 200 if session["status"] == "idle" else 202
+ session["status"] = "idle"
+ return httpx.Response(status, json={"id": session_id, "type": "session", "status": "canceling"})
if parts[2] == "events":
if verb == "POST":
- return reply({"data": [self.send(session_id, e) for e in body["events"]]})
+ events = [self.send(session_id, e) for e in body["events"]]
+ if any("sleep 30 seconds" in str(e.get("content", "")) for e in body["events"]):
+ self.objects[session_id]["status"] = "running"
+ return reply({"data": events})
events = self.events[session_id]
after = request.headers.get("Last-Event-ID") or request.url.params.get("after_id")
if after:
@@ -210,7 +271,9 @@ def reply(data):
if len(parts) == 3 and parts[-1] == "run":
item = self.objects[parts[1]]
session = self.session(item)
- execution = self.create("run", {"session_id": session["id"], "status": "completed"})
+ execution = self.create(
+ "run", {"deployment_id": parts[1], "session_id": session["id"], "status": "completed"}
+ )
return reply(execution)
if len(parts) == 3 and parts[-1] == "tasks":
return reply({"data": [{"custom_id": row["custom_id"]} for row in self.outputs[parts[1]]]})
diff --git a/tests/test_api_expansion.py b/tests/test_api_expansion.py
new file mode 100644
index 0000000..827d737
--- /dev/null
+++ b/tests/test_api_expansion.py
@@ -0,0 +1,263 @@
+"""New API contracts frozen from api-doc master; no credentials or network."""
+
+from __future__ import annotations
+
+import inspect
+import json
+import re
+from pathlib import Path
+
+import httpx
+import pytest
+
+from qca import APIConnectionError, APIStatusError, AsyncForward, AsyncManaged, Forward, Managed
+
+OPERATIONS = json.loads((Path(__file__).parent / "fixtures/api-expansion.json").read_text())["operations"]
+SEGMENT = "id /?%#"
+CLIENTS = {
+ (False, "forward"): Forward,
+ (True, "forward"): AsyncForward,
+ (False, "managed"): Managed,
+ (True, "managed"): AsyncManaged,
+}
+
+
+def resource(client, entry):
+ for name in entry.split("."):
+ client = getattr(client, name)
+ return client
+
+
+def kwargs(op):
+ args = {name: SEGMENT for name in re.findall(r"{(\w+)}", op["path"])}
+ if op["entry"] == "vaults.credentials":
+ args["vault_id"] = args.pop("id")
+ args["credential_id"] = args.pop("cred_id")
+ return {**args, **(op["body"] or {}), **op["query"]}
+
+
+async def result(value):
+ return await value if inspect.isawaitable(value) else value
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("op", OPERATIONS, ids=lambda op: op["mode"] + "." + op["entry"] + "." + op["method"])
+async def test_documented_api_expansion(op, async_):
+ def handle(req):
+ assert req.method == op["http"]
+ path = re.sub(r"{\w+}", lambda _: "id%20%2F%3F%25%23", op["path"])
+ assert req.url.raw_path.split(b"?")[0].decode() == "/api/v1/" + op["mode"] + path
+ assert "x-qoder-beta" not in req.headers
+ for key, value in op["query"].items():
+ assert req.url.params.get_list(key) == [
+ str(v).lower() if isinstance(v, bool) else str(v)
+ for v in (value if isinstance(value, list) else [value])
+ ]
+ assert "identity_ids[]" not in req.url.params
+ assert "template_ids[]" not in req.url.params
+ if op["body"] is not None:
+ assert json.loads(req.content) == op["body"]
+ return httpx.Response(
+ 202 if op["entry"] == "sessions" else 201 if op["method"] == "create" else 200, json=op["response"]
+ )
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, op["mode"]](
+ pat="test-token",
+ base_url="https://api.test/api/v1/" + op["mode"],
+ http_client=http,
+ _strict_response_validation=True,
+ )
+ method = {"listIdentities": "list_identities", "listTemplates": "list_templates"}.get(op["method"], op["method"])
+ try:
+ data = await result(getattr(resource(client, op["entry"]), method)(**kwargs(op)))
+ # Timestamp fields normalize to UTC; compare the full schema through JSON values.
+ actual = data.model_dump(mode="json", exclude_unset=True)
+ expected = op["response"]
+ assert actual == expected
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("method", ["list_identities", "list_templates"])
+@pytest.mark.parametrize("backward", [False, True])
+async def test_usage_pagination_keeps_hourly_filters(async_, method, backward):
+ op = next(
+ o for o in OPERATIONS if o["method"] == ("listIdentities" if method == "list_identities" else "listTemplates")
+ )
+ calls = []
+ args = {**op["query"], **({"before_id": "initial"} if backward else {})}
+
+ def handle(req):
+ calls.append(req)
+ assert req.url.params["start_at"] == args["start_at"]
+ assert req.url.params["end_at"] == args["end_at"]
+ assert req.url.params.get_list("identity_ids") == args["identity_ids"]
+ assert req.url.params.get_list("template_ids") == args["template_ids"]
+ assert "start_time" not in req.url.params and "end_time" not in req.url.params
+ if len(calls) > 1:
+ assert req.url.params["before_id" if backward else "after_id"] == ("first" if backward else "last")
+ return httpx.Response(
+ 200,
+ json={
+ **op["response"],
+ "data": [op["response"]["data"][0]],
+ "first_id": "first",
+ "last_id": "last",
+ "has_more": len(calls) == 1,
+ },
+ )
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, "forward"](pat="test-token", http_client=http)
+ try:
+ page = await result(getattr(client.usage, method)(**args))
+ assert page.data[0].active_seconds == op["response"]["data"][0]["active_seconds"]
+ assert page.start_at == args["start_at"]
+ await result(page.get_next_page())
+ assert len(calls) == 2
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("status", [200, 202])
+async def test_cancel_lightweight_acknowledgement(async_, status):
+ http = (httpx.AsyncClient if async_ else httpx.Client)(
+ transport=httpx.MockTransport(
+ lambda r: httpx.Response(status, json={"id": "sess_one", "type": "session", "status": "canceling"})
+ )
+ )
+ client = CLIENTS[async_, "managed"](pat="test-token", http_client=http, _strict_response_validation=True)
+ try:
+ assert (await result(client.sessions.cancel("sess_one"))).status == "canceling"
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("failure", [429, 503, "network"])
+async def test_credential_rotation_never_retries(async_, failure):
+ calls = []
+
+ def handle(req):
+ calls.append(req)
+ if failure == "network":
+ raise httpx.ConnectError("rotation uncertain", request=req)
+ return httpx.Response(
+ failure,
+ json={"error": {"type": "api_error", "message": "rotation uncertain"}},
+ headers={"retry-after-ms": "1", "x-should-retry": "true"},
+ )
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, "forward"](pat="test-token", http_client=http, max_retries=3)
+ try:
+ with pytest.raises((APIStatusError, APIConnectionError)):
+ await result(
+ client.vaults.credentials.update(
+ "cred_one",
+ vault_id="vault_one",
+ auth={"type": "static_bearer", "token": "test-secret"},
+ extra_headers={"Idempotency-Key": "caller-key"},
+ )
+ )
+ assert len(calls) == 1
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_deployment_runs_replay_scoped_cursor(async_):
+ calls = []
+ op = next(o for o in OPERATIONS if o["entry"] == "deployments.runs" and o["method"] == "list")
+
+ def handle(req):
+ calls.append(req)
+ assert req.url.path.endswith("/deployments/dep_one/runs")
+ assert req.url.params["triggered_after"] == "2026-06-01T00:00:00Z"
+ assert req.headers["qoder-workspace-id"] == "workspace_one"
+ if len(calls) > 1:
+ assert req.url.params["page"] == "opaque +/=?"
+ return httpx.Response(
+ 200,
+ json={
+ **op["response"],
+ "has_more": len(calls) == 1,
+ "next_page": "opaque +/=?" if len(calls) == 1 else None,
+ },
+ )
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, "managed"](pat="test-token", http_client=http)
+ try:
+ page = await result(
+ client.deployments.runs.list(
+ "dep_one", triggered_after="2026-06-01T00:00:00Z", workspace_id="workspace_one"
+ )
+ )
+ await result(page.get_next_page())
+ assert len(calls) == 2
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_credential_null_patch_and_raw_response(async_):
+ def handle(req):
+ assert req.url.params["identity_id"] == "idn_one"
+ assert json.loads(req.content) == {
+ "auth": {"type": "mcp_oauth", "expires_at": None, "refresh": {"scope": None}},
+ "metadata": None,
+ }
+ return httpx.Response(200, json=next(o for o in OPERATIONS if o["entry"] == "vaults.credentials")["response"])
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, "forward"](pat="test-token", http_client=http)
+ try:
+ raw = await result(
+ client.vaults.credentials.with_raw_response.update(
+ "cred_one",
+ vault_id="vault_one",
+ identity_id="idn_one",
+ auth={"type": "mcp_oauth", "expires_at": None, "refresh": {"scope": None}},
+ metadata=None,
+ )
+ )
+ assert raw.status_code == 200
+ assert (await result(raw.parse())).id == "vcred_xxx"
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+async def test_usage_supports_comma_separated_ids_and_rejects_legacy_keywords(async_):
+ def handle(req):
+ assert req.url.params.get_list("identity_ids") == ["idn_one,idn_two"]
+ assert req.url.params.get_list("template_ids") == ["tmpl_one,tmpl_two"]
+ return httpx.Response(200, json={"data": [], "has_more": False})
+
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ client = CLIENTS[async_, "forward"](pat="test-token", http_client=http)
+ try:
+ await result(
+ client.usage.list_identities(
+ start_at="2026-09-14T09:00:00",
+ end_at="2026-09-14T12:00:00",
+ identity_ids="idn_one,idn_two",
+ template_ids="tmpl_one,tmpl_two",
+ )
+ )
+ with pytest.raises(TypeError):
+ client.usage.list_identities(start_time=123, end_time=456)
+ assert not hasattr(client.vaults, "search")
+ finally:
+ await result(client.close())
+ await result(http.aclose() if async_ else http.close())
diff --git a/tests/test_api_expansion_scenarios_offline.py b/tests/test_api_expansion_scenarios_offline.py
new file mode 100644
index 0000000..9887802
--- /dev/null
+++ b/tests/test_api_expansion_scenarios_offline.py
@@ -0,0 +1,74 @@
+import httpx
+import pytest
+
+from qca import Forward, Managed
+from tests.support.harness import Config, Run
+from tests.support.scenarios.api_expansion import SCENARIOS
+from tests.support.stub import ExampleService
+
+
+@pytest.mark.parametrize("mode,scenario", list(SCENARIOS.values()), ids=list(SCENARIOS))
+def test_new_api_scenario_and_cleanup(mode, scenario):
+ service = ExampleService(mode)
+ context = Run(
+ Config(mode, "test-token", f"https://api.test/api/v1/{mode}", model="auto", timeout=2, poll_interval=0)
+ )
+ cls = Forward if mode == "forward" else Managed
+ with cls(
+ **context.config.client_options(), http_client=httpx.Client(transport=httpx.MockTransport(service))
+ ) as client:
+ try:
+ scenario(client, context)
+ finally:
+ context.cleanup()
+ assert not service.mounts
+ created = [key for key in service.objects if not key.startswith(("event-", "memory-", "run-"))]
+ assert all(key in service.deleted for key in created)
+ assert not context.cleanups
+
+
+@pytest.mark.parametrize("key", ["usage", "credential_update", "session_cancel"])
+def test_new_api_live_assertions_reject_response_drift_and_still_cleanup(key):
+ mode, scenario = SCENARIOS[key]
+ service = ExampleService(mode)
+ injected = False
+
+ def handler(request):
+ nonlocal injected
+ response = service(request)
+ data = response.json()
+ target = (
+ (key == "usage" and "/usage/" in request.url.path)
+ or (
+ key == "credential_update"
+ and request.method == "POST"
+ and "/credentials/" in request.url.path
+ and "added" in request.content.decode()
+ )
+ or (key == "session_cancel" and request.url.path.endswith("/cancel"))
+ )
+ if target and not injected:
+ injected = True
+ if key == "usage":
+ data["type"] = "wrong.list"
+ elif key == "credential_update":
+ data["metadata"]["keep"] = "wrong"
+ else:
+ data["type"] = "wrong"
+ return httpx.Response(response.status_code, json=data)
+ return response
+
+ context = Run(Config(mode, "test-token", f"https://api.test/api/v1/{mode}", model="auto", poll_interval=0))
+ cls = Forward if mode == "forward" else Managed
+ with cls(
+ **context.config.client_options(), http_client=httpx.Client(transport=httpx.MockTransport(handler))
+ ) as client:
+ try:
+ with pytest.raises(AssertionError):
+ scenario(client, context)
+ finally:
+ context.cleanup()
+ assert injected
+ assert not service.mounts
+ created = [item for item in service.objects if not item.startswith(("event-", "memory-", "run-"))]
+ assert all(item in service.deleted for item in created)
diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py
index 60cc0ed..8ba3980 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"]) == 205
+ assert len(CONTRACT_FIXTURE["contracts"]) == 211
@pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)