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)