From b02f5efbddbb9d57ed733d59d05c9313e2e36b0d Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:43:43 +0800 Subject: [PATCH] feat(forward): support Drive files and managed tool configuration --- .env.live.example | 5 + CHANGELOG.md | 5 + Makefile | 4 + README.md | 51 ++ docs/api/reference.md | 582 ++++++++++++++++++ examples/forward/drive.py | 61 ++ src/qca/common/_base_client.py | 4 + src/qca/forward/_client.py | 9 + src/qca/forward/resources/__init__.py | 2 + src/qca/forward/resources/drives.py | 270 ++++++++ src/qca/forward/resources/templates.py | 9 + src/qca/forward/types/__init__.py | 13 + src/qca/forward/types/drive.py | 83 +++ src/qca/forward/types/managed_tool_config.py | 7 + .../types/managed_tool_config_param.py | 7 + src/qca/forward/types/template.py | 2 + .../forward/types/template_create_params.py | 2 + .../forward/types/template_update_params.py | 2 + tests/fixtures/api-contracts.json | 48 ++ tests/integration/test_drive.py | 178 ++++++ tests/test_drive.py | 201 ++++++ tests/test_resource_surface.py | 2 +- 22 files changed, 1546 insertions(+), 1 deletion(-) create mode 100644 examples/forward/drive.py create mode 100644 src/qca/forward/resources/drives.py create mode 100644 src/qca/forward/types/drive.py create mode 100644 src/qca/forward/types/managed_tool_config.py create mode 100644 src/qca/forward/types/managed_tool_config_param.py create mode 100644 tests/integration/test_drive.py create mode 100644 tests/test_drive.py diff --git a/.env.live.example b/.env.live.example index 48f6b89..db1bda3 100644 --- a/.env.live.example +++ b/.env.live.example @@ -51,3 +51,8 @@ QODER_REALTIME_GLOBAL_SAT= # Optional raw PCM16 input (16 kHz mono little-endian); otherwise sends short text. # QODER_REALTIME_CN_PCM_FILE= # QODER_REALTIME_GLOBAL_PCM_FILE= + +# Forward Drive Beta lifecycle (creates isolated Identities; no model execution). +# Uses QODER_FORWARD_CN_PAT, QODER_FORWARD_GLOBAL_PAT, QODER_SA_CN_KEY, +# and QODER_SA_GLOBAL_KEY. Never point clear at a shared Identity. +QODER_DRIVE_LIVE_ALLOW_WRITE=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 750431a..f9cdd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Forward Drive Beta: typed directory listing, upload/download signed URLs, entry deletion and Drive clearing, with required Identity scope, independent cursor pagination, service ordering and canonical directory query encoding. +- Template `managed_tool_config.enabled_tools` in create/update requests and responses, including omission, empty-list and null semantics. Runnable Drive examples keep storage requests separate from QCA authentication; opt-in CN/Global lifecycle tests cover PAT, admin SAT and Identity SAT. + ## [0.5.0] ### Added diff --git a/Makefile b/Makefile index e035134..82b25c8 100644 --- a/Makefile +++ b/Makefile @@ -45,3 +45,7 @@ test-live-realtime: test-live-sa-tokens: QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/sa/test_tokens_live.py -m integration -v -s + +.PHONY: test-live-drive +test-live-drive: + QODER_DRIVE_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_drive.py -m integration -v -s diff --git a/README.md b/README.md index e0cdd0b..aa50764 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,57 @@ credential = client.vaults.credentials.retrieve("credential-id", vault_id="vault memory = client.memory_stores.memories.retrieve("memory-id", memory_store_id="store-id") ``` +## Forward Drive (Beta) + +Drive is a Beta API. It uses `entries` / `next_page_token`, not the ordinary +resource paginator. Pass `identity_id` even with an Identity SAT, preserve the +server order, and reuse a cursor only within the same Identity, directory and +authentication scope. Paths are relative; the SDK preserves slashes when encoding +the list query and leaves path validation to the service. + +Upload/download methods return a signed URL, method, headers and expiry. Send the +storage request with a separate HTTP client using only those returned headers; +do not attach QCA credentials or log signed URLs. Replaying an `Idempotency-Key` +does not renew the URL: use a new key or omit it when requesting a fresh URL. + +Deleting a nonempty directory requires `recursive=true`; `clear` deletes the +entire selected Identity Drive. Partial failures retain `drive_delete_partial` +and `deleted_count` in the standard API error body. Direct Drive APIs do not +require Template tool configuration. + +```python +page = client.drives.list_entries(identity_id=identity_id, path="reports", limit=20) +for entry in page.entries: + print(entry.path) +if page.next_page_token: + page = client.drives.list_entries( + identity_id=identity_id, path="reports", page_token=page.next_page_token, + ) +# Replace the complete managed-tool list, retaining any other tools you need. +client.templates.update(template_id, managed_tool_config={"enabled_tools": ["drive"]}) +``` + +`AsyncForward` exposes the same methods with `await`. Run the +[file lifecycle example](examples/forward/drive.py) with +`python -m examples.forward.drive --region cn` (or `global`); it uses the shared +PAT/SA configuration and creates a fresh Identity. A directory has `size=None`; +a zero-byte file has `size=0`. +For maintainers, `uv run make test-live-drive` tests CN/Global × PAT/admin +SAT/Identity SAT with both sync and async clients. + +To enable Agent-managed Drive tools, use Template `managed_tool_config.enabled_tools`. +It is separate from `tools[].enabled_tools` and replaces the entire list; an empty +list disables managed tools, omission preserves the setting, and `null` clears it. +Full Agent Drive usage also requires the DeliverArtifacts tool. Internal +`/forward-mcp` routes are invoked by the runtime and are not public SDK methods. + +The Drive live suite requires `QODER_DRIVE_LIVE_ALLOW_WRITE=true`, +`QODER_FORWARD_CN_PAT`, `QODER_FORWARD_GLOBAL_PAT`, `QODER_SA_CN_KEY`, and +`QODER_SA_GLOBAL_KEY` in `.env.live` (or `LIVE_ENV_FILE`). It creates isolated +Identities, verifies storage transfers, pagination, deletion, Identity isolation, +and Template configuration, then clears only those new Identities. It does not +run Agent/model tool execution. + ## File uploads and downloads Uploads accept `bytes`, a binary file object, a `Path`, or a `(filename, content[, content type])` tuple. Content is buffered so it can be replayed on retry; a file object you open stays yours to close. diff --git a/docs/api/reference.md b/docs/api/reference.md index 4d32912..cf3ab65 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### drives + +```python +@cached_property +def drives() -> Drives +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### realtime @@ -2263,6 +2274,17 @@ class AsyncForward(AsyncAPIClient) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + + +#### drives + +```python +@cached_property +def drives() -> AsyncDrives +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py) + #### realtime @@ -3301,6 +3323,244 @@ async def retrieve( GET /qr_sessions/{session_key}. + + +# qca.forward.resources.drives + + + +## Drives + +```python +class Drives(SyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +Persistent Identity files (Beta). Identity SATs also require identity_id. + + + +#### list\_entries + +```python +def list_entries( + *, + identity_id: str, + path: str | NotGiven = NOT_GIVEN, + limit: int | NotGiven = NOT_GIVEN, + page_token: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveListEntriesResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path. + + + +#### create\_upload\_url + +```python +def create_upload_url( + *, + identity_id: str, + path: str, + content_type: str | NotGiven = NOT_GIVEN, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveUploadURL +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage. + + + +#### create\_download\_url + +```python +def create_download_url( + *, + identity_id: str, + path: str, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveDownloadURL +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it. + + + +#### delete\_entry + +```python +def delete_entry( + *, + identity_id: str, + path: str, + recursive: bool | 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 +) -> DriveDeleteEntryResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count. + + + +#### clear + +```python +def clear( + *, + identity_id: str, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveClearResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +DELETE /drives. Deletes all files in the selected Identity Drive. + + + +## AsyncDrives + +```python +class AsyncDrives(AsyncAPIResource) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +Persistent Identity files (Beta). Identity SATs also require identity_id. + + + +#### list\_entries + +```python +async def list_entries( + *, + identity_id: str, + path: str | NotGiven = NOT_GIVEN, + limit: int | NotGiven = NOT_GIVEN, + page_token: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveListEntriesResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path. + + + +#### create\_upload\_url + +```python +async def create_upload_url( + *, + identity_id: str, + path: str, + content_type: str | NotGiven = NOT_GIVEN, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveUploadURL +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage. + + + +#### create\_download\_url + +```python +async def create_download_url( + *, + identity_id: str, + path: str, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveDownloadURL +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it. + + + +#### delete\_entry + +```python +async def delete_entry( + *, + identity_id: str, + path: str, + recursive: bool | 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 +) -> DriveDeleteEntryResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count. + + + +#### clear + +```python +async def clear( + *, + identity_id: str, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN +) -> DriveClearResponse +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/drives.py) + +DELETE /drives. Deletes all files in the selected Identity Drive. + # qca.forward.resources.environments @@ -8477,6 +8737,8 @@ def create( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, + NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, @@ -8534,6 +8796,8 @@ def update( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, + NotGiven] = NOT_GIVEN, environment_id: Union[str, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, @@ -8647,6 +8911,8 @@ async def create( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, + NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, @@ -8704,6 +8970,8 @@ async def update( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, + NotGiven] = NOT_GIVEN, environment_id: Union[str, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, @@ -10349,6 +10617,268 @@ class DeletedSkillVersion(BaseModel) #### deleted + + +# qca.forward.types.drive + + + +## DriveEntry + +```python +class DriveEntry(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### path + + + +#### name + + + +#### type + + + +#### size + + + +#### etag + + + +#### last\_modified + + + +## DriveListEntriesResponse + +```python +class DriveListEntriesResponse(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + +One page in service order; pass next_page_token unchanged to continue. + + + +#### entries + + + +#### next\_page\_token + + + +## DriveUploadURL + +```python +class DriveUploadURL(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + +Signed storage URL valid for 15 minutes. Treat url as a credential. + + + +#### url + + + +#### method + + + +#### headers + + + +#### expires\_at + + + +## DriveDownloadURL + +```python +class DriveDownloadURL(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### url + + + +#### method + + + +#### headers + + + +#### expires\_at + + + +#### size + + + +#### etag + + + +#### content\_type + + + +## DriveDeleteEntryResponse + +```python +class DriveDeleteEntryResponse(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### deleted\_count + + + +## DriveClearResponse + +```python +class DriveClearResponse(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### deleted\_count + + + +## DriveListEntriesParams + +```python +class DriveListEntriesParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### identity\_id + + + +#### path + + + +#### limit + + + +#### page\_token + + + +## DriveCreateUploadURLParams + +```python +class DriveCreateUploadURLParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### identity\_id + + + +#### path + + + +#### content\_type + + + +#### idempotency\_key + + + +## DriveCreateDownloadURLParams + +```python +class DriveCreateDownloadURLParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### identity\_id + + + +#### path + + + +#### idempotency\_key + + + +## DriveDeleteEntryParams + +```python +class DriveDeleteEntryParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### identity\_id + + + +#### path + + + +#### recursive + + + +## DriveClearParams + +```python +class DriveClearParams(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/drive.py) + + + +#### identity\_id + # qca.forward.types.effective\_config @@ -11645,6 +12175,46 @@ class ImageSourceParam(TypedDict) #### file\_id + + +# qca.forward.types.managed\_tool\_config + + + +## ManagedToolConfig + +```python +class ManagedToolConfig(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/managed_tool_config.py) + +Forward-managed tools, distinct from tools[].enabled_tools. + + + +#### enabled\_tools + + + +# qca.forward.types.managed\_tool\_config\_param + + + +## ManagedToolConfigParam + +```python +class ManagedToolConfigParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/managed_tool_config_param.py) + +Replaces the complete enabled list; [] disables all managed tools. + + + +#### enabled\_tools + # qca.forward.types.mcp\_server @@ -15453,6 +16023,10 @@ class Template(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template.py) + + +#### managed\_tool\_config + #### type @@ -15587,6 +16161,10 @@ class TemplateCreateParams(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template_create_params.py) + + +#### managed\_tool\_config + #### name @@ -15695,6 +16273,10 @@ class TemplateUpdateParams(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/template_update_params.py) + + +#### managed\_tool\_config + #### name diff --git a/examples/forward/drive.py b/examples/forward/drive.py new file mode 100644 index 0000000..8d040cc --- /dev/null +++ b/examples/forward/drive.py @@ -0,0 +1,61 @@ +"""Drive Beta: upload, page through a directory, download and delete a file. + +Run: python -m examples.forward.drive +Uses the shared PAT / SA example configuration and a new, isolated Identity. +""" + +from __future__ import annotations + +import httpx + +from examples.common.live import Run, name, run_cli +from qca import Forward + + +def run(client: Forward, context: Run) -> None: + identity = client.identities.create(external_id=name("drive-identity")) + context.track("identity", identity.id, lambda: client.identities.delete(identity.id)) + path = "sdk-example/说明.txt" + context.track( + "drive directory", + "sdk-example", + lambda: client.drives.delete_entry(identity_id=identity.id, path="sdk-example", recursive=True), + ) + # A separate HTTP client sends only storage headers, never the QCA credential. + with httpx.Client(timeout=30, follow_redirects=False) as storage: + signed = client.drives.create_upload_url(identity_id=identity.id, path=path, content_type="text/plain") + try: + uploaded = storage.request( + signed.method, signed.url, headers=signed.headers, content=b"Hello from QCA Drive" + ) + except httpx.HTTPError: + raise RuntimeError("Storage upload failed") from None + if not uploaded.is_success: + raise RuntimeError(f"Storage upload HTTP {uploaded.status_code}") + cursor = None + while True: + page = client.drives.list_entries( + identity_id=identity.id, + path="sdk-example", + limit=20, + **({"page_token": cursor} if cursor else {}), + ) + for entry in page.entries: + context.output("drive_entry", {"path": entry.path, "type": entry.type, "size": entry.size}) + cursor = page.next_page_token + if not cursor: + break + signed = client.drives.create_download_url(identity_id=identity.id, path=path) + try: + downloaded = storage.request(signed.method, signed.url, headers=signed.headers) + except httpx.HTTPError: + raise RuntimeError("Storage download failed") from None + if not downloaded.is_success: + raise RuntimeError(f"Storage download HTTP {downloaded.status_code}") + context.output("download", downloaded.text) + deleted = client.drives.delete_entry(identity_id=identity.id, path=path) + context.output("deleted_count", deleted.deleted_count) + + +if __name__ == "__main__": + run_cli("forward", Forward, {"drive": run}) diff --git a/src/qca/common/_base_client.py b/src/qca/common/_base_client.py index d5e5106..44eb7a4 100644 --- a/src/qca/common/_base_client.py +++ b/src/qca/common/_base_client.py @@ -364,6 +364,8 @@ def request( if stream: args["headers"]["Accept"] = "text/event-stream" request = self._client.build_request(**args) + if serializer := options.get("query_serializer"): + request.url = request.url.copy_with(query=serializer(request.url.params.multi_items()).encode("ascii")) request.extensions["qca_explicit_auth"] = any( key.lower() == "authorization" for key in (*self.default_headers, *options.get("headers", {})) ) @@ -498,6 +500,8 @@ async def request( if stream: args["headers"]["Accept"] = "text/event-stream" request = self._client.build_request(**args) + if serializer := options.get("query_serializer"): + request.url = request.url.copy_with(query=serializer(request.url.params.multi_items()).encode("ascii")) request.extensions["qca_explicit_auth"] = any( key.lower() == "authorization" for key in (*self.default_headers, *options.get("headers", {})) ) diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py index edc095c..437fe84 100644 --- a/src/qca/forward/_client.py +++ b/src/qca/forward/_client.py @@ -7,6 +7,7 @@ from .resources.batches.batches import AsyncBatches, Batches from .resources.channel_pairings import AsyncChannelPairings, ChannelPairings from .resources.channels.channels import AsyncChannels, Channels +from .resources.drives import AsyncDrives, Drives from .resources.environments import AsyncEnvironments, Environments from .resources.files import AsyncFiles, Files from .resources.identities.identities import AsyncIdentities, Identities @@ -24,6 +25,10 @@ class Forward(SyncAPIClient): + @cached_property + def drives(self) -> Drives: + return Drives(self) + _default_base_url = "https://api.qoder.com/api/v1/forward/" _base_url_env = "QODER_FORWARD_BASE_URL" @@ -97,6 +102,10 @@ def models(self) -> Models: class AsyncForward(AsyncAPIClient): + @cached_property + def drives(self) -> AsyncDrives: + return AsyncDrives(self) + _default_base_url = "https://api.qoder.com/api/v1/forward/" _base_url_env = "QODER_FORWARD_BASE_URL" diff --git a/src/qca/forward/resources/__init__.py b/src/qca/forward/resources/__init__.py index fc034db..cc2234f 100644 --- a/src/qca/forward/resources/__init__.py +++ b/src/qca/forward/resources/__init__.py @@ -1,5 +1,7 @@ from .channel_pairings import AsyncChannelPairings as AsyncChannelPairings from .channel_pairings import ChannelPairings as ChannelPairings +from .drives import AsyncDrives as AsyncDrives +from .drives import Drives as Drives from .environments import AsyncEnvironments as AsyncEnvironments from .environments import Environments as Environments from .files import AsyncFiles as AsyncFiles diff --git a/src/qca/forward/resources/drives.py b/src/qca/forward/resources/drives.py new file mode 100644 index 0000000..b303ba0 --- /dev/null +++ b/src/qca/forward/resources/drives.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote_plus + +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 +from qca.forward.types.drive import ( + DriveClearResponse, + DriveDeleteEntryResponse, + DriveDownloadURL, + DriveListEntriesResponse, + DriveUploadURL, +) + + +def _drive_query(pairs: list[tuple[str, str]]) -> str: + # Only path uses literal slashes; opaque cursor values stay fully encoded. + return "&".join( + quote_plus(key) + "=" + quote_plus(value, safe="/" if key == "path" else "") for key, value in pairs + ) + + +class Drives(SyncAPIResource): + """Persistent Identity files (Beta). Identity SATs also require identity_id.""" + + def list_entries( + self, + *, + identity_id: str, + path: str | NotGiven = NOT_GIVEN, + limit: int | NotGiven = NOT_GIVEN, + page_token: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveListEntriesResponse: + """GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path.""" + options = make_request_options( + body={}, + query={"identity_id": identity_id, "path": path, "limit": limit, "page_token": page_token}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + options["query_serializer"] = _drive_query + return self._client.request("GET", "/drives/entries", cast_to=DriveListEntriesResponse, options=options) + + def create_upload_url( + self, + *, + identity_id: str, + path: str, + content_type: str | NotGiven = NOT_GIVEN, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveUploadURL: + """POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage.""" + options = make_request_options( + body={"path": path, "content_type": content_type}, + query={"identity_id": identity_id}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("POST", "/drives/upload-url", cast_to=DriveUploadURL, options=options) + + def create_download_url( + self, + *, + identity_id: str, + path: str, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveDownloadURL: + """POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it.""" + options = make_request_options( + body={"path": path}, + query={"identity_id": identity_id}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("POST", "/drives/download-url", cast_to=DriveDownloadURL, options=options) + + def delete_entry( + self, + *, + identity_id: str, + path: str, + recursive: bool | 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, + ) -> DriveDeleteEntryResponse: + """DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count.""" + options = make_request_options( + body={"path": path, "recursive": recursive}, + query={"identity_id": identity_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.request("DELETE", "/drives/entries", cast_to=DriveDeleteEntryResponse, options=options) + + def clear( + self, + *, + identity_id: str, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveClearResponse: + """DELETE /drives. Deletes all files in the selected Identity Drive.""" + options = make_request_options( + body={}, + query={"identity_id": identity_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return self._client.request("DELETE", "/drives", cast_to=DriveClearResponse, options=options) + + +class AsyncDrives(AsyncAPIResource): + """Persistent Identity files (Beta). Identity SATs also require identity_id.""" + + async def list_entries( + self, + *, + identity_id: str, + path: str | NotGiven = NOT_GIVEN, + limit: int | NotGiven = NOT_GIVEN, + page_token: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveListEntriesResponse: + """GET /drives/entries. Returns one page in service order; reuse next_page_token in the same scope/path.""" + options = make_request_options( + body={}, + query={"identity_id": identity_id, "path": path, "limit": limit, "page_token": page_token}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + options["query_serializer"] = _drive_query + return await self._client.request("GET", "/drives/entries", cast_to=DriveListEntriesResponse, options=options) + + async def create_upload_url( + self, + *, + identity_id: str, + path: str, + content_type: str | NotGiven = NOT_GIVEN, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveUploadURL: + """POST /drives/upload-url. Use only the returned headers when uploading; do not send API credentials to storage.""" + options = make_request_options( + body={"path": path, "content_type": content_type}, + query={"identity_id": identity_id}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("POST", "/drives/upload-url", cast_to=DriveUploadURL, options=options) + + async def create_download_url( + self, + *, + identity_id: str, + path: str, + idempotency_key: str | NotGiven = NOT_GIVEN, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveDownloadURL: + """POST /drives/download-url. Use a new Idempotency-Key or omit it to refresh expiry; replay does not renew it.""" + options = make_request_options( + body={"path": path}, + query={"identity_id": identity_id}, + headers={"Idempotency-Key": idempotency_key}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request("POST", "/drives/download-url", cast_to=DriveDownloadURL, options=options) + + async def delete_entry( + self, + *, + identity_id: str, + path: str, + recursive: bool | 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, + ) -> DriveDeleteEntryResponse: + """DELETE /drives/entries. Nonempty directories require recursive=True. Partial failures retain error.deleted_count.""" + options = make_request_options( + body={"path": path, "recursive": recursive}, + query={"identity_id": identity_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.request( + "DELETE", "/drives/entries", cast_to=DriveDeleteEntryResponse, options=options + ) + + async def clear( + self, + *, + identity_id: str, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + extra_body: dict[str, Any] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> DriveClearResponse: + """DELETE /drives. Deletes all files in the selected Identity Drive.""" + options = make_request_options( + body={}, + query={"identity_id": identity_id}, + headers={}, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + if extra_body is None: + options["body"] = None + return await self._client.request("DELETE", "/drives", cast_to=DriveClearResponse, options=options) diff --git a/src/qca/forward/resources/templates.py b/src/qca/forward/resources/templates.py index 2c1e957..271710b 100644 --- a/src/qca/forward/resources/templates.py +++ b/src/qca/forward/resources/templates.py @@ -9,6 +9,7 @@ from qca.common._utils import make_request_options, path_template from qca.common.pagination import AsyncPaginator, SyncPage from qca.forward.types.git_hub_repository_param import GitHubRepositoryParam +from qca.forward.types.managed_tool_config_param import ManagedToolConfigParam from qca.forward.types.mcp_server_param import MCPServerParam from qca.forward.types.model_config_param import ModelConfigParam from qca.forward.types.multiagent_config_param import MultiagentConfigParam @@ -58,6 +59,7 @@ def create( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, github_repositories: Union[Dict[str, GitHubRepositoryParam], None, NotGiven] = NOT_GIVEN, @@ -83,6 +85,7 @@ def create( "mcp_servers": mcp_servers, "skills": skills, "multiagent": multiagent, + "managed_tool_config": managed_tool_config, "vaults": vaults, "files": files, "github_repositories": github_repositories, @@ -132,6 +135,7 @@ def update( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN, environment_id: Union[str, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, @@ -157,6 +161,7 @@ def update( "mcp_servers": mcp_servers, "skills": skills, "multiagent": multiagent, + "managed_tool_config": managed_tool_config, "environment_id": environment_id, "vaults": vaults, "files": files, @@ -262,6 +267,7 @@ async def create( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, github_repositories: Union[Dict[str, GitHubRepositoryParam], None, NotGiven] = NOT_GIVEN, @@ -287,6 +293,7 @@ async def create( "mcp_servers": mcp_servers, "skills": skills, "multiagent": multiagent, + "managed_tool_config": managed_tool_config, "vaults": vaults, "files": files, "github_repositories": github_repositories, @@ -336,6 +343,7 @@ async def update( mcp_servers: Union[List[MCPServerParam], None, NotGiven] = NOT_GIVEN, skills: Union[List[SkillBindingParam], None, NotGiven] = NOT_GIVEN, multiagent: Union[MultiagentConfigParam, None, NotGiven] = NOT_GIVEN, + managed_tool_config: Union[ManagedToolConfigParam, None, NotGiven] = NOT_GIVEN, environment_id: Union[str, None, NotGiven] = NOT_GIVEN, vaults: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, files: Union[Dict[str, ResourceBindingParam], None, NotGiven] = NOT_GIVEN, @@ -361,6 +369,7 @@ async def update( "mcp_servers": mcp_servers, "skills": skills, "multiagent": multiagent, + "managed_tool_config": managed_tool_config, "environment_id": environment_id, "vaults": vaults, "files": files, diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py index 98fb9bc..029381b 100644 --- a/src/qca/forward/types/__init__.py +++ b/src/qca/forward/types/__init__.py @@ -37,6 +37,17 @@ from .deleted_memory_store import DeletedMemoryStore as DeletedMemoryStore from .deleted_memory_store_mount import DeletedMemoryStoreMount as DeletedMemoryStoreMount from .deleted_skill_version import DeletedSkillVersion as DeletedSkillVersion +from .drive import DriveClearParams as DriveClearParams +from .drive import DriveClearResponse as DriveClearResponse +from .drive import DriveCreateDownloadURLParams as DriveCreateDownloadURLParams +from .drive import DriveCreateUploadURLParams as DriveCreateUploadURLParams +from .drive import DriveDeleteEntryParams as DriveDeleteEntryParams +from .drive import DriveDeleteEntryResponse as DriveDeleteEntryResponse +from .drive import DriveDownloadURL as DriveDownloadURL +from .drive import DriveEntry as DriveEntry +from .drive import DriveListEntriesParams as DriveListEntriesParams +from .drive import DriveListEntriesResponse as DriveListEntriesResponse +from .drive import DriveUploadURL as DriveUploadURL from .effective_config import EffectiveConfig as EffectiveConfig from .effective_config_agent import EffectiveConfigAgent as EffectiveConfigAgent from .effective_config_agent_tools_item import EffectiveConfigAgentToolsItem as EffectiveConfigAgentToolsItem @@ -79,6 +90,8 @@ from .identity_update_params import IdentityUpdateParams as IdentityUpdateParams from .identity_usage import IdentityUsage as IdentityUsage from .image_source_param import ImageSourceParam as ImageSourceParam +from .managed_tool_config import ManagedToolConfig as ManagedToolConfig +from .managed_tool_config_param import ManagedToolConfigParam as ManagedToolConfigParam from .mcp_server import MCPServer as MCPServer from .mcp_server_override import MCPServerOverride as MCPServerOverride from .mcp_server_override_param import MCPServerOverrideParam as MCPServerOverrideParam diff --git a/src/qca/forward/types/drive.py b/src/qca/forward/types/drive.py new file mode 100644 index 0000000..8434525 --- /dev/null +++ b/src/qca/forward/types/drive.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from typing_extensions import Required, TypedDict + +from qca.common._models import BaseModel + + +class DriveEntry(BaseModel): + path: str + name: str + type: Literal["file", "directory"] + # Zero for an empty file; absent for directories. + size: int | None = None + etag: str | None = None + last_modified: datetime | None = None + + +class DriveListEntriesResponse(BaseModel): + """One page in service order; pass next_page_token unchanged to continue.""" + + entries: list[DriveEntry] + next_page_token: str | None = None + + +class DriveUploadURL(BaseModel): + """Signed storage URL valid for 15 minutes. Treat url as a credential.""" + + url: str + method: Literal["PUT"] + headers: dict[str, str] + expires_at: datetime + + +class DriveDownloadURL(BaseModel): + url: str + method: Literal["GET"] + headers: dict[str, str] + expires_at: datetime + # May be omitted for zero-byte files. + size: int | None = None + etag: str | None = None + content_type: str | None = None + + +class DriveDeleteEntryResponse(BaseModel): + deleted_count: int + + +class DriveClearResponse(BaseModel): + deleted_count: int + + +class DriveListEntriesParams(TypedDict, total=False): + identity_id: Required[str] + path: str + limit: int + page_token: str + + +class DriveCreateUploadURLParams(TypedDict, total=False): + identity_id: Required[str] + path: Required[str] + content_type: str + idempotency_key: str + + +class DriveCreateDownloadURLParams(TypedDict, total=False): + identity_id: Required[str] + path: Required[str] + idempotency_key: str + + +class DriveDeleteEntryParams(TypedDict, total=False): + identity_id: Required[str] + path: Required[str] + recursive: bool + + +class DriveClearParams(TypedDict): + identity_id: str diff --git a/src/qca/forward/types/managed_tool_config.py b/src/qca/forward/types/managed_tool_config.py new file mode 100644 index 0000000..d673cdc --- /dev/null +++ b/src/qca/forward/types/managed_tool_config.py @@ -0,0 +1,7 @@ +from qca.common._models import BaseModel + + +class ManagedToolConfig(BaseModel): + """Forward-managed tools, distinct from tools[].enabled_tools.""" + + enabled_tools: list[str] diff --git a/src/qca/forward/types/managed_tool_config_param.py b/src/qca/forward/types/managed_tool_config_param.py new file mode 100644 index 0000000..8e6ffdd --- /dev/null +++ b/src/qca/forward/types/managed_tool_config_param.py @@ -0,0 +1,7 @@ +from typing_extensions import TypedDict + + +class ManagedToolConfigParam(TypedDict): + """Replaces the complete enabled list; [] disables all managed tools.""" + + enabled_tools: list[str] diff --git a/src/qca/forward/types/template.py b/src/qca/forward/types/template.py index 46a9edf..92d14cf 100644 --- a/src/qca/forward/types/template.py +++ b/src/qca/forward/types/template.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from .git_hub_repository import GitHubRepository + from .managed_tool_config import ManagedToolConfig from .mcp_server import MCPServer from .model_config import ModelConfig from .multiagent_config import MultiagentConfig @@ -18,6 +19,7 @@ class Template(BaseModel): + managed_tool_config: Optional[ManagedToolConfig] = None type: Optional[str] = None id: Optional[str] = None name: Optional[str] = None diff --git a/src/qca/forward/types/template_create_params.py b/src/qca/forward/types/template_create_params.py index 1ffbe00..0e652d7 100644 --- a/src/qca/forward/types/template_create_params.py +++ b/src/qca/forward/types/template_create_params.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from .git_hub_repository_param import GitHubRepositoryParam + from .managed_tool_config_param import ManagedToolConfigParam from .mcp_server_param import MCPServerParam from .model_config_param import ModelConfigParam from .multiagent_config_param import MultiagentConfigParam @@ -17,6 +18,7 @@ class TemplateCreateParams(TypedDict, total=False): + managed_tool_config: Optional[ManagedToolConfigParam] name: Required[str] model: Required[Union[str, ModelConfigParam]] environment_id: Required[str] diff --git a/src/qca/forward/types/template_update_params.py b/src/qca/forward/types/template_update_params.py index 6c7ab7b..64f8b4d 100644 --- a/src/qca/forward/types/template_update_params.py +++ b/src/qca/forward/types/template_update_params.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from .git_hub_repository_param import GitHubRepositoryParam + from .managed_tool_config_param import ManagedToolConfigParam from .mcp_server_param import MCPServerParam from .model_config_param import ModelConfigParam from .multiagent_config_param import MultiagentConfigParam @@ -17,6 +18,7 @@ class TemplateUpdateParams(TypedDict, total=False): + managed_tool_config: Optional[ManagedToolConfigParam] name: Optional[str] description: Optional[str] model: Union[str, ModelConfigParam] diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json index 2359db4..b38d33a 100644 --- a/tests/fixtures/api-contracts.json +++ b/tests/fixtures/api-contracts.json @@ -1988,6 +1988,54 @@ ], "kind": "model", "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke" + }, + { + "id": "forward.drives.list_entries", + "method": "GET", + "path": "/drives/entries", + "required": [ + "identity_id" + ], + "kind": "model" + }, + { + "id": "forward.drives.create_upload_url", + "method": "POST", + "path": "/drives/upload-url", + "required": [ + "identity_id", + "path" + ], + "kind": "model" + }, + { + "id": "forward.drives.create_download_url", + "method": "POST", + "path": "/drives/download-url", + "required": [ + "identity_id", + "path" + ], + "kind": "model" + }, + { + "id": "forward.drives.delete_entry", + "method": "DELETE", + "path": "/drives/entries", + "required": [ + "identity_id", + "path" + ], + "kind": "model" + }, + { + "id": "forward.drives.clear", + "method": "DELETE", + "path": "/drives", + "required": [ + "identity_id" + ], + "kind": "model" } ], "extensions": [ diff --git a/tests/integration/test_drive.py b/tests/integration/test_drive.py new file mode 100644 index 0000000..9d33175 --- /dev/null +++ b/tests/integration/test_drive.py @@ -0,0 +1,178 @@ +"""Opt-in Drive lifecycle on isolated Identities; never clears an existing Drive.""" + +from __future__ import annotations + +import inspect +import os +from contextlib import AsyncExitStack +from uuid import uuid4 + +import httpx +import pytest + +from qca import APIStatusError, AsyncForward, Forward +from tests.support.harness import read_env + +pytestmark = pytest.mark.integration + + +async def call(method, *args, **kwargs): + result = method(*args, **kwargs) + return await result if inspect.isawaitable(result) else result + + +async def refused(action, status): + try: + await action() + except APIStatusError as error: + assert error.status_code == status + else: + raise AssertionError(f"Expected HTTP {status}") + + +def safe_error(error): + if isinstance(error, APIStatusError): + return f"HTTP {error.status_code} code={error.code} request_id={error.request_id}" + # Never include storage URLs or token responses in failures. + return type(error).__name__ + + +@pytest.mark.parametrize("region", ["cn", "global"]) +@pytest.mark.parametrize("auth", ["pat", "admin_sat", "identity_sat"]) +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +async def test_drive_lifecycle(region, auth, async_mode): + values = {**read_env(os.environ.get("QODER_LIVE_ENV_FILE", ".env.live")), **os.environ} + if values.get("QODER_DRIVE_RUN_LIVE") != "1": + pytest.skip("Set QODER_DRIVE_RUN_LIVE=1 to run Drive tests") + assert values.get("QODER_DRIVE_LIVE_ALLOW_WRITE") == "true" + host = "https://api.qoder.com.cn" if region == "cn" else "https://api.qoder.com" + Client = AsyncForward if async_mode else Forward + options = dict(base_url=f"{host}/api/v1/forward/", max_retries=0, timeout=30, _strict_response_validation=True) + cleanups = [] + stage = "setup" + async with AsyncExitStack() as stack: + + async def client(**authentication): + instance = Client(**options, **authentication) + return await stack.enter_async_context(instance) if async_mode else stack.enter_context(instance) + + try: + issuer = await client(auth_mode="sa") + key = values.get(f"QODER_SA_{region.upper()}_KEY", "") + + async def issue(identity_id=None): + assert key, f"QODER_SA_{region.upper()}_KEY is required" + params = dict(sa_key=key, ttl_seconds=600) + token = ( + await call(issuer.identities.service_account_tokens.create, identity_id, **params) + if identity_id + else await call(issuer.service_account_tokens.create, **params) + ) + cleanups.append( + lambda: call( + issuer.service_account_tokens.revoke, + token.auth_token_id, + sa_key=key, + target_credential_id=token.credential_id, + target_expires_at=token.expires_at, + ) + ) + return await client(auth_mode="sa", access_token=token.access_token) + + if auth == "pat": + pat = values.get(f"QODER_FORWARD_{region.upper()}_PAT") + assert pat, f"QODER_FORWARD_{region.upper()}_PAT is required" + admin = await client(auth_mode="pat", pat=pat) + else: + admin = await issue() + identity = await call(admin.identities.create, external_id=f"sdk-drive-{uuid4().hex}") + cleanups.append(lambda: call(admin.identities.delete, identity.id)) + # This ID was created above and is never supplied by the operator. + cleanups.append(lambda: call(admin.drives.clear, identity_id=identity.id)) + runtime = await issue(identity.id) if auth == "identity_sat" else admin + args = dict(identity_id=identity.id) + print(f"Drive {region}/{auth}/{'async' if async_mode else 'sync'} identity_id={identity.id}", flush=True) + assert (await call(runtime.drives.list_entries, **args)).entries == [] + + stage = "upload" + async with httpx.AsyncClient(timeout=30, follow_redirects=False) as storage: + paths = ["sdk 目录/a+b.txt", "sdk 目录/zero.txt", "sdk 目录/nested/child.txt"] + content = b"SDK Drive verification" + for path in paths: + signed = await call(runtime.drives.create_upload_url, **args, path=path, content_type="text/plain") + assert signed.method == "PUT" + assert not any(key.lower() == "authorization" for key in signed.headers) + response = await storage.request( + signed.method, signed.url, headers=signed.headers, content=b"" if "zero" in path else content + ) + assert 200 <= response.status_code < 300, f"Storage PUT HTTP {response.status_code}" + stage = "pagination" + entries, seen = [], set() + cursor = None + while True: + page = await call( + runtime.drives.list_entries, + **args, + path="sdk 目录", + limit=1, + **({"page_token": cursor} if cursor else {}), + ) + entries.extend(page.entries) + cursor = page.next_page_token + if not cursor: + break + assert cursor not in seen, "Repeated cursor" + seen.add(cursor) + assert len(seen) < 10, "Pagination did not finish" + assert {entry.path for entry in entries} == {paths[0], paths[1], "sdk 目录/nested"} + assert next(entry for entry in entries if entry.path == paths[1]).size == 0 + assert next(entry for entry in entries if entry.type == "directory").size is None + stage = "download" + signed = await call(runtime.drives.create_download_url, **args, path=paths[0]) + assert signed.method == "GET" + assert not any(key.lower() == "authorization" for key in signed.headers) + response = await storage.request(signed.method, signed.url, headers=signed.headers) + assert response.status_code == 200 and response.content == content, "Storage download differs" + stage = "deletion" + await refused(lambda: call(runtime.drives.delete_entry, **args, path="sdk 目录"), 409) + assert (await call(runtime.drives.delete_entry, **args, path=paths[0])).deleted_count == 1 + assert (await call(runtime.drives.delete_entry, **args, path=paths[0])).deleted_count == 0 + assert ( + await call(runtime.drives.delete_entry, **args, path="sdk 目录/nested", recursive=True) + ).deleted_count == 1 + assert (await call(runtime.drives.clear, **args)).deleted_count == 1 + assert (await call(runtime.drives.clear, **args)).deleted_count == 0 + if auth == "identity_sat": + stage = "Identity isolation" + other = await call(admin.identities.create, external_id=f"sdk-drive-other-{uuid4().hex}") + cleanups.append(lambda: call(admin.identities.delete, other.id)) + await refused(lambda: call(runtime.drives.list_entries, identity_id=other.id), 403) + else: + stage = "Template managed tools" + env = await call( + admin.environments.create, name=f"sdk-drive-{uuid4().hex[:12]}", config={"type": "cloud"} + ) + cleanups.append(lambda: call(admin.environments.archive, env.id)) + template = await call( + admin.templates.create, + name=f"sdk-drive-{uuid4().hex[:12]}", + model="auto", + environment_id=env.id, + managed_tool_config={"enabled_tools": ["drive"]}, + ) + cleanups.append(lambda: call(admin.templates.archive, template.id)) + current = await call(admin.templates.retrieve, template.id) + assert current.managed_tool_config.enabled_tools == ["drive"] + current = await call(admin.templates.update, template.id, managed_tool_config={"enabled_tools": []}) + assert current.managed_tool_config is None or current.managed_tool_config.enabled_tools == [] + except Exception as error: + # Suppress exception chains that can contain signed URLs / credentials. + pytest.fail(f"{stage}: {safe_error(error)}", pytrace=False) + finally: + errors = [] + for action in reversed(cleanups): + try: + await action() + except Exception as error: + errors.append(safe_error(error)) + assert not errors, f"Drive cleanup failed: {errors}" diff --git a/tests/test_drive.py b/tests/test_drive.py new file mode 100644 index 0000000..118ef21 --- /dev/null +++ b/tests/test_drive.py @@ -0,0 +1,201 @@ +"""Drive wire behavior from the public API contract, for both client modes.""" + +import inspect +import json + +import httpx +import pytest + +from qca import AsyncForward, Forward, InternalServerError + + +async def resolve(value): + return await value if inspect.isawaitable(value) else value + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_drive_paths_pages_and_typed_metadata(async_): + requests = [] + cursor = "opaque/+%=&" + + def handler(request): + requests.append(request) + assert request.headers["authorization"] == "Bearer test-sat" + assert request.url.params["identity_id"] == "idn_test" + assert request.url.params["path"] == "目录/a b+~*" + assert b"path=%E7%9B%AE%E5%BD%95/a+b%2B~%2A" in request.url.query + assert b"extra=a%2Fb" in request.url.query + if len(requests) == 1: + return httpx.Response( + 200, + json={ + "entries": [ + {"path": "a.txt", "name": "a.txt", "type": "file", "size": 0}, + {"path": "a", "name": "a", "type": "directory"}, + ], + "next_page_token": cursor, + }, + ) + assert request.url.params["page_token"] == cursor + return httpx.Response(200, json={"entries": []}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler)) + client = (AsyncForward if async_ else Forward)( + auth_mode="sa", + access_token="test-sat", + http_client=http, + default_query={"extra": "a/b", "path": "replaced"}, + _strict_response_validation=True, + ) + try: + first = await resolve( + client.drives.list_entries( + identity_id="idn_test", path="replaced-again", extra_query={"path": "目录/a b+~*"} + ) + ) + assert [entry.path for entry in first.entries] == ["a.txt", "a"] + assert first.entries[0].size == 0 + assert first.entries[1].size is None + last = await resolve( + client.drives.list_entries(identity_id="idn_test", path="目录/a b+~*", page_token=first.next_page_token) + ) + assert last.entries == [] and last.next_page_token is None + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_drive_mutation_bodies_signed_headers_and_partial_error(async_): + requests = [] + + def handler(request): + requests.append(request) + assert request.url.params.multi_items() == [("identity_id", "idn_test")] + route = request.url.path.rsplit("/", 1)[-1] + if route.endswith("-url"): + assert request.method == "POST" + assert request.headers["Idempotency-Key"] == "retry-one" + assert json.loads(request.content) == ( + {"path": "reports/readme.txt", "content_type": "text/plain"} + if route == "upload-url" + else {"path": "reports/readme.txt"} + ) + return httpx.Response( + 200, + json={ + "url": "https://storage.test/signed", + "method": "PUT" if route == "upload-url" else "GET", + "headers": {"Content-Type": "text/plain"}, + "expires_at": "2026-10-14T02:15:00Z", + }, + ) + assert request.method == "DELETE" + if route == "entries": + assert json.loads(request.content) == {"path": "reports", "recursive": False} + return httpx.Response(200, json={"deleted_count": 0}) + assert request.content == b"" + return httpx.Response( + 503, + json={ + "request_id": "req_partial", + "error": {"code": "drive_delete_partial", "deleted_count": 7, "message": "partial"}, + }, + ) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler)) + client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http, max_retries=2) + try: + upload = await resolve( + client.drives.create_upload_url( + identity_id="idn_test", + path="reports/readme.txt", + content_type="text/plain", + idempotency_key="retry-one", + ) + ) + assert upload.headers == {"Content-Type": "text/plain"} and upload.method == "PUT" + download = await resolve( + client.drives.create_download_url( + identity_id="idn_test", path="reports/readme.txt", idempotency_key="retry-one" + ) + ) + assert download.method == "GET" and download.size is None + deleted = await resolve(client.drives.delete_entry(identity_id="idn_test", path="reports", recursive=False)) + assert deleted.deleted_count == 0 + with pytest.raises(InternalServerError) as raised: + await resolve(client.drives.clear(identity_id="idn_test")) + assert raised.value.code == "drive_delete_partial" + assert raised.value.request_id == "req_partial" + assert raised.value.body["error"]["deleted_count"] == 7 + assert len(requests) == 4 + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_template_managed_tools_preserves_omission_null_and_empty(async_): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"managed_tool_config": {"enabled_tools": ["drive", "schedule"]}}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler)) + client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http) + try: + await resolve(client.templates.update("tmpl_test")) + await resolve(client.templates.update("tmpl_test", managed_tool_config=None)) + await resolve(client.templates.update("tmpl_test", managed_tool_config={"enabled_tools": []})) + result = await resolve( + client.templates.create( + name="drive", + model="auto", + environment_id="env_test", + managed_tool_config={"enabled_tools": ["drive", "schedule"]}, + ) + ) + assert bodies[:3] == [{}, {"managed_tool_config": None}, {"managed_tool_config": {"enabled_tools": []}}] + assert bodies[3]["managed_tool_config"] == {"enabled_tools": ["drive", "schedule"]} + assert result.managed_tool_config.enabled_tools == ["drive", "schedule"] + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_drive_does_not_normalize_paths_or_clamp_limits(async_): + from qca import BadRequestError + + expected = {} + + def handler(request): + assert request.url.params["path"] == expected["path"] + assert request.url.params["limit"] == str(expected["limit"]) + assert request.url.params.get_list("identity_id") == ["idn_test"] + assert request.content == b"" + if expected["path"] == "" and expected["limit"] in (1, 100): + return httpx.Response(200, json={"entries": []}) + return httpx.Response(400, json={"error": {"code": "invalid_argument", "message": "invalid Drive query"}}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handler)) + client = (AsyncForward if async_ else Forward)(pat="test-pat", http_client=http) + try: + for limit in (1, 100): + expected.update(path="", limit=limit) + assert ( + await resolve(client.drives.list_entries(identity_id="idn_test", path="", limit=limit)) + ).entries == [] + for path, limit in [ + ("/absolute", 20), + ("../other", 20), + ("a\\b", 20), + ("a%2Fb", 20), + (" a ", 20), + ("", 0), + ("", 101), + ]: + expected.update(path=path, limit=limit) + with pytest.raises(BadRequestError) as raised: + await resolve(client.drives.list_entries(identity_id="idn_test", path=path, limit=limit)) + assert raised.value.code == "invalid_argument" + finally: + await resolve(client.close()) diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py index 163bd8d..5fd3568 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"]) == 216 + assert len(CONTRACT_FIXTURE["contracts"]) == 221 @pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)