From 109e49ccd646a77d72a55967052a70ea4165f8c7 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Tue, 15 Sep 2026 17:06:59 +0200 Subject: [PATCH 01/21] feat: proxy Anthropic Files through hybrid Otari Add opt-in provider-native file forwarding with uploader/workspace ownership, account-pinned Messages, and generated-output registration before exposure. Persist file lifecycle and cleanup work in the control plane, enforce transfer limits, and revoke bindings during credential and tenant changes. Include the migration, protocol documentation, generated API contracts, and targeted tests. Keep enablement gated on the any-llm Files dependency and hosted integration verification. Refs #984 Co-Authored-By: gpt-6-astra --- .../c3e5a7b9d1f4_add_provider_files.py | 196 +++ docs/files.md | 97 ++ docs/hybrid-mode-protocol.md | 129 ++ docs/public/openapi.json | 1343 ++++++++++++++++- docs/public/otari.postman_collection.json | 557 ++++++- scripts/generate_openapi.py | 88 +- scripts/sdk_codegen/sdk-endpoints.txt | 16 + src/gateway/api/main.py | 2 + src/gateway/api/routes/_pipeline.py | 2 + src/gateway/api/routes/_platform.py | 6 +- src/gateway/api/routes/chat.py | 11 + src/gateway/api/routes/hybrid_files.py | 323 ++++ src/gateway/api/routes/messages.py | 202 ++- src/gateway/api/routes/provider_files.py | 229 +++ src/gateway/api/routes/responses.py | 15 +- src/gateway/api/routes/users.py | 3 + src/gateway/core/config.py | 49 +- src/gateway/main.py | 9 +- src/gateway/models/__init__.py | 1 + src/gateway/models/provider_files.py | 99 ++ .../tenancy/provider_file_repository.py | 278 ++++ .../services/provider_files/__init__.py | 1 + .../services/provider_files/accounts.py | 131 ++ .../services/provider_files/cleanup.py | 167 ++ src/gateway/services/provider_files/client.py | 63 + .../services/provider_files/contracts.py | 180 +++ .../services/provider_files/executor.py | 41 + .../services/provider_files/inference.py | 128 ++ .../services/provider_files/lifecycle.py | 361 +++++ .../services/provider_files/outputs.py | 223 +++ .../services/provider_files/references.py | 46 + .../services/provider_files/transfers.py | 151 ++ .../services/provider_files/transport.py | 68 + .../tenancy/org_provider_key_service.py | 19 + .../services/tenancy/workspace_service.py | 8 + .../integration/test_hybrid_files_messages.py | 119 ++ tests/integration/test_hybrid_files_routes.py | 159 ++ .../test_hybrid_files_sdk_contract.py | 119 ++ .../test_provider_file_lifecycle.py | 268 ++++ .../test_provider_files_protocol.py | 93 ++ tests/unit/test_provider_file_accounts.py | 52 + tests/unit/test_provider_file_config.py | 21 + tests/unit/test_provider_file_migration.py | 32 + tests/unit/test_provider_file_outputs.py | 95 ++ tests/unit/test_provider_file_references.py | 68 + tests/unit/test_provider_file_transfers.py | 85 ++ web/src/client/schema.ts | 926 +++++++++++- 47 files changed, 7197 insertions(+), 82 deletions(-) create mode 100644 alembic/versions/c3e5a7b9d1f4_add_provider_files.py create mode 100644 src/gateway/api/routes/hybrid_files.py create mode 100644 src/gateway/api/routes/provider_files.py create mode 100644 src/gateway/models/provider_files.py create mode 100644 src/gateway/repositories/tenancy/provider_file_repository.py create mode 100644 src/gateway/services/provider_files/__init__.py create mode 100644 src/gateway/services/provider_files/accounts.py create mode 100644 src/gateway/services/provider_files/cleanup.py create mode 100644 src/gateway/services/provider_files/client.py create mode 100644 src/gateway/services/provider_files/contracts.py create mode 100644 src/gateway/services/provider_files/executor.py create mode 100644 src/gateway/services/provider_files/inference.py create mode 100644 src/gateway/services/provider_files/lifecycle.py create mode 100644 src/gateway/services/provider_files/outputs.py create mode 100644 src/gateway/services/provider_files/references.py create mode 100644 src/gateway/services/provider_files/transfers.py create mode 100644 src/gateway/services/provider_files/transport.py create mode 100644 tests/integration/test_hybrid_files_messages.py create mode 100644 tests/integration/test_hybrid_files_routes.py create mode 100644 tests/integration/test_hybrid_files_sdk_contract.py create mode 100644 tests/integration/test_provider_file_lifecycle.py create mode 100644 tests/integration/test_provider_files_protocol.py create mode 100644 tests/unit/test_provider_file_accounts.py create mode 100644 tests/unit/test_provider_file_config.py create mode 100644 tests/unit/test_provider_file_migration.py create mode 100644 tests/unit/test_provider_file_outputs.py create mode 100644 tests/unit/test_provider_file_references.py create mode 100644 tests/unit/test_provider_file_transfers.py diff --git a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py new file mode 100644 index 0000000000..77aaa964ae --- /dev/null +++ b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py @@ -0,0 +1,196 @@ +"""Add provider-native file bindings and cleanup operations. + +Revision ID: c3e5a7b9d1f4 +Revises: d5f8b2a4c6e9 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +revision: str = "c3e5a7b9d1f4" +down_revision: str | None = "d5f8b2a4c6e9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "provider_account_generations", + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False), + sa.Column("credential_source", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False), + sa.Column("credential_ref", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("upstream_identity_ciphertext", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False), + sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lease_id", sa.Uuid(), nullable=True), + sa.Column("lease_token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True), + sa.Column("lease_gateway_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("lease_deadline", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_id", + "credential_source", + "credential_ref", + "generation", + name="uq_provider_account_generation", + ), + ) + op.create_index( + op.f("ix_provider_account_generations_credential_ref"), + "provider_account_generations", + ["credential_ref"], + unique=False, + ) + op.create_index( + op.f("ix_provider_account_generations_organization_id"), + "provider_account_generations", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_provider_account_generations_status"), "provider_account_generations", ["status"], unique=False + ) + op.create_table( + "provider_file_rate_windows", + sa.Column("workspace_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("window", sa.Integer(), nullable=False), + sa.Column("count", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("workspace_id", "user_id"), + ) + op.create_table( + "provider_file_output_operations", + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("provider_account_generation_id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("workspace_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("initiating_gateway_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("attempt_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("cleanup_token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False), + sa.Column("deadline", sa.DateTime(timezone=True), nullable=False), + sa.Column("state", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False), + sa.Column("reserved_files", sa.Integer(), nullable=False), + sa.Column("reserved_bytes", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["provider_account_generation_id"], ["provider_account_generations.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_provider_file_output_operations_organization_id"), + "provider_file_output_operations", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_provider_file_output_operations_provider_account_generation_id"), + "provider_file_output_operations", + ["provider_account_generation_id"], + unique=False, + ) + op.create_index( + op.f("ix_provider_file_output_operations_user_id"), "provider_file_output_operations", ["user_id"], unique=False + ) + op.create_index( + op.f("ix_provider_file_output_operations_workspace_id"), + "provider_file_output_operations", + ["workspace_id"], + unique=False, + ) + op.create_table( + "provider_file_bindings", + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("provider_file_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("provider_account_generation_id", sa.Uuid(), nullable=False), + sa.Column("output_operation_id", sa.Uuid(), nullable=True), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("workspace_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("encrypted_metadata", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("downloadable", sa.Boolean(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("provider_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("operation_deadline", sa.DateTime(timezone=True), nullable=False), + sa.Column("initiating_gateway_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("cleanup_token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False), + sa.Column("provider_outcome_unknown", sa.Boolean(), nullable=False), + sa.Column("state", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False), + sa.Column("cleanup_reason", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=True), + sa.Column("cleanup_attempts", sa.Integer(), nullable=False), + sa.Column("cleanup_after", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lease_id", sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint(["output_operation_id"], ["provider_file_output_operations.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["provider_account_generation_id"], ["provider_account_generations.id"], ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("provider_account_generation_id", "provider_file_id", name="uq_provider_file_account_id"), + ) + op.create_index(op.f("ix_provider_file_bindings_lease_id"), "provider_file_bindings", ["lease_id"], unique=False) + op.create_index( + op.f("ix_provider_file_bindings_organization_id"), "provider_file_bindings", ["organization_id"], unique=False + ) + op.create_index( + op.f("ix_provider_file_bindings_output_operation_id"), + "provider_file_bindings", + ["output_operation_id"], + unique=False, + ) + op.create_index( + op.f("ix_provider_file_bindings_provider_account_generation_id"), + "provider_file_bindings", + ["provider_account_generation_id"], + unique=False, + ) + op.create_index(op.f("ix_provider_file_bindings_user_id"), "provider_file_bindings", ["user_id"], unique=False) + op.create_index( + op.f("ix_provider_file_bindings_workspace_id"), "provider_file_bindings", ["workspace_id"], unique=False + ) + op.create_index("ix_provider_files_cleanup", "provider_file_bindings", ["state", "cleanup_after"], unique=False) + op.create_index( + "ix_provider_files_owner_page", + "provider_file_bindings", + ["workspace_id", "user_id", "state", "created_at", "id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_provider_files_owner_page", table_name="provider_file_bindings") + op.drop_index("ix_provider_files_cleanup", table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_workspace_id"), table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_user_id"), table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_provider_account_generation_id"), table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_output_operation_id"), table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_organization_id"), table_name="provider_file_bindings") + op.drop_index(op.f("ix_provider_file_bindings_lease_id"), table_name="provider_file_bindings") + op.drop_table("provider_file_bindings") + op.drop_index(op.f("ix_provider_file_output_operations_workspace_id"), table_name="provider_file_output_operations") + op.drop_index(op.f("ix_provider_file_output_operations_user_id"), table_name="provider_file_output_operations") + op.drop_index( + op.f("ix_provider_file_output_operations_provider_account_generation_id"), + table_name="provider_file_output_operations", + ) + op.drop_index( + op.f("ix_provider_file_output_operations_organization_id"), table_name="provider_file_output_operations" + ) + op.drop_table("provider_file_output_operations") + op.drop_table("provider_file_rate_windows") + op.drop_index(op.f("ix_provider_account_generations_status"), table_name="provider_account_generations") + op.drop_index(op.f("ix_provider_account_generations_organization_id"), table_name="provider_account_generations") + op.drop_index(op.f("ix_provider_account_generations_credential_ref"), table_name="provider_account_generations") + op.drop_table("provider_account_generations") diff --git a/docs/files.md b/docs/files.md index 903c60096a..856d8b066c 100644 --- a/docs/files.md +++ b/docs/files.md @@ -125,3 +125,100 @@ Text/office/PDF extraction uses [markitdown](https://github.com/microsoft/markit (Apache-2.0). Both are permissively licensed, deliberately avoiding AGPL PDF libraries since Otari is a network service. OCR is optional; install the `ocr` extra (`pip install gateway[ocr]`) to enable it. + +## Hybrid Anthropic Files (opt-in) + +Hybrid gateways can forward the Anthropic GA Files API through any-llm while +keeping bytes at Anthropic. Enable `files_provider_native_enabled` only after the +control plane contributes the Files protocol and the deployed any-llm version +contains its Files interface. The initial implementation targets any-llm 1.28. +The gateway returns a fixed 502 if that interface or the control-plane protocol +is unavailable. The default remains disabled. + +Use the official Anthropic SDK's GA `files` resource, not `beta.files`: + +```python +from anthropic import Anthropic + +client = Anthropic( + auth_token="YOUR_OTARI_WORKSPACE_API_KEY", + base_url="https://gateway.example/api/", +) +with open("input.csv", "rb") as source: + uploaded = client.files.upload(file=("input.csv", source, "text/csv")) + +message = client.messages.create( + model="anthropic:YOUR_AUTHORIZED_CLAUDE_MODEL", + max_tokens=1024, + messages=[{ + "role": "user", + "content": [ + {"type": "container_upload", "file_id": uploaded.id}, + {"type": "text", "text": "Analyze this CSV."}, + ], + }], + tools=[{"type": "code_execution_20250825", "name": "code_execution"}], +) +client.files.delete(uploaded.id) +``` + +The SDK appends `/v1/files`, so the direct deployment base URL ends in `/api/`. +Files requests require `anthropic-version`, which the SDK supplies. Every Files +verb rejects the legacy `files-api-2025-04-14` beta. Listings use `page`, `limit`, +and `ids[]`, with `data` and `next_page` responses. `ids[]` cannot be combined +with pagination. Legacy `after_id`, `before_id`, and `order` are rejected. + +Files belong to the API key's uploader and workspace. Sharing a workspace does +not grant another user access. Listings come from those scoped bindings, never +from an account-wide Anthropic listing. Unknown, foreign, expired, and deleted +IDs are indistinguishable. Uploaded inputs are not downloadable when Anthropic +marks them `downloadable: false`; eligible generated outputs can be downloaded +with `client.files.download(file_id)`. + +Every structured reference in Messages history is checked before dispatch. +The authorized model plan must include the binding's exact Anthropic account +generation. File-bearing requests have no account or provider fallback. Chat +Completions and Responses reject provider file references; use Messages. +Managed credentials still reject caller-selected container reuse. + +### Limits and cleanup + +| Setting | Default / requirement | +| --- | --- | +| `files_max_bytes` | 512 MiB per file | +| `files_transfer_timeout_seconds` | 300 seconds, covering receipt and upload | +| `files_idle_timeout_seconds` | 30 seconds | +| `files_rate_limit_rpm` | 60 operations per uploader/workspace, enforced in the control plane | +| `files_retention_hours` | Hybrid default 168; provider range 1–2160 hours | +| `files_max_count`, `files_max_outstanding_bytes` | Explicit positive control-plane quotas required | +| `files_temporary_capacity_bytes` | 2 GiB shared admission ceiling across local workers | +| `files_operation_timeout_seconds` | 600 seconds | +| `files_diagnostic_retention_days` | 30 days for unbound operation diagnostics | + +Uploads spool to private, request-scoped temporary files. Multipart receipt +finishes before provider upload starts. The initial spool admission mechanism +requires a POSIX filesystem and coordinates workers under the same operating +system user. Use an ephemeral, quota-limited temporary volume; reservations are +reclaimed after process termination, and rolled-over file buffers are unlinked +temporary files. No durable gateway file store is used. + +Deletion revokes local access before contacting Anthropic. Failed deletions stay +in a durable cleanup queue. Gateways claim fenced, five-minute leases of up to +20 files; failures back off from one minute to six hours. Replacing, removing, +or restoring a retired credential waits for required cleanup. Workspace-key +disabling and user/workspace deletion also revoke affected bindings. + +An upload or generated ID is withheld until its binding commits. Uploads are +never retried after an uncertain provider outcome. A crash or lost response can +leave an inaccessible upstream orphan. Uploaded bytes receive finite provider +retention. Generated outputs have **no guaranteed provider retention** unless +Anthropic reports it; local expiry alone cannot delete an unknown upstream ID. + +### Release verification + +The core contract has been exercised with the merged any-llm Files implementation +at `2524c196c4c8cbeb8698a9e0b6f90d73aa659a9d` and Anthropic Python SDK 0.125.0. +The published any-llm 1.28 dependency pin and lockfile update remain a release +gate. Before hosted enablement, verify the composed hosted adapter, generated +output expiry, and the Octonous workflow without managed container reuse. +The canonical server contract is in [Hybrid mode protocol](hybrid-mode-protocol.md#provider-native-files). diff --git a/docs/hybrid-mode-protocol.md b/docs/hybrid-mode-protocol.md index b9fb2a4582..53b7d81545 100644 --- a/docs/hybrid-mode-protocol.md +++ b/docs/hybrid-mode-protocol.md @@ -629,3 +629,132 @@ flag. | `STREAMING_FALLBACK_FIRST_CHUNK_TIMEOUT_MS` | `2000` | Per-attempt budget for the streaming first-chunk gate. Forwarded provider-tool requests retain it on non-final attempts. | | `STREAMING_FALLBACK_FIRST_CHUNK_TIMEOUT_MS_TOOL_LOOP` | `30000` | Gate budget for a gateway-managed tool loop, and the final-attempt base for a request that forwards provider tools. | | `STREAMING_FALLBACK_FINAL_ATTEMPT_EXTRA_FIRST_CHUNK_TIMEOUT_MS` | `0` | Extra first-chunk grace for the sole/final attempt, added on top of whichever base budget applies. | + +## Provider-native Files + +Provider-native Files is an additive, opt-in protocol. Its shared models live in +`gateway.services.provider_files.contracts`; core persistence, cleanup, and +credential retirement live in that package and `models/provider_files.py`. +The control plane transfers no file bytes. A hybrid gateway uses any-llm's Files +interface directly and stores no durable bindings or cleanup jobs. + +All Files responses, including errors, carry `Cache-Control: private, no-store` +and `X-Otari-Files-Protocol: 1`. A 404 without that protocol marker is treated as +an older peer and becomes a fixed public 502. Consumers ignore unknown response +fields. Metadata preserves native fields and omitted values, excluding normalized +`purpose` and `status` fields that are not Anthropic Files fields. + +### Authentication and composition + +A deployment contributes `create_provider_files_router(...)` behind its +attached-gateway capability. The factory requires foreground authentication, +gateway authentication, and inference-attempt authorization callbacks. No +permissive default authenticator exists. Paths below are relative to the +control-plane API base, like the existing `/gateway/provider-keys/resolve` path. + +Foreground calls carry `X-Gateway-Token` and `X-User-Token`. Authentication must +resolve a registered gateway and a live, workspace-scoped API key. Construct +`FileScope` from that key's organization, workspace, and billed `user_id`. +Cookies, operator master keys, and request-body tenant identifiers cannot supply +this scope. The `default_gateway` flag comes only from registered gateway +identity. + +Cleanup calls authenticate the gateway without a user token. Their scope comes +from gateway registration. Operation tokens bind the operation, initiating +gateway, and an unguessable nonce; they allow cleanup after key or user +revocation, never new reads or inference. Lease tokens additionally bind the +current lease generation, its deadline, and exact work items. + +BYO resolution is core-owned: a workspace pin wins, then the organization +default, then a unique usable Anthropic key. Multiple candidates return 409. +A disabled explicit selection fails closed. There is no oldest-key fallback. +A hosted resolver is called only after BYO selection and only for the trusted +default gateway, before a managed secret is read. It supplies an existing core +account generation and a transient credential, including any trusted upstream +workspace selection. Keep credential-source locking and generation creation in +that same transaction. An adapter must retire every affected generation through +`retire_account_generation` before releasing an old hosted secret, and prevent +new source selection throughout retirement. + +`authorize_attempt` must intersect the original authorized inference request and +attempt, including its model, workspace tool policy, and account generation. +The inference resolver includes `provider_account_generation_id` on eligible +Anthropic attempts. Files cannot introduce a new credential or model into that +plan. The gateway rejects missing generation identity for file-aware dispatch. + +### Operations + +| Method and path | Body and result | +| --- | --- | +| `POST /gateway/files/uploads/prepare` | `PrepareUpload` → `Operation` | +| `POST /gateway/files/uploads/{binding_id}/finalize` | `FinalizeUpload` → `FileMetadata` | +| `POST /gateway/files/uploads/{binding_id}/abandon` | `AbandonUpload` → acknowledgement | +| `POST /gateway/files/list` | `FileListRequest` → `FilePage` | +| `POST /gateway/files/{file_id}/resolve` | `ResolveFile` → `ResolvedFile` | +| `POST /gateway/files/references/resolve` | `References` → `FileAccount` | +| `POST /gateway/files/{binding_id}/cleanup-result` | `CleanupResult` → acknowledgement | +| `GET /gateway/files/status` | Scoped aggregate pending and unknown-outcome counts | +| `POST /gateway/files/cleanup/claim` | `CleanupClaim` → `{ "lease": CleanupLease \| null }` | +| `POST /gateway/files/cleanup/{lease_id}/result` | `LeaseResult` → acknowledgement | +| `POST /gateway/files/outputs/prepare` | `OutputPrepare` → `Operation` | +| `POST /gateway/files/outputs/register` | `OutputRegister` → `FileMetadata` | +| `POST /gateway/files/outputs/{operation_id}/abandon` | `AbandonUpload` → `OutputCleanup` | +| `POST /gateway/files/outputs/{operation_id}/complete` | `CleanupResult` → acknowledgement | + +`PrepareUpload.operation_id` is generated once by the gateway. `size_bytes` +requests a maximum reservation, capped by the control plane. Preparation is +idempotent and precedes multipart receipt. `Operation` returns the committed +intent ID, scoped cleanup token, deadline, account credential, size cap, and +finite retention cap. A smaller caller retention is passed to Anthropic and to +finalization as `expires_in_seconds`; it cannot widen the prepared cap. + +Finalization accepts metadata observed from that one provider upload. Identical +retries return the committed metadata; ownership collisions or incompatible +results return 409. Revoked or late results enter cleanup and cannot reactivate +access. Never return the public upload response before finalization succeeds. +Provider upload retries are disabled. Finalization and cleanup reports may retry +three times with the same operation identity. Abandonment distinguishes a +confirmed rejection, an unknown provider outcome, and a known file needing +cleanup. Unknown outcomes release reservations at the operation deadline but +retain their diagnostic marker for `files_diagnostic_retention_days` after the +deadline (default 30 days). + +Lists query active bindings for the uploader and workspace. Default page size +is 20, maximum 1000, and at most 100 `ids[]` values are accepted. Cursors are +opaque encrypted values bound to scope, page size, a snapshot, and the stable +`(created_at, id)` order. Files activated after the snapshot do not enter later +pages. Deletion and expiry can shrink later pages. Unknown and foreign `ids[]` +values are omitted without disclosing their existence. + +Resolve supports `metadata`, `download`, and `delete`. Metadata is local to the +control plane. Download requires the stored provider download permission. +Delete first marks the binding `pending_cleanup`, then returns cleanup authority. +This contract uses the internal **binding ID** for `cleanup-result`, rather than +an ambiguous account-wide provider ID. A successful provider deletion or provider +404 completes cleanup; other failures preserve it and return a public error. + +Output operations reserve capacity before dispatch, including attempts without +input files. Registration retrieves provider metadata and commits a binding +before a non-streaming response or a structured streaming file block is released. +Streaming event order is preserved while the block is held. Echoed input IDs +are not registered again. Non-streaming usage settlement occurs before output +binding finalization, so a registration failure does not lose provider usage. + +Output abandonment requires the scoped operation token and the observed +metadata or file ID. It creates or revokes only a binding owned by that output +operation. A file bound to another operation is never deleted as compensation. +Known cleanup is recorded before the gateway attempts deletion. Completion +releases unused output capacity; the durable operation deadline recovers a lost +completion report. + +Cleanup leases are scoped to the authenticated gateway's organization and +managed-account eligibility. Attached gateways cannot claim hosted work. Expired +lease results cannot overwrite a newer lease. Account replacement and tenant +deletion serialize against prepare and finalization; cleanup retains denormalized +ownership after tenancy rows disappear. Provider expiry revokes access, and due +expiry is converted to cleanup when the executor claims work. + +Uploaded objects receive finite provider retention. Unknown generated outputs +have no finite upstream-retention promise. Never report an uncertain upload as a +confirmed rejection or expose an unbound ID. Deployment release gates and the +public SDK setup are documented in [Files](files.md#hybrid-anthropic-files-opt-in). diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 54dfbbb95a..b0ba4e3b42 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1,6 +1,54 @@ { "components": { "schemas": { + "AbandonUpload": { + "properties": { + "cleanup_token": { + "format": "password", + "title": "Cleanup Token", + "type": "string", + "writeOnly": true + }, + "deleted": { + "default": false, + "title": "Deleted", + "type": "boolean" + }, + "file_id": { + "anyOf": [ + { + "maxLength": 255, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "File Id" + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileMetadata" + }, + { + "type": "null" + } + ] + }, + "outcome_unknown": { + "default": false, + "title": "Outcome Unknown", + "type": "boolean" + } + }, + "required": [ + "cleanup_token" + ], + "title": "AbandonUpload", + "type": "object" + }, "AcceptInvitationRequest": { "properties": { "token": { @@ -1635,6 +1683,12 @@ }, "Body_files-create_file": { "properties": { + "expires_in_seconds": { + "description": "Hybrid provider retention, capped by the control-plane maximum.", + "maximum": 7776000, + "minimum": 3600, + "type": "integer" + }, "file": { "contentMediaType": "application/octet-stream", "title": "File", @@ -3364,6 +3418,39 @@ "title": "ChatCompletionRequest", "type": "object" }, + "CleanupClaim": { + "properties": { + "limit": { + "default": 20, + "maximum": 20.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + } + }, + "title": "CleanupClaim", + "type": "object" + }, + "CleanupResult": { + "properties": { + "cleanup_token": { + "format": "password", + "title": "Cleanup Token", + "type": "string", + "writeOnly": true + }, + "deleted": { + "title": "Deleted", + "type": "boolean" + } + }, + "required": [ + "cleanup_token", + "deleted" + ], + "title": "CleanupResult", + "type": "object" + }, "ConfigField": { "description": "One effective config value surfaced to the dashboard's config viewer.", "properties": { @@ -5479,6 +5566,172 @@ "title": "ExternalUsageEvent", "type": "object" }, + "FileListRequest": { + "properties": { + "ids": { + "anyOf": [ + { + "items": { + "maxLength": 255, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ids" + }, + "limit": { + "anyOf": [ + { + "maximum": 1000.0, + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + }, + "page": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Page" + } + }, + "title": "FileListRequest", + "type": "object" + }, + "FileMetadata": { + "additionalProperties": true, + "description": "Anthropic's public metadata, without provider-neutral-only fields.", + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "downloadable": { + "title": "Downloadable", + "type": "boolean" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "filename": { + "maxLength": 1024, + "title": "Filename", + "type": "string" + }, + "id": { + "maxLength": 255, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Id", + "type": "string" + }, + "mime_type": { + "maxLength": 255, + "title": "Mime Type", + "type": "string" + }, + "size_bytes": { + "minimum": 0.0, + "title": "Size Bytes", + "type": "integer" + }, + "type": { + "const": "file", + "default": "file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "filename", + "mime_type", + "size_bytes", + "created_at", + "downloadable" + ], + "title": "FileMetadata", + "type": "object" + }, + "FilePage": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/FileMetadata" + }, + "title": "Data", + "type": "array" + }, + "next_page": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Page" + } + }, + "required": [ + "data" + ], + "title": "FilePage", + "type": "object" + }, + "FinalizeUpload": { + "properties": { + "expires_in_seconds": { + "anyOf": [ + { + "maximum": 7776000.0, + "minimum": 3600.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires In Seconds" + }, + "metadata": { + "$ref": "#/components/schemas/FileMetadata" + } + }, + "required": [ + "metadata" + ], + "title": "FinalizeUpload", + "type": "object" + }, "GateResultResponse": { "properties": { "detail": { @@ -6553,6 +6806,33 @@ "title": "LearnedPolicy", "type": "object" }, + "LeaseResult": { + "properties": { + "results": { + "additionalProperties": { + "type": "boolean" + }, + "maxProperties": 20, + "propertyNames": { + "format": "uuid" + }, + "title": "Results", + "type": "object" + }, + "token": { + "format": "password", + "title": "Token", + "type": "string", + "writeOnly": true + } + }, + "required": [ + "token", + "results" + ], + "title": "LeaseResult", + "type": "object" + }, "MailSettings": { "description": "What this deployment can send, and what stands in the way if it cannot.", "properties": { @@ -7746,6 +8026,25 @@ "title": "ModerationResult", "type": "object" }, + "NativeFileDeleted": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "type": { + "const": "file_deleted", + "default": "file_deleted", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "NativeFileDeleted", + "type": "object" + }, "OAuthCallbackRequest": { "description": "The authorization code a provider handed the browser.\n\nNo ``redirect_uri``: this deployment derives its own from ``public_base_url``\nso the URI used to build the authorization request and the one sent with the\nexchange are the same string by construction, and a browser cannot choose\nwhat this server sends to a provider.\n\n``state`` is required, and is what binds this callback to an authorization\nrequest this deployment actually made: it is claimed from\n``oauth_pending_state`` before the code is sent anywhere, and the row it\nclaims is what carries the PKCE verifier the exchange needs. The flow\ncookie ``/authorize`` set travels alongside and binds it to the browser.", "properties": { @@ -9690,6 +9989,56 @@ "title": "OrganizationScopedBudgetsPublic", "type": "object" }, + "OutputPrepare": { + "properties": { + "attempt_id": { + "maxLength": 255, + "title": "Attempt Id", + "type": "string" + }, + "generation_id": { + "format": "uuid", + "title": "Generation Id", + "type": "string" + }, + "operation_id": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + }, + "request_id": { + "maxLength": 255, + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "operation_id", + "request_id", + "attempt_id", + "generation_id" + ], + "title": "OutputPrepare", + "type": "object" + }, + "OutputRegister": { + "properties": { + "metadata": { + "$ref": "#/components/schemas/FileMetadata" + }, + "operation_id": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + } + }, + "required": [ + "operation_id", + "metadata" + ], + "title": "OutputRegister", + "type": "object" + }, "OutputShape": { "description": "The decision form a guardrail produces (aligns with the populated ``GuardrailOutput`` fields).\n\n``SCORE`` and ``RUBRIC`` are also the queryable signal for whether\n``GuardrailOutput.score`` can ever be populated: a guardrail declaring\n**neither** always leaves ``score`` as ``None`` (it only emits a\ncategorical/binary verdict, not a calibrated risk value). A guardrail\ndeclaring **either** populates ``score`` in the common, successfully-parsed\ncase, but individual guardrails may still leave it ``None`` in specific\nedge cases (e.g. a fail-closed parse-failure path, or a guardrail that\nflags something but has nothing to score) \u2014 consult the guardrail's own\ndocstring for those exceptions.", "enum": [ @@ -10607,26 +10956,59 @@ "title": "PoolStatus", "type": "object" }, - "PricingDriftRow": { - "description": "A stored deployment rate beside the default it shadows.", + "PrepareUpload": { "properties": { - "default_input_price_per_million": { + "expires_in_seconds": { "anyOf": [ { - "type": "number" + "maximum": 7776000.0, + "minimum": 3600.0, + "type": "integer" }, { "type": "null" } ], - "description": "What genai-prices would meter this key at today. Null when the dataset does not know it.", - "title": "Default Input Price Per Million" + "title": "Expires In Seconds" }, - "default_output_price_per_million": { - "anyOf": [ - { - "type": "number" - }, + "operation_id": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + }, + "size_bytes": { + "minimum": 0.0, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "operation_id", + "size_bytes" + ], + "title": "PrepareUpload", + "type": "object" + }, + "PricingDriftRow": { + "description": "A stored deployment rate beside the default it shadows.", + "properties": { + "default_input_price_per_million": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "What genai-prices would meter this key at today. Null when the dataset does not know it.", + "title": "Default Input Price Per Million" + }, + "default_output_price_per_million": { + "anyOf": [ + { + "type": "number" + }, { "type": "null" } @@ -11366,6 +11748,27 @@ "title": "ReencryptSearchToolsResponse", "type": "object" }, + "References": { + "properties": { + "ids": { + "items": { + "maxLength": 255, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Ids", + "type": "array" + } + }, + "required": [ + "ids" + ], + "title": "References", + "type": "object" + }, "RegisterPasskeyRequest": { "description": "A completed registration ceremony, with the label to file it under.", "properties": { @@ -11575,6 +11978,24 @@ "title": "ResetPasswordRequest", "type": "object" }, + "ResolveFile": { + "properties": { + "operation": { + "enum": [ + "metadata", + "download", + "delete" + ], + "title": "Operation", + "type": "string" + } + }, + "required": [ + "operation" + ], + "title": "ResolveFile", + "type": "object" + }, "ResourceLink": { "additionalProperties": true, "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", @@ -20424,7 +20845,7 @@ }, "/api/v1/files": { "get": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "operationId": "files-list_files", "parameters": [ { @@ -20475,6 +20896,37 @@ ], "title": "Workspace Id" } + }, + { + "description": "Required in hybrid provider-native mode.", + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Hybrid GA cursor.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Hybrid IDs filter; mutually exclusive with page and limit.", + "in": "query", + "name": "ids[]", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + } } ], "responses": { @@ -20482,9 +20934,16 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Files-List Files", - "type": "object" + "anyOf": [ + { + "additionalProperties": true, + "title": "Response Files-List Files", + "type": "object" + }, + { + "$ref": "#/components/schemas/FilePage" + } + ] } } }, @@ -20515,8 +20974,19 @@ ] }, "post": { - "description": "OpenAI-compatible file upload endpoint.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "operationId": "files-create_file", + "parameters": [ + { + "description": "Required in hybrid provider-native mode.", + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "multipart/form-data": { @@ -20532,9 +21002,16 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Files-Create File", - "type": "object" + "anyOf": [ + { + "additionalProperties": true, + "title": "Response Files-Create File", + "type": "object" + }, + { + "$ref": "#/components/schemas/FileMetadata" + } + ] } } }, @@ -20567,7 +21044,7 @@ }, "/api/v1/files/{file_id}": { "delete": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "operationId": "files-delete_file", "parameters": [ { @@ -20594,6 +21071,15 @@ ], "title": "User" } + }, + { + "description": "Required in hybrid provider-native mode.", + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -20601,9 +21087,16 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Files-Delete File", - "type": "object" + "anyOf": [ + { + "additionalProperties": true, + "title": "Response Files-Delete File", + "type": "object" + }, + { + "$ref": "#/components/schemas/NativeFileDeleted" + } + ] } } }, @@ -20634,7 +21127,7 @@ ] }, "get": { - "description": "Retrieve metadata for a single file.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "operationId": "files-get_file", "parameters": [ { @@ -20661,6 +21154,15 @@ ], "title": "User" } + }, + { + "description": "Required in hybrid provider-native mode.", + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -20668,9 +21170,16 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Files-Get File", - "type": "object" + "anyOf": [ + { + "additionalProperties": true, + "title": "Response Files-Get File", + "type": "object" + }, + { + "$ref": "#/components/schemas/FileMetadata" + } + ] } } }, @@ -20703,7 +21212,7 @@ }, "/api/v1/files/{file_id}/content": { "get": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "operationId": "files-get_file_content", "parameters": [ { @@ -20730,6 +21239,15 @@ ], "title": "User" } + }, + { + "description": "Required in hybrid provider-native mode.", + "in": "header", + "name": "anthropic-version", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -20783,6 +21301,775 @@ ] } }, + "/api/v1/gateway/files/cleanup/claim": { + "post": { + "operationId": "provider-files-claim", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupClaim" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Claim" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Claim", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/cleanup/{lease_id}/result": { + "post": { + "operationId": "provider-files-complete_lease", + "parameters": [ + { + "in": "path", + "name": "lease_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Lease Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaseResult" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Provider-Files-Complete Lease", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Complete Lease", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/list": { + "post": { + "operationId": "provider-files-list_files", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-List Files" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "List Files", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/outputs/prepare": { + "post": { + "operationId": "provider-files-prepare_output", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutputPrepare" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Prepare Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Prepare Output", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/outputs/register": { + "post": { + "operationId": "provider-files-register_output", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutputRegister" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Register Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Register Output", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/outputs/{operation_id}/abandon": { + "post": { + "operationId": "provider-files-abandon_output", + "parameters": [ + { + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbandonUpload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Abandon Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Abandon Output", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/outputs/{operation_id}/complete": { + "post": { + "operationId": "provider-files-complete_output", + "parameters": [ + { + "in": "path", + "name": "operation_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Operation Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResult" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Provider-Files-Complete Output", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Complete Output", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/references/resolve": { + "post": { + "operationId": "provider-files-references", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/References" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-References" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "References", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/status": { + "get": { + "operationId": "provider-files-backlog", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Provider-Files-Backlog", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Backlog", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/uploads/prepare": { + "post": { + "operationId": "provider-files-prepare", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrepareUpload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Prepare" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Prepare", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/uploads/{binding_id}/abandon": { + "post": { + "operationId": "provider-files-abandon", + "parameters": [ + { + "in": "path", + "name": "binding_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Binding Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbandonUpload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Provider-Files-Abandon", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Abandon", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/uploads/{binding_id}/finalize": { + "post": { + "operationId": "provider-files-finalize", + "parameters": [ + { + "in": "path", + "name": "binding_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Binding Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinalizeUpload" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Finalize" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Finalize", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/{binding_id}/cleanup-result": { + "post": { + "operationId": "provider-files-cleanup_result", + "parameters": [ + { + "in": "path", + "name": "binding_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Binding Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResult" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Provider-Files-Cleanup Result", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Cleanup Result", + "tags": [ + "provider-files" + ] + } + }, + "/api/v1/gateway/files/{file_id}/resolve": { + "post": { + "operationId": "provider-files-resolve", + "parameters": [ + { + "in": "path", + "name": "file_id", + "required": true, + "schema": { + "title": "File Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveFile" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Provider-Files-Resolve" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "XApiKeyAuth": [] + } + ], + "summary": "Resolve", + "tags": [ + "provider-files" + ] + } + }, "/api/v1/health": { "get": { "description": "General health check endpoint.\n\nReturns basic health status. For infrastructure monitoring,\nuse /health/readiness or /health/liveness instead.", diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index a671f57f41..abfad6fb85 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -1763,7 +1763,7 @@ { "name": "List Files", "request": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1793,16 +1793,28 @@ "disabled": true, "key": "workspace_id", "value": "" + }, + { + "description": "Hybrid GA cursor.", + "disabled": true, + "key": "page", + "value": "" + }, + { + "description": "Hybrid IDs filter; mutually exclusive with page and limit.", + "disabled": true, + "key": "ids[]", + "value": "" } ], - "raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=" + "raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=&page=&ids[]=" } } }, { "name": "Create File", "request": { - "description": "OpenAI-compatible file upload endpoint.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "header": [], "method": "POST", "url": { @@ -1821,7 +1833,7 @@ { "name": "Get File", "request": { - "description": "Retrieve metadata for a single file.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1856,7 +1868,7 @@ { "name": "Delete File", "request": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "header": [], "method": "DELETE", "url": { @@ -1891,7 +1903,7 @@ { "name": "Get File Content", "request": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1927,6 +1939,539 @@ ], "name": "files" }, + { + "item": [ + { + "name": "Claim", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"limit\": 20\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "cleanup", + "claim" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/cleanup/claim" + } + } + }, + { + "name": "Complete Lease", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"token\": \"string\",\n \"results\": {}\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "cleanup", + ":lease_id", + "result" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/cleanup/:lease_id/result", + "variable": [ + { + "description": "path parameter", + "key": "lease_id", + "value": "" + } + ] + } + } + }, + { + "name": "List Files", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"ids\": [\n \"string\"\n ],\n \"limit\": 0,\n \"page\": \"string\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "list" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/list" + } + } + }, + { + "name": "Prepare Output", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"operation_id\": \"00000000-0000-0000-0000-000000000000\",\n \"request_id\": \"string\",\n \"attempt_id\": \"string\",\n \"generation_id\": \"00000000-0000-0000-0000-000000000000\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "outputs", + "prepare" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/outputs/prepare" + } + } + }, + { + "name": "Register Output", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"operation_id\": \"00000000-0000-0000-0000-000000000000\",\n \"metadata\": {\n \"id\": \"string\",\n \"filename\": \"string\",\n \"mime_type\": \"string\",\n \"size_bytes\": 0,\n \"created_at\": \"2024-01-01T00:00:00Z\",\n \"downloadable\": false\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "outputs", + "register" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/outputs/register" + } + } + }, + { + "name": "Abandon Output", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"cleanup_token\": \"string\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "outputs", + ":operation_id", + "abandon" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/outputs/:operation_id/abandon", + "variable": [ + { + "description": "path parameter", + "key": "operation_id", + "value": "" + } + ] + } + } + }, + { + "name": "Complete Output", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"cleanup_token\": \"string\",\n \"deleted\": false\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "outputs", + ":operation_id", + "complete" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/outputs/:operation_id/complete", + "variable": [ + { + "description": "path parameter", + "key": "operation_id", + "value": "" + } + ] + } + } + }, + { + "name": "References", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"ids\": [\n \"string\"\n ]\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "references", + "resolve" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/references/resolve" + } + } + }, + { + "name": "Backlog", + "request": { + "header": [], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "status" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/status" + } + } + }, + { + "name": "Prepare", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"operation_id\": \"00000000-0000-0000-0000-000000000000\",\n \"size_bytes\": 0\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "uploads", + "prepare" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/uploads/prepare" + } + } + }, + { + "name": "Abandon", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"cleanup_token\": \"string\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "uploads", + ":binding_id", + "abandon" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/uploads/:binding_id/abandon", + "variable": [ + { + "description": "path parameter", + "key": "binding_id", + "value": "" + } + ] + } + } + }, + { + "name": "Finalize", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"metadata\": {\n \"id\": \"string\",\n \"filename\": \"string\",\n \"mime_type\": \"string\",\n \"size_bytes\": 0,\n \"created_at\": \"2024-01-01T00:00:00Z\",\n \"downloadable\": false\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + "uploads", + ":binding_id", + "finalize" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/uploads/:binding_id/finalize", + "variable": [ + { + "description": "path parameter", + "key": "binding_id", + "value": "" + } + ] + } + } + }, + { + "name": "Cleanup Result", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"cleanup_token\": \"string\",\n \"deleted\": false\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + ":binding_id", + "cleanup-result" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/:binding_id/cleanup-result", + "variable": [ + { + "description": "path parameter", + "key": "binding_id", + "value": "" + } + ] + } + } + }, + { + "name": "Resolve", + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "language": "json" + } + }, + "raw": "{\n \"operation\": \"metadata\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "gateway", + "files", + ":file_id", + "resolve" + ], + "raw": "{{baseUrl}}/api/v1/gateway/files/:file_id/resolve", + "variable": [ + { + "description": "path parameter", + "key": "file_id", + "value": "" + } + ] + } + } + } + ], + "name": "provider-files" + }, { "item": [ { diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index f3e97eb703..8cf0603bc1 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -13,15 +13,21 @@ import sys import tempfile from pathlib import Path -from typing import cast +from typing import Any, cast REPO_ROOT = Path(__file__).resolve().parent.parent SRC_ROOT = REPO_ROOT / "src" if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from gateway.core.config import GatewayConfig +from fastapi import APIRouter, FastAPI, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.api.routes.hybrid_files import router as hybrid_files_router +from gateway.api.routes.provider_files import create_provider_files_router +from gateway.core.config import API_ROOT, GatewayConfig from gateway.main import create_app +from gateway.services.provider_files.contracts import FileAccount, FileScope, OutputPrepare def generate_openapi_spec() -> dict[str, object]: @@ -47,7 +53,83 @@ def generate_openapi_spec() -> dict[str, object]: web_search_backend_token="openapi-generation-placeholder", ) app = create_app(config) - return cast(dict[str, object], app.openapi()) + app.include_router( + create_provider_files_router( + authenticate=_schema_identity, + authenticate_gateway=_schema_identity, + authorize_attempt=_schema_attempt, + ), + prefix=API_ROOT, + ) + spec = app.openapi() + _merge_hybrid_files(spec) + return cast(dict[str, object], spec) + + +async def _schema_identity(request: Request, db: AsyncSession) -> FileScope: + raise RuntimeError("Schema-only authentication dependency") + + +async def _schema_attempt(scope: FileScope, request: OutputPrepare, db: AsyncSession) -> FileAccount: + raise RuntimeError("Schema-only inference authorization dependency") + + +def _merge_hybrid_files(spec: dict[str, Any]) -> None: + """Publish both runtime contracts without replacing standalone storage schemas.""" + hybrid = FastAPI() + routes = APIRouter(prefix=API_ROOT) + routes.include_router(hybrid_files_router) + hybrid.include_router(routes) + native = hybrid.openapi() + spec["components"]["schemas"].update(native.get("components", {}).get("schemas", {})) + for path, methods in native["paths"].items(): + for method, operation in methods.items(): + target = spec["paths"][path][method] + target["description"] = target.get("description", "") + ( + "\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. " + "It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. " + "Hosted mode does not serve public file bytes." + ) + schema = operation["responses"].get("200", {}).get("content", {}).get("application/json", {}).get("schema") + if schema and "$ref" in schema: + media = target["responses"]["200"]["content"]["application/json"] + media["schema"] = {"anyOf": [media["schema"], schema]} + target.setdefault("parameters", []).append( + { + "name": "anthropic-version", + "in": "header", + "required": False, + "schema": {"type": "string"}, + "description": "Required in hybrid provider-native mode.", + } + ) + listing = spec["paths"][f"{API_ROOT}/files"]["get"] + listing["parameters"].extend( + [ + { + "name": "page", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "Hybrid GA cursor.", + }, + { + "name": "ids[]", + "in": "query", + "required": False, + "schema": {"type": "array", "items": {"type": "string"}, "maxItems": 100}, + "description": "Hybrid IDs filter; mutually exclusive with page and limit.", + }, + ] + ) + upload = spec["paths"][f"{API_ROOT}/files"]["post"] + body_ref = upload["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"].split("/")[-1] + spec["components"]["schemas"][body_ref]["properties"]["expires_in_seconds"] = { + "type": "integer", + "minimum": 3600, + "maximum": 7776000, + "description": "Hybrid provider retention, capped by the control-plane maximum.", + } def write_spec(spec: dict[str, object], output_path: Path) -> None: diff --git a/scripts/sdk_codegen/sdk-endpoints.txt b/scripts/sdk_codegen/sdk-endpoints.txt index a05bafe6d3..259cae43c9 100644 --- a/scripts/sdk_codegen/sdk-endpoints.txt +++ b/scripts/sdk_codegen/sdk-endpoints.txt @@ -393,3 +393,19 @@ DELETE /api/v1/workspaces/{workspace_id}/mcp-servers/{server_id} # # token; an application reaches the same search through the otari_web_search # tool on a completion request. GET /api/v1/web-search/search # gateway-to-gateway + +# Provider-native Files control-plane protocol. +POST /api/v1/gateway/files/cleanup/claim # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/cleanup/{lease_id}/result # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/list # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/outputs/prepare # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/outputs/register # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/outputs/{operation_id}/abandon # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/outputs/{operation_id}/complete # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/references/resolve # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/uploads/prepare # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/uploads/{binding_id}/abandon # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/uploads/{binding_id}/finalize # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/{binding_id}/cleanup-result # internal gateway authentication and file lifecycle +POST /api/v1/gateway/files/{file_id}/resolve # internal gateway authentication and file lifecycle +GET /api/v1/gateway/files/status # internal gateway authentication and cleanup backlog diff --git a/src/gateway/api/main.py b/src/gateway/api/main.py index 4fa9c38428..e8c76b9e1a 100644 --- a/src/gateway/api/main.py +++ b/src/gateway/api/main.py @@ -23,6 +23,7 @@ health, hooks, hosted_mode, + hybrid_files, hybrid_mode, images, invitations, @@ -158,6 +159,7 @@ def _register_core_routers(api: APIRouter, config: GatewayConfig, enabled_featur api.include_router(hooks.router) if config.is_hybrid_mode: + api.include_router(hybrid_files.router) # The hybrid stub router is mounted by register_routers, after the # contributed routers; see the note there. return # Remaining routers (including batches) are standalone-mode only diff --git a/src/gateway/api/routes/_pipeline.py b/src/gateway/api/routes/_pipeline.py index 64d3841475..7e3e754215 100644 --- a/src/gateway/api/routes/_pipeline.py +++ b/src/gateway/api/routes/_pipeline.py @@ -4584,6 +4584,7 @@ async def run_platform_non_stream( config: GatewayConfig, rate_limit_info: RateLimitInfo | None, session_label: str | None = None, + build_kwargs: Callable[[ResolvedAttempt, dict[str, Any]], dict[str, Any]] = default_attempt_kwargs, ) -> ResultT: """Drive the multi-attempt hybrid-mode non-streaming path via the shared ``run_platform_attempts`` runner, dispatching each attempt through the @@ -4654,6 +4655,7 @@ def _on_attempt_success(attempt: ResolvedAttempt) -> None: try: result = await run_platform_attempts( + build_kwargs=build_kwargs, route=route, attempts=attempts, base_request_fields=base_request_fields, diff --git a/src/gateway/api/routes/_platform.py b/src/gateway/api/routes/_platform.py index 6a5a15ae20..b9595ea319 100644 --- a/src/gateway/api/routes/_platform.py +++ b/src/gateway/api/routes/_platform.py @@ -134,6 +134,7 @@ def record_abandoned_attempt(provider: str, model: str, reason: str, position: i class ResolvedAttempt(BaseModel): """A single resolution attempt returned by the platform.""" + provider_account_generation_id: str | None = None attempt_id: str position: int provider: str @@ -294,6 +295,7 @@ async def run_platform_attempts( report_attempt_outcome: Callable[[ResolvedAttempt, str, Any, str | None, bool], None], on_success: Callable[[ResolvedAttempt], None], max_tool_iterations: int, + build_kwargs: Callable[[ResolvedAttempt, dict[str, Any]], dict[str, Any]] = default_attempt_kwargs, ) -> T: """Iterate ``attempts``, returning the first one that succeeds. @@ -345,7 +347,7 @@ async def run_platform_attempts( last_exc: BaseException | None = None for index, attempt in enumerate(attempts): - completion_kwargs = default_attempt_kwargs(attempt, base_request_fields) + completion_kwargs = build_kwargs(attempt, base_request_fields) is_last_planned_attempt = index == len(attempts) - 1 # Per-attempt lock-in flag. Flipped the moment the upstream returns @@ -638,6 +640,7 @@ def _parse_resolve_payload(payload: dict[str, Any]) -> ResolvedRoute: api_key=str(att["api_key"]), managed=bool(att.get("managed", False)), extra_params=att.get("extra_params"), + provider_account_generation_id=att.get("provider_account_generation_id"), ) for att in attempts_payload ] @@ -673,6 +676,7 @@ def _parse_resolve_payload(payload: dict[str, Any]) -> ResolvedRoute: api_key=str(payload["api_key"]), managed=bool(payload.get("managed", False)), extra_params=payload.get("extra_params"), + provider_account_generation_id=payload.get("provider_account_generation_id"), ) ], ) diff --git a/src/gateway/api/routes/chat.py b/src/gateway/api/routes/chat.py index 57668c4804..623a0c448b 100644 --- a/src/gateway/api/routes/chat.py +++ b/src/gateway/api/routes/chat.py @@ -54,6 +54,8 @@ mcp_tool_loop, mcp_tool_loop_stream, ) +from gateway.services.provider_files.contracts import FilesError +from gateway.services.provider_files.references import collect_file_references from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import OPENAI_STREAM_FORMAT, StreamFormat from gateway.types.attempt import Attempt @@ -468,6 +470,15 @@ async def _normalize( output_cap = _effective_output_cap(request.max_tokens, request.max_completion_tokens) + if config.is_hybrid_mode: + if {"extra_body", "extra_query"} & (request.model_extra or {}).keys(): + raise HTTPException(400, "Transport body overrides are not supported in hybrid mode") + try: + if collect_file_references(request.messages): + raise FilesError(400, "Use Messages for provider-native file references") + except FilesError as exc: + raise HTTPException(exc.status_code, exc.detail) from None + ctx = await resolve_request_context( adapter=adapter, raw_request=raw_request, diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py new file mode 100644 index 0000000000..8bcde7ce5c --- /dev/null +++ b/src/gateway/api/routes/hybrid_files.py @@ -0,0 +1,323 @@ +"""Anthropic-compatible provider-native Files on a stateless hybrid gateway.""" + +import asyncio +import uuid +from collections.abc import AsyncIterator, Callable, Coroutine +from contextlib import AsyncExitStack +from typing import Annotated, Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import StreamingResponse +from fastapi.routing import APIRoute +from pydantic import ValidationError +from starlette.types import Receive, Scope, Send + +from gateway.api.deps import _extract_bearer_token, get_config +from gateway.core.config import GatewayConfig +from gateway.inflight import track_request +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import ( + FileListRequest, + FileMetadata, + FilePage, + FilesError, + NativeFileDeleted, + Operation, + ResolvedFile, + WireModel, +) +from gateway.services.provider_files.transfers import UploadAdmission, receive_upload +from gateway.services.provider_files.transport import provider_client, provider_error + + +class FileDownloadResponse(StreamingResponse): + def __init__(self, *args: Any, stack: AsyncExitStack, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.stack = stack + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + try: + await super().__call__(scope, receive, send) + finally: + await self.stack.aclose() + + +class FilesRoute(APIRoute): + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def handle(request: Request) -> Response: + try: + result = await handler(request) + except RequestValidationError: + raise HTTPException( + 400, + "Invalid file operation", + headers={ + "Cache-Control": "private, no-store", + "X-Otari-Files-Protocol": "1", + }, + ) from None + except FilesError as exc: + raise HTTPException( + exc.status_code, + exc.detail, + headers={"Cache-Control": "private, no-store", "X-Otari-Files-Protocol": "1", **exc.headers}, + ) from None + except TimeoutError: + raise HTTPException( + 504, + "File transfer timed out", + headers={ + "Cache-Control": "private, no-store", + "X-Otari-Files-Protocol": "1", + }, + ) from None + result.headers["Cache-Control"] = "private, no-store" + result.headers["X-Otari-Files-Protocol"] = "1" + return result + + return handle + + +router = APIRouter(tags=["files"], route_class=FilesRoute) +Config = Annotated[GatewayConfig, Depends(get_config)] + + +def file_headers(request: Request) -> dict[str, str]: + version = request.headers.get("anthropic-version") + if not version: + raise FilesError(400, "Hybrid provider-native Files require the Anthropic API contract (anthropic-version)") + beta = request.headers.get("anthropic-beta", "") + if "files-api-2025-04-14" in {value.strip() for value in beta.split(",")}: + raise FilesError(400, "Hybrid Files require the GA API; the legacy Files beta is unsupported") + return {"anthropic-version": version, **({"anthropic-beta": beta} if beta else {})} + + +def files_client(request: Request, config: GatewayConfig) -> PlatformFilesClient: + if not config.files_enabled or not config.files_provider_native_enabled: + raise FilesError(404, "Provider-native Files are not enabled") + file_headers(request) + token = _extract_bearer_token(request, config) + base = config.platform.get("base_url") + if not base or not config.platform_token: + raise FilesError(502, "Authorization service unavailable") + return PlatformFilesClient( + base, config.platform_token, token, timeout=int(config.platform.get("resolve_timeout_ms", 5000)) / 1000 + ) + + +def _admission(request: Request, config: GatewayConfig) -> UploadAdmission: + admission = getattr(request.app.state, "provider_file_upload_admission", None) + if not isinstance(admission, UploadAdmission): + admission = UploadAdmission(config.files_temporary_capacity_bytes) + request.app.state.provider_file_upload_admission = admission + return admission + + +@router.post("/files", response_model=FileMetadata, response_model_exclude_unset=True) +async def upload_file(request: Request, config: Config) -> FileMetadata: + client = files_client(request, config) + headers = file_headers(request) + operation: Operation | None = None + metadata: FileMetadata | None = None + started = False + try: + async with asyncio.timeout(config.files_transfer_timeout_seconds): + operation = await client.retry( + "uploads/prepare", + { + "operation_id": str(uuid.uuid4()), + "size_bytes": config.files_max_bytes, + }, + Operation, + ) + maximum = min(config.files_max_bytes, operation.max_bytes) + track_request(request, endpoint="/files", model="files", provider="anthropic") + async with _admission(request, config).reserve(maximum + 65536): + async with receive_upload( + request.headers, request.stream(), max_bytes=maximum, idle_seconds=config.files_idle_timeout_seconds + ) as (upload, duration): + retention = min(duration or operation.expires_in_seconds, operation.expires_in_seconds) + async with provider_client( + operation.account, idle_timeout=config.files_idle_timeout_seconds + ) as provider: + started = True + result = await provider.aupload_file( + upload.file, + filename=upload.filename, + mime_type=upload.content_type, + expires_in=retention, + max_retries=0, + extra_headers=headers, + ) + metadata = FileMetadata.model_validate(result.model_dump(exclude_unset=True)) + return await client.retry( + f"uploads/{operation.id}/finalize", + { + "metadata": metadata.model_dump(mode="json", exclude_unset=True), + "expires_in_seconds": retention, + }, + FileMetadata, + ) + except BaseException as exc: + if operation is not None: + await _compensate_upload(client, operation, metadata, headers, started, exc) + if isinstance(exc, (FilesError, asyncio.CancelledError, TimeoutError)): + raise + if isinstance(exc, Exception): + raise provider_error(exc) from None + raise + + +async def _compensate_upload( + client: PlatformFilesClient, + operation: Operation, + metadata: FileMetadata | None, + headers: dict[str, str], + started: bool, + failure: BaseException, +) -> None: + async def compensate() -> None: + deleted = False + if metadata is not None and not (isinstance(failure, FilesError) and failure.status_code == 409): + try: + async with provider_client(operation.account) as provider: + await provider.adelete_file(metadata.id, max_retries=0, extra_headers=headers) + deleted = True + except Exception as exc: + deleted = provider_error(exc).status_code == 404 + try: + await client.retry( + f"uploads/{operation.id}/abandon", + { + "cleanup_token": operation.cleanup_token.get_secret_value(), + "metadata": metadata.model_dump(mode="json", exclude_unset=True) if metadata else None, + "deleted": deleted, + "outcome_unknown": started + and metadata is None + and ( + not isinstance(failure, Exception) + or provider_error(failure).status_code not in {400, 404, 413, 429} + ), + }, + WireModel, + ) + except FilesError: + pass + + task = asyncio.create_task(compensate()) + try: + await asyncio.shield(asyncio.wait_for(task, timeout=20)) + except (TimeoutError, asyncio.CancelledError): + task.cancel() + + +@router.get("/files", response_model=FilePage, response_model_exclude_unset=True) +async def list_files(request: Request, config: Config) -> FilePage: + client = files_client(request, config) + query = request.query_params + if set(query) - {"page", "limit", "ids[]"}: + raise FilesError(400, "Hybrid Files require GA pagination (page, limit, ids[])") + if len(query.getlist("page")) > 1 or len(query.getlist("limit")) > 1: + raise FilesError(400, "Duplicate pagination parameter") + try: + parsed = FileListRequest( + page=query.get("page"), + limit=int(query["limit"]) if "limit" in query else None, + ids=query.getlist("ids[]") if "ids[]" in query else None, + ) + except (ValueError, ValidationError): + raise FilesError(400, "Invalid Files pagination") from None + return await client.post("list", parsed.model_dump(exclude_none=True), FilePage) + + +@router.get("/files/{file_id}", response_model=FileMetadata, response_model_exclude_unset=True) +async def retrieve_file(file_id: str, request: Request, config: Config) -> FileMetadata: + resolved = await files_client(request, config).post( + f"{quote(file_id, safe='')}/resolve", {"operation": "metadata"}, ResolvedFile + ) + return resolved.metadata + + +@router.get("/files/{file_id}/content") +async def download_file(file_id: str, request: Request, config: Config) -> Response: + client = files_client(request, config) + resolved = await client.post(f"{quote(file_id, safe='')}/resolve", {"operation": "download"}, ResolvedFile) + if resolved.account is None or not resolved.metadata.downloadable: + raise FilesError(400, "This file is not downloadable") + track_request(request, endpoint="/files", model="files", provider="anthropic") + stack = AsyncExitStack() + deadline = asyncio.get_running_loop().time() + config.files_transfer_timeout_seconds + try: + provider = await stack.enter_async_context( + provider_client(resolved.account, idle_timeout=config.files_idle_timeout_seconds) + ) + async with asyncio.timeout(config.files_idle_timeout_seconds): + download = await stack.enter_async_context( + provider.adownload_file(file_id, max_retries=0, extra_headers=file_headers(request)) + ) + headers = { + name: value + for name, value in download.headers.items() + if name.lower() in {"content-type", "content-disposition"} + } + except BaseException as exc: + await stack.aclose() + if not isinstance(exc, Exception): + raise + raise provider_error(exc) from None + + async def chunks() -> AsyncIterator[bytes]: + total = 0 + try: + iterator = aiter(download) + while True: + try: + async with asyncio.timeout_at( + min(deadline, asyncio.get_running_loop().time() + config.files_idle_timeout_seconds) + ): + chunk = await anext(iterator) + except StopAsyncIteration: + break + total += len(chunk) + if total > config.files_max_bytes: + raise FilesError(413, "File size limit exceeded") + yield chunk + finally: + await stack.aclose() + + return FileDownloadResponse(chunks(), stack=stack, headers=headers, media_type="application/octet-stream") + + +@router.delete("/files/{file_id}", response_model=NativeFileDeleted) +async def delete_file(file_id: str, request: Request, config: Config) -> dict[str, str]: + client = files_client(request, config) + resolved = await client.post(f"{quote(file_id, safe='')}/resolve", {"operation": "delete"}, ResolvedFile) + if resolved.account is None or resolved.cleanup_token is None or resolved.operation_id is None: + raise FilesError(502, "Authorization service returned an invalid cleanup response") + track_request(request, endpoint="/files", model="files", provider="anthropic") + failure = None + try: + async with ( + asyncio.timeout(config.files_transfer_timeout_seconds), + provider_client(resolved.account) as provider, + ): + await provider.adelete_file(file_id, max_retries=0, extra_headers=file_headers(request)) + except Exception as exc: + failure = provider_error(exc) + if failure.status_code == 404: + failure = None + await client.retry( + f"{resolved.operation_id}/cleanup-result", + { + "cleanup_token": resolved.cleanup_token.get_secret_value(), + "deleted": failure is None, + }, + WireModel, + ) + if failure is not None: + raise failure + return {"id": file_id, "type": "file_deleted"} diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 115d243c72..6da928eae1 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -68,6 +68,10 @@ anthropic_tool_loop, anthropic_tool_loop_stream, ) +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FileAccount, FilesError, Operation +from gateway.services.provider_files.inference import FileOutputBinder +from gateway.services.provider_files.references import collect_file_references from gateway.services.tool_format import inject_purpose_hints_anthropic, openai_to_anthropic_tools from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import ANTHROPIC_STREAM_FORMAT, StreamFormat @@ -229,9 +233,7 @@ def _strip_gateway_minted_blocks(messages: Any) -> Any: continue # Two passes: identify our web-search results and our provenance-prefixed # MCP uses, then drop each complete pair. A provider's pair matches neither. - minted_web_ids = { - block.get("tool_use_id") for block in content if _is_gateway_minted_result(block) - } + minted_web_ids = {block.get("tool_use_id") for block in content if _is_gateway_minted_result(block)} minted_mcp_ids = { block.get("id") if block.get("type") == "mcp_tool_use" else block.get("tool_use_id") for block in content @@ -342,12 +344,8 @@ def _billable_messages_usage(usage: Any) -> GatewayUsage: prompt_tokens=input_tokens, completion_tokens=output_tokens, total_tokens=input_tokens + output_tokens, - cache_read_tokens=sum( - (getattr(part, "cache_read_input_tokens", None) or 0) for part in billable_parts - ), - cache_write_tokens=sum( - (getattr(part, "cache_creation_input_tokens", None) or 0) for part in billable_parts - ), + cache_read_tokens=sum((getattr(part, "cache_read_input_tokens", None) or 0) for part in billable_parts), + cache_write_tokens=sum((getattr(part, "cache_creation_input_tokens", None) or 0) for part in billable_parts), cache_write_1h_tokens=sum(_cache_write_1h_tokens(part) for part in billable_parts), cache_tokens_in_prompt=False, ) @@ -614,6 +612,131 @@ def _reject_container_on_managed_credential(ctx: RequestContext) -> None: ) +class _FileMessagesAdapter(_MessagesAdapter): + def __init__(self, client: PlatformFilesClient, request_id: str, references: list[str]) -> None: + self.files_client = client + self.files_request_id = request_id + self.file_references = references + self.pending_binder: FileOutputBinder | None = None + + def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str, Any]) -> dict[str, Any]: + result = super().attempt_kwargs(attempt, base_request_fields) + result["api_key"], result["api_base"] = attempt.api_key, attempt.api_base + result["client_args"] = {"max_retries": 0} + supplied = result.get("extra_headers") + result["extra_headers"] = ( + { + key.lower(): value + for key, value in supplied.items() + if key.lower() in {"anthropic-version", "anthropic-beta"} and isinstance(value, str) + } + if isinstance(supplied, dict) + else {} + ) + result["_file_attempt"] = attempt + return result + + async def _binder(self, kwargs: dict[str, Any]) -> FileOutputBinder: + attempt = kwargs.pop("_file_attempt") + if attempt.provider != "anthropic" or not attempt.provider_account_generation_id: + raise FilesError(403, "Provider file outputs require an authorized Anthropic account") + operation = await self.files_client.post( + "outputs/prepare", + { + "operation_id": str(uuid.uuid4()), + "request_id": self.files_request_id, + "attempt_id": attempt.attempt_id, + "generation_id": attempt.provider_account_generation_id, + }, + Operation, + ) + if ( + operation.account.api_key.get_secret_value() != attempt.api_key + or operation.account.api_base != attempt.api_base + or str(operation.account.generation_id) != attempt.provider_account_generation_id + ): + raise FilesError(409, "Inference provider account changed before dispatch") + if operation.account.workspace is not None: + kwargs["client_args"]["default_headers"] = {"anthropic-workspace-id": operation.account.workspace} + return FileOutputBinder(self.files_client, operation, self.file_references) + + async def call_provider(self, kwargs: dict[str, Any]) -> MessageResponse: + binder = await self._binder(kwargs) + try: + result = await super().call_provider(kwargs) + except BaseException: + await binder.complete() + raise + self.pending_binder = binder + return result + + async def finalize_outputs(self, result: MessageResponse) -> None: + binder, self.pending_binder = self.pending_binder, None + if binder is not None: + try: + await binder.register(result.model_dump(exclude_unset=True)) + finally: + await binder.complete() + + async def open_provider_stream(self, kwargs: dict[str, Any]) -> AsyncIterator[MessageStreamEvent]: + binder = await self._binder(kwargs) + try: + stream = await super().open_provider_stream(kwargs) + except BaseException: + await binder.complete() + raise + return binder.stream(stream) + + async def run_tool_loop( + self, + kwargs: dict[str, Any], + pool: ToolBackend, + max_iterations: int, + on_first_response: Callable[[], None] | None = None, + *, + emit_native_web_search: bool = False, + web_search_budget: WebSearchBudget | None = None, + ) -> MessageResponse: + binder = await self._binder(kwargs) + try: + result = await super().run_tool_loop( + kwargs, + pool, + max_iterations, + on_first_response, + emit_native_web_search=emit_native_web_search, + web_search_budget=web_search_budget, + ) + except BaseException: + await binder.complete() + raise + self.pending_binder = binder + return result + + def open_tool_loop_stream( + self, + kwargs: dict[str, Any], + pool: ToolBackend, + max_iterations: int, + *, + emit_native_web_search: bool = False, + web_search_budget: WebSearchBudget | None = None, + ) -> AsyncIterator[MessageStreamEvent]: + async def stream() -> AsyncIterator[MessageStreamEvent]: + binder = await self._binder(kwargs) + source = super(_FileMessagesAdapter, self).open_tool_loop_stream( + kwargs, + pool, + max_iterations, + emit_native_web_search=emit_native_web_search, + web_search_budget=web_search_budget, + ) + async for event in binder.stream(source): + yield event + + return stream() + + _ADAPTER = _MessagesAdapter() @@ -636,6 +759,8 @@ async def create_message( fallback across the resolved route, tool-loop requests included (fallback applies up to the pre-lock-in point, same as chat). """ + if config.is_hybrid_mode and {"extra_body", "extra_query"} & (request.model_extra or {}).keys(): + raise _anthropic_error(_ERR_API, "Transport body overrides are not supported in hybrid mode", 400) user_from_metadata = request.metadata.get("user_id") if request.metadata else None merged_betas = _merge_anthropic_betas(request.betas, raw_request) if merged_betas is not None: @@ -648,6 +773,8 @@ async def create_message( # independent of whether the current request enables the same tool again. request.messages = _strip_gateway_minted_blocks(request.messages) + adapter = _ADAPTER + async def _normalize( user_id: str, provider: LLMProvider | None, @@ -674,7 +801,7 @@ async def _normalize( try: ctx = await resolve_request_context( - adapter=_ADAPTER, + adapter=adapter, raw_request=raw_request, response=response, db=db, @@ -713,8 +840,41 @@ async def _normalize( await release_reservation(ctx) raise + if ctx.hybrid_mode: + try: + references = collect_file_references(request.messages) + native_outputs = ( + any( + isinstance(tool, dict) and str(tool.get("type", "")).startswith("code_execution_") + for tool in (request.tools or []) + ) + or request.container is not None + ) + if references or (native_outputs and config.files_provider_native_enabled): + if not config.files_provider_native_enabled: + raise FilesError(400, "Hybrid provider file references and native outputs are not enabled") + assert ctx.route is not None and ctx.user_token is not None + client = PlatformFilesClient(config.platform["base_url"], config.platform_token or "", ctx.user_token) + if references: + account = await client.post("references/resolve", {"ids": references}, FileAccount) + attempts = [ + attempt + for attempt in ctx.route.attempts + if attempt.provider == "anthropic" + and attempt.provider_account_generation_id == str(account.generation_id) + ] + if not attempts: + raise FilesError(403, "File account is not authorized by the requested model policy") + selected = attempts[0] + selected.api_key, selected.api_base = account.api_key.get_secret_value(), account.api_base + selected.extra_params = None + ctx.route.attempts, ctx.route.fallback_enabled = [selected], False + adapter = _FileMessagesAdapter(client, ctx.route.request_id, references) + except FilesError as exc: + raise _anthropic_error(_ERR_API, exc.detail, exc.status_code) from None + tool_ctx = await prepare_gateway_tools( - adapter=_ADAPTER, + adapter=adapter, ctx=ctx, response=response, guardrails=request.guardrails, @@ -762,7 +922,7 @@ async def _normalize( ) try: return await run_streaming_with_fallback( - adapter=_ADAPTER, + adapter=adapter, route=route, base_request_fields=request_fields, config=config, @@ -781,15 +941,15 @@ async def _normalize( raise raise converted from exc except Exception as exc: - raise_all_streaming_attempts_failed(_ADAPTER, exc, route) + raise_all_streaming_attempts_failed(adapter, exc, route) # Standalone: single attempt streaming. resolved = await resolve_dispatch_provider( - ctx, config, request.model, adapter=_ADAPTER, model_provider=model_provider + ctx, config, request.model, adapter=adapter, model_provider=model_provider ) call_kwargs = {**resolved.kwargs, **request_fields, "model": resolved.dispatch_model} return await run_single_attempt_stream( - adapter=_ADAPTER, + adapter=adapter, ctx=ctx, tool_ctx=tool_ctx, call_kwargs=call_kwargs, @@ -808,7 +968,8 @@ async def _normalize( assert route is not None # guaranteed by the hybrid-mode preamble try: result = await run_platform_non_stream( - adapter=_ADAPTER, + build_kwargs=adapter.attempt_kwargs, + adapter=adapter, route=route, base_request_fields=request_fields, tool_ctx=tool_ctx, @@ -827,15 +988,20 @@ async def _normalize( if converted is exc: raise raise converted from exc + if isinstance(adapter, _FileMessagesAdapter): + try: + await adapter.finalize_outputs(result) + except FilesError as exc: + raise _anthropic_error(_ERR_API, exc.detail, exc.status_code) from None return result.model_dump(exclude_none=True) # Standalone non-stream path resolved = await resolve_dispatch_provider( - ctx, config, request.model, adapter=_ADAPTER, model_provider=model_provider + ctx, config, request.model, adapter=adapter, model_provider=model_provider ) call_kwargs = {**resolved.kwargs, **request_fields, "model": resolved.dispatch_model} result = await run_standalone_non_stream( - adapter=_ADAPTER, + adapter=adapter, ctx=ctx, tool_ctx=tool_ctx, call_kwargs=call_kwargs, diff --git a/src/gateway/api/routes/provider_files.py b/src/gateway/api/routes/provider_files.py new file mode 100644 index 0000000000..4524692d5c --- /dev/null +++ b/src/gateway/api/routes/provider_files.py @@ -0,0 +1,229 @@ +"""Contributed control-plane Files router with deployment-owned gateway authentication.""" + +import uuid +from collections.abc import Awaitable, Callable +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import SecretStr +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.api.deps import get_config, get_db +from gateway.api.routes.hybrid_files import FilesRoute +from gateway.core.config import GatewayConfig +from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.cleanup import ProviderFileCleanup +from gateway.services.provider_files.contracts import ( + AbandonUpload, + CleanupClaim, + CleanupResult, + FileAccount, + FileListRequest, + FileScope, + FilesError, + FinalizeUpload, + LeaseResult, + OutputPrepare, + OutputRegister, + PrepareUpload, + References, + ResolveFile, + WireModel, +) +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.provider_files.outputs import ProviderFileOutputs + +# Authenticators must resolve registered gateway identity; foreground also requires a workspace API key. +ForegroundAuthenticator = Callable[[Request, AsyncSession], Awaitable[FileScope]] +GatewayAuthenticator = Callable[[Request, AsyncSession], Awaitable[FileScope]] +AttemptAuthorizer = Callable[[FileScope, OutputPrepare, AsyncSession], Awaitable[FileAccount]] +AccountResolver = Callable[[FileScope, uuid.UUID | None, bool, AsyncSession], Awaitable[FileAccount]] + + +def _wire(value: Any) -> Any: + """Secrets are exposed only at the authenticated, no-store internal response boundary.""" + if isinstance(value, SecretStr): + return value.get_secret_value() + if isinstance(value, WireModel): + result = {key: _wire(getattr(value, key)) for key in type(value).model_fields if key in value.model_fields_set} + result.update({key: _wire(item) for key, item in (value.model_extra or {}).items()}) + return result + if isinstance(value, list): + return [_wire(item) for item in value] + if isinstance(value, dict): + return {key: _wire(item) for key, item in value.items()} + return value + + +def create_provider_files_router( + *, + authenticate: ForegroundAuthenticator, + authenticate_gateway: GatewayAuthenticator, + authorize_attempt: AttemptAuthorizer, + resolve_hosted: AccountResolver | None = None, +) -> APIRouter: + """Contribute behind attached-gateway capability; authentication is never optional.""" + router = APIRouter(prefix="/gateway/files", tags=["provider-files"], route_class=FilesRoute) + Db = Annotated[AsyncSession, Depends(get_db)] + Config = Annotated[GatewayConfig, Depends(get_config)] + + async def principal(request: Request, db: Db, response: Response) -> FileScope: + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Otari-Files-Protocol"] = "1" + scope = await authenticate(request, db) + await FileAccountResolver(db).repo.lock_user(scope.user_id) + return scope + + async def gateway(request: Request, db: Db, response: Response) -> FileScope: + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Otari-Files-Protocol"] = "1" + return await authenticate_gateway(request, db) + + def service(db: AsyncSession, config: GatewayConfig) -> ProviderFileService: + if not config.files_provider_native_enabled: + raise FilesError(404, "Provider-native Files are not enabled") + if config.files_max_count is None or config.files_max_outstanding_bytes is None: + raise FilesError(503, "Provider file quotas are not configured") + return ProviderFileService( + db, + max_bytes=config.files_max_bytes, + max_files=config.files_max_count, + max_outstanding_bytes=config.files_max_outstanding_bytes, + rate_limit_rpm=config.files_rate_limit_rpm, + retention_seconds=(config.files_retention_hours or 168) * 3600, + operation_seconds=config.files_operation_timeout_seconds, + diagnostic_seconds=config.files_diagnostic_retention_days * 86400, + ) + + async def account( + scope: FileScope, db: AsyncSession, generation_id: uuid.UUID | None = None, *, cleanup: bool = False + ) -> FileAccount: + resolver = FileAccountResolver(db) + if generation_id is None: + selected = await resolver.select_byo(scope) + if selected is not None: + return selected + else: + row = await resolver.repo.account(generation_id) + if row is None or row.organization_id != scope.organization_id: + raise FilesError(404, "Provider account unavailable") + if row.credential_source == "organization_key": + return await resolver.resolve_byo(generation_id, scope.organization_id, cleanup=cleanup) + if resolve_hosted is None: + raise FilesError(404, "Anthropic provider account unavailable") + if not scope.default_gateway: + raise FilesError(403, "Managed provider files require the default gateway") + return await resolve_hosted(scope, generation_id, cleanup, db) + + Principal = Annotated[FileScope, Depends(principal)] + Gateway = Annotated[FileScope, Depends(gateway)] + + @router.post("/uploads/prepare") + async def prepare(body: PrepareUpload, scope: Principal, db: Db, config: Config) -> Any: + return _wire(await service(db, config).prepare(scope, await account(scope, db), body)) + + @router.post("/uploads/{binding_id}/finalize") + async def finalize(binding_id: uuid.UUID, body: FinalizeUpload, scope: Principal, db: Db, config: Config) -> Any: + return _wire(await service(db, config).finalize(scope, binding_id, body.metadata, body.expires_in_seconds)) + + @router.post("/uploads/{binding_id}/abandon") + async def abandon( + binding_id: uuid.UUID, body: AbandonUpload, scope: Gateway, db: Db, config: Config + ) -> dict[str, bool]: + await service(db, config).abandon(binding_id, scope.gateway_id, body) + return {"ok": True} + + @router.post("/list") + async def list_files(body: FileListRequest, scope: Principal, db: Db, config: Config) -> Any: + return _wire(await service(db, config).list_files(scope, body)) + + @router.post("/references/resolve") + async def references(body: References, scope: Principal, db: Db, config: Config) -> Any: + generation = await service(db, config).references(scope, body.ids) + return _wire(await account(scope, db, generation)) + + @router.post("/outputs/prepare") + async def prepare_output(body: OutputPrepare, scope: Principal, db: Db, config: Config) -> Any: + # The authorizer intersects the original model plan, prices, and workspace tool policy. + generation = await FileAccountResolver(db).repo.account(body.generation_id) + if generation is None or generation.organization_id != scope.organization_id: + raise FilesError(404, "Provider account unavailable") + if generation.credential_source == "hosted_backend" and not scope.default_gateway: + raise FilesError(403, "Managed provider files require the default gateway") + selected = await authorize_attempt(scope, body, db) + return _wire(await ProviderFileOutputs(service(db, config)).prepare(scope, selected, body)) + + @router.post("/outputs/register") + async def register_output(body: OutputRegister, scope: Principal, db: Db, config: Config) -> Any: + return _wire(await ProviderFileOutputs(service(db, config)).register(scope, body.operation_id, body.metadata)) + + @router.post("/outputs/{operation_id}/abandon") + async def abandon_output( + operation_id: uuid.UUID, + body: AbandonUpload, + scope: Gateway, + db: Db, + config: Config, + ) -> Any: + return _wire( + await ProviderFileOutputs(service(db, config)).abandon( + operation_id, + scope.gateway_id, + body.cleanup_token.get_secret_value(), + body.metadata, + ) + ) + + @router.post("/outputs/{operation_id}/complete") + async def complete_output( + operation_id: uuid.UUID, body: CleanupResult, scope: Gateway, db: Db, config: Config + ) -> dict[str, bool]: + await ProviderFileOutputs(service(db, config)).complete( + operation_id, scope.gateway_id, body.cleanup_token.get_secret_value() + ) + return {"ok": True} + + @router.get("/status") + async def backlog(scope: Gateway, db: Db, config: Config) -> dict[str, int]: + return await service(db, config).repo.backlog(scope.organization_id) + + @router.post("/cleanup/claim") + async def claim(body: CleanupClaim, scope: Gateway, db: Db, config: Config) -> Any: + async def credential(generation_id: uuid.UUID) -> FileAccount: + return await account(scope, db, generation_id, cleanup=True) + + lease = await ProviderFileCleanup(service(db, config)).claim( + scope.organization_id, + scope.gateway_id, + body.limit, + include_managed=scope.default_gateway, + resolve_account=credential, + ) + return {"lease": _wire(lease)} + + @router.post("/cleanup/{lease_id}/result") + async def complete_lease( + lease_id: uuid.UUID, body: LeaseResult, scope: Gateway, db: Db, config: Config + ) -> dict[str, bool]: + await ProviderFileCleanup(service(db, config)).complete(scope.organization_id, scope.gateway_id, lease_id, body) + return {"ok": True} + + @router.post("/{file_id}/resolve") + async def resolve(file_id: str, body: ResolveFile, scope: Principal, db: Db, config: Config) -> Any: + lifecycle = service(db, config) + selected = None + if body.operation != "metadata": + generation = await lifecycle.references(scope, [file_id]) + selected = await account(scope, db, generation) + return _wire(await lifecycle.resolve(scope, file_id, body.operation, selected)) + + @router.post("/{binding_id}/cleanup-result") + async def cleanup_result( + binding_id: uuid.UUID, body: CleanupResult, scope: Gateway, db: Db, config: Config + ) -> dict[str, bool]: + await service(db, config).cleanup_result( + binding_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.deleted + ) + return {"ok": True} + + return router diff --git a/src/gateway/api/routes/responses.py b/src/gateway/api/routes/responses.py index e4d6b92ad8..76dd59bf9c 100644 --- a/src/gateway/api/routes/responses.py +++ b/src/gateway/api/routes/responses.py @@ -51,6 +51,8 @@ responses_tool_loop, responses_tool_loop_stream, ) +from gateway.services.provider_files.contracts import FilesError +from gateway.services.provider_files.references import collect_file_references from gateway.services.tool_format import inject_purpose_hints_responses, openai_to_responses_tools from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import RESPONSES_STREAM_FORMAT, StreamFormat @@ -195,9 +197,7 @@ def _strip_gateway_minted_items(input_data: Any) -> Any: if not isinstance(input_data, list): return input_data kept = [ - item - for item in input_data - if not (isinstance(item, dict) and item.get("type") in _GATEWAY_MINTED_ITEM_TYPES) + item for item in input_data if not (isinstance(item, dict) and item.get("type") in _GATEWAY_MINTED_ITEM_TYPES) ] if len(kept) != len(input_data): logger.debug("Stripped %d gateway-minted output item(s) from the inbound input", len(input_data) - len(kept)) @@ -503,6 +503,15 @@ async def _normalize( chars = len(str(request_body.input)) + len(str(getattr(request_body, "instructions", "") or "")) return chars, stats.vision_usage() + if config.is_hybrid_mode: + if {"extra_body", "extra_query"} & (request_body.model_extra or {}).keys(): + raise HTTPException(400, "Transport body overrides are not supported in hybrid mode") + try: + if collect_file_references(request_body.input): + raise FilesError(400, "Use Messages for provider-native file references") + except FilesError as exc: + raise HTTPException(exc.status_code, exc.detail) from None + ctx = await resolve_request_context( adapter=_ADAPTER, raw_request=raw_request, diff --git a/src/gateway/api/routes/users.py b/src/gateway/api/routes/users.py index d96dfab19b..9f677d93f8 100644 --- a/src/gateway/api/routes/users.py +++ b/src/gateway/api/routes/users.py @@ -24,6 +24,7 @@ from gateway.models.money import as_float from gateway.models.usage import UsageLog from gateway.models.users import User +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.repositories.users_repository import in_organization from gateway.services.budget_periods import budget_window from gateway.services.model_access import validate_allowed_models @@ -399,12 +400,14 @@ async def delete_user( detail="Could not erase this user's telemetry; the user was not deleted", ) from None + await ProviderFileRepository(db).lock_user(user_id) await db.execute( update(APIKey) .where(APIKey.user_id == user_id) .values(is_active=False) .execution_options(synchronize_session=False) ) + await ProviderFileRepository(db).revoke_user(user_id, datetime.now(UTC)) user.deleted_at = datetime.now(UTC) try: diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index 9134fda1d1..fa5222d919 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -876,6 +876,40 @@ class GatewayConfig(BudgetSettings, PricingSettings, BaseSettings): default=True, description="Enable the /api/v1/files upload/storage endpoints (standalone mode).", ) + files_provider_native_enabled: Annotated[bool, Shown(SettingsGroup.FILES)] = Field( + default=False, + description="Enable hybrid Anthropic Files after provider and control-plane contract verification.", + ) + files_transfer_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=300, ge=1) + files_idle_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=30, ge=1) + files_rate_limit_rpm: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=60, ge=1) + files_max_count: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field(default=None, ge=1) + files_max_outstanding_bytes: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field(default=None, ge=1) + files_temporary_capacity_bytes: Annotated[int, Shown(SettingsGroup.FILES)] = Field( + default=2 * 1024 * 1024 * 1024, ge=1 + ) + files_operation_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=600, ge=1) + files_diagnostic_retention_days: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=30, ge=1, le=365) + + @model_validator(mode="after") + def validate_provider_file_limits(self) -> "GatewayConfig": + if ( + (self.is_hybrid_mode or self.files_provider_native_enabled) + and self.files_retention_hours is not None + and self.files_retention_hours > 2160 + ): + raise ValueError("Hybrid file retention cannot exceed 90 days") + if self.files_provider_native_enabled and self.files_temporary_capacity_bytes < self.files_max_bytes + 65536: + raise ValueError( + "Temporary upload capacity must admit one maximum-size file plus 64 KiB multipart overhead" + ) + if self.is_hosted_mode and self.files_provider_native_enabled: + if self.files_max_count is None or self.files_max_outstanding_bytes is None: + raise ValueError("Hosted provider files require explicit file-count and outstanding-byte quotas") + if self.files_max_outstanding_bytes < self.files_max_bytes: + raise ValueError("Outstanding-byte quota must admit one maximum-size file") + return self + files_backend: Annotated[str, Shown(SettingsGroup.FILES)] = Field( default="local", description="Blob backend for uploaded file bytes: 'local' (filesystem) or 's3'. Future: 'gcs'.", @@ -1112,8 +1146,7 @@ class GatewayConfig(BudgetSettings, PricingSettings, BaseSettings): mcp_allow_loopback: Annotated[bool, Shown(SettingsGroup.TOOLS)] = Field( default=True, description=( - "SSRF gate: allow MCP server URLs that resolve to loopback (useful for same-host " - "sidecars). On by default." + "SSRF gate: allow MCP server URLs that resolve to loopback (useful for same-host sidecars). On by default." ), ) mcp_allow_private_hosts: Annotated[bool, Shown(SettingsGroup.TOOLS)] = Field( @@ -1538,8 +1571,7 @@ def validate_routing_policies(self) -> None: raise ValueError(msg) if ":" in name or "/" in name: msg = ( - f"routing policy name '{name}' must not contain ':' or '/' " - "(it would shadow a real model selector)." + f"routing policy name '{name}' must not contain ':' or '/' (it would shadow a real model selector)." ) raise ValueError(msg) if name in self.providers: @@ -1632,10 +1664,7 @@ def validate_provider_instances(self) -> None: try: LLMProvider(impl) except ValueError as exc: - msg = ( - f"providers.{instance}.provider_type '{declared}' is not a known provider " - "implementation." - ) + msg = f"providers.{instance}.provider_type '{declared}' is not a known provider implementation." raise ValueError(msg) from exc models = entry.get("models") if models is not None and not (isinstance(models, list) and all(isinstance(m, str) for m in models)): @@ -2068,9 +2097,7 @@ def _validate_platform_streaming_timeouts(cls, platform: dict[str, Any]) -> dict try: inline_timeout = int(raw_inline_timeout) except (TypeError, ValueError): - raise ValueError( - f"{inline_key} must be a positive integer, got {raw_inline_timeout!r}" - ) from None + raise ValueError(f"{inline_key} must be a positive integer, got {raw_inline_timeout!r}") from None if ( isinstance(raw_inline_timeout, bool) or (isinstance(raw_inline_timeout, float) and not raw_inline_timeout.is_integer()) diff --git a/src/gateway/main.py b/src/gateway/main.py index a4e2f1933b..e16074c913 100644 --- a/src/gateway/main.py +++ b/src/gateway/main.py @@ -62,6 +62,7 @@ run_price_update_poller, ) from gateway.services.pricing_service import configure_default_pricing, configure_provider_types +from gateway.services.provider_files.executor import run_provider_file_cleanup from gateway.services.provider_store_service import ( load_providers_at_startup, reset_provider_cache, @@ -433,6 +434,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: feature_workers: list[tuple[asyncio.Task[None], str]] = [] if config.is_hybrid_mode: log_writer = NoopLogWriter() + if config.files_provider_native_enabled: + feature_workers.append( + (asyncio.create_task(run_provider_file_cleanup(config)), "provider file cleanup") + ) else: init_db(config) async with create_session() as session: @@ -511,9 +516,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.log_writer = log_writer yield finally: - await _stop_refreshers( - [(task, f"{worker.name} refresher") for task, worker in workers] + feature_workers - ) + await _stop_refreshers([(task, f"{worker.name} refresher") for task, worker in workers] + feature_workers) for _task, worker in workers: if worker.reset is not None: worker.reset() diff --git a/src/gateway/models/__init__.py b/src/gateway/models/__init__.py index 24b38582ef..99b9f8ec0d 100644 --- a/src/gateway/models/__init__.py +++ b/src/gateway/models/__init__.py @@ -16,6 +16,7 @@ platform, playground, pricing, + provider_files, provider_keys, providers, routing, diff --git a/src/gateway/models/provider_files.py b/src/gateway/models/provider_files.py new file mode 100644 index 0000000000..f944ce2d38 --- /dev/null +++ b/src/gateway/models/provider_files.py @@ -0,0 +1,99 @@ +"""Durable provider accounts and file operations; no file bytes or provider secrets.""" + +import uuid +from datetime import datetime + +from sqlalchemy import Index, UniqueConstraint +from sqlmodel import Field, SQLModel + +from gateway.models.tenancy import CreatedAtMixin, PrimaryKeyMixin, UpdatedAtMixin, UtcDateTime + + +class ProviderAccountGeneration(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table=True): + __tablename__ = "provider_account_generations" + __table_args__ = ( + UniqueConstraint( + "organization_id", + "credential_source", + "credential_ref", + "generation", + name="uq_provider_account_generation", + ), + ) + + provider: str = Field(default="anthropic", max_length=32) + credential_source: str = Field(max_length=32) + credential_ref: str = Field(max_length=255, index=True) + organization_id: uuid.UUID = Field(index=True) + upstream_identity_ciphertext: str | None = None + generation: int = Field(default=1) + status: str = Field(default="active", max_length=16, index=True) + retired_at: datetime | None = Field(default=None, sa_type=UtcDateTime) + lease_id: uuid.UUID | None = None + lease_token_hash: str | None = Field(default=None, max_length=64) + lease_gateway_id: str | None = Field(default=None, max_length=255) + lease_deadline: datetime | None = Field(default=None, sa_type=UtcDateTime) + + +class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMixin, table=True): + __tablename__ = "provider_file_bindings" + __table_args__ = ( + UniqueConstraint("provider_account_generation_id", "provider_file_id", name="uq_provider_file_account_id"), + Index("ix_provider_files_owner_page", "workspace_id", "user_id", "state", "created_at", "id"), + Index("ix_provider_files_cleanup", "state", "cleanup_after"), + ) + + provider_file_id: str | None = Field(default=None, max_length=255) + provider_account_generation_id: uuid.UUID = Field( + foreign_key="provider_account_generations.id", ondelete="RESTRICT", index=True + ) + output_operation_id: uuid.UUID | None = Field( + default=None, foreign_key="provider_file_output_operations.id", ondelete="RESTRICT", index=True + ) + # Ownership survives tenant removal so cleanup never relies on a deleted row. + organization_id: uuid.UUID = Field(index=True) + workspace_id: uuid.UUID = Field(index=True) + user_id: str = Field(max_length=255, index=True) + encrypted_metadata: str | None = None + size_bytes: int = 0 + downloadable: bool = False + expires_at: datetime = Field(sa_type=UtcDateTime) + provider_expires_at: datetime | None = Field(default=None, sa_type=UtcDateTime) + operation_deadline: datetime = Field(sa_type=UtcDateTime) + initiating_gateway_id: str = Field(max_length=255) + cleanup_token_hash: str = Field(max_length=64) + provider_outcome_unknown: bool = False + state: str = Field(default="pending_upload", max_length=32) + cleanup_reason: str | None = Field(default=None, max_length=32) + cleanup_attempts: int = 0 + cleanup_after: datetime | None = Field(default=None, sa_type=UtcDateTime) + deleted_at: datetime | None = Field(default=None, sa_type=UtcDateTime) + lease_id: uuid.UUID | None = Field(default=None, index=True) + + +class ProviderFileOutputOperation(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table=True): + __tablename__ = "provider_file_output_operations" + + provider_account_generation_id: uuid.UUID = Field( + foreign_key="provider_account_generations.id", ondelete="RESTRICT", index=True + ) + organization_id: uuid.UUID = Field(index=True) + workspace_id: uuid.UUID = Field(index=True) + user_id: str = Field(max_length=255, index=True) + initiating_gateway_id: str = Field(max_length=255) + request_id: str = Field(max_length=255) + attempt_id: str = Field(max_length=255) + cleanup_token_hash: str = Field(max_length=64) + deadline: datetime = Field(sa_type=UtcDateTime) + state: str = Field(default="active", max_length=16) + reserved_files: int + reserved_bytes: int + + +class ProviderFileRateWindow(SQLModel, table=True): + __tablename__ = "provider_file_rate_windows" + + workspace_id: uuid.UUID = Field(primary_key=True) + user_id: str = Field(primary_key=True, max_length=255) + window: int + count: int = 0 diff --git a/src/gateway/repositories/tenancy/provider_file_repository.py b/src/gateway/repositories/tenancy/provider_file_repository.py new file mode 100644 index 0000000000..6c938e2ef9 --- /dev/null +++ b/src/gateway/repositories/tenancy/provider_file_repository.py @@ -0,0 +1,278 @@ +"""Scoped persistence and locking for provider-native file operations.""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import case, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel, col + +from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding, ProviderFileOutputOperation +from gateway.models.tenancy import Organization, Workspace +from gateway.models.users import User +from gateway.repositories.base_repository import BaseRepository + + +class ProviderFileRepository(BaseRepository[ProviderFileBinding, SQLModel, SQLModel]): + def __init__(self, db: AsyncSession) -> None: + super().__init__(db, ProviderFileBinding) + + async def lock_organization(self, organization_id: uuid.UUID) -> bool: + return ( + await self.db.execute( + select(col(Organization.id)).where(col(Organization.id) == organization_id).with_for_update() + ) + ).scalar_one_or_none() is not None + + async def workspace_exists(self, workspace_id: uuid.UUID, organization_id: uuid.UUID) -> bool: + return ( + await self.db.execute( + select(col(Workspace.id)).where( + col(Workspace.id) == workspace_id, col(Workspace.organization_id) == organization_id + ) + ) + ).scalar_one_or_none() is not None + + async def account(self, generation_id: uuid.UUID) -> ProviderAccountGeneration | None: + return ( + await self.db.execute( + select(ProviderAccountGeneration) + .where(col(ProviderAccountGeneration.id) == generation_id) + .with_for_update() + ) + ).scalar_one_or_none() + + async def latest_account( + self, source: str, ref: str, organization_id: uuid.UUID + ) -> ProviderAccountGeneration | None: + return ( + await self.db.execute( + select(ProviderAccountGeneration) + .where( + col(ProviderAccountGeneration.organization_id) == organization_id, + col(ProviderAccountGeneration.credential_source) == source, + col(ProviderAccountGeneration.credential_ref) == ref, + ) + .order_by(col(ProviderAccountGeneration.generation).desc()) + .limit(1) + .with_for_update() + ) + ).scalar_one_or_none() + + async def by_provider_id(self, generation_id: uuid.UUID, file_id: str) -> ProviderFileBinding | None: + return ( + await self.db.execute( + select(ProviderFileBinding).where( + col(ProviderFileBinding.provider_account_generation_id) == generation_id, + col(ProviderFileBinding.provider_file_id) == file_id, + ) + ) + ).scalar_one_or_none() + + async def visible( + self, + organization_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: str, + now: datetime, + ids: list[str] | None = None, + limit: int = 1001, + before: tuple[datetime, uuid.UUID] | None = None, + snapshot: datetime | None = None, + ) -> list[ProviderFileBinding]: + statement = ( + select(ProviderFileBinding) + .join(ProviderAccountGeneration) + .where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.workspace_id) == workspace_id, + col(ProviderFileBinding.user_id) == user_id, + col(ProviderFileBinding.state) == "active", + col(ProviderFileBinding.expires_at) > now, + col(ProviderAccountGeneration.status) == "active", + ) + ) + if ids is not None: + statement = statement.where(col(ProviderFileBinding.provider_file_id).in_(ids)) + if snapshot is not None: + statement = statement.where( + col(ProviderFileBinding.created_at) <= snapshot, + func.coalesce(col(ProviderFileBinding.updated_at), col(ProviderFileBinding.created_at)) <= snapshot, + ) + if before is not None: + timestamp, identity = before + statement = statement.where( + or_( + col(ProviderFileBinding.created_at) < timestamp, + (col(ProviderFileBinding.created_at) == timestamp) & (col(ProviderFileBinding.id) < identity), + ) + ) + result = await self.db.execute( + statement.order_by(col(ProviderFileBinding.created_at).desc(), col(ProviderFileBinding.id).desc()).limit( + limit + ) + ) + return list(result.scalars().all()) + + async def capacity(self, workspace_id: uuid.UUID, user_id: str, now: datetime) -> tuple[int, int]: + count, size = ( + await self.db.execute( + select(func.count(), func.coalesce(func.sum(col(ProviderFileBinding.size_bytes)), 0)).where( + col(ProviderFileBinding.workspace_id) == workspace_id, + col(ProviderFileBinding.user_id) == user_id, + or_( + col(ProviderFileBinding.state) == "active", + (col(ProviderFileBinding.state) == "pending_cleanup") + & col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.state).in_(["pending_upload", "pending_cleanup"]) + & (col(ProviderFileBinding.operation_deadline) > now), + ), + ) + ) + ).one() + output_count, output_size = ( + await self.db.execute( + select( + func.coalesce(func.sum(col(ProviderFileOutputOperation.reserved_files)), 0), + func.coalesce(func.sum(col(ProviderFileOutputOperation.reserved_bytes)), 0), + ).where( + col(ProviderFileOutputOperation.workspace_id) == workspace_id, + col(ProviderFileOutputOperation.user_id) == user_id, + col(ProviderFileOutputOperation.state) != "completed", + col(ProviderFileOutputOperation.deadline) > now, + ) + ) + ).one() + return int(count + output_count), int(size + output_size) + + async def revoke( + self, + now: datetime, + reason: str, + *, + organization_id: uuid.UUID, + generation_id: uuid.UUID | None = None, + workspace_id: uuid.UUID | None = None, + user_id: str | None = None, + ) -> None: + statement = update(ProviderFileBinding).where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.state).in_(["active", "pending_upload"]), + ) + output = update(ProviderFileOutputOperation).where( + col(ProviderFileOutputOperation.organization_id) == organization_id, + col(ProviderFileOutputOperation.state) == "active", + ) + if generation_id is not None: + statement = statement.where(col(ProviderFileBinding.provider_account_generation_id) == generation_id) + output = output.where(col(ProviderFileOutputOperation.provider_account_generation_id) == generation_id) + if workspace_id is not None: + statement = statement.where(col(ProviderFileBinding.workspace_id) == workspace_id) + output = output.where(col(ProviderFileOutputOperation.workspace_id) == workspace_id) + if user_id is not None: + statement = statement.where(col(ProviderFileBinding.user_id) == user_id) + output = output.where(col(ProviderFileOutputOperation.user_id) == user_id) + await self.db.execute(statement.values(state="pending_cleanup", cleanup_reason=reason, cleanup_after=now)) + await self.db.execute(output.values(state="revoked")) + await self.db.flush() + + async def account_busy(self, generation_id: uuid.UUID, now: datetime) -> bool: + binding = ( + await self.db.execute( + select(col(ProviderFileBinding.id)) + .where( + col(ProviderFileBinding.provider_account_generation_id) == generation_id, + col(ProviderFileBinding.state) != "deleted", + or_( + col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.operation_deadline) > now, + ), + ) + .limit(1) + ) + ).scalar_one_or_none() + operation = ( + await self.db.execute( + select(col(ProviderFileOutputOperation.id)) + .where( + col(ProviderFileOutputOperation.provider_account_generation_id) == generation_id, + col(ProviderFileOutputOperation.state) != "completed", + col(ProviderFileOutputOperation.deadline) > now, + ) + .limit(1) + ) + ).scalar_one_or_none() + return binding is not None or operation is not None + + async def active_user(self, user_id: str) -> bool: + return ( + await self.db.execute( + select(User.user_id).where( + User.user_id == user_id, + User.deleted_at.is_(None), + User.blocked.is_(False), + ) + ) + ).scalar_one_or_none() is not None + + async def revoke_user(self, user_id: str, now: datetime) -> None: + organizations = ( + ( + await self.db.execute( + select(col(ProviderFileBinding.organization_id)) + .where( + col(ProviderFileBinding.user_id) == user_id, + ) + .union( + select(col(ProviderFileOutputOperation.organization_id)).where( + col(ProviderFileOutputOperation.user_id) == user_id, + ) + ) + .order_by("organization_id") + ) + ) + .scalars() + .all() + ) + for organization_id in organizations: + await self.lock_organization(organization_id) + await self.revoke(now, "user_deletion", organization_id=organization_id, user_id=user_id) + + async def lock_user(self, user_id: str) -> None: + await self.db.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + + async def backlog(self, organization_id: uuid.UUID) -> dict[str, int]: + now = datetime.now(UTC) + pending = or_( + col(ProviderFileBinding.state) == "pending_cleanup", + (col(ProviderFileBinding.state) == "active") & (col(ProviderFileBinding.expires_at) <= now), + ) + result = ( + await self.db.execute( + select( + func.coalesce(func.sum(case((col(ProviderFileBinding.state) == "pending_upload", 1), else_=0)), 0), + func.coalesce(func.sum(case((pending, 1), else_=0)), 0), + func.coalesce( + func.sum( + case( + ( + col(ProviderFileBinding.provider_outcome_unknown) + & (col(ProviderFileBinding.state) != "deleted"), + 1, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(col(ProviderFileBinding.cleanup_attempts)), 0), + ).where(col(ProviderFileBinding.organization_id) == organization_id) + ) + ).one() + return dict( + zip( + ("pending_upload", "pending_cleanup", "unknown_outcomes", "cleanup_attempts"), + (int(value) for value in result), + strict=True, + ) + ) diff --git a/src/gateway/services/provider_files/__init__.py b/src/gateway/services/provider_files/__init__.py new file mode 100644 index 0000000000..386cc129ce --- /dev/null +++ b/src/gateway/services/provider_files/__init__.py @@ -0,0 +1 @@ +"""Provider-native file authorization and lifecycle.""" diff --git a/src/gateway/services/provider_files/accounts.py b/src/gateway/services/provider_files/accounts.py new file mode 100644 index 0000000000..fa33231971 --- /dev/null +++ b/src/gateway/services/provider_files/accounts.py @@ -0,0 +1,131 @@ +"""Conservative account selection and credential retirement.""" + +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime + +from pydantic import SecretStr +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.provider_files import ProviderAccountGeneration +from gateway.models.provider_keys import OrgProviderKey, WorkspaceProviderKeyOverride +from gateway.repositories.tenancy.org_provider_key_repository import WorkspaceProviderKeyOverrideRepository +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError +from gateway.services.secret_box import decrypt_secret + + +def select_file_key( + candidates: Sequence[tuple[OrgProviderKey, WorkspaceProviderKeyOverride | None]], +) -> OrgProviderKey | None: + """Select explicit defaults or a unique live key; never choose the oldest key.""" + pinned = [(key, override) for key, override in candidates if override is not None and override.is_default] + defaults = [(key, override) for key, override in candidates if key.is_org_default] + selected = pinned or defaults + if len(selected) > 1: + raise FilesError(409, "Anthropic provider account is ambiguous") + if selected: + key, override = selected[0] + if key.archived_at is not None or (override is not None and override.disabled): + raise FilesError(404, "Anthropic provider account unavailable") + return key + live = [ + key + for key, override in candidates + if key.archived_at is None and not (override is not None and override.disabled) + ] + if len(live) > 1: + raise FilesError(409, "Anthropic provider account is ambiguous") + return live[0] if live else None + + +class FileAccountResolver: + def __init__(self, db: AsyncSession) -> None: + self.db = db + self.repo = ProviderFileRepository(db) + + async def select_byo(self, scope: FileScope) -> FileAccount | None: + await self.repo.lock_user(scope.user_id) + if not await self.repo.lock_organization(scope.organization_id): + raise FilesError(404, "Anthropic provider account unavailable") + candidates = await WorkspaceProviderKeyOverrideRepository(self.db).all_candidates( + organization_id=scope.organization_id, workspace_id=scope.workspace_id + ) + key = select_file_key([(key, override) for key, override in candidates if key.provider == "anthropic"]) + if key is None: + return None + generation = await self.repo.latest_account("organization_key", str(key.id), scope.organization_id) + if generation is None or generation.status == "retired": + number = generation.generation + 1 if generation is not None else 1 + generation = ProviderAccountGeneration( + generation=number, + credential_source="organization_key", + credential_ref=str(key.id), + organization_id=scope.organization_id, + ) + self.db.add(generation) + await self.db.flush() + elif generation.status != "active": + raise FilesError(409, "Anthropic provider account is retiring") + return self._credential(key, generation) + + @staticmethod + def _credential(key: OrgProviderKey, generation: ProviderAccountGeneration) -> FileAccount: + if not key.encrypted_api_key: + raise FilesError(404, "Anthropic provider account unavailable") + # Client args may change account selection or transport. The initial contract accepts none. + if key.client_args: + raise FilesError(400, "Provider file account requires a standard Anthropic credential") + return FileAccount( + generation_id=generation.id, api_key=SecretStr(decrypt_secret(key.encrypted_api_key)), api_base=key.api_base + ) + + async def resolve_byo( + self, generation_id: uuid.UUID, organization_id: uuid.UUID, *, cleanup: bool = False + ) -> FileAccount: + row = await self.repo.account(generation_id) + if ( + row is None + or row.organization_id != organization_id + or row.credential_source != "organization_key" + or (not cleanup and row.status != "active") + ): + raise FilesError(404, "Anthropic provider account unavailable") + key = await self.db.get(OrgProviderKey, uuid.UUID(row.credential_ref)) + if key is None or key.organization_id != organization_id or (not cleanup and key.archived_at is not None): + raise FilesError(404, "Anthropic provider account unavailable") + return self._credential(key, row) + + +async def retire_byo_account(db: AsyncSession, key: OrgProviderKey, *, release_secret: bool) -> None: + """Revoke first, retaining the old secret until all known work settles.""" + repo = ProviderFileRepository(db) + await repo.lock_organization(key.organization_id) + await db.refresh(key) + row = await repo.latest_account("organization_key", str(key.id), key.organization_id) + if row is None: + return + await retire_account_generation(db, row, release_secret=release_secret) + + +async def retire_account_generation( + db: AsyncSession, + row: ProviderAccountGeneration, + *, + release_secret: bool, +) -> None: + """Apply retirement for BYO or an adapter-owned source in the credential transaction.""" + repo = ProviderFileRepository(db) + await repo.lock_organization(row.organization_id) + await db.refresh(row) + now = datetime.now(UTC) + row.status = "retiring" + await repo.revoke(now, "credential_retirement", organization_id=row.organization_id, generation_id=row.id) + if release_secret and await repo.account_busy(row.id, now): + # Retirement must persist even though replacement is refused. + await db.commit() + from gateway.services.tenancy.errors import TenancyConflictError + + raise TenancyConflictError("Provider file cleanup must finish before replacing or deleting this credential") + if release_secret: + row.status, row.retired_at = "retired", now diff --git a/src/gateway/services/provider_files/cleanup.py b/src/gateway/services/provider_files/cleanup.py new file mode 100644 index 0000000000..53028a5c98 --- /dev/null +++ b/src/gateway/services/provider_files/cleanup.py @@ -0,0 +1,167 @@ +"""Durable, fenced cleanup leases for stateless gateway executors.""" + +import hashlib +import hmac +import secrets +import uuid +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta + +from pydantic import SecretStr +from sqlalchemy import or_, select, update +from sqlmodel import col + +from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding +from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.contracts import CleanupItem, CleanupLease, FileAccount, FilesError, LeaseResult +from gateway.services.provider_files.lifecycle import ProviderFileService + + +class ProviderFileCleanup: + def __init__(self, service: ProviderFileService) -> None: + self.service = service + self.db = service.db + self.repo = service.repo + + async def claim( + self, + organization_id: uuid.UUID, + gateway_id: str, + limit: int = 20, + *, + include_managed: bool = False, + resolve_account: Callable[[uuid.UUID], Awaitable[FileAccount]] | None = None, + ) -> CleanupLease | None: + """Organization scope must come from the registered gateway, never the request body.""" + now = datetime.now(UTC) + await self.repo.lock_organization(organization_id) + await self.db.execute( + update(ProviderFileBinding) + .where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.state) == "active", + col(ProviderFileBinding.expires_at) <= now, + ) + .values(state="pending_cleanup", cleanup_reason="expiry", cleanup_after=now) + ) + await self.db.execute( + update(ProviderFileBinding) + .where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.provider_file_id).is_(None), + col(ProviderFileBinding.operation_deadline) < now - timedelta(seconds=self.service.diagnostic_seconds), + col(ProviderFileBinding.state).in_(["pending_upload", "pending_cleanup"]), + ) + .values(state="deleted", deleted_at=now, provider_outcome_unknown=False) + ) + due = select(col(ProviderFileBinding.provider_account_generation_id)).where( + col(ProviderFileBinding.state) == "pending_cleanup", + col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.cleanup_after) <= now, + ) + account = ( + await self.db.execute( + select(ProviderAccountGeneration) + .where( + col(ProviderAccountGeneration.organization_id) == organization_id, + col(ProviderAccountGeneration.credential_source).in_( + ["organization_key", "hosted_backend"] + if include_managed and resolve_account is not None + else ["organization_key"] + ), + col(ProviderAccountGeneration.id).in_(due), + or_( + col(ProviderAccountGeneration.lease_deadline).is_(None), + col(ProviderAccountGeneration.lease_deadline) <= now, + ), + ) + .order_by(col(ProviderAccountGeneration.id)) + .limit(1) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + if account is None: + await self.db.commit() + return None + credential = ( + await resolve_account(account.id) + if resolve_account is not None + else await FileAccountResolver(self.db).resolve_byo(account.id, organization_id, cleanup=True) + ) + rows = list( + ( + await self.db.execute( + select(ProviderFileBinding) + .where( + col(ProviderFileBinding.provider_account_generation_id) == account.id, + col(ProviderFileBinding.state) == "pending_cleanup", + col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.cleanup_after) <= now, + ) + .order_by(col(ProviderFileBinding.cleanup_after), col(ProviderFileBinding.id)) + .limit(min(20, max(1, limit))) + ) + ).scalars() + ) + token = secrets.token_urlsafe(32) + account.lease_id = uuid.uuid4() + account.lease_token_hash = hashlib.sha256(token.encode()).hexdigest() + account.lease_gateway_id = gateway_id + account.lease_deadline = now + timedelta(seconds=300) + for row in rows: + row.lease_id = account.lease_id + result = CleanupLease( + id=account.lease_id, + token=SecretStr(token), + deadline=account.lease_deadline, + account=credential, + items=[ + CleanupItem(binding_id=row.id, file_id=row.provider_file_id) + for row in rows + if row.provider_file_id is not None + ], + ) + await self.db.commit() + return result + + async def complete( + self, organization_id: uuid.UUID, gateway_id: str, lease_id: uuid.UUID, result: LeaseResult + ) -> None: + await self.repo.lock_organization(organization_id) + account = ( + await self.db.execute( + select(ProviderAccountGeneration) + .where( + col(ProviderAccountGeneration.organization_id) == organization_id, + col(ProviderAccountGeneration.lease_id) == lease_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + digest = hashlib.sha256(result.token.get_secret_value().encode()).hexdigest() + if ( + account is None + or account.lease_gateway_id != gateway_id + or account.lease_deadline is None + or account.lease_deadline <= datetime.now(UTC) + or not hmac.compare_digest(account.lease_token_hash or "", digest) + ): + raise FilesError(409, "Cleanup lease unavailable") + rows = list( + ( + await self.db.execute( + select(ProviderFileBinding).where( + col(ProviderFileBinding.provider_account_generation_id) == account.id, + col(ProviderFileBinding.lease_id) == lease_id, + ) + ) + ).scalars() + ) + if set(result.results) - {row.id for row in rows}: + raise FilesError(409, "Cleanup lease item conflict") + for row in rows: + if row.state == "pending_cleanup": + self.service.apply_cleanup(row, result.results.get(row.id, False)) + row.lease_id = None + account.lease_id = account.lease_token_hash = account.lease_gateway_id = account.lease_deadline = None + await self.db.commit() diff --git a/src/gateway/services/provider_files/client.py b/src/gateway/services/provider_files/client.py new file mode 100644 index 0000000000..b753a6917d --- /dev/null +++ b/src/gateway/services/provider_files/client.py @@ -0,0 +1,63 @@ +"""Stateless client for the trusted Files authority; never transfers file bytes.""" + +from typing import Any, TypeVar + +import httpx +from pydantic import BaseModel, ValidationError + +from gateway.services.provider_files.contracts import FilesError + +T = TypeVar("T", bound=BaseModel) + + +class PlatformFilesClient: + def __init__(self, base_url: str, gateway_token: str, user_token: str | None, *, timeout: float = 5) -> None: + self.base_url = base_url.rstrip("/") + self._gateway_token = gateway_token + self._user_token = user_token + self.timeout = timeout + + async def post(self, path: str, body: dict[str, Any], result_type: type[T]) -> T: + headers = {"X-Gateway-Token": self._gateway_token} + if self._user_token is not None: + headers["X-User-Token"] = self._user_token + try: + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=False) as client: + response = await client.post(f"{self.base_url}/gateway/files/{path}", headers=headers, json=body) + except httpx.HTTPError: + raise FilesError(502, "Authorization service unavailable") from None + if response.status_code == 404: + # A supporting peer marks all Files responses, including missing bindings. + if response.headers.get("X-Otari-Files-Protocol") != "1": + raise FilesError(502, "Authorization service does not support provider-native Files") + raise FilesError(404, "File or provider account unavailable") + if response.status_code in {400, 401, 403, 409, 413, 429}: + details = { + 400: "Invalid file operation", + 401: "Invalid authentication token", + 403: "File operation forbidden", + 409: "File operation conflict", + 413: "File size limit exceeded", + 429: "File operation limit exceeded", + } + raise FilesError( + response.status_code, + details[response.status_code], + {"Retry-After": response.headers["Retry-After"]} if "Retry-After" in response.headers else None, + ) + if response.status_code != 200: + raise FilesError(502, "Authorization service unavailable") + try: + return result_type.model_validate(response.json()) + except (ValueError, ValidationError): + raise FilesError(502, "Authorization service returned an invalid file response") from None + + async def retry(self, path: str, body: dict[str, Any], result_type: type[T]) -> T: + """Bound retries to idempotent finalization and cleanup reports only.""" + for attempt in range(3): + try: + return await self.post(path, body, result_type) + except FilesError as exc: + if exc.status_code != 502 or attempt == 2: + raise + raise AssertionError("unreachable") diff --git a/src/gateway/services/provider_files/contracts.py b/src/gateway/services/provider_files/contracts.py new file mode 100644 index 0000000000..3da3d2f2f0 --- /dev/null +++ b/src/gateway/services/provider_files/contracts.py @@ -0,0 +1,180 @@ +"""Additive gateway/control-plane contracts for provider-native files.""" + +import uuid +from datetime import datetime +from typing import Annotated, Any, Literal, Self + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, model_validator + + +class FilesError(Exception): + """A fixed, caller-safe file operation failure.""" + + def __init__(self, status_code: int, detail: str, headers: dict[str, str] | None = None) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + self.headers = headers or ({"Retry-After": "60"} if status_code == 429 else {}) + + +class WireModel(BaseModel): + model_config = ConfigDict(extra="ignore") + + +ProviderFileId = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")] + + +class FileMetadata(WireModel): + """Anthropic's public metadata, without provider-neutral-only fields.""" + + model_config = ConfigDict(extra="allow") + + id: ProviderFileId + type: Literal["file"] = "file" + filename: str = Field(max_length=1024) + mime_type: str = Field(max_length=255) + size_bytes: int = Field(ge=0) + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + downloadable: bool + + @model_validator(mode="before") + @classmethod + def native_fields(cls, value: Any) -> Any: + if isinstance(value, dict): + return {key: item for key, item in value.items() if key not in {"purpose", "status"}} + return value + + @model_validator(mode="after") + def bounded_metadata(self) -> Self: + if len(self.model_dump_json().encode()) > 16384: + raise ValueError("Provider file metadata exceeds the size limit") + return self + + +class FilePage(WireModel): + data: list[FileMetadata] + next_page: str | None = None + + +class FileListRequest(WireModel): + page: str | None = Field(default=None, max_length=4096) + limit: int | None = Field(default=None, ge=1, le=1000) + ids: list[ProviderFileId] | None = Field(default=None, max_length=100) + + @model_validator(mode="after") + def compatible_filters(self) -> Self: + if self.ids is not None and (self.page is not None or self.limit is not None): + raise ValueError("ids[] cannot be combined with page or limit") + return self + + +class FileScope(WireModel): + """Derived from authenticated gateway and workspace API key, never a public body.""" + + organization_id: uuid.UUID + workspace_id: uuid.UUID + user_id: str = Field(max_length=255) + gateway_id: str = Field(max_length=255) + default_gateway: bool = False + + +class FileAccount(WireModel): + generation_id: uuid.UUID + provider: Literal["anthropic"] = "anthropic" + api_key: SecretStr + api_base: str | None = None + workspace: str | None = None + managed: bool = False + + +class Operation(WireModel): + id: uuid.UUID + cleanup_token: SecretStr + deadline: datetime + account: FileAccount + max_bytes: int = Field(gt=0) + expires_in_seconds: int = Field(ge=3600, le=7776000) + + +class PrepareUpload(WireModel): + operation_id: uuid.UUID + size_bytes: int = Field(ge=0) + expires_in_seconds: int | None = Field(default=None, ge=3600, le=7776000) + + +class FinalizeUpload(WireModel): + expires_in_seconds: int | None = Field(default=None, ge=3600, le=7776000) + metadata: FileMetadata + + +class AbandonUpload(WireModel): + file_id: str | None = Field(default=None, max_length=255, pattern=r"^[A-Za-z0-9_-]+$") + cleanup_token: SecretStr + metadata: FileMetadata | None = None + deleted: bool = False + outcome_unknown: bool = False + + +class ResolveFile(WireModel): + operation: Literal["metadata", "download", "delete"] + + +class ResolvedFile(WireModel): + metadata: FileMetadata + account: FileAccount | None = None + operation_id: uuid.UUID | None = None + cleanup_token: SecretStr | None = None + + +class References(WireModel): + ids: list[ProviderFileId] = Field(min_length=1, max_length=100) + + +class CleanupResult(WireModel): + cleanup_token: SecretStr + deleted: bool + + +class OutputPrepare(WireModel): + operation_id: uuid.UUID + request_id: str = Field(max_length=255) + attempt_id: str = Field(max_length=255) + generation_id: uuid.UUID + + +class OutputRegister(WireModel): + operation_id: uuid.UUID + metadata: FileMetadata + + +class CleanupClaim(WireModel): + limit: int = Field(default=20, ge=1, le=20) + + +class CleanupItem(WireModel): + binding_id: uuid.UUID + file_id: str + + +class CleanupLease(WireModel): + id: uuid.UUID + token: SecretStr + deadline: datetime + account: FileAccount + items: list[CleanupItem] + + +class LeaseResult(WireModel): + token: SecretStr + results: dict[uuid.UUID, bool] = Field(max_length=20) + + +class OutputCleanup(WireModel): + operation_id: uuid.UUID + cleanup_token: SecretStr + + +class NativeFileDeleted(WireModel): + id: str + type: Literal["file_deleted"] = "file_deleted" diff --git a/src/gateway/services/provider_files/executor.py b/src/gateway/services/provider_files/executor.py new file mode 100644 index 0000000000..6d0296e4b5 --- /dev/null +++ b/src/gateway/services/provider_files/executor.py @@ -0,0 +1,41 @@ +"""Stateless cleanup executor, with all scheduling authority in the control plane.""" + +import asyncio + +from gateway.core.config import GatewayConfig +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import CleanupLease, WireModel +from gateway.services.provider_files.transport import provider_client, provider_error + + +class ClaimedCleanup(WireModel): + lease: CleanupLease | None = None + + +async def run_provider_file_cleanup(config: GatewayConfig) -> None: + client = PlatformFilesClient(config.platform["base_url"], config.platform_token or "", None) + while True: + try: + claimed = await client.post("cleanup/claim", {"limit": 20}, ClaimedCleanup) + if claimed.lease is not None: + lease = claimed.lease + results: dict[str, bool] = {} + async with asyncio.timeout(240), provider_client(lease.account) as provider: + for item in lease.items: + try: + await provider.adelete_file(item.file_id, max_retries=0) + results[str(item.binding_id)] = True + except Exception as exc: + results[str(item.binding_id)] = provider_error(exc).status_code == 404 + await client.retry( + f"cleanup/{lease.id}/result", + { + "token": lease.token.get_secret_value(), + "results": results, + }, + WireModel, + ) + except Exception: + # The lease remains durable and may be reclaimed after its deadline. + pass + await asyncio.sleep(60) diff --git a/src/gateway/services/provider_files/inference.py b/src/gateway/services/provider_files/inference.py new file mode 100644 index 0000000000..147dc5dc5b --- /dev/null +++ b/src/gateway/services/provider_files/inference.py @@ -0,0 +1,128 @@ +"""Withhold provider output references until their durable binding is active.""" + +import json +from collections.abc import AsyncIterator +from typing import Any + +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FileMetadata, FilesError, Operation, OutputCleanup, WireModel +from gateway.services.provider_files.references import collect_file_references +from gateway.services.provider_files.transport import provider_client, provider_error + + +class FileOutputBinder: + def __init__(self, client: PlatformFilesClient, operation: Operation, existing_ids: list[str]) -> None: + self.client = client + self.operation = operation + self.bound = set(existing_ids) + + async def register(self, value: Any) -> None: + for file_id in collect_file_references(value): + if file_id in self.bound: + continue + try: + async with provider_client(self.operation.account) as provider: + result = await provider.aretrieve_file(file_id, max_retries=0) + metadata = FileMetadata.model_validate(result.model_dump(exclude_unset=True)) + except Exception: + await self.compensate(None, file_id) + raise FilesError(502, "Provider file metadata could not be registered") from None + try: + await self.client.retry( + "outputs/register", + { + "operation_id": str(self.operation.id), + "metadata": metadata.model_dump(mode="json", exclude_unset=True), + }, + FileMetadata, + ) + except FilesError: + await self.compensate(metadata) + raise + self.bound.add(file_id) + + async def complete(self) -> None: + try: + await self.client.retry( + f"outputs/{self.operation.id}/complete", + {"cleanup_token": self.operation.cleanup_token.get_secret_value(), "deleted": False}, + WireModel, + ) + except FilesError: + # The durable deadline releases reserved capacity if the peer is unavailable. + pass + + async def compensate(self, metadata: FileMetadata | None, file_id: str | None = None) -> None: + try: + cleanup = await self.client.retry( + f"outputs/{self.operation.id}/abandon", + { + "cleanup_token": self.operation.cleanup_token.get_secret_value(), + "metadata": metadata.model_dump(mode="json", exclude_unset=True) if metadata else None, + "file_id": file_id, + }, + OutputCleanup, + ) + # The authority proves this ID belongs to this operation before deletion. + if cleanup.operation_id is None or cleanup.cleanup_token is None: + return + deleted = False + try: + async with provider_client(self.operation.account) as provider: + await provider.adelete_file(metadata.id if metadata else file_id, max_retries=0) + deleted = True + except Exception as exc: + deleted = provider_error(exc).status_code == 404 + await self.client.retry( + f"{cleanup.operation_id}/cleanup-result", + {"cleanup_token": cleanup.cleanup_token.get_secret_value(), "deleted": deleted}, + WireModel, + ) + except FilesError: + pass + + async def stream(self, source: AsyncIterator[Any]) -> AsyncIterator[Any]: + held: list[Any] = [] + block: dict[str, Any] | None = None + size = 0 + partial = "" + try: + async for event in source: + payload = event.model_dump(exclude_unset=True) + kind = payload.get("type") + if kind == "content_block_start": + candidate = payload.get("content_block", {}) + # Hold structured provider results, keeping all later events behind them. + if isinstance(candidate, dict) and ( + "tool_result" in candidate.get("type", "") or collect_file_references(candidate) + ): + block = candidate + if block is not None: + size += len(event.model_dump_json()) + if size > 1024 * 1024 or len(held) >= 4096: + raise FilesError(502, "Provider file output exceeds registration limits") + held.append(event) + delta = payload.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "input_json_delta": + partial += delta.get("partial_json", "") + if kind == "content_block_stop": + if partial: + try: + decoded = json.loads(partial) + except ValueError: + raise FilesError(502, "Invalid provider file output") from None + await self.register(decoded) + await self.register(block) + for buffered in held: + yield buffered + held, block, size, partial = [], None, 0, "" + else: + await self.register(payload) + yield event + if held: + raise FilesError(502, "Incomplete provider file output") + finally: + close = getattr(source, "aclose", None) + if close is not None: + await close() + await self.complete() diff --git a/src/gateway/services/provider_files/lifecycle.py b/src/gateway/services/provider_files/lifecycle.py new file mode 100644 index 0000000000..7b1c81b9af --- /dev/null +++ b/src/gateway/services/provider_files/lifecycle.py @@ -0,0 +1,361 @@ +"""Control-plane authorization and atomic provider-file state transitions.""" + +import json +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +from pydantic import SecretStr +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding, ProviderFileRateWindow +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.contracts import ( + AbandonUpload, + FileAccount, + FileListRequest, + FileMetadata, + FilePage, + FileScope, + FilesError, + Operation, + PrepareUpload, + ResolvedFile, +) +from gateway.services.secret_box import decrypt_secret, encrypt_secret + + +class ProviderFileService: + """Own transactions; callers supply identities derived by their gateway authenticator.""" + + def __init__( + self, + db: AsyncSession, + *, + max_bytes: int, + max_files: int, + max_outstanding_bytes: int, + rate_limit_rpm: int = 60, + retention_seconds: int = 604800, + operation_seconds: int = 600, + diagnostic_seconds: int = 2592000, + ) -> None: + if not (max_bytes > 0 and max_files > 0 and max_outstanding_bytes >= max_bytes): + raise ValueError("Explicit positive file and outstanding-byte quotas are required") + if not 3600 <= retention_seconds <= 7776000 or operation_seconds <= 0 or rate_limit_rpm <= 0: + raise ValueError("Invalid provider file limits") + self.db = db + self.repo = ProviderFileRepository(db) + self.max_bytes = max_bytes + self.max_files = max_files + self.max_outstanding_bytes = max_outstanding_bytes + self.rate_limit_rpm = rate_limit_rpm + self.retention_seconds = retention_seconds + self.operation_seconds = operation_seconds + self.diagnostic_seconds = diagnostic_seconds + + async def _lock_scope(self, scope: FileScope) -> None: + await self.repo.lock_user(scope.user_id) + if not await self.repo.lock_organization(scope.organization_id): + raise FilesError(404, "File unavailable") + if not await self.repo.active_user(scope.user_id): + raise FilesError(404, "File unavailable") + if not await self.repo.workspace_exists(scope.workspace_id, scope.organization_id): + raise FilesError(404, "File unavailable") + + async def _rate_limit(self, scope: FileScope, now: datetime) -> None: + window = int(now.timestamp()) // 60 + row = await self.db.get(ProviderFileRateWindow, (scope.workspace_id, scope.user_id)) + if row is None: + row = ProviderFileRateWindow(workspace_id=scope.workspace_id, user_id=scope.user_id, window=window) + self.db.add(row) + if row.window != window: + row.window, row.count = window, 0 + if row.count >= self.rate_limit_rpm: + raise FilesError(429, "File operation rate limit exceeded") + row.count += 1 + await self.db.flush() + + async def _account(self, scope: FileScope, account: FileAccount) -> ProviderAccountGeneration: + row = await self.repo.account(account.generation_id) + if row is None or row.organization_id != scope.organization_id or row.status != "active": + raise FilesError(404, "Provider account unavailable") + if row.credential_source == "hosted_backend" and not scope.default_gateway: + raise FilesError(403, "Managed provider files require the default gateway") + return row + + @staticmethod + def _owns(row: ProviderFileBinding, scope: FileScope) -> bool: + return (row.organization_id, row.workspace_id, row.user_id) == ( + scope.organization_id, + scope.workspace_id, + scope.user_id, + ) + + @staticmethod + def _metadata(row: ProviderFileBinding) -> FileMetadata: + if row.encrypted_metadata is None: + raise FilesError(404, "File unavailable") + return FileMetadata.model_validate_json(decrypt_secret(row.encrypted_metadata)) + + @staticmethod + def _token(row: ProviderFileBinding) -> str: + # Authenticated encryption permits idempotent prepare to mint equivalent scoped tokens. + return encrypt_secret( + json.dumps({"id": str(row.id), "gateway": row.initiating_gateway_id, "nonce": row.cleanup_token_hash}) + ) + + @staticmethod + def _check_token(row: ProviderFileBinding, gateway_id: str, token: str) -> None: + try: + payload = json.loads(decrypt_secret(token)) + except (ValueError, TypeError): + raise FilesError(403, "Invalid cleanup authority") from None + expected = {"id": str(row.id), "gateway": gateway_id, "nonce": row.cleanup_token_hash} + if payload != expected or row.initiating_gateway_id != gateway_id: + raise FilesError(403, "Invalid cleanup authority") + + def _operation(self, row: ProviderFileBinding, account: FileAccount) -> Operation: + return Operation( + id=row.id, + cleanup_token=SecretStr(self._token(row)), + deadline=row.operation_deadline, + account=account, + max_bytes=row.size_bytes, + expires_in_seconds=int((row.expires_at - row.created_at).total_seconds()), + ) + + async def prepare(self, scope: FileScope, account: FileAccount, request: PrepareUpload) -> Operation: + now = datetime.now(UTC) + await self._lock_scope(scope) + await self._account(scope, account) + existing = await self.repo.get(request.operation_id) + duration = min(request.expires_in_seconds or self.retention_seconds, self.retention_seconds) + reserved_bytes = min(request.size_bytes, self.max_bytes) + if existing is not None: + if ( + not self._owns(existing, scope) + or existing.initiating_gateway_id != scope.gateway_id + or existing.provider_account_generation_id != account.generation_id + or existing.size_bytes != reserved_bytes + or int((existing.expires_at - existing.created_at).total_seconds()) != duration + ): + raise FilesError(409, "Upload operation conflict") + if existing.state != "pending_upload" or existing.operation_deadline <= now: + raise FilesError(409, "Upload operation is no longer pending") + return self._operation(existing, account) + if request.size_bytes <= 0: + raise FilesError(413, "File size limit exceeded") + await self._rate_limit(scope, now) + count, size = await self.repo.capacity(scope.workspace_id, scope.user_id, now) + if count >= self.max_files or size + reserved_bytes > self.max_outstanding_bytes: + raise FilesError(429, "File capacity exceeded") + row = ProviderFileBinding( + id=request.operation_id, + organization_id=scope.organization_id, + workspace_id=scope.workspace_id, + user_id=scope.user_id, + provider_account_generation_id=account.generation_id, + size_bytes=reserved_bytes, + created_at=now, + expires_at=now + timedelta(seconds=duration), + operation_deadline=now + timedelta(seconds=self.operation_seconds), + initiating_gateway_id=scope.gateway_id, + cleanup_token_hash=secrets.token_hex(32), + ) + self.db.add(row) + await self.db.commit() + return self._operation(row, account) + + async def finalize( + self, + scope: FileScope, + binding_id: uuid.UUID, + metadata: FileMetadata, + expires_in_seconds: int | None = None, + ) -> FileMetadata: + now = datetime.now(UTC) + await self.repo.lock_user(scope.user_id) + await self.repo.lock_organization(scope.organization_id) + row = await self.repo.get(binding_id) + if row is None or not self._owns(row, scope) or row.initiating_gateway_id != scope.gateway_id: + raise FilesError(404, "Upload operation unavailable") + collision = await self.repo.by_provider_id(row.provider_account_generation_id, metadata.id) + if collision is not None and collision.id != row.id: + raise FilesError(409, "Provider file ownership conflict") + if row.provider_file_id is not None: + if row.provider_file_id != metadata.id or self._metadata(row) != metadata: + raise FilesError(409, "Upload operation conflict") + if row.state == "active" and row.expires_at > now: + return self._metadata(row) + raise FilesError(409, "Upload operation has been revoked") + account = await self.repo.account(row.provider_account_generation_id) + active = ( + row.state == "pending_upload" + and row.operation_deadline > now + and account is not None + and account.status == "active" + and metadata.size_bytes <= row.size_bytes + and await self.repo.active_user(scope.user_id) + and await self.repo.workspace_exists(scope.workspace_id, scope.organization_id) + ) + row.provider_file_id = metadata.id + row.encrypted_metadata = encrypt_secret(metadata.model_dump_json(exclude_unset=True)) + row.size_bytes = metadata.size_bytes + row.downloadable = metadata.downloadable + if expires_in_seconds is not None: + if not 3600 <= expires_in_seconds <= 7776000: + raise FilesError(400, "Invalid file retention") + row.expires_at = min(row.expires_at, row.created_at + timedelta(seconds=expires_in_seconds)) + row.provider_expires_at = metadata.expires_at + if metadata.expires_at is not None: + row.expires_at = min(row.expires_at, metadata.expires_at) + active = active and row.expires_at > now + row.state = "active" if active else "pending_cleanup" + if not active: + row.cleanup_after, row.cleanup_reason = now, "revoked_operation" + row.updated_at = now + await self.db.commit() + if not active: + raise FilesError(409, "Upload operation has been revoked") + return metadata + + async def abandon(self, binding_id: uuid.UUID, gateway_id: str, request: AbandonUpload) -> None: + row = await self.repo.get(binding_id) + if row is None: + raise FilesError(404, "Upload operation unavailable") + await self.repo.lock_organization(row.organization_id) + await self.db.refresh(row) + self._check_token(row, gateway_id, request.cleanup_token.get_secret_value()) + if row.state == "active" and (request.metadata is None or self._metadata(row) != request.metadata): + raise FilesError(409, "Upload was already finalized") + if request.metadata is not None: + collision = await self.repo.by_provider_id(row.provider_account_generation_id, request.metadata.id) + if collision is not None and collision.id != row.id: + raise FilesError(409, "Provider file ownership conflict") + if row.provider_file_id is not None and row.provider_file_id != request.metadata.id: + raise FilesError(409, "Upload operation conflict") + row.provider_file_id = request.metadata.id + row.encrypted_metadata = encrypt_secret(request.metadata.model_dump_json(exclude_unset=True)) + row.provider_outcome_unknown = request.outcome_unknown + if request.deleted or (row.provider_file_id is None and not request.outcome_unknown): + row.state, row.deleted_at = "deleted", datetime.now(UTC) + elif row.provider_file_id is not None: + row.state, row.cleanup_after = "pending_cleanup", datetime.now(UTC) + row.cleanup_reason = "upload_abandoned" + await self.db.commit() + + async def list_files(self, scope: FileScope, request: FileListRequest) -> FilePage: + now = datetime.now(UTC) + await self._lock_scope(scope) + await self._rate_limit(scope, now) + snapshot, before = now, None + limit = request.limit or 20 + scope_key = f"{scope.organization_id}:{scope.workspace_id}:{scope.user_id}" + if request.page: + try: + cursor = json.loads(decrypt_secret(request.page)) + if cursor["scope"] != scope_key or cursor["limit"] != limit: + raise ValueError + snapshot = datetime.fromisoformat(cursor["snapshot"]) + before = (datetime.fromisoformat(cursor["created_at"]), uuid.UUID(cursor["id"])) + except (ValueError, KeyError, TypeError): + raise FilesError(400, "Invalid file page") from None + rows = await self.repo.visible( + scope.organization_id, + scope.workspace_id, + scope.user_id, + now, + ids=request.ids, + limit=101 if request.ids is not None else limit + 1, + before=before, + snapshot=snapshot, + ) + next_page = None + if request.ids is None and len(rows) > limit: + rows = rows[:limit] + last = rows[-1] + next_page = encrypt_secret( + json.dumps( + { + "scope": scope_key, + "limit": limit, + "snapshot": snapshot.isoformat(), + "created_at": last.created_at.isoformat(), + "id": str(last.id), + } + ) + ) + result = FilePage(data=[self._metadata(row) for row in rows], next_page=next_page) + await self.db.commit() + return result + + async def resolve( + self, + scope: FileScope, + file_id: str, + operation: str, + account: FileAccount | None = None, + ) -> ResolvedFile: + now = datetime.now(UTC) + await self._lock_scope(scope) + await self._rate_limit(scope, now) + rows = await self.repo.visible(scope.organization_id, scope.workspace_id, scope.user_id, now, ids=[file_id]) + if len(rows) != 1: + raise FilesError(404, "File unavailable") + row = rows[0] + if operation == "download" and not row.downloadable: + raise FilesError(400, "This file is not downloadable") + if operation != "metadata": + if account is None or account.generation_id != row.provider_account_generation_id: + raise FilesError(404, "Provider account unavailable") + await self._account(scope, account) + if operation == "delete": + row.state, row.cleanup_reason, row.cleanup_after = "pending_cleanup", "delete", now + row.initiating_gateway_id = scope.gateway_id + result = ResolvedFile( + metadata=self._metadata(row), + account=account if operation != "metadata" else None, + operation_id=row.id if operation == "delete" else None, + cleanup_token=SecretStr(self._token(row)) if operation == "delete" else None, + ) + await self.db.commit() + return result + + async def references(self, scope: FileScope, ids: list[str]) -> uuid.UUID: + await self._lock_scope(scope) + if not ids or len(ids) > 100: + raise FilesError(400, "Invalid file references") + rows = await self.repo.visible( + scope.organization_id, scope.workspace_id, scope.user_id, datetime.now(UTC), ids=list(set(ids)) + ) + if len(rows) != len(set(ids)): + raise FilesError(404, "File unavailable") + accounts = {row.provider_account_generation_id for row in rows} + if len(accounts) != 1: + raise FilesError(400, "Files must belong to one provider account") + return accounts.pop() + + async def cleanup_result(self, binding_id: uuid.UUID, gateway_id: str, token: str, deleted: bool) -> None: + row = await self.repo.get(binding_id) + if row is None: + raise FilesError(404, "Cleanup operation unavailable") + await self.repo.lock_organization(row.organization_id) + await self.db.refresh(row) + self._check_token(row, gateway_id, token) + if row.state == "deleted": + return + if row.state != "pending_cleanup": + raise FilesError(409, "File is not awaiting cleanup") + self.apply_cleanup(row, deleted) + await self.db.commit() + + @staticmethod + def apply_cleanup(row: ProviderFileBinding, deleted: bool) -> None: + now = datetime.now(UTC) + if deleted: + row.state, row.deleted_at, row.cleanup_after = "deleted", now, None + else: + row.cleanup_attempts += 1 + row.cleanup_after = now + timedelta(seconds=min(21600, 60 * 2 ** min(row.cleanup_attempts - 1, 9))) + row.updated_at = now diff --git a/src/gateway/services/provider_files/outputs.py b/src/gateway/services/provider_files/outputs.py new file mode 100644 index 0000000000..1224b0febd --- /dev/null +++ b/src/gateway/services/provider_files/outputs.py @@ -0,0 +1,223 @@ +"""Reserve output capacity before dispatch and commit generated IDs before exposure.""" + +import json +import secrets +import uuid +from datetime import UTC, datetime, timedelta + +from pydantic import SecretStr + +from gateway.models.provider_files import ProviderFileBinding, ProviderFileOutputOperation +from gateway.services.provider_files.contracts import ( + FileAccount, + FileMetadata, + FileScope, + FilesError, + Operation, + OutputCleanup, + OutputPrepare, +) +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.secret_box import decrypt_secret, encrypt_secret + + +class ProviderFileOutputs: + def __init__(self, service: ProviderFileService) -> None: + self.service = service + self.db = service.db + self.repo = service.repo + + @staticmethod + def _token(row: ProviderFileOutputOperation) -> str: + return encrypt_secret( + json.dumps({"output": str(row.id), "gateway": row.initiating_gateway_id, "nonce": row.cleanup_token_hash}) + ) + + async def prepare(self, scope: FileScope, account: FileAccount, request: OutputPrepare) -> Operation: + """The gateway authority must verify request_id/attempt_id before calling this method.""" + now = datetime.now(UTC) + await self.service._lock_scope(scope) + await self.service._account(scope, account) + if request.generation_id != account.generation_id: + raise FilesError(409, "Inference account conflict") + row = await self.db.get(ProviderFileOutputOperation, request.operation_id) + if row is not None: + if ( + ( + row.organization_id, + row.workspace_id, + row.user_id, + row.initiating_gateway_id, + row.request_id, + row.attempt_id, + row.provider_account_generation_id, + ) + != ( + scope.organization_id, + scope.workspace_id, + scope.user_id, + scope.gateway_id, + request.request_id, + request.attempt_id, + request.generation_id, + ) + or row.state != "active" + or row.deadline <= now + ): + raise FilesError(409, "Output operation conflict") + else: + count, size = await self.repo.capacity(scope.workspace_id, scope.user_id, now) + reserved = min(20, self.service.max_files - count) + available = min(reserved * self.service.max_bytes, self.service.max_outstanding_bytes - size) + if reserved <= 0 or available <= 0: + raise FilesError(429, "File capacity exceeded") + row = ProviderFileOutputOperation( + id=request.operation_id, + organization_id=scope.organization_id, + workspace_id=scope.workspace_id, + user_id=scope.user_id, + provider_account_generation_id=account.generation_id, + initiating_gateway_id=scope.gateway_id, + request_id=request.request_id, + attempt_id=request.attempt_id, + cleanup_token_hash=secrets.token_hex(32), + deadline=now + timedelta(seconds=self.service.operation_seconds), + reserved_files=reserved, + reserved_bytes=available, + ) + self.db.add(row) + await self.db.commit() + return Operation( + id=row.id, + cleanup_token=SecretStr(self._token(row)), + deadline=row.deadline, + account=account, + max_bytes=self.service.max_bytes, + expires_in_seconds=self.service.retention_seconds, + ) + + async def register(self, scope: FileScope, operation_id: uuid.UUID, metadata: FileMetadata) -> FileMetadata: + now = datetime.now(UTC) + await self.repo.lock_user(scope.user_id) + await self.repo.lock_organization(scope.organization_id) + row = await self.db.get(ProviderFileOutputOperation, operation_id) + if row is None or (row.organization_id, row.workspace_id, row.user_id, row.initiating_gateway_id) != ( + scope.organization_id, + scope.workspace_id, + scope.user_id, + scope.gateway_id, + ): + raise FilesError(404, "Output operation unavailable") + existing = await self.repo.by_provider_id(row.provider_account_generation_id, metadata.id) + if existing is not None: + if not self.service._owns(existing, scope): + raise FilesError(409, "Provider file ownership conflict") + if existing.state != "active" or existing.expires_at <= now: + raise FilesError(409, "Provider file is no longer active") + return self.service._metadata(existing) + account = await self.repo.account(row.provider_account_generation_id) + active = ( + row.state == "active" + and row.deadline > now + and account is not None + and account.status == "active" + and row.reserved_files > 0 + and row.reserved_bytes >= metadata.size_bytes + and metadata.size_bytes <= self.service.max_bytes + and await self.repo.active_user(scope.user_id) + and await self.repo.workspace_exists(scope.workspace_id, scope.organization_id) + ) + expires = now + timedelta(seconds=self.service.retention_seconds) + if metadata.expires_at is not None: + expires = min(expires, metadata.expires_at) + active = active and expires > now + binding = ProviderFileBinding( + output_operation_id=row.id, + organization_id=row.organization_id, + workspace_id=row.workspace_id, + user_id=row.user_id, + provider_account_generation_id=row.provider_account_generation_id, + provider_file_id=metadata.id, + encrypted_metadata=encrypt_secret(metadata.model_dump_json(exclude_unset=True)), + size_bytes=metadata.size_bytes, + downloadable=metadata.downloadable, + expires_at=expires, + provider_expires_at=metadata.expires_at, + operation_deadline=row.deadline, + initiating_gateway_id=row.initiating_gateway_id, + cleanup_token_hash=secrets.token_hex(32), + state="active" if active else "pending_cleanup", + cleanup_reason=None if active else "revoked_output", + cleanup_after=None if active else now, + ) + self.db.add(binding) + row.reserved_files = max(0, row.reserved_files - 1) + row.reserved_bytes = max(0, row.reserved_bytes - metadata.size_bytes) + await self.db.commit() + if not active: + raise FilesError(409, "Output operation has been revoked") + return metadata + + async def complete(self, operation_id: uuid.UUID, gateway_id: str, token: str) -> None: + row = await self.db.get(ProviderFileOutputOperation, operation_id) + if row is None: + raise FilesError(404, "Output operation unavailable") + await self.repo.lock_organization(row.organization_id) + await self.db.refresh(row) + try: + payload = json.loads(decrypt_secret(token)) + except (ValueError, TypeError): + raise FilesError(403, "Invalid cleanup authority") from None + if payload != {"output": str(row.id), "gateway": gateway_id, "nonce": row.cleanup_token_hash}: + raise FilesError(403, "Invalid cleanup authority") + row.state, row.reserved_bytes, row.reserved_files = "completed", 0, 0 + await self.db.commit() + + async def abandon( + self, + operation_id: uuid.UUID, + gateway_id: str, + token: str, + metadata: FileMetadata | None, + file_id: str | None = None, + ) -> OutputCleanup: + row = await self.db.get(ProviderFileOutputOperation, operation_id) + if row is None: + raise FilesError(404, "Output operation unavailable") + await self.repo.lock_organization(row.organization_id) + await self.db.refresh(row) + try: + payload = json.loads(decrypt_secret(token)) + except (ValueError, TypeError): + raise FilesError(403, "Invalid cleanup authority") from None + if payload != {"output": str(row.id), "gateway": gateway_id, "nonce": row.cleanup_token_hash}: + raise FilesError(403, "Invalid cleanup authority") + identifier = metadata.id if metadata is not None else file_id + if not identifier: + raise FilesError(400, "Provider file ID is required") + existing = await self.repo.by_provider_id(row.provider_account_generation_id, identifier) + if existing is not None and existing.output_operation_id != operation_id: + raise FilesError(409, "Provider file ownership conflict") + if existing is None: + existing = ProviderFileBinding( + output_operation_id=row.id, + organization_id=row.organization_id, + workspace_id=row.workspace_id, + user_id=row.user_id, + provider_account_generation_id=row.provider_account_generation_id, + provider_file_id=identifier, + encrypted_metadata=encrypt_secret(metadata.model_dump_json(exclude_unset=True)) + if metadata is not None + else None, + size_bytes=metadata.size_bytes if metadata is not None else 0, + downloadable=metadata.downloadable if metadata is not None else False, + expires_at=datetime.now(UTC), + operation_deadline=row.deadline, + initiating_gateway_id=gateway_id, + cleanup_token_hash=secrets.token_hex(32), + ) + self.db.add(existing) + existing.state = "pending_cleanup" + existing.cleanup_reason, existing.cleanup_after = "output_abandoned", datetime.now(UTC) + await self.db.commit() + return OutputCleanup(operation_id=existing.id, cleanup_token=SecretStr(self.service._token(existing))) diff --git a/src/gateway/services/provider_files/references.py b/src/gateway/services/provider_files/references.py new file mode 100644 index 0000000000..4f09203474 --- /dev/null +++ b/src/gateway/services/provider_files/references.py @@ -0,0 +1,46 @@ +"""Bounded inspection of structured Anthropic file references.""" + +from typing import Any + +from gateway.services.provider_files.contracts import FilesError + +_MAX_NODES = 20000 +_MAX_DEPTH = 32 +_MAX_REFERENCES = 100 + + +def collect_file_references(value: Any) -> list[str]: + """Collect references throughout message history without interpreting ordinary text.""" + found: dict[str, None] = {} + pending = [(value, 0)] + nodes = 0 + while pending: + item, depth = pending.pop() + nodes += 1 + if nodes > _MAX_NODES or depth > _MAX_DEPTH: + raise FilesError(400, "File reference structure exceeds configured limits") + if isinstance(item, list): + if len(item) + len(pending) > _MAX_NODES: + raise FilesError(400, "File reference structure exceeds configured limits") + pending.extend((child, depth + 1) for child in reversed(item)) + elif isinstance(item, dict): + if len(item) + len(pending) > _MAX_NODES: + raise FilesError(400, "File reference structure exceeds configured limits") + nested_file = item.get("file") + if isinstance(nested_file, dict) and "file_id" in nested_file: + raise FilesError(400, "Use Messages for provider-native file references") + file_id = item.get("file_id") + if file_id is not None: + kind = item.get("type") + if kind not in {"file", "container_upload", "code_execution_output"}: + raise FilesError(400, "Unsupported structured file reference") + if not isinstance(file_id, str) or not file_id or len(file_id) > 255: + raise FilesError(400, "Invalid file reference") + found[file_id] = None + if len(found) > _MAX_REFERENCES: + raise FilesError(400, "Too many file references") + # Text, schemas, and arbitrary tool inputs are not provider file-reference positions. + pending.extend( + (item[key], depth + 1) for key in ("messages", "content", "source", "output", "results") if key in item + ) + return list(found) diff --git a/src/gateway/services/provider_files/transfers.py b/src/gateway/services/provider_files/transfers.py new file mode 100644 index 0000000000..c41a661878 --- /dev/null +++ b/src/gateway/services/provider_files/transfers.py @@ -0,0 +1,151 @@ +"""Bounded request-scoped multipart spooling.""" + +import asyncio +import os +import secrets +import shutil +import tempfile +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import anyio +from starlette.datastructures import FormData, Headers, UploadFile +from starlette.formparsers import MultiPartException, MultiPartParser + +from gateway.services.provider_files.contracts import FilesError + +_ENVELOPE_BYTES = 65536 + + +class UploadAdmission: + """Cross-process ephemeral reservations, released by close or process termination.""" + + def __init__(self, capacity_bytes: int) -> None: + self.capacity = capacity_bytes + self.reserved = 0 + + def _claim(self, size: int) -> tuple[int, Path]: + if os.name != "posix": + raise FilesError(503, "Provider file spooling requires a POSIX ephemeral filesystem") + import fcntl + + directory = Path(tempfile.gettempdir()) / f"otari-file-admission-{os.getuid()}" + directory.mkdir(mode=0o700, exist_ok=True) + info = directory.lstat() + if directory.is_symlink() or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise FilesError(503, "Upload temporary storage is not private") + coordinator = os.open(directory / "admission.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(coordinator, fcntl.LOCK_EX) + reserved = 0 + for path in directory.glob("reservation-*"): + try: + descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW) + except FileNotFoundError: + continue + try: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + reserved += int(os.read(descriptor, 32)) + else: + path.unlink(missing_ok=True) + finally: + os.close(descriptor) + if reserved + size > min(self.capacity, shutil.disk_usage(directory).free): + raise FilesError(429, "Upload temporary storage capacity exhausted") + path = directory / f"reservation-{secrets.token_hex(16)}" + descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + os.write(descriptor, str(size).encode()) + except BaseException: + os.close(descriptor) + path.unlink(missing_ok=True) + raise + return descriptor, path + finally: + os.close(coordinator) + + @staticmethod + def _release(reservation: tuple[int, Path]) -> None: + descriptor, path = reservation + # Unlink while locked: another process can never reclaim a live reservation. + path.unlink(missing_ok=True) + os.close(descriptor) + + @asynccontextmanager + async def reserve(self, size: int) -> AsyncIterator[None]: + task = asyncio.create_task(asyncio.to_thread(self._claim, size)) + try: + reservation = await asyncio.shield(task) + except asyncio.CancelledError: + with anyio.CancelScope(shield=True): + reservation = await task + await asyncio.to_thread(self._release, reservation) + raise + self.reserved += size + try: + yield + finally: + self.reserved -= size + with anyio.CancelScope(shield=True): + await asyncio.to_thread(self._release, reservation) + + +@asynccontextmanager +async def receive_upload( + headers: Headers, + stream: AsyncIterator[bytes], + *, + max_bytes: int, + idle_seconds: float, +) -> AsyncIterator[tuple[UploadFile, int | None]]: + async def bounded() -> AsyncGenerator[bytes, None]: + total = 0 + iterator = aiter(stream) + while True: + try: + async with asyncio.timeout(idle_seconds): + chunk = await anext(iterator) + except StopAsyncIteration: + break + total += len(chunk) + if total > max_bytes + _ENVELOPE_BYTES: + raise FilesError(413, "File size limit exceeded") + yield chunk + + parser = MultiPartParser(headers, bounded(), max_files=1, max_fields=1, max_part_size=_ENVELOPE_BYTES) + form: FormData | None = None + try: + form = await parser.parse() + if set(form.keys()) - {"file", "expires_in_seconds"}: + raise FilesError(400, "Unsupported upload field") + if len(form.getlist("file")) != 1 or len(form.getlist("expires_in_seconds")) > 1: + raise FilesError(400, "Expected one file") + upload = form.get("file") + if not isinstance(upload, UploadFile): + raise FilesError(400, "Expected one file") + if upload.size is None or upload.size > max_bytes: + raise FilesError(413, "File size limit exceeded") + raw = form.get("expires_in_seconds") + duration = None + if raw is not None: + if not isinstance(raw, str) or not raw.isascii() or not raw.isdigit() or len(raw) > 8: + raise FilesError(400, "Invalid file retention") + duration = int(raw) + if not 3600 <= duration <= 7776000: + raise FilesError(400, "File retention must be between one hour and 90 days") + yield upload, duration + except MultiPartException: + raise FilesError(400, "Invalid multipart upload") from None + finally: + if form is not None: + with anyio.CancelScope(shield=True): + await form.close() + else: + # Starlette closes on MultiPartException only; limits and cancellation also must close. + for spool in parser._files_to_close_on_error: + with anyio.CancelScope(shield=True): + await asyncio.to_thread(spool.close) diff --git a/src/gateway/services/provider_files/transport.py b/src/gateway/services/provider_files/transport.py new file mode 100644 index 0000000000..19c919954c --- /dev/null +++ b/src/gateway/services/provider_files/transport.py @@ -0,0 +1,68 @@ +"""Provider Files transport through any-llm's public interface.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any +from urllib.parse import urlsplit + +from any_llm import AnyLLM +from httpx import AsyncClient + +from gateway.services.provider_files.contracts import FileAccount, FilesError +from gateway.services.url_safety import UnsafeURLError, validate_provider_api_base + + +@asynccontextmanager +async def provider_client(account: FileAccount, *, idle_timeout: float = 30) -> AsyncIterator[Any]: + base = account.api_base or "https://api.anthropic.com" + parsed = urlsplit(base) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise FilesError(502, "Invalid provider file endpoint") + try: + await validate_provider_api_base(base) + except UnsafeURLError: + raise FilesError(502, "Invalid provider file endpoint") from None + async with AsyncClient(timeout=idle_timeout, follow_redirects=False) as http_client: + client = AnyLLM.create( + "anthropic", + api_key=account.api_key.get_secret_value(), + api_base=base, + http_client=http_client, + max_retries=0, + default_headers={"anthropic-workspace-id": account.workspace} if account.workspace else {}, + ) + if not hasattr(client, "aupload_file"): + raise FilesError(502, "Provider-native Files require any-llm-sdk 1.28 or later") + yield client + + +def provider_error(exc: Exception) -> FilesError: + code = getattr(exc, "status_code", None) + if code is None: + original = getattr(exc, "original_exception", None) + code = getattr(original, "status_code", None) + source = getattr(exc, "original_exception", None) or exc + response = getattr(source, "response", None) + retry_after = getattr(response, "headers", {}).get("Retry-After") + headers = None + if isinstance(retry_after, str) and len(retry_after) <= 128 and not {"\r", "\n"} & set(retry_after): + headers = {"Retry-After": retry_after} + if code in {400, 404, 413, 429}: + return FilesError( + code, + { + 400: "Provider rejected the file operation", + 404: "File unavailable", + 413: "File size limit exceeded", + 429: "Provider file rate limit exceeded", + }[code], + headers=headers if code == 429 else None, + ) + return FilesError(502, "Provider file operation failed") diff --git a/src/gateway/services/tenancy/org_provider_key_service.py b/src/gateway/services/tenancy/org_provider_key_service.py index 1489cb9e47..44da81881a 100644 --- a/src/gateway/services/tenancy/org_provider_key_service.py +++ b/src/gateway/services/tenancy/org_provider_key_service.py @@ -77,6 +77,8 @@ WorkspaceRepository, resolve_active_key, ) +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.accounts import retire_byo_account from gateway.services.secret_box import ( SecretBoxUnavailableError, SecretDecryptionError, @@ -496,6 +498,9 @@ async def update_key_for_user( update_data["encrypted_api_key"] = encrypted_api_key update_data["last4"] = last4 + if {"encrypted_api_key", "api_base", "client_args"} & update_data.keys(): + await retire_byo_account(self.db, key, release_secret=True) + try: updated = await self.keys.update_key(key, update_data) await self.db.commit() @@ -520,6 +525,7 @@ async def archive_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro if key is None: raise OrgProviderKeyNotFoundError(key_id) + await retire_byo_account(self.db, key, release_secret=False) updated = await self.keys.update_key(key, {"archived_at": datetime.now(UTC), "is_org_default": False}) await self.db.commit() await refresh_org_provider_cache(self.db) @@ -534,6 +540,7 @@ async def restore_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro if key is None: raise OrgProviderKeyNotFoundError(key_id) + await retire_byo_account(self.db, key, release_secret=True) updated = await self.keys.update_key(key, {"archived_at": None}) await self.db.commit() await refresh_org_provider_cache(self.db) @@ -553,6 +560,7 @@ async def delete_key_for_user(self, *, user: User, key_id: uuid.UUID) -> None: if key.archived_at is None: raise OrgProviderKeyNotArchivedError(key_id) + await retire_byo_account(self.db, key, release_secret=True) await self.keys.delete_key(key) await self.db.commit() await refresh_org_provider_cache(self.db) @@ -665,6 +673,7 @@ async def set_workspace_override_for_user( # spans the variable set of override rows a "pin" can land in (unlike # `set_org_default`, which is a single row the partial unique index # already arbitrates). + await ProviderFileRepository(self.db).lock_organization(workspace.organization_id) await WorkspaceRepository(self.db).lock(workspace.id) existing = await self.overrides.get(workspace_id=workspace.id, org_provider_key_id=key.id) @@ -707,6 +716,16 @@ async def set_workspace_override_for_user( result_default, result_disabled = created.is_default, created.disabled if new_disabled and not current_disabled: + files = ProviderFileRepository(self.db) + generation = await files.latest_account("organization_key", str(key.id), workspace.organization_id) + if generation is not None: + await files.revoke( + datetime.now(UTC), + "workspace_credential_disabled", + organization_id=workspace.organization_id, + workspace_id=workspace.id, + generation_id=generation.id, + ) await self.restrictions.delete_for_workspace_key(workspace_id=workspace.id, org_provider_key_id=key.id) await self.db.commit() diff --git a/src/gateway/services/tenancy/workspace_service.py b/src/gateway/services/tenancy/workspace_service.py index f880842d1f..689b60f7b7 100644 --- a/src/gateway/services/tenancy/workspace_service.py +++ b/src/gateway/services/tenancy/workspace_service.py @@ -16,6 +16,7 @@ """ import uuid +from datetime import UTC, datetime from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -35,6 +36,7 @@ WorkspaceUpdate, ) from gateway.repositories.tenancy import WorkspaceMemberRepository, WorkspaceRepository +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.services.tenancy import authorization from gateway.services.tenancy.errors import ( InvalidRoleError, @@ -298,6 +300,12 @@ async def delete_workspace(self, *, user: User, workspace_id: uuid.UUID) -> None try: member_ids = await self.members.ids_for_workspace(workspace_id) await self._membership_listener.workspace_deleted(workspace_id, member_ids) + await ProviderFileRepository(self.db).revoke( + datetime.now(UTC), + "workspace_deletion", + organization_id=organization.id, + workspace_id=workspace_id, + ) await self.workspaces.delete_workspace(workspace) await self.db.commit() except IntegrityError: diff --git a/tests/integration/test_hybrid_files_messages.py b/tests/integration/test_hybrid_files_messages.py new file mode 100644 index 0000000000..aa415cc3bb --- /dev/null +++ b/tests/integration/test_hybrid_files_messages.py @@ -0,0 +1,119 @@ +"""File ownership gates dispatch and output failures preserve inference accounting.""" + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FileAccount, FilesError, Operation +from gateway.services.provider_files.inference import FileOutputBinder + +from .conftest import app_for +from .test_hybrid_mode_messages import _attempt, _message_response, _resolve_payload + + +@pytest.mark.parametrize("outcome", ["success", "foreign", "wrong_generation", "registration_failure"]) +def test_file_reference_dispatch_and_accounting(monkeypatch: pytest.MonkeyPatch, outcome: str) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + generation = uuid.uuid4() + account = FileAccount(generation_id=generation, api_key=SecretStr("owned-key")) + attempts = [ + _attempt(0, str(uuid.uuid4()), "other-model", "other-key"), + _attempt(1, str(uuid.uuid4()), "owned-model", "owned-key"), + ] + attempts[1]["provider_account_generation_id"] = str(uuid.uuid4() if outcome == "wrong_generation" else generation) + events: list[str] = [] + + async def platform(url: str, **kwargs: Any) -> httpx.Response: + if url.endswith("/resolve"): + return httpx.Response(200, json=_resolve_payload(attempts)) + events.append("usage") + body = kwargs["body"] + assert body["status"] == "success" + return httpx.Response( + 200, + json={ + "correlation_id": body["correlation_id"], + "status": "completed", + "outcome": "success", + "cost_usd": "0.01", + "currency": "USD", + "usage_status": "reported", + "pricing": {"source": "managed"}, + }, + ) + + async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> Any: + events.append(path) + if path == "references/resolve": + assert body == {"ids": ["file_history"]} + if outcome == "foreign": + raise FilesError(404, "File not found") + return account + if path == "outputs/prepare": + assert body["attempt_id"] == attempts[1]["attempt_id"] + return Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=5), + account=account, + max_bytes=1024, + expires_in_seconds=3600, + ) + return None + + async def provider(**kwargs: Any) -> Any: + events.append("provider") + assert kwargs["api_key"] == "owned-key" + assert kwargs["model"] == "anthropic:owned-model" + return _message_response() + + async def register(self: Any, value: Any) -> None: + events.append("register") + assert "usage" in events + if outcome == "registration_failure": + raise FilesError(502, "Unable to register generated files") + + monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) + monkeypatch.setattr("gateway.api.routes.messages.amessages", provider) + monkeypatch.setattr(PlatformFilesClient, "post", files) + monkeypatch.setattr(FileOutputBinder, "register", register) + app = app_for( + GatewayConfig( + mode="hybrid", + platform={"base_url": "http://platform.test/api/v1"}, + files_provider_native_enabled=True, + ) + ) + with TestClient(app) as client: + response = client.post( + f"{API_ROOT}/messages", + headers={"Authorization": "Bearer user-token"}, + json={ + "model": "routed-model", + "max_tokens": 100, + "messages": [ + { + "role": "user", + "content": [{"type": "document", "source": {"type": "file", "file_id": "file_history"}}], + }, + {"role": "assistant", "content": "Read it."}, + {"role": "user", "content": "Summarize the earlier document."}, + ], + }, + ) + assert ( + response.status_code + == {"success": 200, "foreign": 404, "wrong_generation": 403, "registration_failure": 502}[outcome] + ), response.text + if outcome in {"foreign", "wrong_generation"}: + assert "provider" not in events + else: + assert events.count("provider") == 1 + assert events.index("usage") < events.index("register") diff --git a/tests/integration/test_hybrid_files_routes.py b/tests/integration/test_hybrid_files_routes.py new file mode 100644 index 0000000000..778bc2a5d4 --- /dev/null +++ b/tests/integration/test_hybrid_files_routes.py @@ -0,0 +1,159 @@ +"""Public Files routing is scoped, GA-only, and never exposes unfinalized IDs.""" + +import uuid +from collections.abc import AsyncIterator, Generator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from gateway.api.deps import get_config +from gateway.api.routes import hybrid_files +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import ( + FileAccount, + FileMetadata, + FilePage, + FilesError, + Operation, + ResolvedFile, + WireModel, +) + + +@pytest.fixture +def file_client(monkeypatch: pytest.MonkeyPatch) -> Generator[tuple[TestClient, list[str]]]: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + config = GatewayConfig( + mode="hybrid", + files_provider_native_enabled=True, + platform={"base_url": "https://authority.test"}, + files_max_bytes=1024, + ) + app = FastAPI() + app.dependency_overrides[get_config] = lambda: config + app.include_router(hybrid_files.router, prefix=API_ROOT) + events: list[str] = [] + data = FileMetadata( + id="file_provider", + filename="example.csv", + mime_type="text/csv", + size_bytes=4, + created_at=datetime.now(UTC), + downloadable=True, + ) + account = FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("provider-secret")) + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("operation-token"), + deadline=datetime.now(UTC) + timedelta(minutes=5), + account=account, + max_bytes=1024, + expires_in_seconds=604800, + ) + + async def post(self: object, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + events.append(path) + if path == "uploads/prepare": + return operation + if path.endswith("/finalize"): + return data + if path == "list": + return FilePage(data=[data], next_page=None) + if path.endswith("/resolve"): + return ResolvedFile( + metadata=data, account=account, operation_id=operation.id, cleanup_token=SecretStr("operation-token") + ) + return WireModel() + + class Provider: + async def aupload_file(self, file: Any, **kwargs: Any) -> FileMetadata: + events.append("provider-upload") + assert kwargs["max_retries"] == 0 + assert file.read() == b"data" + assert "x-api-key" not in kwargs["extra_headers"] + assert "anthropic-workspace-id" not in kwargs["extra_headers"] + return data + + async def adelete_file(self, file_id: str, **kwargs: Any) -> None: + events.append("provider-delete") + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + yield Provider() + + monkeypatch.setattr(PlatformFilesClient, "post", post) + monkeypatch.setattr(hybrid_files, "provider_client", provider) + with TestClient(app) as client: + yield client, events + + +HEADERS = {"anthropic-version": "2023-06-01", "Authorization": "Bearer caller-token"} + + +@pytest.mark.parametrize( + "method,path", + [ + ("POST", "/files"), + ("GET", "/files"), + ("GET", "/files/file_x"), + ("GET", "/files/file_x/content"), + ("DELETE", "/files/file_x"), + ], +) +def test_legacy_beta_rejected_on_every_verb(file_client: tuple[TestClient, list[str]], method: str, path: str) -> None: + client, events = file_client + response = client.request( + method, API_ROOT + path, headers={**HEADERS, "AnThRoPiC-BeTa": "other, files-api-2025-04-14 "} + ) + assert response.status_code == 400 + assert events == [] + + +def test_upload_finalized_before_id_returned(file_client: tuple[TestClient, list[str]]) -> None: + client, events = file_client + response = client.post( + API_ROOT + "/files", + headers={**HEADERS, "anthropic-workspace-id": "foreign"}, + files={"file": ("example.csv", b"data", "text/csv")}, + ) + assert response.status_code == 200, response.text + assert response.json()["id"] == "file_provider" + assert events[0] == "uploads/prepare" + assert events[1] == "provider-upload" + assert events[2].endswith("/finalize") + assert response.headers["cache-control"] == "private, no-store" + assert "purpose" not in response.json() + + +def test_legacy_pagination_rejected_before_authority(file_client: tuple[TestClient, list[str]]) -> None: + client, events = file_client + response = client.get(API_ROOT + "/files?after_id=file_x", headers=HEADERS) + assert response.status_code == 400 and not events + + +def test_finalize_failure_compensates_without_exposing_id( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch +) -> None: + client, events = file_client + original = PlatformFilesClient.post + + async def fail(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + if path.endswith("/finalize"): + events.append("finalize-failed") + raise FilesError(502, "Authorization service unavailable") + return await original(self, path, body, result_type) + + monkeypatch.setattr(PlatformFilesClient, "post", fail) + response = client.post(API_ROOT + "/files", headers=HEADERS, files={"file": ("example.csv", b"data")}) + assert response.status_code == 502 + assert "file_provider" not in response.text + assert events.count("provider-upload") == 1 + assert events.count("finalize-failed") == 3 + assert "provider-delete" in events + assert events[-1].endswith("/abandon") diff --git a/tests/integration/test_hybrid_files_sdk_contract.py b/tests/integration/test_hybrid_files_sdk_contract.py new file mode 100644 index 0000000000..12cdfa22f0 --- /dev/null +++ b/tests/integration/test_hybrid_files_sdk_contract.py @@ -0,0 +1,119 @@ +"""Official GA Anthropic client through Otari and the merged any-llm Files transport.""" + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import anthropic +import httpx +import pytest +from any_llm import AnyLLM +from fastapi import FastAPI +from pydantic import SecretStr + +from gateway.api.deps import get_config +from gateway.api.routes import hybrid_files +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.provider_files import transport +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import ( + FileAccount, + FileMetadata, + FilePage, + Operation, + ResolvedFile, + WireModel, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif( + not hasattr(AnyLLM, "aupload_file"), reason="Requires any-llm Files interface (#1395, planned 1.28)" + ), +] + + +async def test_official_sdk_upload_list_download_delete(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + config = GatewayConfig( + mode="hybrid", + files_provider_native_enabled=True, + platform={"base_url": "https://authority.test"}, + files_max_bytes=1024, + ) + app = FastAPI() + app.dependency_overrides[get_config] = lambda: config + app.include_router(hybrid_files.router, prefix=API_ROOT) + account = FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("provider-secret")) + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=5), + account=account, + max_bytes=1024, + expires_in_seconds=604800, + ) + metadata = FileMetadata( + id="file_contract", + type="file", + filename="input.csv", + mime_type="text/csv", + size_bytes=4, + created_at=datetime.now(UTC), + downloadable=True, + ) + calls: list[str] = [] + + async def authority(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + calls.append(path) + if path == "uploads/prepare": + return operation + if path.endswith("/finalize"): + assert body["metadata"]["id"] == "file_contract" + return metadata + if path == "list": + return FilePage(data=[metadata], next_page=None) + if path.endswith("/resolve"): + return ResolvedFile( + metadata=metadata, account=account, operation_id=operation.id, cleanup_token=SecretStr("cleanup") + ) + return WireModel() + + async def upstream(request: httpx.Request) -> httpx.Response: + assert request.headers["x-api-key"] == "provider-secret" + assert "anthropic-workspace-id" not in request.headers + calls.append(f"provider:{request.method}:{request.url.path}") + if request.method == "POST": + body = await request.aread() + assert b"data" in body and b"604800" in body + return httpx.Response(200, json=metadata.model_dump(mode="json", exclude_unset=True)) + if request.method == "DELETE": + return httpx.Response(200, json={"id": "file_contract", "type": "file_deleted"}) + return httpx.Response( + 200, + content=b"data", + headers={"content-type": "text/csv", "content-disposition": 'attachment; filename="output.csv"'}, + ) + + monkeypatch.setattr(PlatformFilesClient, "post", authority) + original_client = httpx.AsyncClient + gateway_http = original_client(transport=httpx.ASGITransport(app=app)) + monkeypatch.setattr( + transport, + "AsyncClient", + lambda **kwargs: original_client(transport=httpx.MockTransport(upstream), **kwargs), + ) + async with anthropic.AsyncAnthropic( + auth_token="user-token", base_url="http://gateway.test/api/", http_client=gateway_http, max_retries=0 + ) as sdk: + uploaded = await sdk.files.upload(file=("input.csv", b"data", "text/csv")) + assert uploaded.id == "file_contract" + assert calls.index("provider:POST:/v1/files") < next( + i for i, call in enumerate(calls) if call.endswith("/finalize") + ) + page = await sdk.files.list() + assert [item.id for item in page.data] == [uploaded.id] + downloaded = await sdk.files.download(uploaded.id) + assert await downloaded.read() == b"data" + deleted = await sdk.files.delete(uploaded.id) + assert deleted.id == uploaded.id and deleted.type == "file_deleted" diff --git a/tests/integration/test_provider_file_lifecycle.py b/tests/integration/test_provider_file_lifecycle.py new file mode 100644 index 0000000000..f00d3ccb5b --- /dev/null +++ b/tests/integration/test_provider_file_lifecycle.py @@ -0,0 +1,268 @@ +"""Provider file state survives failed finalization and tenant isolation attempts.""" + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +import pytest_asyncio +from pydantic import SecretStr +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.provider_files import ProviderAccountGeneration +from gateway.models.users import User +from gateway.repositories.tenancy.organization_repository import OrganizationRepository +from gateway.repositories.tenancy.workspace_repository import WorkspaceRepository +from gateway.services.provider_files.contracts import ( + FileAccount, + FileListRequest, + FileMetadata, + FileScope, + FilesError, + PrepareUpload, +) +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.secret_box import generate_secret_key + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def files_setup( + async_db: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> tuple[ProviderFileService, FileScope, FileAccount]: + monkeypatch.setenv("OTARI_SECRET_KEY", generate_secret_key()) + organization = await OrganizationRepository(async_db).create_organization( + name="Files", slug="files", created_by_user_id=None + ) + workspace = await WorkspaceRepository(async_db).create_workspace( + name="Files", organization_id=organization.id, created_by_user_id=None + ) + account = ProviderAccountGeneration( + organization_id=organization.id, credential_source="organization_key", credential_ref=str(uuid.uuid4()) + ) + async_db.add_all([User(user_id="uploader"), User(user_id="other")]) + async_db.add(account) + await async_db.commit() + scope = FileScope( + organization_id=organization.id, workspace_id=workspace.id, user_id="uploader", gateway_id="gateway" + ) + service = ProviderFileService(async_db, max_bytes=1024, max_files=10, max_outstanding_bytes=10240) + return service, scope, FileAccount(generation_id=account.id, api_key=SecretStr("test-key")) + + +def metadata(file_id: str = "file_uploaded") -> FileMetadata: + return FileMetadata( + id=file_id, + filename="private.csv", + mime_type="text/csv", + size_bytes=10, + created_at=datetime.now(UTC), + downloadable=False, + ) + + +async def test_finalize_is_idempotent_and_metadata_encrypted( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + data = metadata() + assert await service.finalize(scope, operation.id, data) == data + assert await service.finalize(scope, operation.id, data) == data + row = await service.repo.get(operation.id) + assert row is not None and row.encrypted_metadata is not None + assert "private.csv" not in row.encrypted_metadata + with pytest.raises(FilesError, match="conflict"): + await service.finalize(scope, operation.id, metadata("file_different")) + + +async def test_foreign_owner_and_workspace_hidden( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await service.finalize(scope, operation.id, metadata()) + for foreign in [ + scope.model_copy(update={"user_id": "other"}), + scope.model_copy(update={"workspace_id": uuid.uuid4()}), + ]: + with pytest.raises(FilesError) as error: + await service.resolve(foreign, "file_uploaded", "metadata") + assert error.value.status_code == 404 + result = await service.list_files( + scope.model_copy(update={"user_id": "other"}), FileListRequest(ids=["file_uploaded"]) + ) + assert result.data == [] + + +async def test_retired_upload_cannot_reactivate( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + generation = await service.repo.account(account.generation_id) + assert generation is not None + generation.status = "retiring" + await service.db.commit() + with pytest.raises(FilesError, match="revoked"): + await service.finalize(scope, operation.id, metadata()) + row = await service.repo.get(operation.id) + assert row is not None and row.state == "pending_cleanup" and row.provider_file_id == "file_uploaded" + + +async def test_delete_revokes_before_provider_and_retries_survive( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await service.finalize(scope, operation.id, metadata()) + resolved = await service.resolve(scope, "file_uploaded", "delete", account) + assert resolved.cleanup_token is not None + with pytest.raises(FilesError): + await service.resolve(scope, "file_uploaded", "metadata") + await service.cleanup_result(operation.id, scope.gateway_id, resolved.cleanup_token.get_secret_value(), False) + row = await service.repo.get(operation.id) + assert row is not None and row.state == "pending_cleanup" and row.cleanup_attempts == 1 + await service.cleanup_result(operation.id, scope.gateway_id, resolved.cleanup_token.get_secret_value(), True) + assert row.state == "deleted" + + +async def test_expired_files_are_hidden(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await service.finalize(scope, operation.id, metadata()) + row = await service.repo.get(operation.id) + assert row is not None + row.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await service.db.commit() + with pytest.raises(FilesError): + await service.references(scope, ["file_uploaded"]) + + +async def test_output_only_registration_and_collision( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + from gateway.services.provider_files.contracts import OutputPrepare + from gateway.services.provider_files.outputs import ProviderFileOutputs + + service, scope, account = files_setup + outputs = ProviderFileOutputs(service) + operation = await outputs.prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + data = metadata("file_generated").model_copy(update={"downloadable": True}) + assert await outputs.register(scope, operation.id, data) == data + assert await outputs.register(scope, operation.id, data) == data + other_scope = scope.model_copy(update={"user_id": "other"}) + other = await outputs.prepare( + other_scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), + request_id="other-request", + attempt_id="other-attempt", + generation_id=account.generation_id, + ), + ) + with pytest.raises(FilesError, match="ownership conflict"): + await outputs.register(other_scope, other.id, data) + with pytest.raises(FilesError, match="ownership conflict"): + await outputs.abandon(other.id, scope.gateway_id, other.cleanup_token.get_secret_value(), data) + resolved = await service.resolve(scope, data.id, "metadata") + assert resolved.metadata.downloadable + + +async def test_output_cleanup_survives_user_revocation( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + from gateway.services.provider_files.contracts import OutputPrepare + from gateway.services.provider_files.outputs import ProviderFileOutputs + + service, scope, account = files_setup + outputs = ProviderFileOutputs(service) + operation = await outputs.prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + await service.repo.revoke_user(scope.user_id, datetime.now(UTC)) + user = await service.db.get(User, scope.user_id) + assert user is not None + user.deleted_at = datetime.now(UTC) + await service.db.commit() + cleanup = await outputs.abandon( + operation.id, scope.gateway_id, operation.cleanup_token.get_secret_value(), None, "file_late" + ) + row = await service.repo.get(cleanup.operation_id) + assert row is not None and row.state == "pending_cleanup" and row.provider_file_id == "file_late" + await service.cleanup_result(row.id, scope.gateway_id, cleanup.cleanup_token.get_secret_value(), True) + assert row.state == "deleted" + + +async def test_cursor_scope_and_snapshot(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: + service, scope, account = files_setup + for index in range(3): + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await service.finalize(scope, operation.id, metadata(f"file_{index}")) + first = await service.list_files(scope, FileListRequest(limit=1)) + assert first.next_page is not None + with pytest.raises(FilesError, match="Invalid file page"): + await service.list_files( + scope.model_copy(update={"user_id": "other"}), FileListRequest(limit=1, page=first.next_page) + ) + second = await service.list_files(scope, FileListRequest(limit=1, page=first.next_page)) + assert first.data[0].id != second.data[0].id + + +async def test_cleanup_lease_fencing(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: + from gateway.services.provider_files.cleanup import ProviderFileCleanup + from gateway.services.provider_files.contracts import LeaseResult + + service, scope, account = files_setup + operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await service.finalize(scope, operation.id, metadata()) + await service.resolve(scope, "file_uploaded", "delete", account) + cleanup = ProviderFileCleanup(service) + + async def resolve(generation: uuid.UUID) -> FileAccount: + assert generation == account.generation_id + return account + + lease = await cleanup.claim(scope.organization_id, scope.gateway_id, resolve_account=resolve) + assert lease is not None and len(lease.items) == 1 + assert await cleanup.claim(scope.organization_id, scope.gateway_id, resolve_account=resolve) is None + with pytest.raises(FilesError, match="lease unavailable"): + await cleanup.complete( + scope.organization_id, + "foreign-gateway", + lease.id, + LeaseResult(token=lease.token, results={operation.id: True}), + ) + await cleanup.complete( + scope.organization_id, scope.gateway_id, lease.id, LeaseResult(token=lease.token, results={operation.id: True}) + ) + row = await service.repo.get(operation.id) + assert row is not None and row.state == "deleted" + assert not await service.repo.account_busy(account.generation_id, datetime.now(UTC)) + + +async def test_postgres_file_migration_round_trip(postgres_url: str) -> None: + from pathlib import Path + + from alembic import command + from alembic.config import Config + + root = Path(__file__).resolve().parents[2] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + config.set_main_option("sqlalchemy.url", postgres_url) + try: + command.downgrade(config, "d5f8b2a4c6e9") + finally: + command.upgrade(config, "c3e5a7b9d1f4") diff --git a/tests/integration/test_provider_files_protocol.py b/tests/integration/test_provider_files_protocol.py new file mode 100644 index 0000000000..7f3c145fd3 --- /dev/null +++ b/tests/integration/test_provider_files_protocol.py @@ -0,0 +1,93 @@ +"""A composed core Files authority derives ownership and returns transient credentials.""" + +import uuid +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.api.deps import get_config, get_db +from gateway.api.routes.provider_files import create_provider_files_router +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.models.provider_keys import OrgProviderKey +from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.secret_box import encrypt_secret + +from .test_provider_file_lifecycle import files_setup as files_setup +from .test_provider_file_lifecycle import metadata + +pytestmark = pytest.mark.asyncio + + +async def test_authenticated_protocol_prepares_and_finalizes( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + generation = await service.repo.account(account.generation_id) + assert generation is not None + key = OrgProviderKey( + id=uuid.UUID(generation.credential_ref), + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("upstream-key"), + ) + service.db.add(key) + await service.db.commit() + + async def authenticate(request: Request, db: AsyncSession) -> FileScope: + if request.headers.get("X-Gateway-Token") != "gateway" or request.headers.get("X-User-Token") != "user": + raise FilesError(401, "Invalid authentication") + return scope + + async def authorize(scope: FileScope, body: OutputPrepare, db: AsyncSession) -> FileAccount: + return account + + async def session() -> AsyncSession: + return service.db + + app = FastAPI() + app.dependency_overrides[get_db] = session + app.dependency_overrides[get_config] = lambda: GatewayConfig( + mode="hosted", + files_provider_native_enabled=True, + files_max_count=10, + files_max_bytes=1024, + files_max_outstanding_bytes=10240, + ) + app.include_router( + create_provider_files_router( + authenticate=authenticate, authenticate_gateway=authenticate, authorize_attempt=authorize + ), + prefix=API_ROOT, + ) + headers = {"X-Gateway-Token": "gateway", "X-User-Token": "user"} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://control") as client: + denied = await client.post( + f"{API_ROOT}/gateway/files/uploads/prepare", json={"operation_id": str(uuid.uuid4()), "size_bytes": 100} + ) + assert denied.status_code == 401 and denied.headers["X-Otari-Files-Protocol"] == "1" + request: dict[str, Any] = { + "operation_id": str(uuid.uuid4()), + "size_bytes": 100, + "user_id": "foreign", + "workspace_id": str(uuid.uuid4()), + } + prepared = await client.post(f"{API_ROOT}/gateway/files/uploads/prepare", json=request, headers=headers) + assert prepared.status_code == 200, prepared.text + assert prepared.json()["account"]["api_key"] == "upstream-key" + assert prepared.headers["Cache-Control"] == "private, no-store" + data = metadata() + finalized = await client.post( + f"{API_ROOT}/gateway/files/uploads/{prepared.json()['id']}/finalize", + json={"metadata": data.model_dump(mode="json")}, + headers=headers, + ) + assert finalized.status_code == 200, finalized.text + listing = await client.post(f"{API_ROOT}/gateway/files/list", json={}, headers=headers) + assert listing.json()["data"][0]["id"] == data.id + stored = await service.repo.get(uuid.UUID(prepared.json()["id"])) + assert stored is not None and stored.user_id == scope.user_id and stored.workspace_id == scope.workspace_id diff --git a/tests/unit/test_provider_file_accounts.py b/tests/unit/test_provider_file_accounts.py new file mode 100644 index 0000000000..8cd693d321 --- /dev/null +++ b/tests/unit/test_provider_file_accounts.py @@ -0,0 +1,52 @@ +"""Durable files require explicit or unambiguous account selection.""" + +import uuid +from datetime import UTC, datetime + +import pytest + +from gateway.models.provider_keys import OrgProviderKey, WorkspaceProviderKeyOverride +from gateway.services.provider_files.accounts import select_file_key +from gateway.services.provider_files.contracts import FilesError + + +def _key(*, default: bool = False) -> OrgProviderKey: + return OrgProviderKey(organization_id=uuid.uuid4(), provider="anthropic", name="test", is_org_default=default) + + +def test_ambiguous_keys_do_not_use_inference_oldest_fallback() -> None: + with pytest.raises(FilesError, match="ambiguous"): + select_file_key([(_key(), None), (_key(), None)]) + + +def test_workspace_pin_precedes_organization_default() -> None: + default, pinned = _key(default=True), _key() + override = WorkspaceProviderKeyOverride( + organization_id=pinned.organization_id, + workspace_id=uuid.uuid4(), + org_provider_key_id=pinned.id, + is_default=True, + ) + assert select_file_key([(default, None), (pinned, override)]) is pinned + + +def test_disabled_explicit_default_fails_closed() -> None: + key = _key(default=True) + override = WorkspaceProviderKeyOverride( + organization_id=key.organization_id, workspace_id=uuid.uuid4(), org_provider_key_id=key.id, disabled=True + ) + with pytest.raises(FilesError, match="unavailable"): + select_file_key([(key, override), (_key(), None)]) + + +def test_archived_explicit_default_fails_closed() -> None: + key = _key(default=True) + key.archived_at = datetime.now(UTC) + with pytest.raises(FilesError, match="unavailable"): + select_file_key([(key, None), (_key(), None)]) + + +def test_empty_and_unique_selection() -> None: + assert select_file_key([]) is None + key = _key() + assert select_file_key([(key, None)]) is key diff --git a/tests/unit/test_provider_file_config.py b/tests/unit/test_provider_file_config.py new file mode 100644 index 0000000000..d1aa126f62 --- /dev/null +++ b/tests/unit/test_provider_file_config.py @@ -0,0 +1,21 @@ +"""Provider-native Files require usable resource limits before enablement.""" + +import pytest +from pydantic import ValidationError + +from gateway.core.config import GatewayConfig + + +@pytest.mark.parametrize( + "overrides, message", + [ + ({"files_retention_hours": 2161}, "90 days"), + ({"files_max_bytes": 1024, "files_temporary_capacity_bytes": 1024}, "64 KiB"), + ({"mode": "hosted"}, "explicit file-count"), + ({"mode": "hosted", "files_max_count": 10, "files_max_outstanding_bytes": 1}, "Outstanding-byte quota"), + ], +) +def test_native_file_limits(monkeypatch: pytest.MonkeyPatch, overrides: dict[str, object], message: str) -> None: + monkeypatch.delenv("OTARI_AI_TOKEN", raising=False) + with pytest.raises(ValidationError, match=message): + GatewayConfig.model_validate({"files_provider_native_enabled": True, **overrides}) diff --git a/tests/unit/test_provider_file_migration.py b/tests/unit/test_provider_file_migration.py new file mode 100644 index 0000000000..8a2a6dcbfd --- /dev/null +++ b/tests/unit/test_provider_file_migration.py @@ -0,0 +1,32 @@ +"""The provider-files revision round-trips on the standalone SQLite engine.""" + +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect + +TABLES = { + "provider_account_generations", + "provider_file_bindings", + "provider_file_output_operations", + "provider_file_rate_windows", +} + + +def test_provider_file_migration_round_trip(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[2] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + url = f"sqlite:///{tmp_path / 'files.db'}" + config.set_main_option("sqlalchemy.url", url) + command.upgrade(config, "c3e5a7b9d1f4") + engine = create_engine(url) + try: + assert TABLES <= set(inspect(engine).get_table_names()) + command.downgrade(config, "d5f8b2a4c6e9") + assert not TABLES & set(inspect(engine).get_table_names()) + command.upgrade(config, "c3e5a7b9d1f4") + assert TABLES <= set(inspect(engine).get_table_names()) + finally: + engine.dispose() diff --git a/tests/unit/test_provider_file_outputs.py b/tests/unit/test_provider_file_outputs.py new file mode 100644 index 0000000000..77e3339606 --- /dev/null +++ b/tests/unit/test_provider_file_outputs.py @@ -0,0 +1,95 @@ +"""Structured stream blocks cannot expose an uncommitted provider file ID.""" + +import asyncio +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from pydantic import BaseModel, ConfigDict, SecretStr + +from gateway.services.provider_files import inference +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FileAccount, FileMetadata, FilesError, Operation + + +class Event(BaseModel): + model_config = ConfigDict(extra="allow") + type: str + content_block: dict[str, Any] | None = None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fail", [False, True]) +async def test_file_block_is_held_until_registration(monkeypatch: pytest.MonkeyPatch, fail: bool) -> None: + entered, release = asyncio.Event(), asyncio.Event() + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=1), + account=FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("key")), + max_bytes=100, + expires_in_seconds=3600, + ) + metadata = FileMetadata( + id="file_generated", + filename="output.csv", + mime_type="text/csv", + size_bytes=4, + created_at=datetime.now(UTC), + downloadable=True, + ) + emitted: list[str] = [] + + class Provider: + async def aretrieve_file(self, *args: Any, **kwargs: Any) -> FileMetadata: + return metadata + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + yield Provider() + + async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + if path == "outputs/register": + entered.set() + await release.wait() + if fail: + raise FilesError(502, "unavailable") + return metadata + if path.endswith("/abandon"): + raise FilesError(502, "unavailable") + return result_type() + + monkeypatch.setattr(inference, "provider_client", provider) + monkeypatch.setattr(PlatformFilesClient, "retry", retry) + binder = inference.FileOutputBinder(PlatformFilesClient("https://authority", "gateway", "user"), operation, []) + + async def source() -> AsyncIterator[Event]: + yield Event(type="message_start") + yield Event( + type="content_block_start", + content_block={ + "type": "bash_code_execution_tool_result", + "content": [{"type": "code_execution_output", "file_id": "file_generated"}], + }, + ) + yield Event(type="content_block_stop") + yield Event(type="message_stop") + + async def consume() -> None: + async for event in binder.stream(source()): + emitted.append(event.type) # noqa: PERF401 (observe emission before the stream finishes) + + task = asyncio.create_task(consume()) + await asyncio.wait_for(entered.wait(), timeout=1) + assert emitted == ["message_start"] + release.set() + if fail: + with pytest.raises(FilesError): + await task + assert emitted == ["message_start"] + else: + await task + assert emitted == ["message_start", "content_block_start", "content_block_stop", "message_stop"] diff --git a/tests/unit/test_provider_file_references.py b/tests/unit/test_provider_file_references.py new file mode 100644 index 0000000000..83457c37bf --- /dev/null +++ b/tests/unit/test_provider_file_references.py @@ -0,0 +1,68 @@ +"""File history parsing rejects bypasses without interpreting prose.""" + +import pytest + +from gateway.services.provider_files.contracts import FileListRequest, FilesError +from gateway.services.provider_files.references import collect_file_references + + +def test_complete_history_and_nested_results() -> None: + history = [ + {"role": "user", "content": [{"type": "document", "source": {"type": "file", "file_id": "file_old"}}]}, + { + "role": "assistant", + "content": [ + { + "type": "bash_code_execution_tool_result", + "content": { + "type": "bash_code_execution_result", + "content": [{"type": "code_execution_output", "file_id": "file_out"}], + }, + } + ], + }, + {"role": "user", "content": [{"type": "container_upload", "file_id": "file_old"}]}, + ] + assert collect_file_references(history) == ["file_old", "file_out"] + + +def test_text_and_tool_input_are_not_references() -> None: + assert ( + collect_file_references( + [ + {"role": "user", "content": "file_secret"}, + { + "type": "tool_use", + "input": {"file_id": "file_customer_identifier"}, + }, + ] + ) + == [] + ) + + +@pytest.mark.parametrize( + "value", + [ + {"type": "unknown_file", "file_id": "file_bad"}, + {"type": "file", "file_id": 3}, + [{"type": "file", "file_id": f"file_{n}"} for n in range(101)], + [None] * 20001, + ], +) +def test_unsupported_or_excessive_references_fail_closed(value: object) -> None: + with pytest.raises(FilesError): + collect_file_references(value) + + +def test_nested_reference_limit() -> None: + value: object = {"type": "file", "file_id": "file_deep"} + for _ in range(33): + value = {"content": value} + with pytest.raises(FilesError): + collect_file_references(value) + + +def test_ids_cannot_change_pagination_semantics() -> None: + with pytest.raises(ValueError): + FileListRequest(ids=["file_a"], limit=20) diff --git a/tests/unit/test_provider_file_transfers.py b/tests/unit/test_provider_file_transfers.py new file mode 100644 index 0000000000..4c48f7ac78 --- /dev/null +++ b/tests/unit/test_provider_file_transfers.py @@ -0,0 +1,85 @@ +"""Multipart admission limits and cleanup happen before provider upload.""" + +from collections.abc import AsyncIterator + +import httpx +import pytest +from starlette.datastructures import Headers +from starlette.requests import Request + +from gateway.api.routes.hybrid_files import file_headers +from gateway.services.provider_files.contracts import FilesError +from gateway.services.provider_files.transfers import UploadAdmission, receive_upload + + +@pytest.mark.parametrize("beta", ["files-api-2025-04-14", "other-beta, files-api-2025-04-14 "]) +def test_legacy_beta_rejected_case_insensitive_header_name(beta: str) -> None: + request = Request( + {"type": "http", "headers": [(b"anthropic-version", b"2023-06-01"), (b"anthropic-beta", beta.encode())]} + ) + with pytest.raises(FilesError, match="GA API"): + file_headers(request) + + +def test_only_contract_headers_forwarded() -> None: + request = Request( + { + "type": "http", + "headers": [ + (b"anthropic-version", b"2023-06-01"), + (b"x-api-key", b"caller-key"), + (b"anthropic-workspace-id", b"foreign"), + (b"anthropic-beta", b"other-beta"), + ], + } + ) + assert file_headers(request) == {"anthropic-version": "2023-06-01", "anthropic-beta": "other-beta"} + + +@pytest.mark.asyncio +async def test_spooled_upload_closes_on_success_and_failure() -> None: + for reject in (False, True): + request = httpx.Request("POST", "https://test", files={"file": ("private.csv", b"col\nvalue", "text/csv")}) + body = request.read() + + async def chunks() -> AsyncIterator[bytes]: + yield body + + upload = None + try: + async with receive_upload(Headers(request.headers), chunks(), max_bytes=100, idle_seconds=1) as ( + upload, + duration, + ): + assert upload.file.read() == b"col\nvalue" + assert duration is None + if reject: + raise ValueError("intentional failure") + except ValueError: + assert reject + assert upload is not None and upload.file.closed + + +@pytest.mark.asyncio +async def test_body_limit_before_multipart_completion() -> None: + async def chunks() -> AsyncIterator[bytes]: + yield b"x" * 70000 + + with pytest.raises(FilesError) as error: + async with receive_upload( + Headers({"content-type": "multipart/form-data; boundary=test"}), chunks(), max_bytes=1, idle_seconds=1 + ): + pytest.fail("Oversized upload was accepted") + assert error.value.status_code == 413 + + +@pytest.mark.asyncio +async def test_aggregate_admission_and_release() -> None: + admission = UploadAdmission(100) + async with admission.reserve(70): + with pytest.raises(FilesError): + async with admission.reserve(40): + pytest.fail("Overcommitted temporary storage") + assert admission.reserved == 0 + async with admission.reserve(100): + assert admission.reserved == 100 diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts index 0ac463d0f7..32d443613a 100644 --- a/web/src/client/schema.ts +++ b/web/src/client/schema.ts @@ -1101,12 +1101,16 @@ export interface paths { * * ``workspace_id`` narrows a master-key listing to one workspace; a keyed * request is already confined to its key's own and cannot widen or move it. + * + * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. */ get: operations["files-list_files"]; put?: never; /** * Create File * @description OpenAI-compatible file upload endpoint. + * + * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. */ post: operations["files-create_file"]; delete?: never; @@ -1125,6 +1129,8 @@ export interface paths { /** * Get File * @description Retrieve metadata for a single file. + * + * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. */ get: operations["files-get_file"]; put?: never; @@ -1132,6 +1138,8 @@ export interface paths { /** * Delete File * @description Soft-delete a file's metadata and remove its bytes from the backend. + * + * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. */ delete: operations["files-delete_file"]; options?: never; @@ -1149,6 +1157,8 @@ export interface paths { /** * Get File Content * @description Download the raw bytes of a file, streamed rather than buffered whole. + * + * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. */ get: operations["files-get_file_content"]; put?: never; @@ -1159,6 +1169,244 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/gateway/files/cleanup/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Claim */ + post: operations["provider-files-claim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/cleanup/{lease_id}/result": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete Lease */ + post: operations["provider-files-complete_lease"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List Files */ + post: operations["provider-files-list_files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/outputs/prepare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Prepare Output */ + post: operations["provider-files-prepare_output"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/outputs/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register Output */ + post: operations["provider-files-register_output"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/outputs/{operation_id}/abandon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Abandon Output */ + post: operations["provider-files-abandon_output"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/outputs/{operation_id}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete Output */ + post: operations["provider-files-complete_output"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/references/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** References */ + post: operations["provider-files-references"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Backlog */ + get: operations["provider-files-backlog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/uploads/prepare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Prepare */ + post: operations["provider-files-prepare"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/uploads/{binding_id}/abandon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Abandon */ + post: operations["provider-files-abandon"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/uploads/{binding_id}/finalize": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Finalize */ + post: operations["provider-files-finalize"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/{binding_id}/cleanup-result": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cleanup Result */ + post: operations["provider-files-cleanup_result"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/gateway/files/{file_id}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve */ + post: operations["provider-files-resolve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/health": { parameters: { query?: never; @@ -5096,6 +5344,27 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AbandonUpload */ + AbandonUpload: { + /** + * Cleanup Token + * Format: password + */ + cleanup_token: string; + /** + * Deleted + * @default false + */ + deleted: boolean; + /** File Id */ + file_id?: string | null; + metadata?: components["schemas"]["FileMetadata"] | null; + /** + * Outcome Unknown + * @default false + */ + outcome_unknown: boolean; + }; /** AcceptInvitationRequest */ AcceptInvitationRequest: { /** Token */ @@ -5857,6 +6126,8 @@ export interface components { }; /** Body_files-create_file */ "Body_files-create_file": { + /** @description Hybrid provider retention, capped by the control-plane maximum. */ + expires_in_seconds?: number; /** File */ file: string; /** @@ -6622,6 +6893,24 @@ export interface components { /** User */ user?: string | null; }; + /** CleanupClaim */ + CleanupClaim: { + /** + * Limit + * @default 20 + */ + limit: number; + }; + /** CleanupResult */ + CleanupResult: { + /** + * Cleanup Token + * Format: password + */ + cleanup_token: string; + /** Deleted */ + deleted: boolean; + }; /** * ConfigField * @description One effective config value surfaced to the dashboard's config viewer. @@ -7606,6 +7895,59 @@ export interface components { /** User Id */ user_id?: string | null; }; + /** FileListRequest */ + FileListRequest: { + /** Ids */ + ids?: string[] | null; + /** Limit */ + limit?: number | null; + /** Page */ + page?: string | null; + }; + /** + * FileMetadata + * @description Anthropic's public metadata, without provider-neutral-only fields. + */ + FileMetadata: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Downloadable */ + downloadable: boolean; + /** Expires At */ + expires_at?: string | null; + /** Filename */ + filename: string; + /** Id */ + id: string; + /** Mime Type */ + mime_type: string; + /** Size Bytes */ + size_bytes: number; + /** + * Type + * @default file + * @constant + */ + type: "file"; + } & { + [key: string]: unknown; + }; + /** FilePage */ + FilePage: { + /** Data */ + data: components["schemas"]["FileMetadata"][]; + /** Next Page */ + next_page?: string | null; + }; + /** FinalizeUpload */ + FinalizeUpload: { + /** Expires In Seconds */ + expires_in_seconds?: number | null; + metadata: components["schemas"]["FileMetadata"]; + }; /** GateResultResponse */ GateResultResponse: { /** Detail */ @@ -8134,6 +8476,18 @@ export interface components { /** Name */ name: string; }; + /** LeaseResult */ + LeaseResult: { + /** Results */ + results: { + [key: string]: boolean; + }; + /** + * Token + * Format: password + */ + token: string; + }; /** * MailSettings * @description What this deployment can send, and what stands in the way if it cannot. @@ -8694,6 +9048,17 @@ export interface components { [key: string]: unknown; } | null; }; + /** NativeFileDeleted */ + NativeFileDeleted: { + /** Id */ + id: string; + /** + * Type + * @default file_deleted + * @constant + */ + type: "file_deleted"; + }; /** * OAuthCallbackRequest * @description The authorization code a provider handed the browser. @@ -9633,6 +9998,32 @@ export interface components { /** Data */ data: components["schemas"]["OrganizationScopedBudgetPublic"][]; }; + /** OutputPrepare */ + OutputPrepare: { + /** Attempt Id */ + attempt_id: string; + /** + * Generation Id + * Format: uuid + */ + generation_id: string; + /** + * Operation Id + * Format: uuid + */ + operation_id: string; + /** Request Id */ + request_id: string; + }; + /** OutputRegister */ + OutputRegister: { + metadata: components["schemas"]["FileMetadata"]; + /** + * Operation Id + * Format: uuid + */ + operation_id: string; + }; /** * OutputShape * @description The decision form a guardrail produces (aligns with the populated ``GuardrailOutput`` fields). @@ -10157,6 +10548,18 @@ export interface components { /** Warm */ warm: boolean; }; + /** PrepareUpload */ + PrepareUpload: { + /** Expires In Seconds */ + expires_in_seconds?: number | null; + /** + * Operation Id + * Format: uuid + */ + operation_id: string; + /** Size Bytes */ + size_bytes: number; + }; /** * PricingDriftRow * @description A stored deployment rate beside the default it shadows. @@ -10507,6 +10910,11 @@ export interface components { */ unreadable: number; }; + /** References */ + References: { + /** Ids */ + ids: string[]; + }; /** * RegisterPasskeyRequest * @description A completed registration ceremony, with the label to file it under. @@ -10627,6 +11035,14 @@ export interface components { */ token: string; }; + /** ResolveFile */ + ResolveFile: { + /** + * Operation + * @enum {string} + */ + operation: "metadata" | "download" | "delete"; + }; /** * ResourceLink * @description A resource that the server is capable of reading, included in a prompt or tool call result. @@ -14530,8 +14946,15 @@ export interface operations { user?: string | null; purpose?: string | null; workspace_id?: string | null; + /** @description Hybrid GA cursor. */ + page?: string; + /** @description Hybrid IDs filter; mutually exclusive with page and limit. */ + "ids[]"?: string[]; + }; + header?: { + /** @description Required in hybrid provider-native mode. */ + "anthropic-version"?: string; }; - header?: never; path?: never; cookie?: never; }; @@ -14545,7 +14968,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - }; + } | components["schemas"]["FilePage"]; }; }; /** @description Validation Error */ @@ -14562,7 +14985,10 @@ export interface operations { "files-create_file": { parameters: { query?: never; - header?: never; + header?: { + /** @description Required in hybrid provider-native mode. */ + "anthropic-version"?: string; + }; path?: never; cookie?: never; }; @@ -14580,7 +15006,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - }; + } | components["schemas"]["FileMetadata"]; }; }; /** @description Validation Error */ @@ -14599,7 +15025,10 @@ export interface operations { query?: { user?: string | null; }; - header?: never; + header?: { + /** @description Required in hybrid provider-native mode. */ + "anthropic-version"?: string; + }; path: { file_id: string; }; @@ -14615,7 +15044,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - }; + } | components["schemas"]["FileMetadata"]; }; }; /** @description Validation Error */ @@ -14634,7 +15063,10 @@ export interface operations { query?: { user?: string | null; }; - header?: never; + header?: { + /** @description Required in hybrid provider-native mode. */ + "anthropic-version"?: string; + }; path: { file_id: string; }; @@ -14650,7 +15082,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - }; + } | components["schemas"]["NativeFileDeleted"]; }; }; /** @description Validation Error */ @@ -14669,7 +15101,10 @@ export interface operations { query?: { user?: string | null; }; - header?: never; + header?: { + /** @description Required in hybrid provider-native mode. */ + "anthropic-version"?: string; + }; path: { file_id: string; }; @@ -14700,6 +15135,479 @@ export interface operations { }; }; }; + "provider-files-claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CleanupClaim"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-complete_lease": { + parameters: { + query?: never; + header?: never; + path: { + lease_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LeaseResult"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-list_files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FileListRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-prepare_output": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OutputPrepare"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-register_output": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OutputRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-abandon_output": { + parameters: { + query?: never; + header?: never; + path: { + operation_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AbandonUpload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-complete_output": { + parameters: { + query?: never; + header?: never; + path: { + operation_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CleanupResult"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-references": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["References"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-backlog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: number; + }; + }; + }; + }; + }; + "provider-files-prepare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PrepareUpload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-abandon": { + parameters: { + query?: never; + header?: never; + path: { + binding_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AbandonUpload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-finalize": { + parameters: { + query?: never; + header?: never; + path: { + binding_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FinalizeUpload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-cleanup_result": { + parameters: { + query?: never; + header?: never; + path: { + binding_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CleanupResult"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "provider-files-resolve": { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResolveFile"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; "health-health_check": { parameters: { query?: never; From 5ccb5b67fc3be6d2584af1c68999bffc741b2791 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Fri, 18 Sep 2026 12:32:29 +0200 Subject: [PATCH 02/21] fix(files): require released any-llm 1.28.0 Update the dependency floor and lockfile to the published Files release. Remove the obsolete compatibility guard and test skip, cover metadata retrieval in the SDK contract test, and document the remaining hosted enablement gates. --- docs/files.md | 18 +++++++++--------- pyproject.toml | 2 +- .../services/provider_files/transport.py | 2 -- .../test_hybrid_files_sdk_contract.py | 18 +++++++++--------- uv.lock | 14 +++++++------- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/docs/files.md b/docs/files.md index 856d8b066c..e48c1baa65 100644 --- a/docs/files.md +++ b/docs/files.md @@ -130,10 +130,9 @@ libraries since Otari is a network service. OCR is optional; install the Hybrid gateways can forward the Anthropic GA Files API through any-llm while keeping bytes at Anthropic. Enable `files_provider_native_enabled` only after the -control plane contributes the Files protocol and the deployed any-llm version -contains its Files interface. The initial implementation targets any-llm 1.28. -The gateway returns a fixed 502 if that interface or the control-plane protocol -is unavailable. The default remains disabled. +control plane contributes the Files protocol. The gateway requires any-llm-sdk +1.28.0 or later, which includes the Anthropic Files interface. The gateway returns +a fixed 502 if the control-plane protocol is unavailable. The default remains disabled. Use the official Anthropic SDK's GA `files` resource, not `beta.files`: @@ -216,9 +215,10 @@ Anthropic reports it; local expiry alone cannot delete an unknown upstream ID. ### Release verification -The core contract has been exercised with the merged any-llm Files implementation -at `2524c196c4c8cbeb8698a9e0b6f90d73aa659a9d` and Anthropic Python SDK 0.125.0. -The published any-llm 1.28 dependency pin and lockfile update remain a release -gate. Before hosted enablement, verify the composed hosted adapter, generated -output expiry, and the Octonous workflow without managed container reuse. +The dependency floor is any-llm-sdk 1.28.0, and the lockfile selects that published +release. The mandatory SDK contract test covers upload, scoped listing, metadata +retrieval, download, and deletion through the official Anthropic client and Otari, +using a mocked control plane and provider transport. +Before hosted enablement, verify the composed hosted adapter, generated output +expiry, and the Octonous workflow without managed container reuse. The canonical server contract is in [Hybrid mode protocol](hybrid-mode-protocol.md#provider-native-files). diff --git a/pyproject.toml b/pyproject.toml index 16f08d78d6..8c0386afd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ version = "0.0.0" description = "otari, an OpenAI-compatible LLM gateway" requires-python = ">=3.13" dependencies = [ - "any-llm-sdk[all]>=1.27.1", + "any-llm-sdk[all]>=1.28.0", # The guardrail catalog only (`services/guardrail_catalog.py`). Its # `parameter_registry` is a stdlib+pydantic leaf built so a consumer can # render a configuration form without importing a model backend, so no diff --git a/src/gateway/services/provider_files/transport.py b/src/gateway/services/provider_files/transport.py index 19c919954c..6ecb74eb8f 100644 --- a/src/gateway/services/provider_files/transport.py +++ b/src/gateway/services/provider_files/transport.py @@ -38,8 +38,6 @@ async def provider_client(account: FileAccount, *, idle_timeout: float = 30) -> max_retries=0, default_headers={"anthropic-workspace-id": account.workspace} if account.workspace else {}, ) - if not hasattr(client, "aupload_file"): - raise FilesError(502, "Provider-native Files require any-llm-sdk 1.28 or later") yield client diff --git a/tests/integration/test_hybrid_files_sdk_contract.py b/tests/integration/test_hybrid_files_sdk_contract.py index 12cdfa22f0..4ef56640af 100644 --- a/tests/integration/test_hybrid_files_sdk_contract.py +++ b/tests/integration/test_hybrid_files_sdk_contract.py @@ -1,4 +1,4 @@ -"""Official GA Anthropic client through Otari and the merged any-llm Files transport.""" +"""Official GA Anthropic client through Otari and the released any-llm Files transport.""" import uuid from datetime import UTC, datetime, timedelta @@ -7,7 +7,6 @@ import anthropic import httpx import pytest -from any_llm import AnyLLM from fastapi import FastAPI from pydantic import SecretStr @@ -25,15 +24,10 @@ WireModel, ) -pytestmark = [ - pytest.mark.asyncio, - pytest.mark.skipif( - not hasattr(AnyLLM, "aupload_file"), reason="Requires any-llm Files interface (#1395, planned 1.28)" - ), -] +pytestmark = pytest.mark.asyncio -async def test_official_sdk_upload_list_download_delete(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_official_sdk_upload_list_retrieve_download_delete(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") config = GatewayConfig( mode="hybrid", @@ -74,6 +68,7 @@ async def authority(self: Any, path: str, body: dict[str, Any], result_type: typ if path == "list": return FilePage(data=[metadata], next_page=None) if path.endswith("/resolve"): + calls.append(f"resolve:{body['operation']}") return ResolvedFile( metadata=metadata, account=account, operation_id=operation.id, cleanup_token=SecretStr("cleanup") ) @@ -113,6 +108,11 @@ async def upstream(request: httpx.Request) -> httpx.Response: ) page = await sdk.files.list() assert [item.id for item in page.data] == [uploaded.id] + retrieved = await sdk.files.retrieve_metadata(uploaded.id) + assert retrieved.id == uploaded.id + assert retrieved.filename == "input.csv" + assert retrieved.size_bytes == 4 + assert "resolve:metadata" in calls downloaded = await sdk.files.download(uploaded.id) assert await downloaded.read() == b"data" deleted = await sdk.files.delete(uploaded.id) diff --git a/uv.lock b/uv.lock index a7bfbd98a9..aaff8c5d0b 100644 --- a/uv.lock +++ b/uv.lock @@ -210,7 +210,7 @@ wheels = [ [[package]] name = "any-llm-sdk" -version = "1.27.1" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, @@ -221,9 +221,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/67/e4ec7b8b065210e4069f54e4075871ad017b3dc988cf9230259c1abdad86/any_llm_sdk-1.27.1.tar.gz", hash = "sha256:605a44b24e67e3396f8cef9489bf5cf6a4cc59d25cd022ceb823aae268590cf0", size = 210430, upload-time = "2026-09-04T09:58:00.719Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/0a/c4fbcc0cd516e28eccafba9cfe5d87b45f8849b54e7be0369c339a4a12a2/any_llm_sdk-1.28.0.tar.gz", hash = "sha256:cf9e27f18647ee92f54def3faed035547f6fb9acaa626794c66edcb48f5c118f", size = 228780, upload-time = "2026-09-18T10:09:26.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/14/07f949b26734222d1cc0be2b461c68c7bd4cfb3849ed15ce9edcf640be7e/any_llm_sdk-1.27.1-py3-none-any.whl", hash = "sha256:be3a919c07163db94b88b0d8676d0abbc9a6ed432ca4d415741a71cbc25b91d7", size = 265047, upload-time = "2026-09-04T09:57:59.175Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/379d3885fc1be7a85061d9a049a6561f06f5b4ab0aa98cf93908aa33d025/any_llm_sdk-1.28.0-py3-none-any.whl", hash = "sha256:4fb5e2c82b9e51be6293a97cf6efd6f3fce79684b97537f4f7926d4c3aa5abde", size = 285107, upload-time = "2026-09-18T10:09:24.572Z" }, ] [package.optional-dependencies] @@ -1094,7 +1094,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "alembic", specifier = ">=1.13.0" }, { name = "any-guardrail", specifier = ">=0.7.7,<0.8.0" }, - { name = "any-llm-sdk", extras = ["all"], specifier = ">=1.27.1" }, + { name = "any-llm-sdk", extras = ["all"], specifier = ">=1.28.0" }, { name = "apron-auth", specifier = ">=0.15.1,<0.16.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, @@ -2595,7 +2595,7 @@ wheels = [ [[package]] name = "otari" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2603,9 +2603,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/57/a121d08fd41fde51948f7a8bc5176323a58988a394e0c28f5f16a550e7c6/otari-0.3.0.tar.gz", hash = "sha256:15b2408ade8cee9dfeb45adaf3396b3a3ba2a39d91f6645d53295447f96c817d", size = 259831, upload-time = "2026-08-14T08:27:57.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/4e/453a36fe9d147daecae2932e128a687e18153763a3caf385f5c9ba70049b/otari-0.4.0.tar.gz", hash = "sha256:42d8bdc3e666f3ddca928a6fdbfec1bd0b4977699d13f5d8dc518bfdeef60fae", size = 430152, upload-time = "2026-09-16T07:06:44.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/57/b5244e8b99d7f29cba2eac730e0ad60d38823132d83bf84da5faed15fed4/otari-0.3.0-py3-none-any.whl", hash = "sha256:9097c795b2b8007899750e34dcba106dbe713d20d4b8fc63d92a02df70145262", size = 669174, upload-time = "2026-08-14T08:27:56.064Z" }, + { url = "https://files.pythonhosted.org/packages/6b/91/165edde1b66d33c08539b582998a6cb40cee240556c0ddc12eaa52d62759/otari-0.4.0-py3-none-any.whl", hash = "sha256:3d7d2cc613d5c94a8e4b4c4fd44e0e62b2dc961f5e79fc436e74e885a44e4c08", size = 1128394, upload-time = "2026-09-16T07:06:42.035Z" }, ] [[package]] From 98835898b09509a0b4e05bb775b5cfc626a4665c Mon Sep 17 00:00:00 2001 From: Hareesh Date: Fri, 18 Sep 2026 13:31:42 +0200 Subject: [PATCH 03/21] feat(files): support provider-neutral hybrid file operations Separate native API envelopes from shared account resolution, metadata, ownership, quotas, and cleanup. Add OpenAI Files operations through any-llm with explicit provider selection and require Files protocol 2. Isolate Anthropic inference binding and reject unsupported OpenAI file state before dispatch. Cover cross-provider isolation, SDK contracts, retention, pagination, and migrations with regression tests. Fold the new columns into the unmerged provider-files migration and regenerate API artifacts. --- .../c3e5a7b9d1f4_add_provider_files.py | 2 + docs/files.md | 86 ++- docs/hybrid-mode-protocol.md | 83 ++- docs/public/openapi.json | 660 +++++++++++++++--- docs/public/otari.postman_collection.json | 36 +- scripts/generate_openapi.py | 53 +- src/gateway/api/routes/_file_formats.py | 207 ++++++ src/gateway/api/routes/chat.py | 5 +- src/gateway/api/routes/hybrid_files.py | 140 ++-- src/gateway/api/routes/messages.py | 19 +- src/gateway/api/routes/provider_files.py | 155 ++-- src/gateway/api/routes/responses.py | 5 +- src/gateway/core/config.py | 2 +- src/gateway/models/provider_files.py | 4 +- .../tenancy/provider_file_repository.py | 156 ++++- .../services/provider_files/accounts.py | 154 ++-- .../provider_files/anthropic_inference.py | 60 ++ .../services/provider_files/capabilities.py | 29 + .../services/provider_files/cleanup.py | 180 ++--- src/gateway/services/provider_files/client.py | 9 +- .../services/provider_files/contracts.py | 52 +- .../services/provider_files/inference.py | 55 +- .../services/provider_files/lifecycle.py | 173 ++++- .../services/provider_files/outputs.py | 75 +- .../services/provider_files/references.py | 58 +- .../services/provider_files/transfers.py | 30 +- .../services/provider_files/transport.py | 12 +- .../tenancy/org_provider_key_service.py | 50 +- .../test_hybrid_files_inference_guards.py | 60 ++ .../integration/test_hybrid_files_messages.py | 26 +- .../test_hybrid_files_openai_sdk.py | 132 ++++ .../test_hybrid_files_sdk_contract.py | 3 +- .../test_provider_file_lifecycle.py | 60 +- .../test_provider_file_multi_provider.py | 203 ++++++ .../test_provider_file_transactions.py | 162 +++++ .../test_provider_files_protocol.py | 49 +- tests/unit/test_gateway_lifespan_shutdown.py | 23 + tests/unit/test_provider_file_contracts.py | 74 ++ tests/unit/test_provider_file_formats.py | 56 ++ tests/unit/test_provider_file_migration.py | 12 + tests/unit/test_provider_file_openapi.py | 29 + tests/unit/test_provider_file_outputs.py | 40 +- .../test_provider_file_protocol_version.py | 24 + tests/unit/test_provider_file_references.py | 10 +- tests/unit/test_provider_file_transfers.py | 13 +- web/src/client/schema.ts | 242 +++++-- 46 files changed, 3020 insertions(+), 748 deletions(-) create mode 100644 src/gateway/api/routes/_file_formats.py create mode 100644 src/gateway/services/provider_files/anthropic_inference.py create mode 100644 src/gateway/services/provider_files/capabilities.py create mode 100644 tests/integration/test_hybrid_files_inference_guards.py create mode 100644 tests/integration/test_hybrid_files_openai_sdk.py create mode 100644 tests/integration/test_provider_file_multi_provider.py create mode 100644 tests/integration/test_provider_file_transactions.py create mode 100644 tests/unit/test_provider_file_contracts.py create mode 100644 tests/unit/test_provider_file_formats.py create mode 100644 tests/unit/test_provider_file_openapi.py create mode 100644 tests/unit/test_provider_file_protocol_version.py diff --git a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py index 77aaa964ae..2317abc20b 100644 --- a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py +++ b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py @@ -119,6 +119,8 @@ def upgrade() -> None: sa.Column("workspace_id", sa.Uuid(), nullable=False), sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), sa.Column("encrypted_metadata", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("provider_created_at", sa.DateTime(timezone=True), nullable=True), sa.Column("size_bytes", sa.Integer(), nullable=False), sa.Column("downloadable", sa.Boolean(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), diff --git a/docs/files.md b/docs/files.md index e48c1baa65..3cc2973177 100644 --- a/docs/files.md +++ b/docs/files.md @@ -126,13 +126,19 @@ Text/office/PDF extraction uses [markitdown](https://github.com/microsoft/markit libraries since Otari is a network service. OCR is optional; install the `ocr` extra (`pip install gateway[ocr]`) to enable it. -## Hybrid Anthropic Files (opt-in) + -Hybrid gateways can forward the Anthropic GA Files API through any-llm while -keeping bytes at Anthropic. Enable `files_provider_native_enabled` only after the -control plane contributes the Files protocol. The gateway requires any-llm-sdk -1.28.0 or later, which includes the Anthropic Files interface. The gateway returns -a fixed 502 if the control-plane protocol is unavailable. The default remains disabled. +## Hybrid provider-native Files (opt-in) + +Hybrid gateways support Anthropic and OpenAI Files through any-llm, keeping bytes +at the selected provider. Enable `files_provider_native_enabled` only after the +control plane contributes Files protocol version 2. The gateway requires +any-llm-sdk 1.28.0 or later. A missing or older authority protocol returns a fixed +502; the feature remains disabled by default. + +`X-Otari-Files-Provider` selects the provider and public API envelope. It defaults +to `anthropic` for existing clients. The selector only narrows authorized +credentials; it cannot supply an account, secret, or upstream endpoint. Use the official Anthropic SDK's GA `files` resource, not `beta.files`: @@ -180,6 +186,61 @@ generation. File-bearing requests have no account or provider fallback. Chat Completions and Responses reject provider file references; use Messages. Managed credentials still reject caller-selected container reuse. +### OpenAI Files + +Use the official OpenAI SDK with an explicit provider header: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="YOUR_OTARI_WORKSPACE_API_KEY", + base_url="https://gateway.example/api/v1/", + default_headers={"X-Otari-Files-Provider": "openai"}, +) +with open("input.csv", "rb") as source: + uploaded = client.files.create(file=source, purpose="user_data") +metadata = client.files.retrieve(uploaded.id) +page = client.files.list(purpose="user_data", limit=20) +content = client.files.content(uploaded.id) +client.files.delete(uploaded.id) +``` + +OpenAI uploads require `purpose`; optional `expires_after` must use the +`created_at` anchor, stay within OpenAI's 1-hour to 30-day range, and are capped +by the authority's retention policy. Without a caller expiry, OpenAI uploads use +the smaller of that policy and 30 days. Lists support `after`, `before`, `order`, `limit`, and `purpose`. They read local owned +bindings, never an account-wide provider listing. Always use the same provider +header for subsequent operations. An ID shared by providers does not cross the +provider boundary; ambiguous IDs within one provider fail closed. + +These operations do not enable OpenAI file references in Chat Completions or +Responses. Hybrid Chat Completions and Responses also reject native +`code_interpreter`, `file_search`, and `shell` tools, stored item/compaction +references, conversation reuse, and file references in native tool options: +those can read or create account-scoped files without passing this ownership +protocol. Standalone behavior is unchanged. +Inference binding currently supports Anthropic Messages only. +Gemini Files is unsupported in any-llm 1.28.0 and remains disabled here. + +### Extension boundary + +any-llm owns provider SDK calls, option translation, normalized metadata, and +operation capabilities. Otari owns credentials, tenant isolation, quotas, +retention policy, cleanup, and public API envelopes. The shared lifecycle keeps +unknown metadata fields unknown, including download permission and size. An +unknown upload size retains its full reservation; an unknown generated size +charges the per-file maximum. Explicit `downloadable: false` denies downloads. +When permission is unknown, an owned file can reach a supported download +operation, whose upstream response decides whether access is permitted. + +A new upstream provider does not automatically enable a public gateway API. +Register its API-format adapter, authorize its credentials, and verify SDK +capabilities and lifecycle semantics. Provider-specific HTTP clients do not +belong here. Anthropic message parsing and stream buffering are isolated from +shared output registration so another inference envelope need not duplicate +ownership or cleanup logic. + ### Limits and cleanup | Setting | Default / requirement | @@ -188,7 +249,7 @@ Managed credentials still reject caller-selected container reuse. | `files_transfer_timeout_seconds` | 300 seconds, covering receipt and upload | | `files_idle_timeout_seconds` | 30 seconds | | `files_rate_limit_rpm` | 60 operations per uploader/workspace, enforced in the control plane | -| `files_retention_hours` | Hybrid default 168; provider range 1–2160 hours | +| `files_retention_hours` | Hybrid default 168; gateway policy range 1–2160 hours, also subject to provider limits | | `files_max_count`, `files_max_outstanding_bytes` | Explicit positive control-plane quotas required | | `files_temporary_capacity_bytes` | 2 GiB shared admission ceiling across local workers | | `files_operation_timeout_seconds` | 600 seconds | @@ -201,7 +262,7 @@ system user. Use an ephemeral, quota-limited temporary volume; reservations are reclaimed after process termination, and rolled-over file buffers are unlinked temporary files. No durable gateway file store is used. -Deletion revokes local access before contacting Anthropic. Failed deletions stay +Deletion revokes local access before contacting the provider. Failed deletions stay in a durable cleanup queue. Gateways claim fenced, five-minute leases of up to 20 files; failures back off from one minute to six hours. Replacing, removing, or restoring a retired credential waits for required cleanup. Workspace-key @@ -216,9 +277,12 @@ Anthropic reports it; local expiry alone cannot delete an unknown upstream ID. ### Release verification The dependency floor is any-llm-sdk 1.28.0, and the lockfile selects that published -release. The mandatory SDK contract test covers upload, scoped listing, metadata -retrieval, download, and deletion through the official Anthropic client and Otari, -using a mocked control plane and provider transport. +release. Mandatory SDK contract tests cover upload, scoped listing, metadata +retrieval, download, and deletion through the official Anthropic and OpenAI +clients and Otari, using mocked control-plane and provider transports. Apply +migration `c3e5a7b9d1f4` for provider-native file storage, purpose filtering, and +provider-time ordering. Upgrade the authority +and gateways together to Files protocol 2; older peers fail closed. Before hosted enablement, verify the composed hosted adapter, generated output expiry, and the Octonous workflow without managed container reuse. The canonical server contract is in [Hybrid mode protocol](hybrid-mode-protocol.md#provider-native-files). diff --git a/docs/hybrid-mode-protocol.md b/docs/hybrid-mode-protocol.md index 53b7d81545..5838216b2e 100644 --- a/docs/hybrid-mode-protocol.md +++ b/docs/hybrid-mode-protocol.md @@ -639,10 +639,16 @@ The control plane transfers no file bytes. A hybrid gateway uses any-llm's Files interface directly and stores no durable bindings or cleanup jobs. All Files responses, including errors, carry `Cache-Control: private, no-store` -and `X-Otari-Files-Protocol: 1`. A 404 without that protocol marker is treated as -an older peer and becomes a fixed public 502. Consumers ignore unknown response -fields. Metadata preserves native fields and omitted values, excluding normalized -`purpose` and `status` fields that are not Anthropic Files fields. +and `X-Otari-Files-Protocol: 2`. Gateway requests carry the same version header. +The authority rejects other request versions after authentication, before file +operations. The gateway rejects any response with a missing or different version, +including a successful response, with a fixed public 502. This prevents a version-1 +peer from ignoring provider selection. Deploy authority and gateway updates together. + +Metadata follows any-llm's normalized `FileMetadata`, preserving provider extras, +`purpose`, `status`, and absent values. The Anthropic public envelope alone drops +normalized-only fields; the OpenAI envelope maps byte counts and timestamps to its +native shape. The control plane never stores a public API envelope as its schema. ### Authentication and composition @@ -666,15 +672,28 @@ revocation, never new reads or inference. Lease tokens additionally bind the current lease generation, its deadline, and exact work items. BYO resolution is core-owned: a workspace pin wins, then the organization -default, then a unique usable Anthropic key. Multiple candidates return 409. +default, then a unique usable key for the requested provider. Multiple candidates +return 409. `PrepareUpload`, `FileListRequest`, `ResolveFile`, and `References` +carry `provider` (default `anthropic`); account generations and transient +`FileAccount` credentials carry the same provider. The authority checks that the +requested, stored, and credential providers agree. A disabled explicit selection fails closed. There is no oldest-key fallback. A hosted resolver is called only after BYO selection and only for the trusted default gateway, before a managed secret is read. It supplies an existing core account generation and a transient credential, including any trusted upstream -workspace selection. Keep credential-source locking and generation creation in -that same transaction. An adapter must retire every affected generation through -`retire_account_generation` before releasing an old hosted secret, and prevent -new source selection throughout retirement. +workspace selection. Its callback signature is +`resolve_hosted(scope, provider, generation_id, cleanup, uow)`. All router +callbacks receive the request's open `UnitOfWork`, not a raw database session; +they use repositories and never commit independently. Standard API-key credentials +are supported; arbitrary `client_args` remain rejected. Keep credential-source +locking and generation creation in that same transaction. + +An adapter must call `retire_account_generation(uow, generation, release_secret=...)` +inside the same Unit of Work block as secret replacement or deletion, and prevent +new source selection throughout retirement. A `True` result means cleanup blocks +secret release: leave the secret intact, let the block commit revocation, and only +then report the conflict. A `False` result permits the secret change within that +block, so failure rolls back both the change and retirement. `authorize_attempt` must intersect the original authorized inference request and attempt, including its model, workspace tool policy, and account generation. @@ -705,8 +724,11 @@ plan. The gateway rejects missing generation identity for file-aware dispatch. requests a maximum reservation, capped by the control plane. Preparation is idempotent and precedes multipart receipt. `Operation` returns the committed intent ID, scoped cleanup token, deadline, account credential, size cap, and -finite retention cap. A smaller caller retention is passed to Anthropic and to -finalization as `expires_in_seconds`; it cannot widen the prepared cap. +finite retention cap. A smaller caller retention is passed through any-llm and to +finalization as `expires_in_seconds`; it cannot widen the prepared cap. The OpenAI +public adapter further caps default upstream retention at 30 days and validates +explicit expiry against its native 1-hour to 30-day range. Local policy may be +stricter; provider expiry is never silently omitted. Finalization accepts metadata observed from that one provider upload. Identical retries return the committed metadata; ownership collisions or incompatible @@ -719,15 +741,31 @@ cleanup. Unknown outcomes release reservations at the operation deadline but retain their diagnostic marker for `files_diagnostic_retention_days` after the deadline (default 30 days). -Lists query active bindings for the uploader and workspace. Default page size -is 20, maximum 1000, and at most 100 `ids[]` values are accepted. Cursors are -opaque encrypted values bound to scope, page size, a snapshot, and the stable -`(created_at, id)` order. Files activated after the snapshot do not enter later -pages. Deletion and expiry can shrink later pages. Unknown and foreign `ids[]` -values are omitted without disclosing their existence. +Lists query active bindings for the uploader, workspace, and provider, with an +optional normalized `purpose` filter. Default page size is 20, maximum 1000, +and at most 100 `ids[]` values are accepted. Anthropic `page` cursors are opaque +encrypted values bound to tenant scope, provider, purpose, order, page size, +and snapshot. Files activated after the snapshot do not enter later pages. +Deletion and expiry can shrink later pages. Unknown and foreign `ids[]` values +are omitted without disclosing their existence. + +OpenAI `after` and `before` map to internal `after_id` and `before_id`, using an +owned active binding as the cursor anchor and `order` (`asc` or `desc`). The +OpenAI adapter requests `sort_by: provider_created_at`; the authority orders by +the persisted provider timestamp, falling back to binding creation when unknown. +The default `sort_by: binding_created_at` preserves Anthropic snapshot ordering. +Native ID cursors are not snapshot cursors. Only one cursor form is accepted. A cursor +from another provider or owner is invalid; IDs ambiguous across accounts of one +provider fail closed. The public OpenAI envelope emits `first_id`, `last_id`, and +`has_more`; the internal result remains `FilePage`. Resolve supports `metadata`, `download`, and `delete`. Metadata is local to the -control plane. Download requires the stored provider download permission. +control plane. Explicit `downloadable: false` denies downloads. Unknown permission +stays unknown: the gateway checks ownership and any-llm's download capability, +then defers the provider's per-file permission decision to its download response. +An absent size retains the upload reservation or charges the generated-file maximum; +it never counts as zero bytes. The normalized purpose and provider creation time +are stored separately by migration `c3e5a7b9d1f4`; remaining metadata stays encrypted. Delete first marks the binding `pending_cleanup`, then returns cleanup authority. This contract uses the internal **binding ID** for `cleanup-result`, rather than an ambiguous account-wide provider ID. A successful provider deletion or provider @@ -736,7 +774,14 @@ an ambiguous account-wide provider ID. A successful provider deletion or provide Output operations reserve capacity before dispatch, including attempts without input files. Registration retrieves provider metadata and commits a binding before a non-streaming response or a structured streaming file block is released. -Streaming event order is preserved while the block is held. Echoed input IDs +Anthropic Messages is the only enabled inference envelope. Its reference parser +and stream buffer wrap shared provider-neutral registration; OpenAI Files CRUD +does not enable file use in Responses or Chat Completions. Those hybrid routes +reject native code-interpreter/file-search/shell tools, stored item/compaction +references, conversation reuse, and file references in tool options before +resolution, since each could bypass file ownership. +Streaming event order +is preserved while the block is held. Echoed input IDs are not registered again. Non-streaming usage settlement occurs before output binding finalization, so a registration failure does not lose provider usage. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index b0ba4e3b42..9bdbc1e269 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1364,6 +1364,110 @@ "title": "Annotations", "type": "object" }, + "AnthropicFileDeleted": { + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "type": { + "const": "file_deleted", + "default": "file_deleted", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "AnthropicFileDeleted", + "type": "object" + }, + "AnthropicFileMetadata": { + "additionalProperties": true, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "downloadable": { + "title": "Downloadable", + "type": "boolean" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "mime_type": { + "title": "Mime Type", + "type": "string" + }, + "size_bytes": { + "title": "Size Bytes", + "type": "integer" + }, + "type": { + "const": "file", + "default": "file", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "filename", + "mime_type", + "size_bytes", + "created_at", + "downloadable" + ], + "title": "AnthropicFileMetadata", + "type": "object" + }, + "AnthropicFilePage": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AnthropicFileMetadata" + }, + "title": "Data", + "type": "array" + }, + "next_page": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Page" + } + }, + "required": [ + "data" + ], + "title": "AnthropicFilePage", + "type": "object" + }, "AudioContent": { "additionalProperties": true, "description": "Audio content for a message.", @@ -1683,8 +1787,20 @@ }, "Body_files-create_file": { "properties": { + "expires_after[anchor]": { + "description": "OpenAI hybrid expiry anchor.", + "enum": [ + "created_at" + ], + "type": "string" + }, + "expires_after[seconds]": { + "description": "OpenAI hybrid retention, capped by the control-plane maximum.", + "minimum": 1, + "type": "integer" + }, "expires_in_seconds": { - "description": "Hybrid provider retention, capped by the control-plane maximum.", + "description": "Anthropic hybrid retention, capped by the control-plane maximum.", "maximum": 7776000, "minimum": 3600, "type": "integer" @@ -1696,6 +1812,7 @@ }, "purpose": { "default": "user_data", + "description": "Required for OpenAI hybrid uploads; unsupported for Anthropic hybrid uploads.", "title": "Purpose", "type": "string" }, @@ -5568,6 +5685,34 @@ }, "FileListRequest": { "properties": { + "after_id": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After Id" + }, + "before_id": { + "anyOf": [ + { + "maxLength": 255, + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before Id" + }, "ids": { "anyOf": [ { @@ -5599,6 +5744,15 @@ ], "title": "Limit" }, + "order": { + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "title": "Order", + "type": "string" + }, "page": { "anyOf": [ { @@ -5610,6 +5764,35 @@ } ], "title": "Page" + }, + "provider": { + "default": "anthropic", + "maxLength": 32, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Provider", + "type": "string" + }, + "purpose": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Purpose" + }, + "sort_by": { + "default": "binding_created_at", + "enum": [ + "binding_created_at", + "provider_created_at" + ], + "title": "Sort By", + "type": "string" } }, "title": "FileListRequest", @@ -5617,16 +5800,30 @@ }, "FileMetadata": { "additionalProperties": true, - "description": "Anthropic's public metadata, without provider-neutral-only fields.", + "description": "Bounded any-llm metadata; absent provider fields remain unknown.", "properties": { "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" }, "downloadable": { - "title": "Downloadable", - "type": "boolean" + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Downloadable" }, "expires_at": { "anyOf": [ @@ -5641,9 +5838,16 @@ "title": "Expires At" }, "filename": { - "maxLength": 1024, - "title": "Filename", - "type": "string" + "anyOf": [ + { + "maxLength": 1024, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" }, "id": { "maxLength": 255, @@ -5653,58 +5857,58 @@ "type": "string" }, "mime_type": { - "maxLength": 255, - "title": "Mime Type", - "type": "string" + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mime Type" }, - "size_bytes": { - "minimum": 0.0, - "title": "Size Bytes", - "type": "integer" + "purpose": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Purpose" }, - "type": { - "const": "file", - "default": "file", - "title": "Type", - "type": "string" - } - }, - "required": [ - "id", - "filename", - "mime_type", - "size_bytes", - "created_at", - "downloadable" - ], - "title": "FileMetadata", - "type": "object" - }, - "FilePage": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/FileMetadata" - }, - "title": "Data", - "type": "array" + "size_bytes": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size Bytes" }, - "next_page": { + "status": { "anyOf": [ { + "maxLength": 255, "type": "string" }, { "type": "null" } ], - "title": "Next Page" + "title": "Status" } }, "required": [ - "data" + "id" ], - "title": "FilePage", + "title": "FileMetadata", "type": "object" }, "FinalizeUpload": { @@ -8026,25 +8230,6 @@ "title": "ModerationResult", "type": "object" }, - "NativeFileDeleted": { - "properties": { - "id": { - "title": "Id", - "type": "string" - }, - "type": { - "const": "file_deleted", - "default": "file_deleted", - "title": "Type", - "type": "string" - } - }, - "required": [ - "id" - ], - "title": "NativeFileDeleted", - "type": "object" - }, "OAuthCallbackRequest": { "description": "The authorization code a provider handed the browser.\n\nNo ``redirect_uri``: this deployment derives its own from ``public_base_url``\nso the URI used to build the authorization request and the one sent with the\nexchange are the same string by construction, and a browser cannot choose\nwhat this server sends to a provider.\n\n``state`` is required, and is what binds this callback to an authorization\nrequest this deployment actually made: it is claimed from\n``oauth_pending_state`` before the code is sent anywhere, and the row it\nclaims is what carries the PKCE verifier the exchange needs. The flow\ncookie ``/authorize`` set travels alongside and binds it to the browser.", "properties": { @@ -8153,6 +8338,168 @@ "title": "OfferingUsage", "type": "object" }, + "OpenAIFileDeleted": { + "properties": { + "deleted": { + "const": true, + "default": true, + "title": "Deleted", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "object": { + "const": "file", + "default": "file", + "title": "Object", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "OpenAIFileDeleted", + "type": "object" + }, + "OpenAIFileMetadata": { + "additionalProperties": true, + "properties": { + "bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Bytes" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "expires_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + }, + "id": { + "title": "Id", + "type": "string" + }, + "object": { + "const": "file", + "default": "file", + "title": "Object", + "type": "string" + }, + "purpose": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Purpose" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "required": [ + "id" + ], + "title": "OpenAIFileMetadata", + "type": "object" + }, + "OpenAIFilePage": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/OpenAIFileMetadata" + }, + "title": "Data", + "type": "array" + }, + "first_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "First Id" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "last_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Id" + }, + "object": { + "const": "list", + "default": "list", + "title": "Object", + "type": "string" + } + }, + "required": [ + "data", + "first_id", + "last_id", + "has_more" + ], + "title": "OpenAIFilePage", + "type": "object" + }, "OrgProviderKeyCreateRequest": { "description": "What a caller sends to create a key.\n\nThe plaintext key is never stored as sent: the service encrypts it\n(`services/secret_box.py`) and keeps only the ciphertext and ``last4``,\nthe same convention `providers.ProviderCredential` already uses.", "properties": { @@ -10976,6 +11323,14 @@ "title": "Operation Id", "type": "string" }, + "provider": { + "default": "anthropic", + "maxLength": 32, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Provider", + "type": "string" + }, "size_bytes": { "minimum": 0.0, "title": "Size Bytes", @@ -11761,6 +12116,14 @@ "minItems": 1, "title": "Ids", "type": "array" + }, + "provider": { + "default": "anthropic", + "maxLength": 32, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Provider", + "type": "string" } }, "required": [ @@ -11988,6 +12351,14 @@ ], "title": "Operation", "type": "string" + }, + "provider": { + "default": "anthropic", + "maxLength": 32, + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "title": "Provider", + "type": "string" } }, "required": [ @@ -20845,7 +21216,7 @@ }, "/api/v1/files": { "get": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-list_files", "parameters": [ { @@ -20898,7 +21269,7 @@ } }, { - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", "in": "header", "name": "anthropic-version", "required": false, @@ -20906,6 +21277,20 @@ "type": "string" } }, + { + "description": "Hybrid Files provider selector; credentials remain authority-selected.", + "in": "header", + "name": "X-Otari-Files-Provider", + "required": false, + "schema": { + "default": "anthropic", + "enum": [ + "anthropic", + "openai" + ], + "type": "string" + } + }, { "description": "Hybrid GA cursor.", "in": "query", @@ -20927,6 +21312,33 @@ "maxItems": 100, "type": "array" } + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "in": "query", + "name": "after", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "in": "query", + "name": "before", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "in": "query", + "name": "order", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -20941,7 +21353,15 @@ "type": "object" }, { - "$ref": "#/components/schemas/FilePage" + "anyOf": [ + { + "$ref": "#/components/schemas/AnthropicFilePage" + }, + { + "$ref": "#/components/schemas/OpenAIFilePage" + } + ], + "title": "Response List Files Api V1 Files Get" } ] } @@ -20974,17 +21394,31 @@ ] }, "post": { - "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-create_file", "parameters": [ { - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", "in": "header", "name": "anthropic-version", "required": false, "schema": { "type": "string" } + }, + { + "description": "Hybrid Files provider selector; credentials remain authority-selected.", + "in": "header", + "name": "X-Otari-Files-Provider", + "required": false, + "schema": { + "default": "anthropic", + "enum": [ + "anthropic", + "openai" + ], + "type": "string" + } } ], "requestBody": { @@ -21009,7 +21443,15 @@ "type": "object" }, { - "$ref": "#/components/schemas/FileMetadata" + "anyOf": [ + { + "$ref": "#/components/schemas/AnthropicFileMetadata" + }, + { + "$ref": "#/components/schemas/OpenAIFileMetadata" + } + ], + "title": "Response Upload File Api V1 Files Post" } ] } @@ -21044,7 +21486,7 @@ }, "/api/v1/files/{file_id}": { "delete": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-delete_file", "parameters": [ { @@ -21073,13 +21515,27 @@ } }, { - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", "in": "header", "name": "anthropic-version", "required": false, "schema": { "type": "string" } + }, + { + "description": "Hybrid Files provider selector; credentials remain authority-selected.", + "in": "header", + "name": "X-Otari-Files-Provider", + "required": false, + "schema": { + "default": "anthropic", + "enum": [ + "anthropic", + "openai" + ], + "type": "string" + } } ], "responses": { @@ -21094,7 +21550,15 @@ "type": "object" }, { - "$ref": "#/components/schemas/NativeFileDeleted" + "anyOf": [ + { + "$ref": "#/components/schemas/AnthropicFileDeleted" + }, + { + "$ref": "#/components/schemas/OpenAIFileDeleted" + } + ], + "title": "Response Delete File Api V1 Files File Id Delete" } ] } @@ -21127,7 +21591,7 @@ ] }, "get": { - "description": "Retrieve metadata for a single file.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-get_file", "parameters": [ { @@ -21156,13 +21620,27 @@ } }, { - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", "in": "header", "name": "anthropic-version", "required": false, "schema": { "type": "string" } + }, + { + "description": "Hybrid Files provider selector; credentials remain authority-selected.", + "in": "header", + "name": "X-Otari-Files-Provider", + "required": false, + "schema": { + "default": "anthropic", + "enum": [ + "anthropic", + "openai" + ], + "type": "string" + } } ], "responses": { @@ -21177,7 +21655,15 @@ "type": "object" }, { - "$ref": "#/components/schemas/FileMetadata" + "anyOf": [ + { + "$ref": "#/components/schemas/AnthropicFileMetadata" + }, + { + "$ref": "#/components/schemas/OpenAIFileMetadata" + } + ], + "title": "Response Retrieve File Api V1 Files File Id Get" } ] } @@ -21212,7 +21698,7 @@ }, "/api/v1/files/{file_id}/content": { "get": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-get_file_content", "parameters": [ { @@ -21241,13 +21727,27 @@ } }, { - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", "in": "header", "name": "anthropic-version", "required": false, "schema": { "type": "string" } + }, + { + "description": "Hybrid Files provider selector; credentials remain authority-selected.", + "in": "header", + "name": "X-Otari-Files-Provider", + "required": false, + "schema": { + "default": "anthropic", + "enum": [ + "anthropic", + "openai" + ], + "type": "string" + } } ], "responses": { diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index abfad6fb85..63f4314003 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -1763,7 +1763,7 @@ { "name": "List Files", "request": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1805,16 +1805,34 @@ "disabled": true, "key": "ids[]", "value": "" + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "disabled": true, + "key": "after", + "value": "" + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "disabled": true, + "key": "before", + "value": "" + }, + { + "description": "OpenAI hybrid Files listing filter or cursor.", + "disabled": true, + "key": "order", + "value": "" } ], - "raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=&page=&ids[]=" + "raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=&page=&ids[]=&after=&before=&order=" } } }, { "name": "Create File", "request": { - "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "POST", "url": { @@ -1833,7 +1851,7 @@ { "name": "Get File", "request": { - "description": "Retrieve metadata for a single file.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1868,7 +1886,7 @@ { "name": "Delete File", "request": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "DELETE", "url": { @@ -1903,7 +1921,7 @@ { "name": "Get File Content", "request": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -2029,7 +2047,7 @@ "language": "json" } }, - "raw": "{\n \"ids\": [\n \"string\"\n ],\n \"limit\": 0,\n \"page\": \"string\"\n}" + "raw": "{\n \"after_id\": \"string\",\n \"before_id\": \"string\",\n \"ids\": [\n \"string\"\n ],\n \"limit\": 0,\n \"order\": \"desc\",\n \"page\": \"string\",\n \"provider\": \"anthropic\",\n \"purpose\": \"string\",\n \"sort_by\": \"binding_created_at\"\n}" }, "header": [ { @@ -2098,7 +2116,7 @@ "language": "json" } }, - "raw": "{\n \"operation_id\": \"00000000-0000-0000-0000-000000000000\",\n \"metadata\": {\n \"id\": \"string\",\n \"filename\": \"string\",\n \"mime_type\": \"string\",\n \"size_bytes\": 0,\n \"created_at\": \"2024-01-01T00:00:00Z\",\n \"downloadable\": false\n }\n}" + "raw": "{\n \"operation_id\": \"00000000-0000-0000-0000-000000000000\",\n \"metadata\": {\n \"id\": \"string\"\n }\n}" }, "header": [ { @@ -2352,7 +2370,7 @@ "language": "json" } }, - "raw": "{\n \"metadata\": {\n \"id\": \"string\",\n \"filename\": \"string\",\n \"mime_type\": \"string\",\n \"size_bytes\": 0,\n \"created_at\": \"2024-01-01T00:00:00Z\",\n \"downloadable\": false\n }\n}" + "raw": "{\n \"metadata\": {\n \"id\": \"string\"\n }\n}" }, "header": [ { diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 8cf0603bc1..0c51b15be5 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -21,11 +21,11 @@ sys.path.insert(0, str(SRC_ROOT)) from fastapi import APIRouter, FastAPI, Request -from sqlalchemy.ext.asyncio import AsyncSession from gateway.api.routes.hybrid_files import router as hybrid_files_router from gateway.api.routes.provider_files import create_provider_files_router from gateway.core.config import API_ROOT, GatewayConfig +from gateway.core.unit_of_work import UnitOfWork from gateway.main import create_app from gateway.services.provider_files.contracts import FileAccount, FileScope, OutputPrepare @@ -66,11 +66,11 @@ def generate_openapi_spec() -> dict[str, object]: return cast(dict[str, object], spec) -async def _schema_identity(request: Request, db: AsyncSession) -> FileScope: +async def _schema_identity(request: Request, uow: UnitOfWork) -> FileScope: raise RuntimeError("Schema-only authentication dependency") -async def _schema_attempt(scope: FileScope, request: OutputPrepare, db: AsyncSession) -> FileAccount: +async def _schema_attempt(scope: FileScope, request: OutputPrepare, uow: UnitOfWork) -> FileAccount: raise RuntimeError("Schema-only inference authorization dependency") @@ -86,12 +86,14 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: for method, operation in methods.items(): target = spec["paths"][path][method] target["description"] = target.get("description", "") + ( - "\n\nHybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. " - "It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. " + "\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. " + "X-Otari-Files-Provider selects anthropic (default) or openai. " + "The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. " + "OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. " "Hosted mode does not serve public file bytes." ) schema = operation["responses"].get("200", {}).get("content", {}).get("application/json", {}).get("schema") - if schema and "$ref" in schema: + if schema: media = target["responses"]["200"]["content"]["application/json"] media["schema"] = {"anyOf": [media["schema"], schema]} target.setdefault("parameters", []).append( @@ -100,7 +102,16 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: "in": "header", "required": False, "schema": {"type": "string"}, - "description": "Required in hybrid provider-native mode.", + "description": "Required for the Anthropic hybrid Files envelope only.", + } + ) + target["parameters"].append( + { + "name": "X-Otari-Files-Provider", + "in": "header", + "required": False, + "schema": {"type": "string", "enum": ["anthropic", "openai"], "default": "anthropic"}, + "description": "Hybrid Files provider selector; credentials remain authority-selected.", } ) listing = spec["paths"][f"{API_ROOT}/files"]["get"] @@ -122,13 +133,39 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: }, ] ) + existing_names = {parameter["name"] for parameter in listing["parameters"]} + for name in ("after", "before", "order", "purpose"): + if name not in existing_names: + listing["parameters"].append( + { + "name": name, + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "OpenAI hybrid Files listing filter or cursor.", + } + ) upload = spec["paths"][f"{API_ROOT}/files"]["post"] body_ref = upload["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"].split("/")[-1] spec["components"]["schemas"][body_ref]["properties"]["expires_in_seconds"] = { "type": "integer", "minimum": 3600, "maximum": 7776000, - "description": "Hybrid provider retention, capped by the control-plane maximum.", + "description": "Anthropic hybrid retention, capped by the control-plane maximum.", + } + properties = spec["components"]["schemas"][body_ref]["properties"] + properties["purpose"]["description"] = ( + "Required for OpenAI hybrid uploads; unsupported for Anthropic hybrid uploads." + ) + properties["expires_after[anchor]"] = { + "type": "string", + "enum": ["created_at"], + "description": "OpenAI hybrid expiry anchor.", + } + properties["expires_after[seconds]"] = { + "type": "integer", + "minimum": 1, + "description": "OpenAI hybrid retention, capped by the control-plane maximum.", } diff --git a/src/gateway/api/routes/_file_formats.py b/src/gateway/api/routes/_file_formats.py new file mode 100644 index 0000000000..1d371644d7 --- /dev/null +++ b/src/gateway/api/routes/_file_formats.py @@ -0,0 +1,207 @@ +"""Public Files envelopes; provider SDK translation belongs to any-llm.""" + +from typing import Any, Literal + +from fastapi import Request +from pydantic import AwareDatetime, BaseModel, ConfigDict, ValidationError + +from gateway.services.provider_files.capabilities import require_file_operation +from gateway.services.provider_files.contracts import FileListRequest, FileMetadata, FilePage, FilesError + + +class AnthropicFileMetadata(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + type: Literal["file"] = "file" + filename: str + mime_type: str + size_bytes: int + created_at: AwareDatetime + expires_at: AwareDatetime | None = None + downloadable: bool + + +class OpenAIFileMetadata(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + object: Literal["file"] = "file" + filename: str | None = None + bytes: int | None = None + created_at: int | None = None + expires_at: int | None = None + purpose: str | None = None + status: str | None = None + + +class AnthropicFilePage(BaseModel): + data: list[AnthropicFileMetadata] + next_page: str | None = None + + +class OpenAIFilePage(BaseModel): + object: Literal["list"] = "list" + data: list[OpenAIFileMetadata] + first_id: str | None + last_id: str | None + has_more: bool + + +class AnthropicFileDeleted(BaseModel): + id: str + type: Literal["file_deleted"] = "file_deleted" + + +class OpenAIFileDeleted(BaseModel): + id: str + object: Literal["file"] = "file" + deleted: Literal[True] = True + + +class AnthropicFilesFormat: + provider = "anthropic" + upload_fields = frozenset({"expires_in_seconds"}) + max_retention_seconds = 7776000 + + def headers(self, request: Request) -> dict[str, str]: + version = request.headers.get("anthropic-version") + if not version: + raise FilesError(400, "Hybrid provider-native Files require the Anthropic API contract (anthropic-version)") + beta = request.headers.get("anthropic-beta", "") + if "files-api-2025-04-14" in {value.strip() for value in beta.split(",")}: + raise FilesError(400, "Hybrid Files require the GA API; the legacy Files beta is unsupported") + return {"anthropic-version": version, **({"anthropic-beta": beta} if beta else {})} + + def upload_options(self, fields: dict[str, str]) -> tuple[int | None, str | None]: + duration = _duration(fields.get("expires_in_seconds")) + if duration is not None and not 3600 <= duration <= 7776000: + raise FilesError(400, "File retention must be between one hour and 90 days") + return duration, None + + def list_request(self, request: Request) -> FileListRequest: + query = request.query_params + if set(query) - {"page", "limit", "ids[]"}: + raise FilesError(400, "Hybrid Files require GA pagination (page, limit, ids[])") + _unique_query(request, {"page", "limit"}) + try: + return FileListRequest( + provider=self.provider, + page=query.get("page"), + limit=int(query["limit"]) if "limit" in query else None, + ids=query.getlist("ids[]") if "ids[]" in query else None, + ) + except (ValueError, ValidationError): + raise FilesError(400, "Invalid Files pagination") from None + + def metadata(self, value: FileMetadata) -> AnthropicFileMetadata: + data = value.model_dump(exclude_unset=True) + for key in ("purpose", "status", "object"): + data.pop(key, None) + try: + return AnthropicFileMetadata.model_validate({**data, "type": "file"}) + except ValidationError: + raise FilesError(502, "Provider returned invalid file metadata") from None + + def page(self, value: FilePage) -> AnthropicFilePage: + return AnthropicFilePage(data=[self.metadata(item) for item in value.data], next_page=value.next_page) + + def deleted(self, file_id: str) -> AnthropicFileDeleted: + return AnthropicFileDeleted(id=file_id) + + +class OpenAIFilesFormat: + provider = "openai" + upload_fields = frozenset({"purpose", "expires_after[anchor]", "expires_after[seconds]"}) + max_retention_seconds = 2592000 + + def headers(self, request: Request) -> dict[str, str]: + if any(name in request.headers for name in ("anthropic-version", "anthropic-beta")): + raise FilesError(400, "Conflicting Files API headers") + return {} + + def upload_options(self, fields: dict[str, str]) -> tuple[int | None, str | None]: + purpose = fields.get("purpose") + if not purpose or not purpose.strip() or len(purpose) > 255: + raise FilesError(400, "File purpose is required") + anchor, seconds = fields.get("expires_after[anchor]"), fields.get("expires_after[seconds]") + if (anchor is not None or seconds is not None) and (anchor != "created_at" or seconds is None): + raise FilesError(400, "Invalid file retention") + duration = _duration(seconds) + if duration is not None and not 3600 <= duration <= self.max_retention_seconds: + raise FilesError(400, "File retention must be between one hour and 30 days") + return duration, purpose + + def list_request(self, request: Request) -> FileListRequest: + query = request.query_params + allowed = {"after", "before", "limit", "order", "purpose"} + if set(query) - allowed: + raise FilesError(400, "Unsupported Files pagination parameter") + _unique_query(request, allowed) + try: + return FileListRequest.model_validate( + { + "provider": self.provider, + "after_id": query.get("after"), + "before_id": query.get("before"), + "limit": int(query["limit"]) if "limit" in query else None, + "order": query.get("order", "desc"), + "sort_by": "provider_created_at", + "purpose": query.get("purpose"), + } + ) + except (ValueError, ValidationError): + raise FilesError(400, "Invalid Files pagination") from None + + def metadata(self, value: FileMetadata) -> OpenAIFileMetadata: + data: dict[str, Any] = value.model_dump(exclude_unset=True) + for key in ("type", "mime_type", "downloadable"): + data.pop(key, None) + if "size_bytes" in data: + data["bytes"] = data.pop("size_bytes") + for key in ("created_at", "expires_at"): + if data.get(key) is not None: + data[key] = int(data[key].timestamp()) + return OpenAIFileMetadata.model_validate({**data, "object": "file"}) + + def page(self, value: FilePage) -> OpenAIFilePage: + return OpenAIFilePage( + object="list", + data=[self.metadata(item) for item in value.data], + first_id=value.data[0].id if value.data else None, + last_id=value.data[-1].id if value.data else None, + has_more=value.next_page is not None, + ) + + def deleted(self, file_id: str) -> OpenAIFileDeleted: + return OpenAIFileDeleted(id=file_id) + + +def _duration(raw: str | None) -> int | None: + if raw is None: + return None + if not raw.isascii() or not raw.isdigit() or len(raw) > 8 or int(raw) <= 0: + raise FilesError(400, "Invalid file retention") + return int(raw) + + +def _unique_query(request: Request, names: set[str]) -> None: + if any(len(request.query_params.getlist(name)) > 1 for name in names): + raise FilesError(400, "Duplicate pagination parameter") + + +FilesFormat = AnthropicFilesFormat | OpenAIFilesFormat +_FORMATS: dict[str, FilesFormat] = {"anthropic": AnthropicFilesFormat(), "openai": OpenAIFilesFormat()} + + +def files_format(request: Request) -> FilesFormat: + values = request.headers.getlist("x-otari-files-provider") + if len(values) > 1: + raise FilesError(400, "Duplicate Files provider header") + provider = values[0] if values else "anthropic" + require_file_operation(provider, "upload") + result = _FORMATS.get(provider) + if result is None: + raise FilesError(400, "No public Files API format is configured for this provider") + result.headers(request) + return result diff --git a/src/gateway/api/routes/chat.py b/src/gateway/api/routes/chat.py index 623a0c448b..7f0d65b582 100644 --- a/src/gateway/api/routes/chat.py +++ b/src/gateway/api/routes/chat.py @@ -55,7 +55,7 @@ mcp_tool_loop_stream, ) from gateway.services.provider_files.contracts import FilesError -from gateway.services.provider_files.references import collect_file_references +from gateway.services.provider_files.references import reject_openai_file_state from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import OPENAI_STREAM_FORMAT, StreamFormat from gateway.types.attempt import Attempt @@ -474,8 +474,7 @@ async def _normalize( if {"extra_body", "extra_query"} & (request.model_extra or {}).keys(): raise HTTPException(400, "Transport body overrides are not supported in hybrid mode") try: - if collect_file_references(request.messages): - raise FilesError(400, "Use Messages for provider-native file references") + reject_openai_file_state(request.model_dump(exclude_unset=True)) except FilesError as exc: raise HTTPException(exc.status_code, exc.detail) from None diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 8bcde7ce5c..1682bc4ee4 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -1,4 +1,4 @@ -"""Anthropic-compatible provider-native Files on a stateless hybrid gateway.""" +"""Provider-native Files with thin native API envelopes on a stateless gateway.""" import asyncio import uuid @@ -11,19 +11,27 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import StreamingResponse from fastapi.routing import APIRoute -from pydantic import ValidationError from starlette.types import Receive, Scope, Send from gateway.api.deps import _extract_bearer_token, get_config +from gateway.api.routes._file_formats import ( + AnthropicFileDeleted, + AnthropicFileMetadata, + AnthropicFilePage, + OpenAIFileDeleted, + OpenAIFileMetadata, + OpenAIFilePage, + files_format, +) from gateway.core.config import GatewayConfig from gateway.inflight import track_request +from gateway.services.provider_files.capabilities import check_file_account, require_download, require_file_operation from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import ( - FileListRequest, + FILES_PROTOCOL_VERSION, FileMetadata, FilePage, FilesError, - NativeFileDeleted, Operation, ResolvedFile, WireModel, @@ -57,14 +65,18 @@ async def handle(request: Request) -> Response: "Invalid file operation", headers={ "Cache-Control": "private, no-store", - "X-Otari-Files-Protocol": "1", + "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION, }, ) from None except FilesError as exc: raise HTTPException( exc.status_code, exc.detail, - headers={"Cache-Control": "private, no-store", "X-Otari-Files-Protocol": "1", **exc.headers}, + headers={ + "Cache-Control": "private, no-store", + "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION, + **exc.headers, + }, ) from None except TimeoutError: raise HTTPException( @@ -72,11 +84,11 @@ async def handle(request: Request) -> Response: "File transfer timed out", headers={ "Cache-Control": "private, no-store", - "X-Otari-Files-Protocol": "1", + "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION, }, ) from None result.headers["Cache-Control"] = "private, no-store" - result.headers["X-Otari-Files-Protocol"] = "1" + result.headers["X-Otari-Files-Protocol"] = FILES_PROTOCOL_VERSION return result return handle @@ -86,20 +98,10 @@ async def handle(request: Request) -> Response: Config = Annotated[GatewayConfig, Depends(get_config)] -def file_headers(request: Request) -> dict[str, str]: - version = request.headers.get("anthropic-version") - if not version: - raise FilesError(400, "Hybrid provider-native Files require the Anthropic API contract (anthropic-version)") - beta = request.headers.get("anthropic-beta", "") - if "files-api-2025-04-14" in {value.strip() for value in beta.split(",")}: - raise FilesError(400, "Hybrid Files require the GA API; the legacy Files beta is unsupported") - return {"anthropic-version": version, **({"anthropic-beta": beta} if beta else {})} - - def files_client(request: Request, config: GatewayConfig) -> PlatformFilesClient: if not config.files_enabled or not config.files_provider_native_enabled: raise FilesError(404, "Provider-native Files are not enabled") - file_headers(request) + files_format(request) token = _extract_bearer_token(request, config) base = config.platform.get("base_url") if not base or not config.platform_token: @@ -117,10 +119,13 @@ def _admission(request: Request, config: GatewayConfig) -> UploadAdmission: return admission -@router.post("/files", response_model=FileMetadata, response_model_exclude_unset=True) -async def upload_file(request: Request, config: Config) -> FileMetadata: +@router.post("/files", response_model=AnthropicFileMetadata | OpenAIFileMetadata, response_model_exclude_unset=True) +async def upload_file(request: Request, config: Config) -> AnthropicFileMetadata | OpenAIFileMetadata: client = files_client(request, config) - headers = file_headers(request) + envelope = files_format(request) + require_file_operation(envelope.provider, "upload") + require_file_operation(envelope.provider, "delete") + headers = envelope.headers(request) operation: Operation | None = None metadata: FileMetadata | None = None started = False @@ -131,16 +136,27 @@ async def upload_file(request: Request, config: Config) -> FileMetadata: { "operation_id": str(uuid.uuid4()), "size_bytes": config.files_max_bytes, + "provider": envelope.provider, }, Operation, ) + check_file_account(operation.account, envelope.provider) maximum = min(config.files_max_bytes, operation.max_bytes) - track_request(request, endpoint="/files", model="files", provider="anthropic") + track_request(request, endpoint="/files", model="files", provider=operation.account.provider) async with _admission(request, config).reserve(maximum + 65536): async with receive_upload( - request.headers, request.stream(), max_bytes=maximum, idle_seconds=config.files_idle_timeout_seconds - ) as (upload, duration): - retention = min(duration or operation.expires_in_seconds, operation.expires_in_seconds) + request.headers, + request.stream(), + max_bytes=maximum, + idle_seconds=config.files_idle_timeout_seconds, + allowed_fields=envelope.upload_fields, + ) as (upload, fields): + duration, purpose = envelope.upload_options(fields) + retention = min( + duration or operation.expires_in_seconds, + operation.expires_in_seconds, + envelope.max_retention_seconds, + ) async with provider_client( operation.account, idle_timeout=config.files_idle_timeout_seconds ) as provider: @@ -150,11 +166,12 @@ async def upload_file(request: Request, config: Config) -> FileMetadata: filename=upload.filename, mime_type=upload.content_type, expires_in=retention, + purpose=purpose, max_retries=0, extra_headers=headers, ) metadata = FileMetadata.model_validate(result.model_dump(exclude_unset=True)) - return await client.retry( + finalized = await client.retry( f"uploads/{operation.id}/finalize", { "metadata": metadata.model_dump(mode="json", exclude_unset=True), @@ -162,6 +179,7 @@ async def upload_file(request: Request, config: Config) -> FileMetadata: }, FileMetadata, ) + return envelope.metadata(finalized) except BaseException as exc: if operation is not None: await _compensate_upload(client, operation, metadata, headers, started, exc) @@ -215,40 +233,39 @@ async def compensate() -> None: task.cancel() -@router.get("/files", response_model=FilePage, response_model_exclude_unset=True) -async def list_files(request: Request, config: Config) -> FilePage: +@router.get("/files", response_model=AnthropicFilePage | OpenAIFilePage, response_model_exclude_unset=True) +async def list_files(request: Request, config: Config) -> AnthropicFilePage | OpenAIFilePage: client = files_client(request, config) - query = request.query_params - if set(query) - {"page", "limit", "ids[]"}: - raise FilesError(400, "Hybrid Files require GA pagination (page, limit, ids[])") - if len(query.getlist("page")) > 1 or len(query.getlist("limit")) > 1: - raise FilesError(400, "Duplicate pagination parameter") - try: - parsed = FileListRequest( - page=query.get("page"), - limit=int(query["limit"]) if "limit" in query else None, - ids=query.getlist("ids[]") if "ids[]" in query else None, - ) - except (ValueError, ValidationError): - raise FilesError(400, "Invalid Files pagination") from None - return await client.post("list", parsed.model_dump(exclude_none=True), FilePage) + envelope = files_format(request) + parsed = envelope.list_request(request) + result = await client.post("list", parsed.model_dump(exclude_none=True), FilePage) + return envelope.page(result) -@router.get("/files/{file_id}", response_model=FileMetadata, response_model_exclude_unset=True) -async def retrieve_file(file_id: str, request: Request, config: Config) -> FileMetadata: - resolved = await files_client(request, config).post( - f"{quote(file_id, safe='')}/resolve", {"operation": "metadata"}, ResolvedFile +@router.get( + "/files/{file_id}", response_model=AnthropicFileMetadata | OpenAIFileMetadata, response_model_exclude_unset=True +) +async def retrieve_file(file_id: str, request: Request, config: Config) -> AnthropicFileMetadata | OpenAIFileMetadata: + client = files_client(request, config) + envelope = files_format(request) + resolved = await client.post( + f"{quote(file_id, safe='')}/resolve", {"operation": "metadata", "provider": envelope.provider}, ResolvedFile ) - return resolved.metadata + return envelope.metadata(resolved.metadata) @router.get("/files/{file_id}/content") async def download_file(file_id: str, request: Request, config: Config) -> Response: client = files_client(request, config) - resolved = await client.post(f"{quote(file_id, safe='')}/resolve", {"operation": "download"}, ResolvedFile) - if resolved.account is None or not resolved.metadata.downloadable: - raise FilesError(400, "This file is not downloadable") - track_request(request, endpoint="/files", model="files", provider="anthropic") + envelope = files_format(request) + resolved = await client.post( + f"{quote(file_id, safe='')}/resolve", {"operation": "download", "provider": envelope.provider}, ResolvedFile + ) + if resolved.account is None: + raise FilesError(502, "Authorization service returned an invalid file account") + check_file_account(resolved.account, envelope.provider) + require_download(envelope.provider, resolved.metadata) + track_request(request, endpoint="/files", model="files", provider=resolved.account.provider) stack = AsyncExitStack() deadline = asyncio.get_running_loop().time() + config.files_transfer_timeout_seconds try: @@ -257,7 +274,7 @@ async def download_file(file_id: str, request: Request, config: Config) -> Respo ) async with asyncio.timeout(config.files_idle_timeout_seconds): download = await stack.enter_async_context( - provider.adownload_file(file_id, max_retries=0, extra_headers=file_headers(request)) + provider.adownload_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) ) headers = { name: value @@ -292,20 +309,25 @@ async def chunks() -> AsyncIterator[bytes]: return FileDownloadResponse(chunks(), stack=stack, headers=headers, media_type="application/octet-stream") -@router.delete("/files/{file_id}", response_model=NativeFileDeleted) -async def delete_file(file_id: str, request: Request, config: Config) -> dict[str, str]: +@router.delete("/files/{file_id}", response_model=AnthropicFileDeleted | OpenAIFileDeleted) +async def delete_file(file_id: str, request: Request, config: Config) -> AnthropicFileDeleted | OpenAIFileDeleted: client = files_client(request, config) - resolved = await client.post(f"{quote(file_id, safe='')}/resolve", {"operation": "delete"}, ResolvedFile) + envelope = files_format(request) + require_file_operation(envelope.provider, "delete") + resolved = await client.post( + f"{quote(file_id, safe='')}/resolve", {"operation": "delete", "provider": envelope.provider}, ResolvedFile + ) if resolved.account is None or resolved.cleanup_token is None or resolved.operation_id is None: raise FilesError(502, "Authorization service returned an invalid cleanup response") - track_request(request, endpoint="/files", model="files", provider="anthropic") + check_file_account(resolved.account, envelope.provider) + track_request(request, endpoint="/files", model="files", provider=resolved.account.provider) failure = None try: async with ( asyncio.timeout(config.files_transfer_timeout_seconds), provider_client(resolved.account) as provider, ): - await provider.adelete_file(file_id, max_retries=0, extra_headers=file_headers(request)) + await provider.adelete_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) except Exception as exc: failure = provider_error(exc) if failure.status_code == 404: @@ -320,4 +342,4 @@ async def delete_file(file_id: str, request: Request, config: Config) -> dict[st ) if failure is not None: raise failure - return {"id": file_id, "type": "file_deleted"} + return envelope.deleted(file_id) diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 6da928eae1..262cbc21f7 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -68,10 +68,11 @@ anthropic_tool_loop, anthropic_tool_loop_stream, ) +from gateway.services.provider_files.anthropic_inference import AnthropicFileOutputBinder +from gateway.services.provider_files.capabilities import check_file_account from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import FileAccount, FilesError, Operation -from gateway.services.provider_files.inference import FileOutputBinder -from gateway.services.provider_files.references import collect_file_references +from gateway.services.provider_files.references import collect_anthropic_file_references from gateway.services.tool_format import inject_purpose_hints_anthropic, openai_to_anthropic_tools from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import ANTHROPIC_STREAM_FORMAT, StreamFormat @@ -617,7 +618,7 @@ def __init__(self, client: PlatformFilesClient, request_id: str, references: lis self.files_client = client self.files_request_id = request_id self.file_references = references - self.pending_binder: FileOutputBinder | None = None + self.pending_binder: AnthropicFileOutputBinder | None = None def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str, Any]) -> dict[str, Any]: result = super().attempt_kwargs(attempt, base_request_fields) @@ -636,7 +637,7 @@ def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str result["_file_attempt"] = attempt return result - async def _binder(self, kwargs: dict[str, Any]) -> FileOutputBinder: + async def _binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: attempt = kwargs.pop("_file_attempt") if attempt.provider != "anthropic" or not attempt.provider_account_generation_id: raise FilesError(403, "Provider file outputs require an authorized Anthropic account") @@ -650,6 +651,7 @@ async def _binder(self, kwargs: dict[str, Any]) -> FileOutputBinder: }, Operation, ) + check_file_account(operation.account, attempt.provider) if ( operation.account.api_key.get_secret_value() != attempt.api_key or operation.account.api_base != attempt.api_base @@ -658,7 +660,7 @@ async def _binder(self, kwargs: dict[str, Any]) -> FileOutputBinder: raise FilesError(409, "Inference provider account changed before dispatch") if operation.account.workspace is not None: kwargs["client_args"]["default_headers"] = {"anthropic-workspace-id": operation.account.workspace} - return FileOutputBinder(self.files_client, operation, self.file_references) + return AnthropicFileOutputBinder(self.files_client, operation, self.file_references) async def call_provider(self, kwargs: dict[str, Any]) -> MessageResponse: binder = await self._binder(kwargs) @@ -842,7 +844,7 @@ async def _normalize( if ctx.hybrid_mode: try: - references = collect_file_references(request.messages) + references = collect_anthropic_file_references(request.messages) native_outputs = ( any( isinstance(tool, dict) and str(tool.get("type", "")).startswith("code_execution_") @@ -856,7 +858,10 @@ async def _normalize( assert ctx.route is not None and ctx.user_token is not None client = PlatformFilesClient(config.platform["base_url"], config.platform_token or "", ctx.user_token) if references: - account = await client.post("references/resolve", {"ids": references}, FileAccount) + account = await client.post( + "references/resolve", {"ids": references, "provider": "anthropic"}, FileAccount + ) + check_file_account(account, "anthropic") attempts = [ attempt for attempt in ctx.route.attempts diff --git a/src/gateway/api/routes/provider_files.py b/src/gateway/api/routes/provider_files.py index 4524692d5c..6b9a4cbbb0 100644 --- a/src/gateway/api/routes/provider_files.py +++ b/src/gateway/api/routes/provider_files.py @@ -6,14 +6,16 @@ from fastapi import APIRouter, Depends, Request, Response from pydantic import SecretStr -from sqlalchemy.ext.asyncio import AsyncSession -from gateway.api.deps import get_config, get_db +from gateway.api.deps import get_config, get_unit_of_work from gateway.api.routes.hybrid_files import FilesRoute from gateway.core.config import GatewayConfig +from gateway.core.unit_of_work import UnitOfWork from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.capabilities import require_file_operation from gateway.services.provider_files.cleanup import ProviderFileCleanup from gateway.services.provider_files.contracts import ( + FILES_PROTOCOL_VERSION, AbandonUpload, CleanupClaim, CleanupResult, @@ -33,11 +35,11 @@ from gateway.services.provider_files.lifecycle import ProviderFileService from gateway.services.provider_files.outputs import ProviderFileOutputs -# Authenticators must resolve registered gateway identity; foreground also requires a workspace API key. -ForegroundAuthenticator = Callable[[Request, AsyncSession], Awaitable[FileScope]] -GatewayAuthenticator = Callable[[Request, AsyncSession], Awaitable[FileScope]] -AttemptAuthorizer = Callable[[FileScope, OutputPrepare, AsyncSession], Awaitable[FileAccount]] -AccountResolver = Callable[[FileScope, uuid.UUID | None, bool, AsyncSession], Awaitable[FileAccount]] +# Callbacks share the request's open Unit of Work and reach it through repositories. +ForegroundAuthenticator = Callable[[Request, UnitOfWork], Awaitable[FileScope]] +GatewayAuthenticator = Callable[[Request, UnitOfWork], Awaitable[FileScope]] +AttemptAuthorizer = Callable[[FileScope, OutputPrepare, UnitOfWork], Awaitable[FileAccount]] +AccountResolver = Callable[[FileScope, str, uuid.UUID | None, bool, UnitOfWork], Awaitable[FileAccount]] def _wire(value: Any) -> Any: @@ -64,28 +66,32 @@ def create_provider_files_router( ) -> APIRouter: """Contribute behind attached-gateway capability; authentication is never optional.""" router = APIRouter(prefix="/gateway/files", tags=["provider-files"], route_class=FilesRoute) - Db = Annotated[AsyncSession, Depends(get_db)] + Uow = Annotated[UnitOfWork, Depends(get_unit_of_work)] Config = Annotated[GatewayConfig, Depends(get_config)] - async def principal(request: Request, db: Db, response: Response) -> FileScope: + async def principal(request: Request, uow: Uow, response: Response) -> FileScope: response.headers["Cache-Control"] = "private, no-store" - response.headers["X-Otari-Files-Protocol"] = "1" - scope = await authenticate(request, db) - await FileAccountResolver(db).repo.lock_user(scope.user_id) + response.headers["X-Otari-Files-Protocol"] = FILES_PROTOCOL_VERSION + scope = await FileAccountResolver(uow).authenticate(lambda: authenticate(request, uow)) + if request.headers.get("X-Otari-Files-Protocol") != FILES_PROTOCOL_VERSION: + raise FilesError(409, "Unsupported Files protocol") return scope - async def gateway(request: Request, db: Db, response: Response) -> FileScope: + async def gateway(request: Request, uow: Uow, response: Response) -> FileScope: response.headers["Cache-Control"] = "private, no-store" - response.headers["X-Otari-Files-Protocol"] = "1" - return await authenticate_gateway(request, db) + response.headers["X-Otari-Files-Protocol"] = FILES_PROTOCOL_VERSION + scope = await FileAccountResolver(uow).authenticate(lambda: authenticate_gateway(request, uow)) + if request.headers.get("X-Otari-Files-Protocol") != FILES_PROTOCOL_VERSION: + raise FilesError(409, "Unsupported Files protocol") + return scope - def service(db: AsyncSession, config: GatewayConfig) -> ProviderFileService: + def service(uow: Uow, config: Config) -> ProviderFileService: if not config.files_provider_native_enabled: raise FilesError(404, "Provider-native Files are not enabled") if config.files_max_count is None or config.files_max_outstanding_bytes is None: raise FilesError(503, "Provider file quotas are not configured") return ProviderFileService( - db, + uow, max_bytes=config.files_max_bytes, max_files=config.files_max_count, max_outstanding_bytes=config.files_max_outstanding_bytes, @@ -96,103 +102,87 @@ def service(db: AsyncSession, config: GatewayConfig) -> ProviderFileService: ) async def account( - scope: FileScope, db: AsyncSession, generation_id: uuid.UUID | None = None, *, cleanup: bool = False + scope: FileScope, + uow: UnitOfWork, + generation_id: uuid.UUID | None = None, + *, + provider: str | None = None, + cleanup: bool = False, ) -> FileAccount: - resolver = FileAccountResolver(db) - if generation_id is None: - selected = await resolver.select_byo(scope) - if selected is not None: - return selected - else: - row = await resolver.repo.account(generation_id) - if row is None or row.organization_id != scope.organization_id: - raise FilesError(404, "Provider account unavailable") - if row.credential_source == "organization_key": - return await resolver.resolve_byo(generation_id, scope.organization_id, cleanup=cleanup) - if resolve_hosted is None: - raise FilesError(404, "Anthropic provider account unavailable") - if not scope.default_gateway: - raise FilesError(403, "Managed provider files require the default gateway") - return await resolve_hosted(scope, generation_id, cleanup, db) + return await FileAccountResolver(uow).resolve( + scope, generation_id, provider=provider, cleanup=cleanup, resolve_hosted=resolve_hosted + ) Principal = Annotated[FileScope, Depends(principal)] Gateway = Annotated[FileScope, Depends(gateway)] + Service = Annotated[ProviderFileService, Depends(service)] @router.post("/uploads/prepare") - async def prepare(body: PrepareUpload, scope: Principal, db: Db, config: Config) -> Any: - return _wire(await service(db, config).prepare(scope, await account(scope, db), body)) + async def prepare(body: PrepareUpload, scope: Principal, uow: Uow, lifecycle: Service) -> Any: + require_file_operation(body.provider, "upload") + require_file_operation(body.provider, "delete") + selected = await account(scope, uow, provider=body.provider) + return _wire(await lifecycle.prepare(scope, selected, body)) @router.post("/uploads/{binding_id}/finalize") - async def finalize(binding_id: uuid.UUID, body: FinalizeUpload, scope: Principal, db: Db, config: Config) -> Any: - return _wire(await service(db, config).finalize(scope, binding_id, body.metadata, body.expires_in_seconds)) + async def finalize(binding_id: uuid.UUID, body: FinalizeUpload, scope: Principal, lifecycle: Service) -> Any: + return _wire(await lifecycle.finalize(scope, binding_id, body.metadata, body.expires_in_seconds)) @router.post("/uploads/{binding_id}/abandon") async def abandon( - binding_id: uuid.UUID, body: AbandonUpload, scope: Gateway, db: Db, config: Config + binding_id: uuid.UUID, body: AbandonUpload, scope: Gateway, lifecycle: Service ) -> dict[str, bool]: - await service(db, config).abandon(binding_id, scope.gateway_id, body) + await lifecycle.abandon(binding_id, scope.gateway_id, body) return {"ok": True} @router.post("/list") - async def list_files(body: FileListRequest, scope: Principal, db: Db, config: Config) -> Any: - return _wire(await service(db, config).list_files(scope, body)) + async def list_files(body: FileListRequest, scope: Principal, lifecycle: Service) -> Any: + return _wire(await lifecycle.list_files(scope, body)) @router.post("/references/resolve") - async def references(body: References, scope: Principal, db: Db, config: Config) -> Any: - generation = await service(db, config).references(scope, body.ids) - return _wire(await account(scope, db, generation)) + async def references(body: References, scope: Principal, uow: Uow, lifecycle: Service) -> Any: + generation = await lifecycle.references(scope, body.ids, provider=body.provider) + return _wire(await account(scope, uow, generation, provider=body.provider)) @router.post("/outputs/prepare") - async def prepare_output(body: OutputPrepare, scope: Principal, db: Db, config: Config) -> Any: + async def prepare_output(body: OutputPrepare, scope: Principal, uow: Uow, lifecycle: Service) -> Any: # The authorizer intersects the original model plan, prices, and workspace tool policy. - generation = await FileAccountResolver(db).repo.account(body.generation_id) - if generation is None or generation.organization_id != scope.organization_id: - raise FilesError(404, "Provider account unavailable") - if generation.credential_source == "hosted_backend" and not scope.default_gateway: - raise FilesError(403, "Managed provider files require the default gateway") - selected = await authorize_attempt(scope, body, db) - return _wire(await ProviderFileOutputs(service(db, config)).prepare(scope, selected, body)) + selected = await FileAccountResolver(uow).authorize_attempt( + scope, body, lambda: authorize_attempt(scope, body, uow) + ) + return _wire(await ProviderFileOutputs(lifecycle).prepare(scope, selected, body)) @router.post("/outputs/register") - async def register_output(body: OutputRegister, scope: Principal, db: Db, config: Config) -> Any: - return _wire(await ProviderFileOutputs(service(db, config)).register(scope, body.operation_id, body.metadata)) + async def register_output(body: OutputRegister, scope: Principal, lifecycle: Service) -> Any: + return _wire(await ProviderFileOutputs(lifecycle).register(scope, body.operation_id, body.metadata)) @router.post("/outputs/{operation_id}/abandon") - async def abandon_output( - operation_id: uuid.UUID, - body: AbandonUpload, - scope: Gateway, - db: Db, - config: Config, - ) -> Any: + async def abandon_output(operation_id: uuid.UUID, body: AbandonUpload, scope: Gateway, lifecycle: Service) -> Any: return _wire( - await ProviderFileOutputs(service(db, config)).abandon( - operation_id, - scope.gateway_id, - body.cleanup_token.get_secret_value(), - body.metadata, + await ProviderFileOutputs(lifecycle).abandon( + operation_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.metadata ) ) @router.post("/outputs/{operation_id}/complete") async def complete_output( - operation_id: uuid.UUID, body: CleanupResult, scope: Gateway, db: Db, config: Config + operation_id: uuid.UUID, body: CleanupResult, scope: Gateway, lifecycle: Service ) -> dict[str, bool]: - await ProviderFileOutputs(service(db, config)).complete( + await ProviderFileOutputs(lifecycle).complete( operation_id, scope.gateway_id, body.cleanup_token.get_secret_value() ) return {"ok": True} @router.get("/status") - async def backlog(scope: Gateway, db: Db, config: Config) -> dict[str, int]: - return await service(db, config).repo.backlog(scope.organization_id) + async def backlog(scope: Gateway, lifecycle: Service) -> dict[str, int]: + return await lifecycle.backlog(scope.organization_id) @router.post("/cleanup/claim") - async def claim(body: CleanupClaim, scope: Gateway, db: Db, config: Config) -> Any: + async def claim(body: CleanupClaim, scope: Gateway, uow: Uow, lifecycle: Service) -> Any: async def credential(generation_id: uuid.UUID) -> FileAccount: - return await account(scope, db, generation_id, cleanup=True) + return await account(scope, uow, generation_id, cleanup=True) - lease = await ProviderFileCleanup(service(db, config)).claim( + lease = await ProviderFileCleanup(lifecycle).claim( scope.organization_id, scope.gateway_id, body.limit, @@ -203,25 +193,24 @@ async def credential(generation_id: uuid.UUID) -> FileAccount: @router.post("/cleanup/{lease_id}/result") async def complete_lease( - lease_id: uuid.UUID, body: LeaseResult, scope: Gateway, db: Db, config: Config + lease_id: uuid.UUID, body: LeaseResult, scope: Gateway, lifecycle: Service ) -> dict[str, bool]: - await ProviderFileCleanup(service(db, config)).complete(scope.organization_id, scope.gateway_id, lease_id, body) + await ProviderFileCleanup(lifecycle).complete(scope.organization_id, scope.gateway_id, lease_id, body) return {"ok": True} @router.post("/{file_id}/resolve") - async def resolve(file_id: str, body: ResolveFile, scope: Principal, db: Db, config: Config) -> Any: - lifecycle = service(db, config) + async def resolve(file_id: str, body: ResolveFile, scope: Principal, uow: Uow, lifecycle: Service) -> Any: selected = None if body.operation != "metadata": - generation = await lifecycle.references(scope, [file_id]) - selected = await account(scope, db, generation) - return _wire(await lifecycle.resolve(scope, file_id, body.operation, selected)) + generation = await lifecycle.references(scope, [file_id], provider=body.provider) + selected = await account(scope, uow, generation, provider=body.provider) + return _wire(await lifecycle.resolve(scope, file_id, body.operation, selected, provider=body.provider)) @router.post("/{binding_id}/cleanup-result") async def cleanup_result( - binding_id: uuid.UUID, body: CleanupResult, scope: Gateway, db: Db, config: Config + binding_id: uuid.UUID, body: CleanupResult, scope: Gateway, lifecycle: Service ) -> dict[str, bool]: - await service(db, config).cleanup_result( + await lifecycle.cleanup_result( binding_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.deleted ) return {"ok": True} diff --git a/src/gateway/api/routes/responses.py b/src/gateway/api/routes/responses.py index 76dd59bf9c..edba0e4a5e 100644 --- a/src/gateway/api/routes/responses.py +++ b/src/gateway/api/routes/responses.py @@ -52,7 +52,7 @@ responses_tool_loop_stream, ) from gateway.services.provider_files.contracts import FilesError -from gateway.services.provider_files.references import collect_file_references +from gateway.services.provider_files.references import reject_openai_file_state from gateway.services.tool_format import inject_purpose_hints_responses, openai_to_responses_tools from gateway.services.web_search_budget import WebSearchBudget from gateway.streaming import RESPONSES_STREAM_FORMAT, StreamFormat @@ -507,8 +507,7 @@ async def _normalize( if {"extra_body", "extra_query"} & (request_body.model_extra or {}).keys(): raise HTTPException(400, "Transport body overrides are not supported in hybrid mode") try: - if collect_file_references(request_body.input): - raise FilesError(400, "Use Messages for provider-native file references") + reject_openai_file_state(request_body.model_dump(exclude_unset=True)) except FilesError as exc: raise HTTPException(exc.status_code, exc.detail) from None diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index fa5222d919..6db00945f3 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -878,7 +878,7 @@ class GatewayConfig(BudgetSettings, PricingSettings, BaseSettings): ) files_provider_native_enabled: Annotated[bool, Shown(SettingsGroup.FILES)] = Field( default=False, - description="Enable hybrid Anthropic Files after provider and control-plane contract verification.", + description="Enable hybrid provider-native Files after provider and control-plane contract verification.", ) files_transfer_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=300, ge=1) files_idle_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=30, ge=1) diff --git a/src/gateway/models/provider_files.py b/src/gateway/models/provider_files.py index f944ce2d38..c026b9dec1 100644 --- a/src/gateway/models/provider_files.py +++ b/src/gateway/models/provider_files.py @@ -6,7 +6,7 @@ from sqlalchemy import Index, UniqueConstraint from sqlmodel import Field, SQLModel -from gateway.models.tenancy import CreatedAtMixin, PrimaryKeyMixin, UpdatedAtMixin, UtcDateTime +from gateway.models.base import CreatedAtMixin, PrimaryKeyMixin, UpdatedAtMixin, UtcDateTime class ProviderAccountGeneration(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table=True): @@ -55,6 +55,8 @@ class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMi workspace_id: uuid.UUID = Field(index=True) user_id: str = Field(max_length=255, index=True) encrypted_metadata: str | None = None + purpose: str | None = Field(default=None, max_length=255) + provider_created_at: datetime | None = Field(default=None, sa_type=UtcDateTime) size_bytes: int = 0 downloadable: bool = False expires_at: datetime = Field(sa_type=UtcDateTime) diff --git a/src/gateway/repositories/tenancy/provider_file_repository.py b/src/gateway/repositories/tenancy/provider_file_repository.py index 6c938e2ef9..fec3c1c447 100644 --- a/src/gateway/repositories/tenancy/provider_file_repository.py +++ b/src/gateway/repositories/tenancy/provider_file_repository.py @@ -1,22 +1,147 @@ """Scoped persistence and locking for provider-native file operations.""" import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from sqlalchemy import case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import SQLModel, col -from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding, ProviderFileOutputOperation +from gateway.core.unit_of_work import UnitOfWork +from gateway.models.provider_files import ( + ProviderAccountGeneration, + ProviderFileBinding, + ProviderFileOutputOperation, + ProviderFileRateWindow, +) +from gateway.models.provider_keys import OrgProviderKey, WorkspaceProviderKeyOverride from gateway.models.tenancy import Organization, Workspace from gateway.models.users import User from gateway.repositories.base_repository import BaseRepository +from gateway.repositories.tenancy.org_provider_key_repository import WorkspaceProviderKeyOverrideRepository class ProviderFileRepository(BaseRepository[ProviderFileBinding, SQLModel, SQLModel]): - def __init__(self, db: AsyncSession) -> None: + def __init__(self, db: AsyncSession | UnitOfWork) -> None: super().__init__(db, ProviderFileBinding) + async def save(self, row: SQLModel) -> None: + self.db.add(row) + await self.db.flush() + + async def refresh(self, row: SQLModel) -> None: + await self.db.refresh(row) + + async def rate_window(self, workspace_id: uuid.UUID, user_id: str, window: int) -> ProviderFileRateWindow: + row = await self.db.get(ProviderFileRateWindow, (workspace_id, user_id)) + if row is None: + row = ProviderFileRateWindow(workspace_id=workspace_id, user_id=user_id, window=window) + await self.save(row) + return row + + async def output_operation(self, operation_id: uuid.UUID) -> ProviderFileOutputOperation | None: + return await self.db.get(ProviderFileOutputOperation, operation_id) + + async def key_candidates( + self, organization_id: uuid.UUID, workspace_id: uuid.UUID + ) -> list[tuple[OrgProviderKey, WorkspaceProviderKeyOverride | None]]: + return await WorkspaceProviderKeyOverrideRepository(self.db).all_candidates( + organization_id=organization_id, workspace_id=workspace_id + ) + + async def provider_key(self, key_id: uuid.UUID) -> OrgProviderKey | None: + return await self.db.get(OrgProviderKey, key_id) + + async def expire_bindings(self, organization_id: uuid.UUID, now: datetime, diagnostic_seconds: int) -> None: + await self.db.execute( + update(ProviderFileBinding) + .where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.state) == "active", + col(ProviderFileBinding.expires_at) <= now, + ) + .values(state="pending_cleanup", cleanup_reason="expiry", cleanup_after=now) + ) + await self.db.execute( + update(ProviderFileBinding) + .where( + col(ProviderFileBinding.organization_id) == organization_id, + col(ProviderFileBinding.provider_file_id).is_(None), + col(ProviderFileBinding.operation_deadline) < now - timedelta(seconds=diagnostic_seconds), + col(ProviderFileBinding.state).in_(["pending_upload", "pending_cleanup"]), + ) + .values(state="deleted", deleted_at=now, provider_outcome_unknown=False) + ) + + async def cleanup_account( + self, organization_id: uuid.UUID, now: datetime, *, include_managed: bool + ) -> ProviderAccountGeneration | None: + due = select(col(ProviderFileBinding.provider_account_generation_id)).where( + col(ProviderFileBinding.state) == "pending_cleanup", + col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.cleanup_after) <= now, + ) + return ( + await self.db.execute( + select(ProviderAccountGeneration) + .where( + col(ProviderAccountGeneration.organization_id) == organization_id, + col(ProviderAccountGeneration.credential_source).in_( + ["organization_key", "hosted_backend"] if include_managed else ["organization_key"] + ), + col(ProviderAccountGeneration.id).in_(due), + or_( + col(ProviderAccountGeneration.lease_deadline).is_(None), + col(ProviderAccountGeneration.lease_deadline) <= now, + ), + ) + .order_by(col(ProviderAccountGeneration.id)) + .limit(1) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + + async def cleanup_bindings(self, generation_id: uuid.UUID, now: datetime, limit: int) -> list[ProviderFileBinding]: + return list( + ( + await self.db.execute( + select(ProviderFileBinding) + .where( + col(ProviderFileBinding.provider_account_generation_id) == generation_id, + col(ProviderFileBinding.state) == "pending_cleanup", + col(ProviderFileBinding.provider_file_id).is_not(None), + col(ProviderFileBinding.cleanup_after) <= now, + ) + .order_by(col(ProviderFileBinding.cleanup_after), col(ProviderFileBinding.id)) + .limit(min(20, max(1, limit))) + ) + ).scalars() + ) + + async def leased_account(self, organization_id: uuid.UUID, lease_id: uuid.UUID) -> ProviderAccountGeneration | None: + return ( + await self.db.execute( + select(ProviderAccountGeneration) + .where( + col(ProviderAccountGeneration.organization_id) == organization_id, + col(ProviderAccountGeneration.lease_id) == lease_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + + async def leased_bindings(self, generation_id: uuid.UUID, lease_id: uuid.UUID) -> list[ProviderFileBinding]: + return list( + ( + await self.db.execute( + select(ProviderFileBinding).where( + col(ProviderFileBinding.provider_account_generation_id) == generation_id, + col(ProviderFileBinding.lease_id) == lease_id, + ) + ) + ).scalars() + ) + async def lock_organization(self, organization_id: uuid.UUID) -> bool: return ( await self.db.execute( @@ -79,6 +204,12 @@ async def visible( limit: int = 1001, before: tuple[datetime, uuid.UUID] | None = None, snapshot: datetime | None = None, + *, + provider: str = "anthropic", + purpose: str | None = None, + ascending: bool = False, + reverse_cursor: bool = False, + provider_order: bool = False, ) -> list[ProviderFileBinding]: statement = ( select(ProviderFileBinding) @@ -90,27 +221,36 @@ async def visible( col(ProviderFileBinding.state) == "active", col(ProviderFileBinding.expires_at) > now, col(ProviderAccountGeneration.status) == "active", + col(ProviderAccountGeneration.provider) == provider, ) ) if ids is not None: statement = statement.where(col(ProviderFileBinding.provider_file_id).in_(ids)) + if purpose is not None: + statement = statement.where(col(ProviderFileBinding.purpose) == purpose) if snapshot is not None: statement = statement.where( col(ProviderFileBinding.created_at) <= snapshot, func.coalesce(col(ProviderFileBinding.updated_at), col(ProviderFileBinding.created_at)) <= snapshot, ) + timestamp_column = ( + func.coalesce(col(ProviderFileBinding.provider_created_at), col(ProviderFileBinding.created_at)) + if provider_order + else col(ProviderFileBinding.created_at) + ) + id_column = col(ProviderFileBinding.id) if before is not None: timestamp, identity = before + later = ascending != reverse_cursor statement = statement.where( or_( - col(ProviderFileBinding.created_at) < timestamp, - (col(ProviderFileBinding.created_at) == timestamp) & (col(ProviderFileBinding.id) < identity), + timestamp_column > timestamp if later else timestamp_column < timestamp, + (timestamp_column == timestamp) & (id_column > identity if later else id_column < identity), ) ) + ordering = (timestamp_column, id_column) result = await self.db.execute( - statement.order_by(col(ProviderFileBinding.created_at).desc(), col(ProviderFileBinding.id).desc()).limit( - limit - ) + statement.order_by(*(column.asc() if ascending else column.desc() for column in ordering)).limit(limit) ) return list(result.scalars().all()) diff --git a/src/gateway/services/provider_files/accounts.py b/src/gateway/services/provider_files/accounts.py index fa33231971..e5a83bb03a 100644 --- a/src/gateway/services/provider_files/accounts.py +++ b/src/gateway/services/provider_files/accounts.py @@ -1,17 +1,17 @@ """Conservative account selection and credential retirement.""" import uuid -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime from pydantic import SecretStr -from sqlalchemy.ext.asyncio import AsyncSession +from gateway.core.unit_of_work import UnitOfWork from gateway.models.provider_files import ProviderAccountGeneration from gateway.models.provider_keys import OrgProviderKey, WorkspaceProviderKeyOverride -from gateway.repositories.tenancy.org_provider_key_repository import WorkspaceProviderKeyOverrideRepository from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository -from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError +from gateway.services.provider_files.capabilities import check_file_account +from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare from gateway.services.secret_box import decrypt_secret @@ -23,11 +23,11 @@ def select_file_key( defaults = [(key, override) for key, override in candidates if key.is_org_default] selected = pinned or defaults if len(selected) > 1: - raise FilesError(409, "Anthropic provider account is ambiguous") + raise FilesError(409, "Provider account is ambiguous") if selected: key, override = selected[0] if key.archived_at is not None or (override is not None and override.disabled): - raise FilesError(404, "Anthropic provider account unavailable") + raise FilesError(404, "Provider account unavailable") return key live = [ key @@ -35,54 +35,123 @@ def select_file_key( if key.archived_at is None and not (override is not None and override.disabled) ] if len(live) > 1: - raise FilesError(409, "Anthropic provider account is ambiguous") + raise FilesError(409, "Provider account is ambiguous") return live[0] if live else None class FileAccountResolver: - def __init__(self, db: AsyncSession) -> None: - self.db = db - self.repo = ProviderFileRepository(db) + def __init__(self, uow: UnitOfWork) -> None: + self.uow = uow + self.repo = ProviderFileRepository(uow) - async def select_byo(self, scope: FileScope) -> FileAccount | None: + async def authenticate(self, authenticate: Callable[[], Awaitable[FileScope]]) -> FileScope: + async with self.uow: + return await authenticate() + + async def authorize_attempt( + self, scope: FileScope, body: OutputPrepare, authorize: Callable[[], Awaitable[FileAccount]] + ) -> FileAccount: + async with self.uow: + generation = await self.repo.account(body.generation_id) + if generation is None or generation.organization_id != scope.organization_id: + raise FilesError(404, "Provider account unavailable") + if generation.credential_source == "hosted_backend" and not scope.default_gateway: + raise FilesError(403, "Managed provider files require the default gateway") + return await authorize() + + async def resolve( + self, + scope: FileScope, + generation_id: uuid.UUID | None = None, + *, + provider: str | None = None, + cleanup: bool = False, + resolve_hosted: Callable[[FileScope, str, uuid.UUID | None, bool, UnitOfWork], Awaitable[FileAccount]] + | None = None, + ) -> FileAccount: + async with self.uow: + if not cleanup: + await self.repo.lock_user(scope.user_id) + if not await self.repo.lock_organization(scope.organization_id): + raise FilesError(404, "Provider account unavailable") + if not await self.repo.active_user(scope.user_id) or not await self.repo.workspace_exists( + scope.workspace_id, scope.organization_id + ): + raise FilesError(404, "Provider account unavailable") + if generation_id is None: + provider = provider or "anthropic" + selected = await self.select_byo(scope, provider=provider) + if selected is not None: + return selected + else: + row = await self.repo.account(generation_id) + if ( + row is None + or row.organization_id != scope.organization_id + or (provider is not None and row.provider != provider) + ): + raise FilesError(404, "Provider account unavailable") + provider = row.provider + if row.credential_source == "organization_key": + return await self.resolve_byo(generation_id, scope.organization_id, cleanup=cleanup) + if resolve_hosted is None: + raise FilesError(404, "Provider account unavailable") + if not scope.default_gateway: + raise FilesError(403, "Managed provider files require the default gateway") + selected = await resolve_hosted(scope, provider, generation_id, cleanup, self.uow) + check_file_account(selected, provider) + if generation_id is not None and selected.generation_id != generation_id: + raise FilesError(502, "Authorization service returned an invalid file account") + return selected + + async def select_byo(self, scope: FileScope, *, provider: str = "anthropic") -> FileAccount | None: + async with self.uow: + return await self._select_byo(scope, provider=provider) + + async def _select_byo(self, scope: FileScope, *, provider: str) -> FileAccount | None: await self.repo.lock_user(scope.user_id) if not await self.repo.lock_organization(scope.organization_id): - raise FilesError(404, "Anthropic provider account unavailable") - candidates = await WorkspaceProviderKeyOverrideRepository(self.db).all_candidates( - organization_id=scope.organization_id, workspace_id=scope.workspace_id - ) - key = select_file_key([(key, override) for key, override in candidates if key.provider == "anthropic"]) + raise FilesError(404, "Provider account unavailable") + candidates = await self.repo.key_candidates(scope.organization_id, scope.workspace_id) + key = select_file_key([(key, override) for key, override in candidates if key.provider == provider]) if key is None: return None generation = await self.repo.latest_account("organization_key", str(key.id), scope.organization_id) if generation is None or generation.status == "retired": number = generation.generation + 1 if generation is not None else 1 generation = ProviderAccountGeneration( + provider=provider, generation=number, credential_source="organization_key", credential_ref=str(key.id), organization_id=scope.organization_id, ) - self.db.add(generation) - await self.db.flush() + await self.repo.save(generation) elif generation.status != "active": - raise FilesError(409, "Anthropic provider account is retiring") + raise FilesError(409, "Provider account is retiring") return self._credential(key, generation) @staticmethod def _credential(key: OrgProviderKey, generation: ProviderAccountGeneration) -> FileAccount: - if not key.encrypted_api_key: - raise FilesError(404, "Anthropic provider account unavailable") + if not key.encrypted_api_key or key.provider != generation.provider: + raise FilesError(404, "Provider account unavailable") # Client args may change account selection or transport. The initial contract accepts none. if key.client_args: - raise FilesError(400, "Provider file account requires a standard Anthropic credential") + raise FilesError(400, "Provider file account requires a standard credential") return FileAccount( - generation_id=generation.id, api_key=SecretStr(decrypt_secret(key.encrypted_api_key)), api_base=key.api_base + generation_id=generation.id, + provider=generation.provider, + api_key=SecretStr(decrypt_secret(key.encrypted_api_key)), + api_base=key.api_base, ) async def resolve_byo( self, generation_id: uuid.UUID, organization_id: uuid.UUID, *, cleanup: bool = False ) -> FileAccount: + async with self.uow: + return await self._resolve_byo(generation_id, organization_id, cleanup=cleanup) + + async def _resolve_byo(self, generation_id: uuid.UUID, organization_id: uuid.UUID, *, cleanup: bool) -> FileAccount: row = await self.repo.account(generation_id) if ( row is None @@ -90,42 +159,43 @@ async def resolve_byo( or row.credential_source != "organization_key" or (not cleanup and row.status != "active") ): - raise FilesError(404, "Anthropic provider account unavailable") - key = await self.db.get(OrgProviderKey, uuid.UUID(row.credential_ref)) + raise FilesError(404, "Provider account unavailable") + key = await self.repo.provider_key(uuid.UUID(row.credential_ref)) if key is None or key.organization_id != organization_id or (not cleanup and key.archived_at is not None): - raise FilesError(404, "Anthropic provider account unavailable") + raise FilesError(404, "Provider account unavailable") return self._credential(key, row) -async def retire_byo_account(db: AsyncSession, key: OrgProviderKey, *, release_secret: bool) -> None: - """Revoke first, retaining the old secret until all known work settles.""" - repo = ProviderFileRepository(db) +async def retire_byo_account(uow: UnitOfWork, key: OrgProviderKey, *, release_secret: bool) -> bool: + """Revoke in the caller's open transaction; return whether cleanup blocks secret release.""" + repo = ProviderFileRepository(uow) await repo.lock_organization(key.organization_id) - await db.refresh(key) + await repo.refresh(key) row = await repo.latest_account("organization_key", str(key.id), key.organization_id) if row is None: - return - await retire_account_generation(db, row, release_secret=release_secret) + return False + return await retire_account_generation(uow, row, release_secret=release_secret) async def retire_account_generation( - db: AsyncSession, + uow: UnitOfWork, row: ProviderAccountGeneration, *, release_secret: bool, -) -> None: - """Apply retirement for BYO or an adapter-owned source in the credential transaction.""" - repo = ProviderFileRepository(db) +) -> bool: + """Return a blocked retirement, so the caller commits revocation before reporting refusal. + + The caller's block must include any secret replacement or deletion. When + blocked, leave the secret intact and raise only after that block commits. + """ + repo = ProviderFileRepository(uow) await repo.lock_organization(row.organization_id) - await db.refresh(row) + await repo.refresh(row) now = datetime.now(UTC) row.status = "retiring" await repo.revoke(now, "credential_retirement", organization_id=row.organization_id, generation_id=row.id) if release_secret and await repo.account_busy(row.id, now): - # Retirement must persist even though replacement is refused. - await db.commit() - from gateway.services.tenancy.errors import TenancyConflictError - - raise TenancyConflictError("Provider file cleanup must finish before replacing or deleting this credential") + return True if release_secret: row.status, row.retired_at = "retired", now + return False diff --git a/src/gateway/services/provider_files/anthropic_inference.py b/src/gateway/services/provider_files/anthropic_inference.py new file mode 100644 index 0000000000..db5d3dcbf1 --- /dev/null +++ b/src/gateway/services/provider_files/anthropic_inference.py @@ -0,0 +1,60 @@ +"""Anthropic message blocks and SSE buffering around shared output registration.""" + +import json +from collections.abc import AsyncIterator +from typing import Any + +from gateway.services.provider_files.contracts import FilesError +from gateway.services.provider_files.inference import FileOutputBinder +from gateway.services.provider_files.references import collect_anthropic_file_references + + +class AnthropicFileOutputBinder(FileOutputBinder): + async def register(self, value: Any) -> None: + await self.register_ids(collect_anthropic_file_references(value)) + + async def stream(self, source: AsyncIterator[Any]) -> AsyncIterator[Any]: + held: list[Any] = [] + block: dict[str, Any] | None = None + size = 0 + partial = "" + try: + async for event in source: + payload = event.model_dump(exclude_unset=True) + kind = payload.get("type") + if kind == "content_block_start": + candidate = payload.get("content_block", {}) + # Hold structured provider results, keeping all later events behind them. + if isinstance(candidate, dict) and ( + "tool_result" in candidate.get("type", "") or collect_anthropic_file_references(candidate) + ): + block = candidate + if block is not None: + size += len(event.model_dump_json()) + if size > 1024 * 1024 or len(held) >= 4096: + raise FilesError(502, "Provider file output exceeds registration limits") + held.append(event) + delta = payload.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "input_json_delta": + partial += delta.get("partial_json", "") + if kind == "content_block_stop": + if partial: + try: + decoded = json.loads(partial) + except ValueError: + raise FilesError(502, "Invalid provider file output") from None + await self.register(decoded) + await self.register(block) + for buffered in held: + yield buffered + held, block, size, partial = [], None, 0, "" + else: + await self.register(payload) + yield event + if held: + raise FilesError(502, "Incomplete provider file output") + finally: + close = getattr(source, "aclose", None) + if close is not None: + await close() + await self.complete() diff --git a/src/gateway/services/provider_files/capabilities.py b/src/gateway/services/provider_files/capabilities.py new file mode 100644 index 0000000000..cc50f6913d --- /dev/null +++ b/src/gateway/services/provider_files/capabilities.py @@ -0,0 +1,29 @@ +"""Discover provider operations from any-llm, without constructing SDK clients.""" + +from any_llm import AnyLLM +from any_llm.exceptions import UnsupportedProviderError +from any_llm.types.files import FileOperation + +from gateway.services.provider_files.contracts import FileAccount, FileMetadata, FilesError + + +def require_file_operation(provider: str, operation: FileOperation) -> type[AnyLLM]: + try: + provider_class = AnyLLM.get_provider_class(provider) + except (UnsupportedProviderError, ValueError, ImportError): + raise FilesError(400, "Unknown file provider") from None + if operation not in provider_class.SUPPORTED_FILE_OPERATIONS: + raise FilesError(400, "Provider does not support this file operation") + return provider_class + + +def check_file_account(account: FileAccount, provider: str) -> None: + if account.provider != provider: + raise FilesError(502, "Authorization service returned an invalid file account") + + +def require_download(provider: str, metadata: FileMetadata) -> None: + require_file_operation(provider, "download") + if metadata.downloadable is False: + raise FilesError(400, "This file is not downloadable") + # Unknown per-file permission is decided upstream, after local ownership authorization. diff --git a/src/gateway/services/provider_files/cleanup.py b/src/gateway/services/provider_files/cleanup.py index 53028a5c98..3eae41ce30 100644 --- a/src/gateway/services/provider_files/cleanup.py +++ b/src/gateway/services/provider_files/cleanup.py @@ -8,10 +8,7 @@ from datetime import UTC, datetime, timedelta from pydantic import SecretStr -from sqlalchemy import or_, select, update -from sqlmodel import col -from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding from gateway.services.provider_files.accounts import FileAccountResolver from gateway.services.provider_files.contracts import CleanupItem, CleanupLease, FileAccount, FilesError, LeaseResult from gateway.services.provider_files.lifecycle import ProviderFileService @@ -20,7 +17,7 @@ class ProviderFileCleanup: def __init__(self, service: ProviderFileService) -> None: self.service = service - self.db = service.db + self.uow = service.uow self.repo = service.repo async def claim( @@ -33,135 +30,60 @@ async def claim( resolve_account: Callable[[uuid.UUID], Awaitable[FileAccount]] | None = None, ) -> CleanupLease | None: """Organization scope must come from the registered gateway, never the request body.""" - now = datetime.now(UTC) - await self.repo.lock_organization(organization_id) - await self.db.execute( - update(ProviderFileBinding) - .where( - col(ProviderFileBinding.organization_id) == organization_id, - col(ProviderFileBinding.state) == "active", - col(ProviderFileBinding.expires_at) <= now, + async with self.uow: + now = datetime.now(UTC) + await self.repo.lock_organization(organization_id) + await self.repo.expire_bindings(organization_id, now, self.service.diagnostic_seconds) + account = await self.repo.cleanup_account( + organization_id, now, include_managed=include_managed and resolve_account is not None ) - .values(state="pending_cleanup", cleanup_reason="expiry", cleanup_after=now) - ) - await self.db.execute( - update(ProviderFileBinding) - .where( - col(ProviderFileBinding.organization_id) == organization_id, - col(ProviderFileBinding.provider_file_id).is_(None), - col(ProviderFileBinding.operation_deadline) < now - timedelta(seconds=self.service.diagnostic_seconds), - col(ProviderFileBinding.state).in_(["pending_upload", "pending_cleanup"]), + if account is None: + return None + credential = ( + await resolve_account(account.id) + if resolve_account is not None + else await FileAccountResolver(self.uow).resolve_byo(account.id, organization_id, cleanup=True) ) - .values(state="deleted", deleted_at=now, provider_outcome_unknown=False) - ) - due = select(col(ProviderFileBinding.provider_account_generation_id)).where( - col(ProviderFileBinding.state) == "pending_cleanup", - col(ProviderFileBinding.provider_file_id).is_not(None), - col(ProviderFileBinding.cleanup_after) <= now, - ) - account = ( - await self.db.execute( - select(ProviderAccountGeneration) - .where( - col(ProviderAccountGeneration.organization_id) == organization_id, - col(ProviderAccountGeneration.credential_source).in_( - ["organization_key", "hosted_backend"] - if include_managed and resolve_account is not None - else ["organization_key"] - ), - col(ProviderAccountGeneration.id).in_(due), - or_( - col(ProviderAccountGeneration.lease_deadline).is_(None), - col(ProviderAccountGeneration.lease_deadline) <= now, - ), - ) - .order_by(col(ProviderAccountGeneration.id)) - .limit(1) - .with_for_update(skip_locked=True) + rows = await self.repo.cleanup_bindings(account.id, now, limit) + token = secrets.token_urlsafe(32) + account.lease_id = uuid.uuid4() + account.lease_token_hash = hashlib.sha256(token.encode()).hexdigest() + account.lease_gateway_id = gateway_id + account.lease_deadline = now + timedelta(seconds=300) + for row in rows: + row.lease_id = account.lease_id + return CleanupLease( + id=account.lease_id, + token=SecretStr(token), + deadline=account.lease_deadline, + account=credential, + items=[ + CleanupItem(binding_id=row.id, file_id=row.provider_file_id) + for row in rows + if row.provider_file_id is not None + ], ) - ).scalar_one_or_none() - if account is None: - await self.db.commit() - return None - credential = ( - await resolve_account(account.id) - if resolve_account is not None - else await FileAccountResolver(self.db).resolve_byo(account.id, organization_id, cleanup=True) - ) - rows = list( - ( - await self.db.execute( - select(ProviderFileBinding) - .where( - col(ProviderFileBinding.provider_account_generation_id) == account.id, - col(ProviderFileBinding.state) == "pending_cleanup", - col(ProviderFileBinding.provider_file_id).is_not(None), - col(ProviderFileBinding.cleanup_after) <= now, - ) - .order_by(col(ProviderFileBinding.cleanup_after), col(ProviderFileBinding.id)) - .limit(min(20, max(1, limit))) - ) - ).scalars() - ) - token = secrets.token_urlsafe(32) - account.lease_id = uuid.uuid4() - account.lease_token_hash = hashlib.sha256(token.encode()).hexdigest() - account.lease_gateway_id = gateway_id - account.lease_deadline = now + timedelta(seconds=300) - for row in rows: - row.lease_id = account.lease_id - result = CleanupLease( - id=account.lease_id, - token=SecretStr(token), - deadline=account.lease_deadline, - account=credential, - items=[ - CleanupItem(binding_id=row.id, file_id=row.provider_file_id) - for row in rows - if row.provider_file_id is not None - ], - ) - await self.db.commit() - return result async def complete( self, organization_id: uuid.UUID, gateway_id: str, lease_id: uuid.UUID, result: LeaseResult ) -> None: - await self.repo.lock_organization(organization_id) - account = ( - await self.db.execute( - select(ProviderAccountGeneration) - .where( - col(ProviderAccountGeneration.organization_id) == organization_id, - col(ProviderAccountGeneration.lease_id) == lease_id, - ) - .with_for_update() - ) - ).scalar_one_or_none() - digest = hashlib.sha256(result.token.get_secret_value().encode()).hexdigest() - if ( - account is None - or account.lease_gateway_id != gateway_id - or account.lease_deadline is None - or account.lease_deadline <= datetime.now(UTC) - or not hmac.compare_digest(account.lease_token_hash or "", digest) - ): - raise FilesError(409, "Cleanup lease unavailable") - rows = list( - ( - await self.db.execute( - select(ProviderFileBinding).where( - col(ProviderFileBinding.provider_account_generation_id) == account.id, - col(ProviderFileBinding.lease_id) == lease_id, - ) - ) - ).scalars() - ) - if set(result.results) - {row.id for row in rows}: - raise FilesError(409, "Cleanup lease item conflict") - for row in rows: - if row.state == "pending_cleanup": - self.service.apply_cleanup(row, result.results.get(row.id, False)) - row.lease_id = None - account.lease_id = account.lease_token_hash = account.lease_gateway_id = account.lease_deadline = None - await self.db.commit() + async with self.uow: + await self.repo.lock_organization(organization_id) + account = await self.repo.leased_account(organization_id, lease_id) + digest = hashlib.sha256(result.token.get_secret_value().encode()).hexdigest() + if ( + account is None + or account.lease_gateway_id != gateway_id + or account.lease_deadline is None + or account.lease_deadline <= datetime.now(UTC) + or not hmac.compare_digest(account.lease_token_hash or "", digest) + ): + raise FilesError(409, "Cleanup lease unavailable") + rows = await self.repo.leased_bindings(account.id, lease_id) + if set(result.results) - {row.id for row in rows}: + raise FilesError(409, "Cleanup lease item conflict") + for row in rows: + if row.state == "pending_cleanup": + self.service.apply_cleanup(row, result.results.get(row.id, False)) + row.lease_id = None + account.lease_id = account.lease_token_hash = account.lease_gateway_id = account.lease_deadline = None diff --git a/src/gateway/services/provider_files/client.py b/src/gateway/services/provider_files/client.py index b753a6917d..dc5f4778d5 100644 --- a/src/gateway/services/provider_files/client.py +++ b/src/gateway/services/provider_files/client.py @@ -5,7 +5,7 @@ import httpx from pydantic import BaseModel, ValidationError -from gateway.services.provider_files.contracts import FilesError +from gateway.services.provider_files.contracts import FILES_PROTOCOL_VERSION, FilesError T = TypeVar("T", bound=BaseModel) @@ -18,7 +18,7 @@ def __init__(self, base_url: str, gateway_token: str, user_token: str | None, *, self.timeout = timeout async def post(self, path: str, body: dict[str, Any], result_type: type[T]) -> T: - headers = {"X-Gateway-Token": self._gateway_token} + headers = {"X-Gateway-Token": self._gateway_token, "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION} if self._user_token is not None: headers["X-User-Token"] = self._user_token try: @@ -26,10 +26,9 @@ async def post(self, path: str, body: dict[str, Any], result_type: type[T]) -> T response = await client.post(f"{self.base_url}/gateway/files/{path}", headers=headers, json=body) except httpx.HTTPError: raise FilesError(502, "Authorization service unavailable") from None + if response.headers.get("X-Otari-Files-Protocol") != FILES_PROTOCOL_VERSION: + raise FilesError(502, "Authorization service does not support this Files protocol") if response.status_code == 404: - # A supporting peer marks all Files responses, including missing bindings. - if response.headers.get("X-Otari-Files-Protocol") != "1": - raise FilesError(502, "Authorization service does not support provider-native Files") raise FilesError(404, "File or provider account unavailable") if response.status_code in {400, 401, 403, 409, 413, 429}: details = { diff --git a/src/gateway/services/provider_files/contracts.py b/src/gateway/services/provider_files/contracts.py index 3da3d2f2f0..9a4f4fec7b 100644 --- a/src/gateway/services/provider_files/contracts.py +++ b/src/gateway/services/provider_files/contracts.py @@ -2,8 +2,9 @@ import uuid from datetime import datetime -from typing import Annotated, Any, Literal, Self +from typing import Annotated, Literal, Self +from any_llm.types.files import FileMetadata as SDKFileMetadata from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, model_validator @@ -24,26 +25,23 @@ class WireModel(BaseModel): ProviderFileId = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")] -class FileMetadata(WireModel): - """Anthropic's public metadata, without provider-neutral-only fields.""" +ProviderName = Annotated[str, Field(min_length=1, max_length=32, pattern=r"^[a-z][a-z0-9_]*$")] +FILES_PROTOCOL_VERSION = "2" + + +class FileMetadata(SDKFileMetadata, WireModel): + """Bounded any-llm metadata; absent provider fields remain unknown.""" model_config = ConfigDict(extra="allow") id: ProviderFileId - type: Literal["file"] = "file" - filename: str = Field(max_length=1024) - mime_type: str = Field(max_length=255) - size_bytes: int = Field(ge=0) - created_at: AwareDatetime + filename: str | None = Field(default=None, max_length=1024) + mime_type: str | None = Field(default=None, max_length=255) + size_bytes: int | None = Field(default=None, ge=0) + created_at: AwareDatetime | None = None expires_at: AwareDatetime | None = None - downloadable: bool - - @model_validator(mode="before") - @classmethod - def native_fields(cls, value: Any) -> Any: - if isinstance(value, dict): - return {key: item for key, item in value.items() if key not in {"purpose", "status"}} - return value + purpose: str | None = Field(default=None, max_length=255) + status: str | None = Field(default=None, max_length=255) @model_validator(mode="after") def bounded_metadata(self) -> Self: @@ -58,13 +56,23 @@ class FilePage(WireModel): class FileListRequest(WireModel): + provider: ProviderName = "anthropic" + purpose: str | None = Field(default=None, max_length=255) + after_id: ProviderFileId | None = None + before_id: ProviderFileId | None = None + order: Literal["asc", "desc"] = "desc" + sort_by: Literal["binding_created_at", "provider_created_at"] = "binding_created_at" page: str | None = Field(default=None, max_length=4096) limit: int | None = Field(default=None, ge=1, le=1000) ids: list[ProviderFileId] | None = Field(default=None, max_length=100) @model_validator(mode="after") def compatible_filters(self) -> Self: - if self.ids is not None and (self.page is not None or self.limit is not None): + if sum(value is not None for value in (self.page, self.after_id, self.before_id)) > 1: + raise ValueError("Only one pagination cursor is allowed") + if self.ids is not None and any( + value is not None for value in (self.page, self.limit, self.after_id, self.before_id) + ): raise ValueError("ids[] cannot be combined with page or limit") return self @@ -81,7 +89,7 @@ class FileScope(WireModel): class FileAccount(WireModel): generation_id: uuid.UUID - provider: Literal["anthropic"] = "anthropic" + provider: ProviderName = "anthropic" api_key: SecretStr api_base: str | None = None workspace: str | None = None @@ -98,6 +106,7 @@ class Operation(WireModel): class PrepareUpload(WireModel): + provider: ProviderName = "anthropic" operation_id: uuid.UUID size_bytes: int = Field(ge=0) expires_in_seconds: int | None = Field(default=None, ge=3600, le=7776000) @@ -117,6 +126,7 @@ class AbandonUpload(WireModel): class ResolveFile(WireModel): + provider: ProviderName = "anthropic" operation: Literal["metadata", "download", "delete"] @@ -128,6 +138,7 @@ class ResolvedFile(WireModel): class References(WireModel): + provider: ProviderName = "anthropic" ids: list[ProviderFileId] = Field(min_length=1, max_length=100) @@ -173,8 +184,3 @@ class LeaseResult(WireModel): class OutputCleanup(WireModel): operation_id: uuid.UUID cleanup_token: SecretStr - - -class NativeFileDeleted(WireModel): - id: str - type: Literal["file_deleted"] = "file_deleted" diff --git a/src/gateway/services/provider_files/inference.py b/src/gateway/services/provider_files/inference.py index 147dc5dc5b..1017e30cde 100644 --- a/src/gateway/services/provider_files/inference.py +++ b/src/gateway/services/provider_files/inference.py @@ -1,12 +1,9 @@ """Withhold provider output references until their durable binding is active.""" -import json -from collections.abc import AsyncIterator -from typing import Any +from collections.abc import Iterable from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import FileMetadata, FilesError, Operation, OutputCleanup, WireModel -from gateway.services.provider_files.references import collect_file_references from gateway.services.provider_files.transport import provider_client, provider_error @@ -16,8 +13,8 @@ def __init__(self, client: PlatformFilesClient, operation: Operation, existing_i self.operation = operation self.bound = set(existing_ids) - async def register(self, value: Any) -> None: - for file_id in collect_file_references(value): + async def register_ids(self, ids: Iterable[str]) -> None: + for file_id in ids: if file_id in self.bound: continue try: @@ -80,49 +77,3 @@ async def compensate(self, metadata: FileMetadata | None, file_id: str | None = ) except FilesError: pass - - async def stream(self, source: AsyncIterator[Any]) -> AsyncIterator[Any]: - held: list[Any] = [] - block: dict[str, Any] | None = None - size = 0 - partial = "" - try: - async for event in source: - payload = event.model_dump(exclude_unset=True) - kind = payload.get("type") - if kind == "content_block_start": - candidate = payload.get("content_block", {}) - # Hold structured provider results, keeping all later events behind them. - if isinstance(candidate, dict) and ( - "tool_result" in candidate.get("type", "") or collect_file_references(candidate) - ): - block = candidate - if block is not None: - size += len(event.model_dump_json()) - if size > 1024 * 1024 or len(held) >= 4096: - raise FilesError(502, "Provider file output exceeds registration limits") - held.append(event) - delta = payload.get("delta", {}) - if isinstance(delta, dict) and delta.get("type") == "input_json_delta": - partial += delta.get("partial_json", "") - if kind == "content_block_stop": - if partial: - try: - decoded = json.loads(partial) - except ValueError: - raise FilesError(502, "Invalid provider file output") from None - await self.register(decoded) - await self.register(block) - for buffered in held: - yield buffered - held, block, size, partial = [], None, 0, "" - else: - await self.register(payload) - yield event - if held: - raise FilesError(502, "Incomplete provider file output") - finally: - close = getattr(source, "aclose", None) - if close is not None: - await close() - await self.complete() diff --git a/src/gateway/services/provider_files/lifecycle.py b/src/gateway/services/provider_files/lifecycle.py index 7b1c81b9af..7d7de1baf3 100644 --- a/src/gateway/services/provider_files/lifecycle.py +++ b/src/gateway/services/provider_files/lifecycle.py @@ -6,9 +6,9 @@ from datetime import UTC, datetime, timedelta from pydantic import SecretStr -from sqlalchemy.ext.asyncio import AsyncSession -from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding, ProviderFileRateWindow +from gateway.core.unit_of_work import UnitOfWork +from gateway.models.provider_files import ProviderAccountGeneration, ProviderFileBinding from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.services.provider_files.contracts import ( AbandonUpload, @@ -30,7 +30,7 @@ class ProviderFileService: def __init__( self, - db: AsyncSession, + uow: UnitOfWork, *, max_bytes: int, max_files: int, @@ -44,8 +44,8 @@ def __init__( raise ValueError("Explicit positive file and outstanding-byte quotas are required") if not 3600 <= retention_seconds <= 7776000 or operation_seconds <= 0 or rate_limit_rpm <= 0: raise ValueError("Invalid provider file limits") - self.db = db - self.repo = ProviderFileRepository(db) + self.uow = uow + self.repo = ProviderFileRepository(uow) self.max_bytes = max_bytes self.max_files = max_files self.max_outstanding_bytes = max_outstanding_bytes @@ -65,20 +65,21 @@ async def _lock_scope(self, scope: FileScope) -> None: async def _rate_limit(self, scope: FileScope, now: datetime) -> None: window = int(now.timestamp()) // 60 - row = await self.db.get(ProviderFileRateWindow, (scope.workspace_id, scope.user_id)) - if row is None: - row = ProviderFileRateWindow(workspace_id=scope.workspace_id, user_id=scope.user_id, window=window) - self.db.add(row) + row = await self.repo.rate_window(scope.workspace_id, scope.user_id, window) if row.window != window: row.window, row.count = window, 0 if row.count >= self.rate_limit_rpm: raise FilesError(429, "File operation rate limit exceeded") row.count += 1 - await self.db.flush() async def _account(self, scope: FileScope, account: FileAccount) -> ProviderAccountGeneration: row = await self.repo.account(account.generation_id) - if row is None or row.organization_id != scope.organization_id or row.status != "active": + if ( + row is None + or row.organization_id != scope.organization_id + or row.status != "active" + or row.provider != account.provider + ): raise FilesError(404, "Provider account unavailable") if row.credential_source == "hosted_backend" and not scope.default_gateway: raise FilesError(403, "Managed provider files require the default gateway") @@ -126,9 +127,15 @@ def _operation(self, row: ProviderFileBinding, account: FileAccount) -> Operatio ) async def prepare(self, scope: FileScope, account: FileAccount, request: PrepareUpload) -> Operation: + async with self.uow: + return await self._prepare(scope, account, request) + + async def _prepare(self, scope: FileScope, account: FileAccount, request: PrepareUpload) -> Operation: now = datetime.now(UTC) await self._lock_scope(scope) await self._account(scope, account) + if request.provider != account.provider: + raise FilesError(400, "File provider does not match the authorized account") existing = await self.repo.get(request.operation_id) duration = min(request.expires_in_seconds or self.retention_seconds, self.retention_seconds) reserved_bytes = min(request.size_bytes, self.max_bytes) @@ -163,8 +170,7 @@ async def prepare(self, scope: FileScope, account: FileAccount, request: Prepare initiating_gateway_id=scope.gateway_id, cleanup_token_hash=secrets.token_hex(32), ) - self.db.add(row) - await self.db.commit() + await self.repo.save(row) return self._operation(row, account) async def finalize( @@ -174,6 +180,19 @@ async def finalize( metadata: FileMetadata, expires_in_seconds: int | None = None, ) -> FileMetadata: + async with self.uow: + result = await self._finalize(scope, binding_id, metadata, expires_in_seconds) + if result is None: + raise FilesError(409, "Upload operation has been revoked") + return result + + async def _finalize( + self, + scope: FileScope, + binding_id: uuid.UUID, + metadata: FileMetadata, + expires_in_seconds: int | None = None, + ) -> FileMetadata | None: now = datetime.now(UTC) await self.repo.lock_user(scope.user_id) await self.repo.lock_organization(scope.organization_id) @@ -190,19 +209,22 @@ async def finalize( return self._metadata(row) raise FilesError(409, "Upload operation has been revoked") account = await self.repo.account(row.provider_account_generation_id) + size = metadata.size_bytes if metadata.size_bytes is not None else row.size_bytes active = ( row.state == "pending_upload" and row.operation_deadline > now and account is not None and account.status == "active" - and metadata.size_bytes <= row.size_bytes + and size <= row.size_bytes and await self.repo.active_user(scope.user_id) and await self.repo.workspace_exists(scope.workspace_id, scope.organization_id) ) row.provider_file_id = metadata.id row.encrypted_metadata = encrypt_secret(metadata.model_dump_json(exclude_unset=True)) - row.size_bytes = metadata.size_bytes - row.downloadable = metadata.downloadable + row.size_bytes = size + row.purpose = metadata.purpose + row.provider_created_at = metadata.created_at + row.downloadable = metadata.downloadable is True if expires_in_seconds is not None: if not 3600 <= expires_in_seconds <= 7776000: raise FilesError(400, "Invalid file retention") @@ -215,17 +237,18 @@ async def finalize( if not active: row.cleanup_after, row.cleanup_reason = now, "revoked_operation" row.updated_at = now - await self.db.commit() - if not active: - raise FilesError(409, "Upload operation has been revoked") - return metadata + return metadata if active else None async def abandon(self, binding_id: uuid.UUID, gateway_id: str, request: AbandonUpload) -> None: + async with self.uow: + await self._abandon(binding_id, gateway_id, request) + + async def _abandon(self, binding_id: uuid.UUID, gateway_id: str, request: AbandonUpload) -> None: row = await self.repo.get(binding_id) if row is None: raise FilesError(404, "Upload operation unavailable") await self.repo.lock_organization(row.organization_id) - await self.db.refresh(row) + await self.repo.refresh(row) self._check_token(row, gateway_id, request.cleanup_token.get_secret_value()) if row.state == "active" and (request.metadata is None or self._metadata(row) != request.metadata): raise FilesError(409, "Upload was already finalized") @@ -243,15 +266,28 @@ async def abandon(self, binding_id: uuid.UUID, gateway_id: str, request: Abandon elif row.provider_file_id is not None: row.state, row.cleanup_after = "pending_cleanup", datetime.now(UTC) row.cleanup_reason = "upload_abandoned" - await self.db.commit() async def list_files(self, scope: FileScope, request: FileListRequest) -> FilePage: + async with self.uow: + return await self._list_files(scope, request) + + async def _list_files(self, scope: FileScope, request: FileListRequest) -> FilePage: now = datetime.now(UTC) await self._lock_scope(scope) await self._rate_limit(scope, now) snapshot, before = now, None limit = request.limit or 20 - scope_key = f"{scope.organization_id}:{scope.workspace_id}:{scope.user_id}" + scope_key = json.dumps( + [ + str(scope.organization_id), + str(scope.workspace_id), + scope.user_id, + request.provider, + request.purpose, + request.order, + request.sort_by, + ] + ) if request.page: try: cursor = json.loads(decrypt_secret(request.page)) @@ -261,6 +297,26 @@ async def list_files(self, scope: FileScope, request: FileListRequest) -> FilePa before = (datetime.fromisoformat(cursor["created_at"]), uuid.UUID(cursor["id"])) except (ValueError, KeyError, TypeError): raise FilesError(400, "Invalid file page") from None + native_cursor = request.after_id or request.before_id + if native_cursor is not None: + anchors = await self.repo.visible( + scope.organization_id, + scope.workspace_id, + scope.user_id, + now, + ids=[native_cursor], + provider=request.provider, + purpose=request.purpose, + ) + if len(anchors) != 1: + raise FilesError(400, "Invalid file page") + anchor = anchors[0] + timestamp = ( + (anchor.provider_created_at or anchor.created_at) + if request.sort_by == "provider_created_at" + else anchor.created_at + ) + before = timestamp, anchor.id rows = await self.repo.visible( scope.organization_id, scope.workspace_id, @@ -270,25 +326,33 @@ async def list_files(self, scope: FileScope, request: FileListRequest) -> FilePa limit=101 if request.ids is not None else limit + 1, before=before, snapshot=snapshot, + provider=request.provider, + purpose=request.purpose, + ascending=request.order == "asc", + reverse_cursor=request.before_id is not None, + provider_order=request.sort_by == "provider_created_at", ) next_page = None if request.ids is None and len(rows) > limit: rows = rows[:limit] last = rows[-1] + timestamp = ( + (last.provider_created_at or last.created_at) + if request.sort_by == "provider_created_at" + else last.created_at + ) next_page = encrypt_secret( json.dumps( { "scope": scope_key, "limit": limit, "snapshot": snapshot.isoformat(), - "created_at": last.created_at.isoformat(), + "created_at": timestamp.isoformat(), "id": str(last.id), } ) ) - result = FilePage(data=[self._metadata(row) for row in rows], next_page=next_page) - await self.db.commit() - return result + return FilePage(data=[self._metadata(row) for row in rows], next_page=next_page) async def resolve( self, @@ -296,18 +360,40 @@ async def resolve( file_id: str, operation: str, account: FileAccount | None = None, + *, + provider: str = "anthropic", + ) -> ResolvedFile: + async with self.uow: + return await self._resolve(scope, file_id, operation, account, provider=provider) + + async def _resolve( + self, + scope: FileScope, + file_id: str, + operation: str, + account: FileAccount | None = None, + *, + provider: str = "anthropic", ) -> ResolvedFile: now = datetime.now(UTC) await self._lock_scope(scope) await self._rate_limit(scope, now) - rows = await self.repo.visible(scope.organization_id, scope.workspace_id, scope.user_id, now, ids=[file_id]) + rows = await self.repo.visible( + scope.organization_id, scope.workspace_id, scope.user_id, now, ids=[file_id], provider=provider + ) if len(rows) != 1: raise FilesError(404, "File unavailable") row = rows[0] - if operation == "download" and not row.downloadable: - raise FilesError(400, "This file is not downloadable") + if operation == "download": + from gateway.services.provider_files.capabilities import require_download + + require_download(provider, self._metadata(row)) if operation != "metadata": - if account is None or account.generation_id != row.provider_account_generation_id: + if ( + account is None + or account.provider != provider + or account.generation_id != row.provider_account_generation_id + ): raise FilesError(404, "Provider account unavailable") await self._account(scope, account) if operation == "delete": @@ -319,15 +405,23 @@ async def resolve( operation_id=row.id if operation == "delete" else None, cleanup_token=SecretStr(self._token(row)) if operation == "delete" else None, ) - await self.db.commit() return result - async def references(self, scope: FileScope, ids: list[str]) -> uuid.UUID: + async def references(self, scope: FileScope, ids: list[str], *, provider: str = "anthropic") -> uuid.UUID: + async with self.uow: + return await self._references(scope, ids, provider=provider) + + async def _references(self, scope: FileScope, ids: list[str], *, provider: str = "anthropic") -> uuid.UUID: await self._lock_scope(scope) if not ids or len(ids) > 100: raise FilesError(400, "Invalid file references") rows = await self.repo.visible( - scope.organization_id, scope.workspace_id, scope.user_id, datetime.now(UTC), ids=list(set(ids)) + scope.organization_id, + scope.workspace_id, + scope.user_id, + datetime.now(UTC), + ids=list(set(ids)), + provider=provider, ) if len(rows) != len(set(ids)): raise FilesError(404, "File unavailable") @@ -336,19 +430,26 @@ async def references(self, scope: FileScope, ids: list[str]) -> uuid.UUID: raise FilesError(400, "Files must belong to one provider account") return accounts.pop() + async def backlog(self, organization_id: uuid.UUID) -> dict[str, int]: + async with self.uow: + return await self.repo.backlog(organization_id) + async def cleanup_result(self, binding_id: uuid.UUID, gateway_id: str, token: str, deleted: bool) -> None: + async with self.uow: + await self._cleanup_result(binding_id, gateway_id, token, deleted) + + async def _cleanup_result(self, binding_id: uuid.UUID, gateway_id: str, token: str, deleted: bool) -> None: row = await self.repo.get(binding_id) if row is None: raise FilesError(404, "Cleanup operation unavailable") await self.repo.lock_organization(row.organization_id) - await self.db.refresh(row) + await self.repo.refresh(row) self._check_token(row, gateway_id, token) if row.state == "deleted": return if row.state != "pending_cleanup": raise FilesError(409, "File is not awaiting cleanup") self.apply_cleanup(row, deleted) - await self.db.commit() @staticmethod def apply_cleanup(row: ProviderFileBinding, deleted: bool) -> None: diff --git a/src/gateway/services/provider_files/outputs.py b/src/gateway/services/provider_files/outputs.py index 1224b0febd..ed855f4ad9 100644 --- a/src/gateway/services/provider_files/outputs.py +++ b/src/gateway/services/provider_files/outputs.py @@ -24,7 +24,7 @@ class ProviderFileOutputs: def __init__(self, service: ProviderFileService) -> None: self.service = service - self.db = service.db + self.uow = service.uow self.repo = service.repo @staticmethod @@ -35,12 +35,16 @@ def _token(row: ProviderFileOutputOperation) -> str: async def prepare(self, scope: FileScope, account: FileAccount, request: OutputPrepare) -> Operation: """The gateway authority must verify request_id/attempt_id before calling this method.""" + async with self.uow: + return await self._prepare(scope, account, request) + + async def _prepare(self, scope: FileScope, account: FileAccount, request: OutputPrepare) -> Operation: now = datetime.now(UTC) await self.service._lock_scope(scope) await self.service._account(scope, account) if request.generation_id != account.generation_id: raise FilesError(409, "Inference account conflict") - row = await self.db.get(ProviderFileOutputOperation, request.operation_id) + row = await self.repo.output_operation(request.operation_id) if row is not None: if ( ( @@ -85,8 +89,7 @@ async def prepare(self, scope: FileScope, account: FileAccount, request: OutputP reserved_files=reserved, reserved_bytes=available, ) - self.db.add(row) - await self.db.commit() + await self.repo.save(row) return Operation( id=row.id, cleanup_token=SecretStr(self._token(row)), @@ -97,10 +100,17 @@ async def prepare(self, scope: FileScope, account: FileAccount, request: OutputP ) async def register(self, scope: FileScope, operation_id: uuid.UUID, metadata: FileMetadata) -> FileMetadata: + async with self.uow: + result = await self._register(scope, operation_id, metadata) + if result is None: + raise FilesError(409, "Output operation has been revoked") + return result + + async def _register(self, scope: FileScope, operation_id: uuid.UUID, metadata: FileMetadata) -> FileMetadata | None: now = datetime.now(UTC) await self.repo.lock_user(scope.user_id) await self.repo.lock_organization(scope.organization_id) - row = await self.db.get(ProviderFileOutputOperation, operation_id) + row = await self.repo.output_operation(operation_id) if row is None or (row.organization_id, row.workspace_id, row.user_id, row.initiating_gateway_id) != ( scope.organization_id, scope.workspace_id, @@ -116,14 +126,15 @@ async def register(self, scope: FileScope, operation_id: uuid.UUID, metadata: Fi raise FilesError(409, "Provider file is no longer active") return self.service._metadata(existing) account = await self.repo.account(row.provider_account_generation_id) + size = metadata.size_bytes if metadata.size_bytes is not None else self.service.max_bytes active = ( row.state == "active" and row.deadline > now and account is not None and account.status == "active" and row.reserved_files > 0 - and row.reserved_bytes >= metadata.size_bytes - and metadata.size_bytes <= self.service.max_bytes + and row.reserved_bytes >= size + and size <= self.service.max_bytes and await self.repo.active_user(scope.user_id) and await self.repo.workspace_exists(scope.workspace_id, scope.organization_id) ) @@ -139,8 +150,10 @@ async def register(self, scope: FileScope, operation_id: uuid.UUID, metadata: Fi provider_account_generation_id=row.provider_account_generation_id, provider_file_id=metadata.id, encrypted_metadata=encrypt_secret(metadata.model_dump_json(exclude_unset=True)), - size_bytes=metadata.size_bytes, - downloadable=metadata.downloadable, + size_bytes=size, + purpose=metadata.purpose, + provider_created_at=metadata.created_at, + downloadable=metadata.downloadable is True, expires_at=expires, provider_expires_at=metadata.expires_at, operation_deadline=row.deadline, @@ -150,20 +163,21 @@ async def register(self, scope: FileScope, operation_id: uuid.UUID, metadata: Fi cleanup_reason=None if active else "revoked_output", cleanup_after=None if active else now, ) - self.db.add(binding) + await self.repo.save(binding) row.reserved_files = max(0, row.reserved_files - 1) - row.reserved_bytes = max(0, row.reserved_bytes - metadata.size_bytes) - await self.db.commit() - if not active: - raise FilesError(409, "Output operation has been revoked") - return metadata + row.reserved_bytes = max(0, row.reserved_bytes - size) + return metadata if active else None async def complete(self, operation_id: uuid.UUID, gateway_id: str, token: str) -> None: - row = await self.db.get(ProviderFileOutputOperation, operation_id) + async with self.uow: + await self._complete(operation_id, gateway_id, token) + + async def _complete(self, operation_id: uuid.UUID, gateway_id: str, token: str) -> None: + row = await self.repo.output_operation(operation_id) if row is None: raise FilesError(404, "Output operation unavailable") await self.repo.lock_organization(row.organization_id) - await self.db.refresh(row) + await self.repo.refresh(row) try: payload = json.loads(decrypt_secret(token)) except (ValueError, TypeError): @@ -171,7 +185,6 @@ async def complete(self, operation_id: uuid.UUID, gateway_id: str, token: str) - if payload != {"output": str(row.id), "gateway": gateway_id, "nonce": row.cleanup_token_hash}: raise FilesError(403, "Invalid cleanup authority") row.state, row.reserved_bytes, row.reserved_files = "completed", 0, 0 - await self.db.commit() async def abandon( self, @@ -181,11 +194,22 @@ async def abandon( metadata: FileMetadata | None, file_id: str | None = None, ) -> OutputCleanup: - row = await self.db.get(ProviderFileOutputOperation, operation_id) + async with self.uow: + return await self._abandon(operation_id, gateway_id, token, metadata, file_id) + + async def _abandon( + self, + operation_id: uuid.UUID, + gateway_id: str, + token: str, + metadata: FileMetadata | None, + file_id: str | None = None, + ) -> OutputCleanup: + row = await self.repo.output_operation(operation_id) if row is None: raise FilesError(404, "Output operation unavailable") await self.repo.lock_organization(row.organization_id) - await self.db.refresh(row) + await self.repo.refresh(row) try: payload = json.loads(decrypt_secret(token)) except (ValueError, TypeError): @@ -209,15 +233,18 @@ async def abandon( encrypted_metadata=encrypt_secret(metadata.model_dump_json(exclude_unset=True)) if metadata is not None else None, - size_bytes=metadata.size_bytes if metadata is not None else 0, - downloadable=metadata.downloadable if metadata is not None else False, + size_bytes=metadata.size_bytes + if metadata is not None and metadata.size_bytes is not None + else self.service.max_bytes, + purpose=metadata.purpose if metadata is not None else None, + provider_created_at=metadata.created_at if metadata is not None else None, + downloadable=metadata is not None and metadata.downloadable is True, expires_at=datetime.now(UTC), operation_deadline=row.deadline, initiating_gateway_id=gateway_id, cleanup_token_hash=secrets.token_hex(32), ) - self.db.add(existing) + await self.repo.save(existing) existing.state = "pending_cleanup" existing.cleanup_reason, existing.cleanup_after = "output_abandoned", datetime.now(UTC) - await self.db.commit() return OutputCleanup(operation_id=existing.id, cleanup_token=SecretStr(self.service._token(existing))) diff --git a/src/gateway/services/provider_files/references.py b/src/gateway/services/provider_files/references.py index 4f09203474..872266aed1 100644 --- a/src/gateway/services/provider_files/references.py +++ b/src/gateway/services/provider_files/references.py @@ -1,4 +1,4 @@ -"""Bounded inspection of structured Anthropic file references.""" +"""Bounded, envelope-specific inspection of structured file references.""" from typing import Any @@ -9,7 +9,7 @@ _MAX_REFERENCES = 100 -def collect_file_references(value: Any) -> list[str]: +def collect_anthropic_file_references(value: Any) -> list[str]: """Collect references throughout message history without interpreting ordinary text.""" found: dict[str, None] = {} pending = [(value, 0)] @@ -44,3 +44,57 @@ def collect_file_references(value: Any) -> list[str]: (item[key], depth + 1) for key in ("messages", "content", "source", "output", "results") if key in item ) return list(found) + + +def reject_openai_file_state(payload: dict[str, Any]) -> None: + """Reject account-scoped OpenAI state until its inference ownership protocol exists.""" + if payload.get("previous_response_id") or payload.get("conversation"): + raise FilesError(400, "Provider conversation reuse is not supported in hybrid mode") + pending = [(payload, 0)] + nodes = 0 + while pending: + item, depth = pending.pop() + nodes += 1 + if nodes > _MAX_NODES or depth > _MAX_DEPTH: + raise FilesError(400, "File reference structure exceeds configured limits") + if isinstance(item, list): + if len(item) + len(pending) > _MAX_NODES: + raise FilesError(400, "File reference structure exceeds configured limits") + pending.extend((child, depth + 1) for child in item) + elif isinstance(item, dict): + kind = item.get("type") + if ( + any(item.get(key) for key in ("file_id", "file_ids", "vector_store_ids", "container_id")) + or ( + isinstance(kind, str) + and kind + in { + "code_interpreter", + "file_search", + "shell", + "item_reference", + "container_reference", + "compaction", + } + ) + or isinstance(item.get("container"), str) + ): + raise FilesError(400, "OpenAI provider file state is not supported in hybrid inference") + pending.extend( + (item[key], depth + 1) + for key in ( + "input", + "messages", + "content", + "file", + "tools", + "container", + "attachments", + "environment", + "input_image_mask", + "output", + "results", + "annotations", + ) + if key in item + ) diff --git a/src/gateway/services/provider_files/transfers.py b/src/gateway/services/provider_files/transfers.py index c41a661878..639601827a 100644 --- a/src/gateway/services/provider_files/transfers.py +++ b/src/gateway/services/provider_files/transfers.py @@ -101,7 +101,8 @@ async def receive_upload( *, max_bytes: int, idle_seconds: float, -) -> AsyncIterator[tuple[UploadFile, int | None]]: + allowed_fields: frozenset[str] = frozenset({"expires_in_seconds"}), +) -> AsyncIterator[tuple[UploadFile, dict[str, str]]]: async def bounded() -> AsyncGenerator[bytes, None]: total = 0 iterator = aiter(stream) @@ -116,28 +117,29 @@ async def bounded() -> AsyncGenerator[bytes, None]: raise FilesError(413, "File size limit exceeded") yield chunk - parser = MultiPartParser(headers, bounded(), max_files=1, max_fields=1, max_part_size=_ENVELOPE_BYTES) + parser = MultiPartParser( + headers, bounded(), max_files=1, max_fields=len(allowed_fields), max_part_size=_ENVELOPE_BYTES + ) form: FormData | None = None try: form = await parser.parse() - if set(form.keys()) - {"file", "expires_in_seconds"}: + if set(form.keys()) - {"file", *allowed_fields}: raise FilesError(400, "Unsupported upload field") - if len(form.getlist("file")) != 1 or len(form.getlist("expires_in_seconds")) > 1: - raise FilesError(400, "Expected one file") + if len(form.getlist("file")) != 1 or any(len(form.getlist(name)) > 1 for name in allowed_fields): + raise FilesError(400, "Expected one file and unique upload fields") upload = form.get("file") if not isinstance(upload, UploadFile): raise FilesError(400, "Expected one file") if upload.size is None or upload.size > max_bytes: raise FilesError(413, "File size limit exceeded") - raw = form.get("expires_in_seconds") - duration = None - if raw is not None: - if not isinstance(raw, str) or not raw.isascii() or not raw.isdigit() or len(raw) > 8: - raise FilesError(400, "Invalid file retention") - duration = int(raw) - if not 3600 <= duration <= 7776000: - raise FilesError(400, "File retention must be between one hour and 90 days") - yield upload, duration + fields: dict[str, str] = {} + for name in allowed_fields: + value = form.get(name) + if value is not None: + if not isinstance(value, str): + raise FilesError(400, "Invalid upload field") + fields[name] = value + yield upload, fields except MultiPartException: raise FilesError(400, "Invalid multipart upload") from None finally: diff --git a/src/gateway/services/provider_files/transport.py b/src/gateway/services/provider_files/transport.py index 6ecb74eb8f..fa79e754b8 100644 --- a/src/gateway/services/provider_files/transport.py +++ b/src/gateway/services/provider_files/transport.py @@ -14,7 +14,15 @@ @asynccontextmanager async def provider_client(account: FileAccount, *, idle_timeout: float = 30) -> AsyncIterator[Any]: - base = account.api_base or "https://api.anthropic.com" + provider_class = AnyLLM.get_provider_class(account.provider) + # any-llm exposes no API_BASE for Anthropic; preserve its explicit endpoint. + base = account.api_base or provider_class.API_BASE + if base is None and account.provider == "anthropic": + base = "https://api.anthropic.com" + if not base: + raise FilesError(502, "Provider file account requires an explicit endpoint") + if account.workspace and account.provider != "anthropic": + raise FilesError(502, "Invalid provider file account options") parsed = urlsplit(base) if ( parsed.scheme != "https" @@ -31,7 +39,7 @@ async def provider_client(account: FileAccount, *, idle_timeout: float = 30) -> raise FilesError(502, "Invalid provider file endpoint") from None async with AsyncClient(timeout=idle_timeout, follow_redirects=False) as http_client: client = AnyLLM.create( - "anthropic", + account.provider, api_key=account.api_key.get_secret_value(), api_base=base, http_client=http_client, diff --git a/src/gateway/services/tenancy/org_provider_key_service.py b/src/gateway/services/tenancy/org_provider_key_service.py index 44da81881a..f3a12cc8b7 100644 --- a/src/gateway/services/tenancy/org_provider_key_service.py +++ b/src/gateway/services/tenancy/org_provider_key_service.py @@ -53,6 +53,7 @@ from gateway.core.config import PROVIDER_TYPE_ALIASES from gateway.core.database import create_session +from gateway.core.unit_of_work import UnitOfWork from gateway.log_config import logger from gateway.models.provider_keys import ( OrgProviderKey, @@ -97,6 +98,7 @@ OrgProviderKeyUnknownProviderError, OrgProviderKeyUnsafeApiBaseError, SecretBoxUnavailableTenancyError, + TenancyConflictError, WorkspaceProviderKeyOverrideConflictError, ) from gateway.services.tenancy.organization_service import OrganizationService @@ -381,6 +383,7 @@ class OrgProviderKeyService: def __init__(self, db: AsyncSession): self.db = db + self.uow = UnitOfWork(db) self.keys = OrgProviderKeyRepository(db) self.overrides = WorkspaceProviderKeyOverrideRepository(db) self.restrictions = WorkspaceProviderModelRestrictionRepository(db) @@ -498,18 +501,20 @@ async def update_key_for_user( update_data["encrypted_api_key"] = encrypted_api_key update_data["last4"] = last4 - if {"encrypted_api_key", "api_base", "client_args"} & update_data.keys(): - await retire_byo_account(self.db, key, release_secret=True) - + blocked = False + provider, name = key.provider, str(update_data.get("name", key.name)) try: - updated = await self.keys.update_key(key, update_data) - await self.db.commit() + async with self.uow: + if {"encrypted_api_key", "api_base", "client_args"} & update_data.keys(): + blocked = await retire_byo_account(self.uow, key, release_secret=True) + if not blocked: + await self.keys.update_key(key, update_data) except IntegrityError: - await self.db.rollback() - raise OrgProviderKeyAlreadyExistsError(key.provider, str(update_data.get("name", key.name))) from None + raise OrgProviderKeyAlreadyExistsError(provider, name) from None + self._raise_if_file_cleanup_pending(blocked) await refresh_org_provider_cache(self.db) - return updated.to_public(usable=key_is_usable(updated)) + return key.to_public(usable=key_is_usable(key)) async def archive_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgProviderKeyPublic: """Archive a key. Organization owners and admins only. @@ -525,9 +530,9 @@ async def archive_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro if key is None: raise OrgProviderKeyNotFoundError(key_id) - await retire_byo_account(self.db, key, release_secret=False) - updated = await self.keys.update_key(key, {"archived_at": datetime.now(UTC), "is_org_default": False}) - await self.db.commit() + async with self.uow: + await retire_byo_account(self.uow, key, release_secret=False) + updated = await self.keys.update_key(key, {"archived_at": datetime.now(UTC), "is_org_default": False}) await refresh_org_provider_cache(self.db) return updated.to_public(usable=key_is_usable(updated)) @@ -540,11 +545,13 @@ async def restore_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro if key is None: raise OrgProviderKeyNotFoundError(key_id) - await retire_byo_account(self.db, key, release_secret=True) - updated = await self.keys.update_key(key, {"archived_at": None}) - await self.db.commit() + async with self.uow: + blocked = await retire_byo_account(self.uow, key, release_secret=True) + if not blocked: + await self.keys.update_key(key, {"archived_at": None}) + self._raise_if_file_cleanup_pending(blocked) await refresh_org_provider_cache(self.db) - return updated.to_public(usable=key_is_usable(updated)) + return key.to_public(usable=key_is_usable(key)) async def delete_key_for_user(self, *, user: User, key_id: uuid.UUID) -> None: """Permanently delete an archived key. Organization owners and admins only. @@ -560,11 +567,18 @@ async def delete_key_for_user(self, *, user: User, key_id: uuid.UUID) -> None: if key.archived_at is None: raise OrgProviderKeyNotArchivedError(key_id) - await retire_byo_account(self.db, key, release_secret=True) - await self.keys.delete_key(key) - await self.db.commit() + async with self.uow: + blocked = await retire_byo_account(self.uow, key, release_secret=True) + if not blocked: + await self.keys.delete_key(key) + self._raise_if_file_cleanup_pending(blocked) await refresh_org_provider_cache(self.db) + @staticmethod + def _raise_if_file_cleanup_pending(blocked: bool) -> None: + if blocked: + raise TenancyConflictError("Provider file cleanup must finish before replacing or deleting this credential") + async def set_org_default_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgProviderKeyPublic: """Make a key the organization's default for its provider. Organization owners and admins only.""" organization = await self.organizations.get_active_organization_for_user(user) diff --git a/tests/integration/test_hybrid_files_inference_guards.py b/tests/integration/test_hybrid_files_inference_guards.py new file mode 100644 index 0000000000..74b400e713 --- /dev/null +++ b/tests/integration/test_hybrid_files_inference_guards.py @@ -0,0 +1,60 @@ +"""OpenAI file state cannot bypass ownership through unsupported inference envelopes.""" + +from typing import Any + +import httpx +import pytest +from fastapi.testclient import TestClient + +from gateway.core.config import API_ROOT, GatewayConfig + +from .conftest import app_for + + +@pytest.mark.parametrize( + "fields", + [ + {"tools": [{"type": "code_interpreter", "container": {"type": "auto", "file_ids": ["file_foreign"]}}]}, + {"tools": [{"type": "code_interpreter", "container": "cntr_foreign"}]}, + {"tools": [{"type": "code_interpreter", "container": {"type": "auto"}}]}, + {"tools": [{"type": "file_search", "vector_store_ids": ["vs_foreign"]}]}, + {"previous_response_id": "resp_foreign"}, + {"conversation": "conv_foreign"}, + {"tools": [{"type": "shell", "environment": {"type": "container_reference", "container_id": "cntr_foreign"}}]}, + {"tools": [{"type": "image_generation", "input_image_mask": {"file_id": "file_foreign"}}]}, + {"input": [{"type": "compaction", "encrypted_content": "opaque-state"}]}, + { + "input": [ + { + "type": "computer_call_output", + "call_id": "call_1", + "output": {"type": "computer_screenshot", "file_id": "file_foreign"}, + } + ] + }, + {"input": [{"type": "item_reference", "id": "item_foreign"}]}, + {"input": [{"role": "user", "content": [{"type": "input_file", "file_id": "file_foreign"}]}]}, + ], +) +def test_openai_state_rejected_before_resolution(monkeypatch: pytest.MonkeyPatch, fields: dict[str, Any]) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + calls: list[str] = [] + + async def platform(url: str, **kwargs: Any) -> httpx.Response: + calls.append(url) + return httpx.Response(502) + + monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) + app = app_for( + GatewayConfig( + mode="hybrid", platform={"base_url": "https://authority.test"}, files_provider_native_enabled=True + ) + ) + with TestClient(app) as client: + response = client.post( + f"{API_ROOT}/responses", + headers={"Authorization": "Bearer user-token"}, + json={"model": "openai:gpt-4o", "input": "Read it", **fields}, + ) + assert response.status_code == 400, response.text + assert calls == [] diff --git a/tests/integration/test_hybrid_files_messages.py b/tests/integration/test_hybrid_files_messages.py index aa415cc3bb..993654323e 100644 --- a/tests/integration/test_hybrid_files_messages.py +++ b/tests/integration/test_hybrid_files_messages.py @@ -10,19 +10,25 @@ from pydantic import SecretStr from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.provider_files.anthropic_inference import AnthropicFileOutputBinder from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import FileAccount, FilesError, Operation -from gateway.services.provider_files.inference import FileOutputBinder from .conftest import app_for from .test_hybrid_mode_messages import _attempt, _message_response, _resolve_payload -@pytest.mark.parametrize("outcome", ["success", "foreign", "wrong_generation", "registration_failure"]) +@pytest.mark.parametrize( + "outcome", ["success", "foreign", "wrong_generation", "wrong_provider", "registration_failure"] +) def test_file_reference_dispatch_and_accounting(monkeypatch: pytest.MonkeyPatch, outcome: str) -> None: monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") generation = uuid.uuid4() - account = FileAccount(generation_id=generation, api_key=SecretStr("owned-key")) + account = FileAccount( + generation_id=generation, + api_key=SecretStr("owned-key"), + provider="openai" if outcome == "wrong_provider" else "anthropic", + ) attempts = [ _attempt(0, str(uuid.uuid4()), "other-model", "other-key"), _attempt(1, str(uuid.uuid4()), "owned-model", "owned-key"), @@ -52,7 +58,7 @@ async def platform(url: str, **kwargs: Any) -> httpx.Response: async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> Any: events.append(path) if path == "references/resolve": - assert body == {"ids": ["file_history"]} + assert body == {"ids": ["file_history"], "provider": "anthropic"} if outcome == "foreign": raise FilesError(404, "File not found") return account @@ -83,7 +89,7 @@ async def register(self: Any, value: Any) -> None: monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) monkeypatch.setattr("gateway.api.routes.messages.amessages", provider) monkeypatch.setattr(PlatformFilesClient, "post", files) - monkeypatch.setattr(FileOutputBinder, "register", register) + monkeypatch.setattr(AnthropicFileOutputBinder, "register", register) app = app_for( GatewayConfig( mode="hybrid", @@ -110,9 +116,15 @@ async def register(self: Any, value: Any) -> None: ) assert ( response.status_code - == {"success": 200, "foreign": 404, "wrong_generation": 403, "registration_failure": 502}[outcome] + == { + "success": 200, + "foreign": 404, + "wrong_generation": 403, + "wrong_provider": 502, + "registration_failure": 502, + }[outcome] ), response.text - if outcome in {"foreign", "wrong_generation"}: + if outcome in {"foreign", "wrong_generation", "wrong_provider"}: assert "provider" not in events else: assert events.count("provider") == 1 diff --git a/tests/integration/test_hybrid_files_openai_sdk.py b/tests/integration/test_hybrid_files_openai_sdk.py new file mode 100644 index 0000000000..f546275d3a --- /dev/null +++ b/tests/integration/test_hybrid_files_openai_sdk.py @@ -0,0 +1,132 @@ +"""The official OpenAI SDK traverses Otari and any-llm, with only network peers mocked.""" + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from openai import AsyncOpenAI +from pydantic import SecretStr + +from gateway.api.deps import get_config +from gateway.api.routes import hybrid_files +from gateway.core.config import API_ROOT, GatewayConfig +from gateway.services.provider_files import transport +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import ( + FileAccount, + FileMetadata, + FilePage, + Operation, + ResolvedFile, + WireModel, +) + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("retention", [604800, 7776000]) +async def test_openai_sdk_files_round_trip(monkeypatch: pytest.MonkeyPatch, retention: int) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + config = GatewayConfig( + mode="hybrid", + files_provider_native_enabled=True, + platform={"base_url": "https://authority.test"}, + files_max_bytes=1024, + ) + app = FastAPI() + app.dependency_overrides[get_config] = lambda: config + app.include_router(hybrid_files.router, prefix=API_ROOT) + account = FileAccount(generation_id=uuid.uuid4(), provider="openai", api_key=SecretStr("provider-secret")) + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=5), + account=account, + max_bytes=1024, + expires_in_seconds=retention, + ) + data = FileMetadata( + id="file_test", + filename="input.csv", + size_bytes=4, + created_at=datetime.now(UTC), + purpose="user_data", + status="processed", + ).model_copy(update={"object": "file"}) + assert data.created_at is not None + created_at = int(data.created_at.timestamp()) + calls: list[str] = [] + + async def authority(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + calls.append(path) + if path in {"uploads/prepare", "list"} or path.endswith("/resolve"): + assert body["provider"] == "openai" + if path == "uploads/prepare": + return operation + if path.endswith("/finalize"): + assert body["metadata"]["purpose"] == "user_data" + assert body["metadata"]["status"] == "processed" + return data + if path == "list": + assert body["purpose"] == "user_data" + return FilePage(data=[data]) + if path.endswith("/resolve"): + return ResolvedFile( + metadata=data, account=account, operation_id=operation.id, cleanup_token=SecretStr("cleanup") + ) + return WireModel() + + async def upstream(request: httpx.Request) -> httpx.Response: + calls.append(f"provider:{request.method}:{request.url.path}") + assert request.headers["authorization"] == "Bearer provider-secret" + assert "anthropic-version" not in request.headers + assert "x-otari-files-provider" not in request.headers + if request.method == "POST": + body = await request.aread() + assert b"user_data" in body and str(min(retention, 2592000)).encode() in body + return httpx.Response( + 200, + json={ + "id": data.id, + "filename": "input.csv", + "bytes": 4, + "created_at": created_at, + "purpose": "user_data", + "status": "processed", + "object": "file", + }, + ) + if request.method == "DELETE": + return httpx.Response(200, json={"id": data.id, "object": "file", "deleted": True}) + return httpx.Response(200, content=b"data", headers={"content-type": "text/csv"}) + + monkeypatch.setattr(PlatformFilesClient, "post", authority) + original = httpx.AsyncClient + gateway_http = original(transport=httpx.ASGITransport(app=app)) + monkeypatch.setattr(transport, "AsyncClient", lambda **kw: original(transport=httpx.MockTransport(upstream), **kw)) + async with AsyncOpenAI( + api_key="user-token", + base_url=f"http://gateway.test{API_ROOT}/", + http_client=gateway_http, + max_retries=0, + default_headers={"X-Otari-Files-Provider": "openai"}, + ) as sdk: + uploaded = await sdk.files.create(file=("input.csv", b"data", "text/csv"), purpose="user_data") + assert uploaded.id == data.id and uploaded.bytes == 4 + assert uploaded.purpose == "user_data" + retrieved = await sdk.files.retrieve(data.id) + assert retrieved.created_at == created_at + page = await sdk.files.list(purpose="user_data", limit=1) + assert [item.id for item in page.data] == [data.id] + assert page.has_more is False + assert page.model_dump(exclude_unset=True)["object"] == "list" + downloaded = await sdk.files.content(data.id) + assert downloaded.read() == b"data" + deleted = await sdk.files.delete(data.id) + assert deleted.deleted is True + assert calls.index("provider:POST:/v1/files") < next( + i for i, path in enumerate(calls) if path.endswith("/finalize") + ) diff --git a/tests/integration/test_hybrid_files_sdk_contract.py b/tests/integration/test_hybrid_files_sdk_contract.py index 4ef56640af..c413f70cf8 100644 --- a/tests/integration/test_hybrid_files_sdk_contract.py +++ b/tests/integration/test_hybrid_files_sdk_contract.py @@ -49,13 +49,12 @@ async def test_official_sdk_upload_list_retrieve_download_delete(monkeypatch: py ) metadata = FileMetadata( id="file_contract", - type="file", filename="input.csv", mime_type="text/csv", size_bytes=4, created_at=datetime.now(UTC), downloadable=True, - ) + ).model_copy(update={"type": "file"}) calls: list[str] = [] async def authority(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: diff --git a/tests/integration/test_provider_file_lifecycle.py b/tests/integration/test_provider_file_lifecycle.py index f00d3ccb5b..05d306e496 100644 --- a/tests/integration/test_provider_file_lifecycle.py +++ b/tests/integration/test_provider_file_lifecycle.py @@ -8,9 +8,11 @@ from pydantic import SecretStr from sqlalchemy.ext.asyncio import AsyncSession +from gateway.core.unit_of_work import OutsideUnitOfWorkError, UnitOfWork from gateway.models.provider_files import ProviderAccountGeneration from gateway.models.users import User from gateway.repositories.tenancy.organization_repository import OrganizationRepository +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.repositories.tenancy.workspace_repository import WorkspaceRepository from gateway.services.provider_files.contracts import ( FileAccount, @@ -46,7 +48,7 @@ async def files_setup( scope = FileScope( organization_id=organization.id, workspace_id=workspace.id, user_id="uploader", gateway_id="gateway" ) - service = ProviderFileService(async_db, max_bytes=1024, max_files=10, max_outstanding_bytes=10240) + service = ProviderFileService(UnitOfWork(async_db), max_bytes=1024, max_files=10, max_outstanding_bytes=10240) return service, scope, FileAccount(generation_id=account.id, api_key=SecretStr("test-key")) @@ -62,6 +64,7 @@ def metadata(file_id: str = "file_uploaded") -> FileMetadata: async def test_finalize_is_idempotent_and_metadata_encrypted( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], ) -> None: service, scope, account = files_setup @@ -69,7 +72,7 @@ async def test_finalize_is_idempotent_and_metadata_encrypted( data = metadata() assert await service.finalize(scope, operation.id, data) == data assert await service.finalize(scope, operation.id, data) == data - row = await service.repo.get(operation.id) + row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None and row.encrypted_metadata is not None assert "private.csv" not in row.encrypted_metadata with pytest.raises(FilesError, match="conflict"): @@ -96,21 +99,23 @@ async def test_foreign_owner_and_workspace_hidden( async def test_retired_upload_cannot_reactivate( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], ) -> None: service, scope, account = files_setup operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) - generation = await service.repo.account(account.generation_id) + generation = await ProviderFileRepository(async_db).account(account.generation_id) assert generation is not None generation.status = "retiring" - await service.db.commit() + await async_db.commit() with pytest.raises(FilesError, match="revoked"): await service.finalize(scope, operation.id, metadata()) - row = await service.repo.get(operation.id) + row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None and row.state == "pending_cleanup" and row.provider_file_id == "file_uploaded" async def test_delete_revokes_before_provider_and_retries_survive( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], ) -> None: service, scope, account = files_setup @@ -121,20 +126,22 @@ async def test_delete_revokes_before_provider_and_retries_survive( with pytest.raises(FilesError): await service.resolve(scope, "file_uploaded", "metadata") await service.cleanup_result(operation.id, scope.gateway_id, resolved.cleanup_token.get_secret_value(), False) - row = await service.repo.get(operation.id) + row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None and row.state == "pending_cleanup" and row.cleanup_attempts == 1 await service.cleanup_result(operation.id, scope.gateway_id, resolved.cleanup_token.get_secret_value(), True) assert row.state == "deleted" -async def test_expired_files_are_hidden(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: +async def test_expired_files_are_hidden( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: service, scope, account = files_setup operation = await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) await service.finalize(scope, operation.id, metadata()) - row = await service.repo.get(operation.id) + row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None row.expires_at = datetime.now(UTC) - timedelta(seconds=1) - await service.db.commit() + await async_db.commit() with pytest.raises(FilesError): await service.references(scope, ["file_uploaded"]) @@ -177,6 +184,7 @@ async def test_output_only_registration_and_collision( async def test_output_cleanup_survives_user_revocation( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], ) -> None: from gateway.services.provider_files.contracts import OutputPrepare @@ -191,15 +199,15 @@ async def test_output_cleanup_survives_user_revocation( operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id ), ) - await service.repo.revoke_user(scope.user_id, datetime.now(UTC)) - user = await service.db.get(User, scope.user_id) + await ProviderFileRepository(async_db).revoke_user(scope.user_id, datetime.now(UTC)) + user = await async_db.get(User, scope.user_id) assert user is not None user.deleted_at = datetime.now(UTC) - await service.db.commit() + await async_db.commit() cleanup = await outputs.abandon( operation.id, scope.gateway_id, operation.cleanup_token.get_secret_value(), None, "file_late" ) - row = await service.repo.get(cleanup.operation_id) + row = await ProviderFileRepository(async_db).get(cleanup.operation_id) assert row is not None and row.state == "pending_cleanup" and row.provider_file_id == "file_late" await service.cleanup_result(row.id, scope.gateway_id, cleanup.cleanup_token.get_secret_value(), True) assert row.state == "deleted" @@ -220,7 +228,9 @@ async def test_cursor_scope_and_snapshot(files_setup: tuple[ProviderFileService, assert first.data[0].id != second.data[0].id -async def test_cleanup_lease_fencing(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: +async def test_cleanup_lease_fencing( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: from gateway.services.provider_files.cleanup import ProviderFileCleanup from gateway.services.provider_files.contracts import LeaseResult @@ -247,9 +257,25 @@ async def resolve(generation: uuid.UUID) -> FileAccount: await cleanup.complete( scope.organization_id, scope.gateway_id, lease.id, LeaseResult(token=lease.token, results={operation.id: True}) ) - row = await service.repo.get(operation.id) + row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None and row.state == "deleted" - assert not await service.repo.account_busy(account.generation_id, datetime.now(UTC)) + assert not await ProviderFileRepository(async_db).account_busy(account.generation_id, datetime.now(UTC)) + + +async def test_capacity_refusal_rolls_back_rate_limit( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], async_db: AsyncSession +) -> None: + from gateway.models.provider_files import ProviderFileRateWindow + + service, scope, account = files_setup + service.max_files = 1 + await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + with pytest.raises(FilesError, match="capacity"): + await service.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + row = await async_db.get(ProviderFileRateWindow, (scope.workspace_id, scope.user_id)) + assert row is not None and row.count == 1 + with pytest.raises(OutsideUnitOfWorkError): + await service.repo.account(account.generation_id) async def test_postgres_file_migration_round_trip(postgres_url: str) -> None: @@ -265,4 +291,4 @@ async def test_postgres_file_migration_round_trip(postgres_url: str) -> None: try: command.downgrade(config, "d5f8b2a4c6e9") finally: - command.upgrade(config, "c3e5a7b9d1f4") + command.upgrade(config, "head") diff --git a/tests/integration/test_provider_file_multi_provider.py b/tests/integration/test_provider_file_multi_provider.py new file mode 100644 index 0000000000..25acef97f1 --- /dev/null +++ b/tests/integration/test_provider_file_multi_provider.py @@ -0,0 +1,203 @@ +"""Provider identity scopes lifecycle operations independently of native file IDs.""" + +import uuid +from typing import Literal + +import pytest +from pydantic import SecretStr + +from gateway.models.provider_files import ProviderAccountGeneration +from gateway.models.provider_keys import OrgProviderKey +from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.contracts import ( + FileAccount, + FileListRequest, + FileMetadata, + FileScope, + FilesError, + PrepareUpload, +) +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.secret_box import encrypt_secret + +from .test_provider_file_lifecycle import files_setup as files_setup +from .test_provider_file_lifecycle import metadata + +pytestmark = pytest.mark.asyncio + + +async def second_account(service: ProviderFileService, scope: FileScope) -> FileAccount: + row = ProviderAccountGeneration( + organization_id=scope.organization_id, + provider="openai", + credential_source="organization_key", + credential_ref=str(uuid.uuid4()), + ) + async with service.uow: + await service.repo.save(row) + return FileAccount(generation_id=row.id, provider="openai", api_key=SecretStr("test-only")) + + +async def put(service: ProviderFileService, scope: FileScope, account: FileAccount, data: FileMetadata) -> uuid.UUID: + operation = await service.prepare( + scope, + account, + PrepareUpload( + operation_id=uuid.uuid4(), + provider=account.provider, + size_bytes=20, + ), + ) + await service.finalize(scope, operation.id, data) + return operation.id + + +async def test_same_file_id_isolated_by_provider( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, anthropic = files_setup + openai = await second_account(service, scope) + await put(service, scope, anthropic, metadata("file_same")) + await put(service, scope, openai, metadata("file_same").model_copy(update={"purpose": "user_data"})) + page = await service.list_files(scope, FileListRequest(provider="openai")) + assert len(page.data) == 1 + assert page.data[0].purpose == "user_data" + assert await service.references(scope, ["file_same"], provider="openai") == openai.generation_id + resolved = await service.resolve(scope, "file_same", "delete", openai, provider="openai") + assert resolved.account == openai + assert (await service.resolve(scope, "file_same", "metadata")).metadata.id == "file_same" + + +async def test_provider_is_bound_into_page_cursor( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + for index in range(2): + await put(service, scope, account, metadata(f"file_{index}")) + first = await service.list_files(scope, FileListRequest(limit=1)) + assert first.next_page is not None + with pytest.raises(FilesError, match="Invalid file page"): + await service.list_files(scope, FileListRequest(provider="openai", limit=1, page=first.next_page)) + + +async def test_unknown_metadata_keeps_reserved_capacity( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, _ = files_setup + account = await second_account(service, scope) + data = FileMetadata(id="file_unknown", purpose="user_data") + binding = await put(service, scope, account, data) + async with service.uow: + row = await service.repo.get(binding) + assert row is not None and row.size_bytes == 20 + fetched = await service.resolve(scope, data.id, "download", account, provider="openai") + assert fetched.metadata.downloadable is None + assert fetched.metadata.size_bytes is None + + +async def test_prepare_rejects_provider_account_mismatch( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + with pytest.raises(FilesError): + await service.prepare( + scope, account, PrepareUpload(operation_id=uuid.uuid4(), provider="openai", size_bytes=20) + ) + forged = account.model_copy(update={"provider": "openai"}) + with pytest.raises(FilesError): + await service.prepare(scope, forged, PrepareUpload(operation_id=uuid.uuid4(), provider="openai", size_bytes=20)) + + +async def test_byo_resolver_selects_requested_provider( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, _ = files_setup + async with service.uow: + for provider in ("anthropic", "openai"): + await service.repo.save( + OrgProviderKey( + organization_id=scope.organization_id, + provider=provider, + name=provider, + encrypted_api_key=encrypt_secret(f"{provider}-test-key"), + ) + ) + selected = await FileAccountResolver(service.uow).select_byo(scope, provider="openai") + assert selected is not None and selected.provider == "openai" + assert selected.api_key.get_secret_value() == "openai-test-key" + async with service.uow: + generation = await service.repo.account(selected.generation_id) + assert generation is not None and generation.provider == "openai" + + +async def test_native_list_cursor_and_purpose_filter( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, _ = files_setup + account = await second_account(service, scope) + for index in range(3): + await put( + service, + scope, + account, + metadata(f"file_{index}").model_copy(update={"purpose": "user_data" if index != 1 else "batch"}), + ) + first = await service.list_files( + scope, FileListRequest(provider="openai", order="asc", limit=1, purpose="user_data") + ) + assert [item.id for item in first.data] == ["file_0"] + second = await service.list_files( + scope, FileListRequest(provider="openai", order="asc", limit=1, purpose="user_data", after_id="file_0") + ) + assert [item.id for item in second.data] == ["file_2"] + + +async def test_unknown_generated_size_reserves_maximum( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + from gateway.services.provider_files.contracts import OutputPrepare + from gateway.services.provider_files.outputs import ProviderFileOutputs + + service, scope, _ = files_setup + account = await second_account(service, scope) + outputs = ProviderFileOutputs(service) + operation = await outputs.prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), + request_id="request", + attempt_id="attempt", + generation_id=account.generation_id, + ), + ) + data = FileMetadata(id="file_generated", purpose="user_data") + assert await outputs.register(scope, operation.id, data) == data + async with service.uow: + row = await service.repo.by_provider_id(account.generation_id, data.id) + assert row is not None and row.size_bytes == service.max_bytes + + +async def test_native_order_uses_provider_timestamp( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + from datetime import UTC, datetime, timedelta + + service, scope, _ = files_setup + account = await second_account(service, scope) + now = datetime.now(UTC) + await put(service, scope, account, metadata("file_a").model_copy(update={"created_at": now})) + await put(service, scope, account, metadata("file_b").model_copy(update={"created_at": now - timedelta(seconds=5)})) + cases: list[tuple[Literal["asc", "desc"], list[str]]] = [ + ("asc", ["file_b", "file_a"]), + ("desc", ["file_a", "file_b"]), + ] + for order, expected in cases: + result = await service.list_files( + scope, FileListRequest(provider="openai", sort_by="provider_created_at", order=order) + ) + assert [item.id for item in result.data] == expected + after = await service.list_files( + scope, FileListRequest(provider="openai", sort_by="provider_created_at", order=order, after_id=expected[0]) + ) + assert [item.id for item in after.data] == expected[1:] diff --git a/tests/integration/test_provider_file_transactions.py b/tests/integration/test_provider_file_transactions.py new file mode 100644 index 0000000000..332002d98a --- /dev/null +++ b/tests/integration/test_provider_file_transactions.py @@ -0,0 +1,162 @@ +"""Files transaction boundaries preserve revocation without partially replacing secrets.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.models.provider_keys import OrgProviderKey, OrgProviderKeyUpdateRequest +from gateway.models.tenancy import Organization +from gateway.models.users import User +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare, PrepareUpload +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.provider_files.outputs import ProviderFileOutputs +from gateway.services.secret_box import encrypt_secret +from gateway.services.tenancy.errors import OrgProviderKeyAlreadyExistsError, TenancyConflictError +from gateway.services.tenancy.org_provider_key_service import OrgProviderKeyService + +from .test_org_provider_keys import _member +from .test_provider_file_lifecycle import files_setup as files_setup +from .test_provider_file_lifecycle import metadata + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("action", ["replace", "restore", "delete"]) +async def test_refused_secret_release_commits_revocation( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], action: str +) -> None: + files, scope, account = files_setup + repo = ProviderFileRepository(async_db) + organization = await async_db.get(Organization, scope.organization_id) + assert organization is not None + owner = await _member(async_db, organization, role="owner", full_name="Owner") + generation = await repo.account(account.generation_id) + assert generation is not None + key_id = uuid.UUID(generation.credential_ref) + secret = encrypt_secret("original-credential") + await repo.save( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=secret, + archived_at=datetime.now(UTC) if action != "replace" else None, + ) + ) + await async_db.commit() + operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await files.finalize(scope, operation.id, metadata()) + keys = OrgProviderKeyService(async_db) + with pytest.raises(TenancyConflictError, match="cleanup must finish"): + if action == "replace": + await keys.update_key_for_user( + user=owner, key_id=key_id, request=OrgProviderKeyUpdateRequest(api_key="replacement") + ) + elif action == "restore": + await keys.restore_key_for_user(user=owner, key_id=key_id) + else: + await keys.delete_key_for_user(user=owner, key_id=key_id) + await async_db.rollback() + stored_key = await repo.provider_key(key_id) + stored_generation = await repo.account(account.generation_id) + binding = await repo.get(operation.id) + assert stored_key is not None and stored_key.encrypted_api_key == secret + assert (stored_key.archived_at is not None) == (action != "replace") + assert stored_generation is not None and stored_generation.status == "retiring" + assert binding is not None and binding.state == "pending_cleanup" + + +async def test_secret_update_failure_rolls_back_retirement( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, scope, account = files_setup + repo = ProviderFileRepository(async_db) + organization = await async_db.get(Organization, scope.organization_id) + assert organization is not None + owner = await _member(async_db, organization, role="owner", full_name="Owner") + generation = await repo.account(account.generation_id) + assert generation is not None + key_id = uuid.UUID(generation.credential_ref) + secret = encrypt_secret("original-credential") + await repo.save( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=secret, + ) + ) + await async_db.commit() + keys = OrgProviderKeyService(async_db) + monkeypatch.setattr( + keys.keys, "update_key", AsyncMock(side_effect=IntegrityError("injected", {}, ValueError("injected conflict"))) + ) + with pytest.raises(OrgProviderKeyAlreadyExistsError): + await keys.update_key_for_user( + user=owner, key_id=key_id, request=OrgProviderKeyUpdateRequest(api_key="replacement") + ) + stored_generation = await repo.account(account.generation_id) + stored_key = await repo.provider_key(key_id) + assert stored_generation is not None and stored_generation.status == "active" + assert stored_key is not None and stored_key.encrypted_api_key == secret + + +async def test_account_resolution_rechecks_revoked_scope( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + files, scope, account = files_setup + repo = ProviderFileRepository(async_db) + generation = await repo.account(account.generation_id) + assert generation is not None + await repo.save( + OrgProviderKey( + id=uuid.UUID(generation.credential_ref), + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("original-credential"), + ) + ) + user = await async_db.get(User, scope.user_id) + assert user is not None + user.deleted_at = datetime.now(UTC) + await async_db.commit() + resolver = FileAccountResolver(files.uow) + with pytest.raises(FilesError, match="unavailable"): + await resolver.resolve(scope, account.generation_id) + cleanup = await resolver.resolve(scope, account.generation_id, cleanup=True) + assert cleanup.generation_id == account.generation_id + + +async def test_rejected_output_registration_commits_cleanup( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + service, scope, account = files_setup + outputs = ProviderFileOutputs(service) + operation = await outputs.prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + repo = ProviderFileRepository(async_db) + generation = await repo.account(account.generation_id) + assert generation is not None + generation.status = "retiring" + await async_db.commit() + with pytest.raises(FilesError, match="revoked"): + await outputs.register(scope, operation.id, metadata("file_late")) + await async_db.rollback() + binding = await repo.by_provider_id(account.generation_id, "file_late") + assert binding is not None and binding.state == "pending_cleanup" diff --git a/tests/integration/test_provider_files_protocol.py b/tests/integration/test_provider_files_protocol.py index 7f3c145fd3..0f13c6a76b 100644 --- a/tests/integration/test_provider_files_protocol.py +++ b/tests/integration/test_provider_files_protocol.py @@ -6,11 +6,11 @@ import httpx import pytest from fastapi import FastAPI, Request -from sqlalchemy.ext.asyncio import AsyncSession -from gateway.api.deps import get_config, get_db +from gateway.api.deps import get_config, get_unit_of_work from gateway.api.routes.provider_files import create_provider_files_router from gateway.core.config import API_ROOT, GatewayConfig +from gateway.core.unit_of_work import UnitOfWork from gateway.models.provider_keys import OrgProviderKey from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare from gateway.services.provider_files.lifecycle import ProviderFileService @@ -22,35 +22,35 @@ pytestmark = pytest.mark.asyncio +@pytest.mark.parametrize("provider", ["anthropic", "openai"]) async def test_authenticated_protocol_prepares_and_finalizes( files_setup: tuple[ProviderFileService, FileScope, FileAccount], + provider: str, ) -> None: service, scope, account = files_setup - generation = await service.repo.account(account.generation_id) - assert generation is not None - key = OrgProviderKey( - id=uuid.UUID(generation.credential_ref), - organization_id=scope.organization_id, - provider="anthropic", - name="Files", - encrypted_api_key=encrypt_secret("upstream-key"), - ) - service.db.add(key) - await service.db.commit() + async with service.uow: + generation = await service.repo.account(account.generation_id) + assert generation is not None + generation.provider = provider + key = OrgProviderKey( + id=uuid.UUID(generation.credential_ref), + organization_id=scope.organization_id, + provider=provider, + name="Files", + encrypted_api_key=encrypt_secret("upstream-key"), + ) + await service.repo.save(key) - async def authenticate(request: Request, db: AsyncSession) -> FileScope: + async def authenticate(request: Request, uow: UnitOfWork) -> FileScope: if request.headers.get("X-Gateway-Token") != "gateway" or request.headers.get("X-User-Token") != "user": raise FilesError(401, "Invalid authentication") return scope - async def authorize(scope: FileScope, body: OutputPrepare, db: AsyncSession) -> FileAccount: + async def authorize(scope: FileScope, body: OutputPrepare, uow: UnitOfWork) -> FileAccount: return account - async def session() -> AsyncSession: - return service.db - app = FastAPI() - app.dependency_overrides[get_db] = session + app.dependency_overrides[get_unit_of_work] = lambda: service.uow app.dependency_overrides[get_config] = lambda: GatewayConfig( mode="hosted", files_provider_native_enabled=True, @@ -64,14 +64,15 @@ async def session() -> AsyncSession: ), prefix=API_ROOT, ) - headers = {"X-Gateway-Token": "gateway", "X-User-Token": "user"} + headers = {"X-Gateway-Token": "gateway", "X-User-Token": "user", "X-Otari-Files-Protocol": "2"} async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://control") as client: denied = await client.post( f"{API_ROOT}/gateway/files/uploads/prepare", json={"operation_id": str(uuid.uuid4()), "size_bytes": 100} ) - assert denied.status_code == 401 and denied.headers["X-Otari-Files-Protocol"] == "1" + assert denied.status_code == 401 and denied.headers["X-Otari-Files-Protocol"] == "2" request: dict[str, Any] = { "operation_id": str(uuid.uuid4()), + "provider": provider, "size_bytes": 100, "user_id": "foreign", "workspace_id": str(uuid.uuid4()), @@ -79,6 +80,7 @@ async def session() -> AsyncSession: prepared = await client.post(f"{API_ROOT}/gateway/files/uploads/prepare", json=request, headers=headers) assert prepared.status_code == 200, prepared.text assert prepared.json()["account"]["api_key"] == "upstream-key" + assert prepared.json()["account"]["provider"] == provider assert prepared.headers["Cache-Control"] == "private, no-store" data = metadata() finalized = await client.post( @@ -87,7 +89,8 @@ async def session() -> AsyncSession: headers=headers, ) assert finalized.status_code == 200, finalized.text - listing = await client.post(f"{API_ROOT}/gateway/files/list", json={}, headers=headers) + listing = await client.post(f"{API_ROOT}/gateway/files/list", json={"provider": provider}, headers=headers) assert listing.json()["data"][0]["id"] == data.id - stored = await service.repo.get(uuid.UUID(prepared.json()["id"])) + async with service.uow: + stored = await service.repo.get(uuid.UUID(prepared.json()["id"])) assert stored is not None and stored.user_id == scope.user_id and stored.workspace_id == scope.workspace_id diff --git a/tests/unit/test_gateway_lifespan_shutdown.py b/tests/unit/test_gateway_lifespan_shutdown.py index 054c1fa0eb..31dc73a848 100644 --- a/tests/unit/test_gateway_lifespan_shutdown.py +++ b/tests/unit/test_gateway_lifespan_shutdown.py @@ -172,6 +172,29 @@ async def test_lifespan_shutdown_completes_despite_a_stuck_refresher( pass +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +async def test_hybrid_file_cleanup_shares_lifespan_shutdown(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "test-gateway") + started, stopped = asyncio.Event(), asyncio.Event() + + async def cleanup(_config: GatewayConfig) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + + monkeypatch.setattr(gateway_main, "run_provider_file_cleanup", cleanup) + app = FastAPI() + app.state.config = GatewayConfig(mode="hybrid", files_provider_native_enabled=enabled) + async with _create_lifespan()(app): + if enabled: + await asyncio.wait_for(started.wait(), timeout=1) + assert started.is_set() is enabled + assert stopped.is_set() is enabled + + def _recording_refresher(name: str, started: list[str]) -> Callable[..., Coroutine[Any, Any, None]]: """A stand-in refresher that records when it is called, not when it is awaited. diff --git a/tests/unit/test_provider_file_contracts.py b/tests/unit/test_provider_file_contracts.py new file mode 100644 index 0000000000..f7c88f2775 --- /dev/null +++ b/tests/unit/test_provider_file_contracts.py @@ -0,0 +1,74 @@ +"""Provider-neutral Files contracts preserve the SDK's unknown values.""" + +import uuid +from datetime import UTC, datetime + +import httpx +import pytest +from any_llm.types.files import FileMetadata as SDKFileMetadata +from pydantic import SecretStr, ValidationError + +from gateway.services.provider_files import transport +from gateway.services.provider_files.contracts import FileAccount, FileMetadata, FilesError + + +@pytest.mark.parametrize("provider", ["anthropic", "openai", "gemini"]) +def test_account_preserves_provider(provider: str) -> None: + account = FileAccount(generation_id=uuid.uuid4(), provider=provider, api_key=SecretStr("test-only")) + assert account.provider == provider + + +def test_normalized_metadata_preserves_unknowns_and_provider_fields() -> None: + native = SDKFileMetadata(id="file_test", size_bytes=4, purpose="user_data", status="processed") + metadata = FileMetadata.model_validate(native.model_dump(exclude_unset=True)) + assert metadata.model_dump(exclude_unset=True) == native.model_dump(exclude_unset=True) + assert metadata.downloadable is None + assert metadata.mime_type is None + + +def test_anthropic_metadata_keeps_native_extras() -> None: + metadata = FileMetadata( + id="file_test", + filename="data.csv", + mime_type="text/csv", + size_bytes=4, + created_at=datetime.now(UTC), + downloadable=False, + ).model_copy(update={"type": "file"}) + assert metadata.model_dump()["type"] == "file" + + +def test_metadata_remains_bounded() -> None: + with pytest.raises(ValidationError): + FileMetadata.model_validate({"id": "file_test", "unexpected": "x" * 16384}) + + +@pytest.mark.asyncio +async def test_transport_dispatches_openai(monkeypatch: pytest.MonkeyPatch) -> None: + async def safe(base: str) -> str: + assert base == "https://api.openai.com/v1" + return base + + async def upstream(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/files/file_test" + assert request.headers["authorization"] == "Bearer test-only" + assert "anthropic-workspace-id" not in request.headers + return httpx.Response(200, json={"id": "file_test", "object": "file", "deleted": True}) + + monkeypatch.setattr(transport, "validate_provider_api_base", safe) + original = httpx.AsyncClient + monkeypatch.setattr(transport, "AsyncClient", lambda **kw: original(transport=httpx.MockTransport(upstream), **kw)) + account = FileAccount(generation_id=uuid.uuid4(), provider="openai", api_key=SecretStr("test-only")) + async with transport.provider_client(account) as client: + result = await client.adelete_file("file_test") + assert result.deleted is True + + +@pytest.mark.asyncio +async def test_transport_rejects_private_endpoint() -> None: + account = FileAccount( + generation_id=uuid.uuid4(), provider="openai", api_key=SecretStr("test-only"), api_base="http://127.0.0.1" + ) + with pytest.raises(FilesError, match="Invalid provider file endpoint"): + async with transport.provider_client(account): + pytest.fail("Unsafe endpoint must not produce a client") diff --git a/tests/unit/test_provider_file_formats.py b/tests/unit/test_provider_file_formats.py new file mode 100644 index 0000000000..b14913c21a --- /dev/null +++ b/tests/unit/test_provider_file_formats.py @@ -0,0 +1,56 @@ +"""Native envelopes stay at the API boundary, including their discriminators.""" + +from datetime import UTC, datetime + +import pytest +from starlette.requests import Request + +from gateway.api.routes._file_formats import AnthropicFilesFormat, OpenAIFilesFormat, files_format +from gateway.services.provider_files.contracts import FileMetadata, FilePage, FilesError + + +def test_metadata_discriminators_survive_exclude_unset() -> None: + data = FileMetadata( + id="file_test", + filename="input.csv", + size_bytes=4, + mime_type="text/csv", + created_at=datetime.now(UTC), + downloadable=True, + purpose="user_data", + status="processed", + ) + native = AnthropicFilesFormat().metadata(data).model_dump(exclude_unset=True) + assert native["type"] == "file" + assert "purpose" not in native and "status" not in native + native = OpenAIFilesFormat().metadata(data).model_dump(exclude_unset=True) + assert native["object"] == "file" + assert native["purpose"] == "user_data" and native["status"] == "processed" + + +def test_empty_openai_page_is_native() -> None: + page = OpenAIFilesFormat().page(FilePage(data=[])).model_dump(exclude_unset=True) + assert page == {"object": "list", "data": [], "first_id": None, "last_id": None, "has_more": False} + + +@pytest.mark.parametrize("provider", ["gemini", "unknown_provider", "", "openai,anthropic"]) +def test_unavailable_provider_is_rejected(provider: str) -> None: + request = Request({"type": "http", "headers": [(b"x-otari-files-provider", provider.encode())]}) + with pytest.raises(FilesError) as error: + files_format(request) + assert error.value.status_code == 400 + + +@pytest.mark.parametrize( + "fields", + [ + {}, + {"purpose": " "}, + {"purpose": "user_data", "expires_after[anchor]": "created_at"}, + {"purpose": "user_data", "expires_after[anchor]": "last_active_at", "expires_after[seconds]": "3600"}, + {"purpose": "user_data", "expires_after[anchor]": "created_at", "expires_after[seconds]": "7776000"}, + ], +) +def test_invalid_openai_upload_options_fail_locally(fields: dict[str, str]) -> None: + with pytest.raises(FilesError): + OpenAIFilesFormat().upload_options(fields) diff --git a/tests/unit/test_provider_file_migration.py b/tests/unit/test_provider_file_migration.py index 8a2a6dcbfd..6dea18352f 100644 --- a/tests/unit/test_provider_file_migration.py +++ b/tests/unit/test_provider_file_migration.py @@ -4,6 +4,7 @@ from alembic import command from alembic.config import Config +from alembic.script import ScriptDirectory from sqlalchemy import create_engine, inspect TABLES = { @@ -18,15 +19,26 @@ def test_provider_file_migration_round_trip(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[2] config = Config(str(root / "alembic.ini")) config.set_main_option("script_location", str(root / "alembic")) + script = ScriptDirectory.from_config(config) + revision = script.get_revision("c3e5a7b9d1f4") + assert revision is not None and revision.down_revision == "d5f8b2a4c6e9" + assert len(script.get_heads()) == 1 url = f"sqlite:///{tmp_path / 'files.db'}" config.set_main_option("sqlalchemy.url", url) command.upgrade(config, "c3e5a7b9d1f4") engine = create_engine(url) try: assert TABLES <= set(inspect(engine).get_table_names()) + assert {"purpose", "provider_created_at"} <= { + column["name"] for column in inspect(engine).get_columns("provider_file_bindings") + } command.downgrade(config, "d5f8b2a4c6e9") + assert "ix_api_keys_internal_dispatch" in {index["name"] for index in inspect(engine).get_indexes("api_keys")} assert not TABLES & set(inspect(engine).get_table_names()) command.upgrade(config, "c3e5a7b9d1f4") assert TABLES <= set(inspect(engine).get_table_names()) + assert {"purpose", "provider_created_at"} <= { + column["name"] for column in inspect(engine).get_columns("provider_file_bindings") + } finally: engine.dispose() diff --git a/tests/unit/test_provider_file_openapi.py b/tests/unit/test_provider_file_openapi.py new file mode 100644 index 0000000000..63a874acd1 --- /dev/null +++ b/tests/unit/test_provider_file_openapi.py @@ -0,0 +1,29 @@ +"""Published Files schemas include both native contracts and explicit provider selection.""" + +import json +import runpy +from pathlib import Path +from typing import Any, cast + +from gateway.core.config import API_ROOT + + +def test_public_file_contracts_are_published() -> None: + module = runpy.run_path(str(Path(__file__).resolve().parents[2] / "scripts/generate_openapi.py")) + spec = cast(dict[str, Any], module["generate_openapi_spec"]()) + paths = spec["paths"] + for method, suffix in ( + ("post", "/files"), + ("get", "/files"), + ("get", "/files/{file_id}"), + ("delete", "/files/{file_id}"), + ): + operation = paths[API_ROOT + suffix][method] + names = {parameter["name"] for parameter in operation["parameters"]} + assert "X-Otari-Files-Provider" in names + schema = json.dumps(operation["responses"]["200"]["content"]["application/json"]["schema"]) + assert "AnthropicFile" in schema and "OpenAIFile" in schema + listing = paths[API_ROOT + "/files"]["get"] + assert {"after", "before", "order", "purpose", "page", "ids[]"} <= { + parameter["name"] for parameter in listing["parameters"] + } diff --git a/tests/unit/test_provider_file_outputs.py b/tests/unit/test_provider_file_outputs.py index 77e3339606..1852966449 100644 --- a/tests/unit/test_provider_file_outputs.py +++ b/tests/unit/test_provider_file_outputs.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict, SecretStr from gateway.services.provider_files import inference +from gateway.services.provider_files.anthropic_inference import AnthropicFileOutputBinder from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import FileAccount, FileMetadata, FilesError, Operation @@ -64,7 +65,7 @@ async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[An monkeypatch.setattr(inference, "provider_client", provider) monkeypatch.setattr(PlatformFilesClient, "retry", retry) - binder = inference.FileOutputBinder(PlatformFilesClient("https://authority", "gateway", "user"), operation, []) + binder = AnthropicFileOutputBinder(PlatformFilesClient("https://authority", "gateway", "user"), operation, []) async def source() -> AsyncIterator[Event]: yield Event(type="message_start") @@ -93,3 +94,40 @@ async def consume() -> None: else: await task assert emitted == ["message_start", "content_block_start", "content_block_stop", "message_stop"] + + +@pytest.mark.asyncio +async def test_generic_output_registration_accepts_ids_without_anthropic_blocks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=1), + account=FileAccount(generation_id=uuid.uuid4(), provider="openai", api_key=SecretStr("key")), + max_bytes=100, + expires_in_seconds=3600, + ) + metadata = FileMetadata(id="file_generated", purpose="user_data") + calls: list[str] = [] + + class Provider: + async def aretrieve_file(self, file_id: str, **kwargs: Any) -> FileMetadata: + calls.append(file_id) + return metadata + + @asynccontextmanager + async def provider(account: FileAccount, **kwargs: Any) -> AsyncIterator[Provider]: + assert account.provider == "openai" + yield Provider() + + async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + assert path == "outputs/register" + assert body["metadata"]["purpose"] == "user_data" + return metadata + + monkeypatch.setattr(inference, "provider_client", provider) + monkeypatch.setattr(PlatformFilesClient, "retry", retry) + binder = inference.FileOutputBinder(PlatformFilesClient("https://authority", "gateway", "user"), operation, []) + await binder.register_ids(["file_generated", "file_generated"]) + assert calls == ["file_generated"] diff --git a/tests/unit/test_provider_file_protocol_version.py b/tests/unit/test_provider_file_protocol_version.py new file mode 100644 index 0000000000..fd34c24956 --- /dev/null +++ b/tests/unit/test_provider_file_protocol_version.py @@ -0,0 +1,24 @@ +"""Provider-aware gateways must not accept authorities that ignore provider selection.""" + +import httpx +import pytest + +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FilePage, FilesError + + +@pytest.mark.asyncio +@pytest.mark.parametrize("version", [None, "1", "2"]) +async def test_authority_version_is_checked_on_success(monkeypatch: pytest.MonkeyPatch, version: str | None) -> None: + def upstream(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Otari-Files-Protocol"] == "2" + return httpx.Response(200, headers={"X-Otari-Files-Protocol": version} if version else {}, json={"data": []}) + + original = httpx.AsyncClient + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=httpx.MockTransport(upstream), **kw)) + client = PlatformFilesClient("https://authority.test", "gateway", "user") + if version == "2": + assert (await client.post("list", {"provider": "openai"}, FilePage)).data == [] + else: + with pytest.raises(FilesError, match="protocol"): + await client.post("list", {"provider": "openai"}, FilePage) diff --git a/tests/unit/test_provider_file_references.py b/tests/unit/test_provider_file_references.py index 83457c37bf..fb313868b4 100644 --- a/tests/unit/test_provider_file_references.py +++ b/tests/unit/test_provider_file_references.py @@ -3,7 +3,7 @@ import pytest from gateway.services.provider_files.contracts import FileListRequest, FilesError -from gateway.services.provider_files.references import collect_file_references +from gateway.services.provider_files.references import collect_anthropic_file_references def test_complete_history_and_nested_results() -> None: @@ -23,12 +23,12 @@ def test_complete_history_and_nested_results() -> None: }, {"role": "user", "content": [{"type": "container_upload", "file_id": "file_old"}]}, ] - assert collect_file_references(history) == ["file_old", "file_out"] + assert collect_anthropic_file_references(history) == ["file_old", "file_out"] def test_text_and_tool_input_are_not_references() -> None: assert ( - collect_file_references( + collect_anthropic_file_references( [ {"role": "user", "content": "file_secret"}, { @@ -52,7 +52,7 @@ def test_text_and_tool_input_are_not_references() -> None: ) def test_unsupported_or_excessive_references_fail_closed(value: object) -> None: with pytest.raises(FilesError): - collect_file_references(value) + collect_anthropic_file_references(value) def test_nested_reference_limit() -> None: @@ -60,7 +60,7 @@ def test_nested_reference_limit() -> None: for _ in range(33): value = {"content": value} with pytest.raises(FilesError): - collect_file_references(value) + collect_anthropic_file_references(value) def test_ids_cannot_change_pagination_semantics() -> None: diff --git a/tests/unit/test_provider_file_transfers.py b/tests/unit/test_provider_file_transfers.py index 4c48f7ac78..b0bff1c46a 100644 --- a/tests/unit/test_provider_file_transfers.py +++ b/tests/unit/test_provider_file_transfers.py @@ -7,7 +7,7 @@ from starlette.datastructures import Headers from starlette.requests import Request -from gateway.api.routes.hybrid_files import file_headers +from gateway.api.routes._file_formats import AnthropicFilesFormat from gateway.services.provider_files.contracts import FilesError from gateway.services.provider_files.transfers import UploadAdmission, receive_upload @@ -18,7 +18,7 @@ def test_legacy_beta_rejected_case_insensitive_header_name(beta: str) -> None: {"type": "http", "headers": [(b"anthropic-version", b"2023-06-01"), (b"anthropic-beta", beta.encode())]} ) with pytest.raises(FilesError, match="GA API"): - file_headers(request) + AnthropicFilesFormat().headers(request) def test_only_contract_headers_forwarded() -> None: @@ -33,7 +33,10 @@ def test_only_contract_headers_forwarded() -> None: ], } ) - assert file_headers(request) == {"anthropic-version": "2023-06-01", "anthropic-beta": "other-beta"} + assert AnthropicFilesFormat().headers(request) == { + "anthropic-version": "2023-06-01", + "anthropic-beta": "other-beta", + } @pytest.mark.asyncio @@ -49,10 +52,10 @@ async def chunks() -> AsyncIterator[bytes]: try: async with receive_upload(Headers(request.headers), chunks(), max_bytes=100, idle_seconds=1) as ( upload, - duration, + fields, ): assert upload.file.read() == b"col\nvalue" - assert duration is None + assert fields == {} if reject: raise ValueError("intentional failure") except ValueError: diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts index 32d443613a..fd3a23c289 100644 --- a/web/src/client/schema.ts +++ b/web/src/client/schema.ts @@ -1102,7 +1102,7 @@ export interface paths { * ``workspace_id`` narrows a master-key listing to one workspace; a keyed * request is already confined to its key's own and cannot widen or move it. * - * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ get: operations["files-list_files"]; put?: never; @@ -1110,7 +1110,7 @@ export interface paths { * Create File * @description OpenAI-compatible file upload endpoint. * - * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ post: operations["files-create_file"]; delete?: never; @@ -1130,7 +1130,7 @@ export interface paths { * Get File * @description Retrieve metadata for a single file. * - * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ get: operations["files-get_file"]; put?: never; @@ -1139,7 +1139,7 @@ export interface paths { * Delete File * @description Soft-delete a file's metadata and remove its bytes from the backend. * - * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ delete: operations["files-delete_file"]; options?: never; @@ -1158,7 +1158,7 @@ export interface paths { * Get File Content * @description Download the raw bytes of a file, streamed rather than buffered whole. * - * Hybrid mode uses the Anthropic GA Files contract with uploader/workspace bindings. It requires anthropic-version, rejects the legacy Files beta, and supports page/next_page pagination. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ get: operations["files-get_file_content"]; put?: never; @@ -5968,6 +5968,52 @@ export interface components { } & { [key: string]: unknown; }; + /** AnthropicFileDeleted */ + AnthropicFileDeleted: { + /** Id */ + id: string; + /** + * Type + * @default file_deleted + * @constant + */ + type: "file_deleted"; + }; + /** AnthropicFileMetadata */ + AnthropicFileMetadata: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Downloadable */ + downloadable: boolean; + /** Expires At */ + expires_at?: string | null; + /** Filename */ + filename: string; + /** Id */ + id: string; + /** Mime Type */ + mime_type: string; + /** Size Bytes */ + size_bytes: number; + /** + * Type + * @default file + * @constant + */ + type: "file"; + } & { + [key: string]: unknown; + }; + /** AnthropicFilePage */ + AnthropicFilePage: { + /** Data */ + data: components["schemas"]["AnthropicFileMetadata"][]; + /** Next Page */ + next_page?: string | null; + }; /** * AudioContent * @description Audio content for a message. @@ -6126,12 +6172,20 @@ export interface components { }; /** Body_files-create_file */ "Body_files-create_file": { - /** @description Hybrid provider retention, capped by the control-plane maximum. */ + /** + * @description OpenAI hybrid expiry anchor. + * @enum {string} + */ + "expires_after[anchor]"?: "created_at"; + /** @description OpenAI hybrid retention, capped by the control-plane maximum. */ + "expires_after[seconds]"?: number; + /** @description Anthropic hybrid retention, capped by the control-plane maximum. */ expires_in_seconds?: number; /** File */ file: string; /** * Purpose + * @description Required for OpenAI hybrid uploads; unsupported for Anthropic hybrid uploads. * @default user_data */ purpose: string; @@ -7897,51 +7951,62 @@ export interface components { }; /** FileListRequest */ FileListRequest: { + /** After Id */ + after_id?: string | null; + /** Before Id */ + before_id?: string | null; /** Ids */ ids?: string[] | null; /** Limit */ limit?: number | null; + /** + * Order + * @default desc + * @enum {string} + */ + order: "asc" | "desc"; /** Page */ page?: string | null; + /** + * Provider + * @default anthropic + */ + provider: string; + /** Purpose */ + purpose?: string | null; + /** + * Sort By + * @default binding_created_at + * @enum {string} + */ + sort_by: "binding_created_at" | "provider_created_at"; }; /** * FileMetadata - * @description Anthropic's public metadata, without provider-neutral-only fields. + * @description Bounded any-llm metadata; absent provider fields remain unknown. */ FileMetadata: { - /** - * Created At - * Format: date-time - */ - created_at: string; + /** Created At */ + created_at?: string | null; /** Downloadable */ - downloadable: boolean; + downloadable?: boolean | null; /** Expires At */ expires_at?: string | null; /** Filename */ - filename: string; + filename?: string | null; /** Id */ id: string; /** Mime Type */ - mime_type: string; + mime_type?: string | null; + /** Purpose */ + purpose?: string | null; /** Size Bytes */ - size_bytes: number; - /** - * Type - * @default file - * @constant - */ - type: "file"; + size_bytes?: number | null; + /** Status */ + status?: string | null; } & { [key: string]: unknown; }; - /** FilePage */ - FilePage: { - /** Data */ - data: components["schemas"]["FileMetadata"][]; - /** Next Page */ - next_page?: string | null; - }; /** FinalizeUpload */ FinalizeUpload: { /** Expires In Seconds */ @@ -9048,17 +9113,6 @@ export interface components { [key: string]: unknown; } | null; }; - /** NativeFileDeleted */ - NativeFileDeleted: { - /** Id */ - id: string; - /** - * Type - * @default file_deleted - * @constant - */ - type: "file_deleted"; - }; /** * OAuthCallbackRequest * @description The authorization code a provider handed the browser. @@ -9141,6 +9195,65 @@ export interface components { /** Total Tokens */ total_tokens: number; }; + /** OpenAIFileDeleted */ + OpenAIFileDeleted: { + /** + * Deleted + * @default true + * @constant + */ + deleted: true; + /** Id */ + id: string; + /** + * Object + * @default file + * @constant + */ + object: "file"; + }; + /** OpenAIFileMetadata */ + OpenAIFileMetadata: { + /** Bytes */ + bytes?: number | null; + /** Created At */ + created_at?: number | null; + /** Expires At */ + expires_at?: number | null; + /** Filename */ + filename?: string | null; + /** Id */ + id: string; + /** + * Object + * @default file + * @constant + */ + object: "file"; + /** Purpose */ + purpose?: string | null; + /** Status */ + status?: string | null; + } & { + [key: string]: unknown; + }; + /** OpenAIFilePage */ + OpenAIFilePage: { + /** Data */ + data: components["schemas"]["OpenAIFileMetadata"][]; + /** First Id */ + first_id: string | null; + /** Has More */ + has_more: boolean; + /** Last Id */ + last_id: string | null; + /** + * Object + * @default list + * @constant + */ + object: "list"; + }; /** * OrgProviderKeyCreateRequest * @description What a caller sends to create a key. @@ -10557,6 +10670,11 @@ export interface components { * Format: uuid */ operation_id: string; + /** + * Provider + * @default anthropic + */ + provider: string; /** Size Bytes */ size_bytes: number; }; @@ -10914,6 +11032,11 @@ export interface components { References: { /** Ids */ ids: string[]; + /** + * Provider + * @default anthropic + */ + provider: string; }; /** * RegisterPasskeyRequest @@ -11042,6 +11165,11 @@ export interface components { * @enum {string} */ operation: "metadata" | "download" | "delete"; + /** + * Provider + * @default anthropic + */ + provider: string; }; /** * ResourceLink @@ -14950,10 +15078,18 @@ export interface operations { page?: string; /** @description Hybrid IDs filter; mutually exclusive with page and limit. */ "ids[]"?: string[]; + /** @description OpenAI hybrid Files listing filter or cursor. */ + after?: string; + /** @description OpenAI hybrid Files listing filter or cursor. */ + before?: string; + /** @description OpenAI hybrid Files listing filter or cursor. */ + order?: string; }; header?: { - /** @description Required in hybrid provider-native mode. */ + /** @description Required for the Anthropic hybrid Files envelope only. */ "anthropic-version"?: string; + /** @description Hybrid Files provider selector; credentials remain authority-selected. */ + "X-Otari-Files-Provider"?: "anthropic" | "openai"; }; path?: never; cookie?: never; @@ -14968,7 +15104,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - } | components["schemas"]["FilePage"]; + } | (components["schemas"]["AnthropicFilePage"] | components["schemas"]["OpenAIFilePage"]); }; }; /** @description Validation Error */ @@ -14986,8 +15122,10 @@ export interface operations { parameters: { query?: never; header?: { - /** @description Required in hybrid provider-native mode. */ + /** @description Required for the Anthropic hybrid Files envelope only. */ "anthropic-version"?: string; + /** @description Hybrid Files provider selector; credentials remain authority-selected. */ + "X-Otari-Files-Provider"?: "anthropic" | "openai"; }; path?: never; cookie?: never; @@ -15006,7 +15144,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - } | components["schemas"]["FileMetadata"]; + } | (components["schemas"]["AnthropicFileMetadata"] | components["schemas"]["OpenAIFileMetadata"]); }; }; /** @description Validation Error */ @@ -15026,8 +15164,10 @@ export interface operations { user?: string | null; }; header?: { - /** @description Required in hybrid provider-native mode. */ + /** @description Required for the Anthropic hybrid Files envelope only. */ "anthropic-version"?: string; + /** @description Hybrid Files provider selector; credentials remain authority-selected. */ + "X-Otari-Files-Provider"?: "anthropic" | "openai"; }; path: { file_id: string; @@ -15044,7 +15184,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - } | components["schemas"]["FileMetadata"]; + } | (components["schemas"]["AnthropicFileMetadata"] | components["schemas"]["OpenAIFileMetadata"]); }; }; /** @description Validation Error */ @@ -15064,8 +15204,10 @@ export interface operations { user?: string | null; }; header?: { - /** @description Required in hybrid provider-native mode. */ + /** @description Required for the Anthropic hybrid Files envelope only. */ "anthropic-version"?: string; + /** @description Hybrid Files provider selector; credentials remain authority-selected. */ + "X-Otari-Files-Provider"?: "anthropic" | "openai"; }; path: { file_id: string; @@ -15082,7 +15224,7 @@ export interface operations { content: { "application/json": { [key: string]: unknown; - } | components["schemas"]["NativeFileDeleted"]; + } | (components["schemas"]["AnthropicFileDeleted"] | components["schemas"]["OpenAIFileDeleted"]); }; }; /** @description Validation Error */ @@ -15102,8 +15244,10 @@ export interface operations { user?: string | null; }; header?: { - /** @description Required in hybrid provider-native mode. */ + /** @description Required for the Anthropic hybrid Files envelope only. */ "anthropic-version"?: string; + /** @description Hybrid Files provider selector; credentials remain authority-selected. */ + "X-Otari-Files-Provider"?: "anthropic" | "openai"; }; path: { file_id: string; From 73d7cb5555bc3e46e524d3135382a898dd03b0d7 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Fri, 18 Sep 2026 15:59:41 +0200 Subject: [PATCH 04/21] fix(files): repair output cleanup and inference reference guards Preserve file IDs during output compensation when metadata retrieval fails. Inspect named prompt variable values for unsupported OpenAI file state and accept Anthropic bash code execution output references. Add regression coverage for cleanup bindings, prompt variables, message history, and streamed output registration. --- src/gateway/api/routes/provider_files.py | 2 +- .../services/provider_files/references.py | 9 +++- .../test_hybrid_files_inference_guards.py | 6 +++ .../test_provider_files_protocol.py | 54 +++++++++++++++++++ tests/unit/test_provider_file_outputs.py | 19 +++++-- tests/unit/test_provider_file_references.py | 21 ++++++-- 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/gateway/api/routes/provider_files.py b/src/gateway/api/routes/provider_files.py index 6b9a4cbbb0..fe77847233 100644 --- a/src/gateway/api/routes/provider_files.py +++ b/src/gateway/api/routes/provider_files.py @@ -160,7 +160,7 @@ async def register_output(body: OutputRegister, scope: Principal, lifecycle: Ser async def abandon_output(operation_id: uuid.UUID, body: AbandonUpload, scope: Gateway, lifecycle: Service) -> Any: return _wire( await ProviderFileOutputs(lifecycle).abandon( - operation_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.metadata + operation_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.metadata, body.file_id ) ) diff --git a/src/gateway/services/provider_files/references.py b/src/gateway/services/provider_files/references.py index 872266aed1..c2549006da 100644 --- a/src/gateway/services/provider_files/references.py +++ b/src/gateway/services/provider_files/references.py @@ -32,7 +32,7 @@ def collect_anthropic_file_references(value: Any) -> list[str]: file_id = item.get("file_id") if file_id is not None: kind = item.get("type") - if kind not in {"file", "container_upload", "code_execution_output"}: + if kind not in {"file", "container_upload", "code_execution_output", "bash_code_execution_output"}: raise FilesError(400, "Unsupported structured file reference") if not isinstance(file_id, str) or not file_id or len(file_id) > 255: raise FilesError(400, "Invalid file reference") @@ -50,7 +50,7 @@ def reject_openai_file_state(payload: dict[str, Any]) -> None: """Reject account-scoped OpenAI state until its inference ownership protocol exists.""" if payload.get("previous_response_id") or payload.get("conversation"): raise FilesError(400, "Provider conversation reuse is not supported in hybrid mode") - pending = [(payload, 0)] + pending: list[tuple[Any, int]] = [(payload, 0)] nodes = 0 while pending: item, depth = pending.pop() @@ -80,9 +80,14 @@ def reject_openai_file_state(payload: dict[str, Any]) -> None: or isinstance(item.get("container"), str) ): raise FilesError(400, "OpenAI provider file state is not supported in hybrid inference") + variables = item.get("variables") + if isinstance(variables, dict): + # Prompt variable names are arbitrary; their values are typed input blocks or text. + pending.append((list(variables.values()), depth + 1)) pending.extend( (item[key], depth + 1) for key in ( + "prompt", "input", "messages", "content", diff --git a/tests/integration/test_hybrid_files_inference_guards.py b/tests/integration/test_hybrid_files_inference_guards.py index 74b400e713..b117db5b05 100644 --- a/tests/integration/test_hybrid_files_inference_guards.py +++ b/tests/integration/test_hybrid_files_inference_guards.py @@ -18,6 +18,12 @@ {"tools": [{"type": "code_interpreter", "container": "cntr_foreign"}]}, {"tools": [{"type": "code_interpreter", "container": {"type": "auto"}}]}, {"tools": [{"type": "file_search", "vector_store_ids": ["vs_foreign"]}]}, + { + "prompt": { + "id": "pmpt_1", + "variables": {"doc": {"type": "input_file", "file_id": "file_foreign"}}, + } + }, {"previous_response_id": "resp_foreign"}, {"conversation": "conv_foreign"}, {"tools": [{"type": "shell", "environment": {"type": "container_reference", "container_id": "cntr_foreign"}}]}, diff --git a/tests/integration/test_provider_files_protocol.py b/tests/integration/test_provider_files_protocol.py index 0f13c6a76b..9cc1aff197 100644 --- a/tests/integration/test_provider_files_protocol.py +++ b/tests/integration/test_provider_files_protocol.py @@ -14,6 +14,7 @@ from gateway.models.provider_keys import OrgProviderKey from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.provider_files.outputs import ProviderFileOutputs from gateway.services.secret_box import encrypt_secret from .test_provider_file_lifecycle import files_setup as files_setup @@ -22,6 +23,59 @@ pytestmark = pytest.mark.asyncio +async def test_output_abandon_without_metadata_creates_cleanup_binding( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + service, scope, account = files_setup + operation = await ProviderFileOutputs(service).prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + + async def authenticate(request: Request, uow: UnitOfWork) -> FileScope: + return scope + + async def authorize(scope: FileScope, body: OutputPrepare, uow: UnitOfWork) -> FileAccount: + return account + + app = FastAPI() + app.dependency_overrides[get_unit_of_work] = lambda: service.uow + app.dependency_overrides[get_config] = lambda: GatewayConfig( + mode="hosted", + files_provider_native_enabled=True, + files_max_count=10, + files_max_bytes=1024, + files_max_outstanding_bytes=10240, + ) + app.include_router( + create_provider_files_router( + authenticate=authenticate, authenticate_gateway=authenticate, authorize_attempt=authorize + ), + prefix=API_ROOT, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://control") as client: + response = await client.post( + f"{API_ROOT}/gateway/files/outputs/{operation.id}/abandon", + json={ + "cleanup_token": operation.cleanup_token.get_secret_value(), + "metadata": None, + "file_id": "file_generated", + }, + headers={"X-Otari-Files-Protocol": "2"}, + ) + assert response.status_code == 200, response.text + async with service.uow: + stored = await service.repo.get(uuid.UUID(response.json()["operation_id"])) + assert stored is not None + assert stored.provider_file_id == "file_generated" + assert stored.output_operation_id == operation.id + assert stored.state == "pending_cleanup" + assert stored.encrypted_metadata is None + + @pytest.mark.parametrize("provider", ["anthropic", "openai"]) async def test_authenticated_protocol_prepares_and_finalizes( files_setup: tuple[ProviderFileService, FileScope, FileAccount], diff --git a/tests/unit/test_provider_file_outputs.py b/tests/unit/test_provider_file_outputs.py index 1852966449..3cf6f35d6c 100644 --- a/tests/unit/test_provider_file_outputs.py +++ b/tests/unit/test_provider_file_outputs.py @@ -24,7 +24,10 @@ class Event(BaseModel): @pytest.mark.asyncio @pytest.mark.parametrize("fail", [False, True]) -async def test_file_block_is_held_until_registration(monkeypatch: pytest.MonkeyPatch, fail: bool) -> None: +@pytest.mark.parametrize("output_type", ["code_execution_output", "bash_code_execution_output"]) +async def test_file_block_is_held_until_registration( + monkeypatch: pytest.MonkeyPatch, fail: bool, output_type: str +) -> None: entered, release = asyncio.Event(), asyncio.Event() operation = Operation( id=uuid.uuid4(), @@ -73,7 +76,10 @@ async def source() -> AsyncIterator[Event]: type="content_block_start", content_block={ "type": "bash_code_execution_tool_result", - "content": [{"type": "code_execution_output", "file_id": "file_generated"}], + "content": { + "type": "bash_code_execution_result", + "content": [{"type": output_type, "file_id": "file_generated"}], + }, }, ) yield Event(type="content_block_stop") @@ -84,7 +90,14 @@ async def consume() -> None: emitted.append(event.type) # noqa: PERF401 (observe emission before the stream finishes) task = asyncio.create_task(consume()) - await asyncio.wait_for(entered.wait(), timeout=1) + registration = asyncio.create_task(entered.wait()) + try: + done, _ = await asyncio.wait({task, registration}, timeout=1, return_when=asyncio.FIRST_COMPLETED) + if task in done: + await task + assert registration in done, "Output registration did not start" + finally: + registration.cancel() assert emitted == ["message_start"] release.set() if fail: diff --git a/tests/unit/test_provider_file_references.py b/tests/unit/test_provider_file_references.py index fb313868b4..4144773e0f 100644 --- a/tests/unit/test_provider_file_references.py +++ b/tests/unit/test_provider_file_references.py @@ -3,10 +3,11 @@ import pytest from gateway.services.provider_files.contracts import FileListRequest, FilesError -from gateway.services.provider_files.references import collect_anthropic_file_references +from gateway.services.provider_files.references import collect_anthropic_file_references, reject_openai_file_state -def test_complete_history_and_nested_results() -> None: +@pytest.mark.parametrize("output_type", ["code_execution_output", "bash_code_execution_output"]) +def test_complete_history_and_nested_results(output_type: str) -> None: history = [ {"role": "user", "content": [{"type": "document", "source": {"type": "file", "file_id": "file_old"}}]}, { @@ -16,7 +17,7 @@ def test_complete_history_and_nested_results() -> None: "type": "bash_code_execution_tool_result", "content": { "type": "bash_code_execution_result", - "content": [{"type": "code_execution_output", "file_id": "file_out"}], + "content": [{"type": output_type, "file_id": "file_out"}], }, } ], @@ -63,6 +64,20 @@ def test_nested_reference_limit() -> None: collect_anthropic_file_references(value) +def test_openai_prompt_text_variables_are_not_file_references() -> None: + reject_openai_file_state( + { + "prompt": { + "id": "pmpt_1", + "variables": { + "doc": "file_mentioned_in_prose", + "description": {"type": "input_text", "text": "file_mentioned_in_text"}, + }, + } + } + ) + + def test_ids_cannot_change_pagination_semantics() -> None: with pytest.raises(ValueError): FileListRequest(ids=["file_a"], limit=20) From 6bfea64ed97964b6e9b8fa416bcd7e616e7055d9 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Fri, 18 Sep 2026 16:14:12 +0200 Subject: [PATCH 05/21] fix(files): preserve upload cleanup on handler cancellation Keep the cleanup timeout inside the shielded task so handler cancellation propagates without interrupting deletion or abandonment reporting. Preserve the 20-second deadline and cover cancellation and timeout behavior with regression tests. --- src/gateway/api/routes/hybrid_files.py | 12 +-- tests/unit/test_hybrid_file_compensation.py | 99 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_hybrid_file_compensation.py diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 1682bc4ee4..3ce317f858 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -226,11 +226,13 @@ async def compensate() -> None: except FilesError: pass - task = asyncio.create_task(compensate()) - try: - await asyncio.shield(asyncio.wait_for(task, timeout=20)) - except (TimeoutError, asyncio.CancelledError): - task.cancel() + async def bounded_compensate() -> None: + try: + await asyncio.wait_for(compensate(), timeout=20) + except TimeoutError: + pass + + await asyncio.shield(asyncio.create_task(bounded_compensate())) @router.get("/files", response_model=AnthropicFilePage | OpenAIFilePage, response_model_exclude_unset=True) diff --git a/tests/unit/test_hybrid_file_compensation.py b/tests/unit/test_hybrid_file_compensation.py new file mode 100644 index 0000000000..27d3fcdcc0 --- /dev/null +++ b/tests/unit/test_hybrid_file_compensation.py @@ -0,0 +1,99 @@ +"""Upload compensation survives handler cancellation but remains time-bounded.""" + +import asyncio +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from pydantic import SecretStr + +from gateway.api.routes import hybrid_files +from gateway.services.provider_files.client import PlatformFilesClient +from gateway.services.provider_files.contracts import FileAccount, FileMetadata, FilesError, Operation + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancel_handler", [False, True]) +@pytest.mark.parametrize("expire", [False, True]) +async def test_upload_compensation_survives_cancellation_and_honors_timeout( + monkeypatch: pytest.MonkeyPatch, cancel_handler: bool, expire: bool +) -> None: + entered, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event() + events: list[str] = [] + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=1), + account=FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("key")), + max_bytes=100, + expires_in_seconds=3600, + ) + metadata = FileMetadata(id="file_uploaded") + + class Provider: + async def adelete_file(self, *args: Any, **kwargs: Any) -> None: + entered.set() + try: + await release.wait() + events.append("deleted") + except asyncio.CancelledError: + events.append("delete-cancelled") + raise + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + yield Provider() + + async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + assert path == f"uploads/{operation.id}/abandon" + assert body["metadata"] == {"id": metadata.id} + assert body["deleted"] is True + events.append("reported") + return result_type() + + real_wait_for = asyncio.wait_for + timer = asyncio.timeout(None) + + async def controlled_wait_for(awaitable: Any, timeout: float) -> Any: + assert timeout == 20 + try: + async with timer: + return await awaitable + finally: + finished.set() + + monkeypatch.setattr(hybrid_files, "provider_client", provider) + monkeypatch.setattr(PlatformFilesClient, "retry", retry) + monkeypatch.setattr(asyncio, "wait_for", controlled_wait_for) + handler = asyncio.create_task( + hybrid_files._compensate_upload( + PlatformFilesClient("https://authority", "gateway", "user"), + operation, + metadata, + {}, + True, + FilesError(502, "unavailable"), + ) + ) + try: + await real_wait_for(entered.wait(), timeout=1) + if cancel_handler: + handler.cancel() + with pytest.raises(asyncio.CancelledError): + await handler + assert events == [] + if expire: + timer.reschedule(asyncio.get_running_loop().time()) + else: + release.set() + await real_wait_for(finished.wait(), timeout=1) + if not cancel_handler: + await handler + assert events == (["delete-cancelled"] if expire else ["deleted", "reported"]) + finally: + release.set() + if not handler.done(): + await real_wait_for(handler, timeout=1) From 8850c04498d949d1735ed74272f24c1ef79e3bb7 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 12:42:51 +0200 Subject: [PATCH 06/21] docs(files): tailor descriptions to each operation --- docs/public/openapi.json | 10 +++++----- docs/public/otari.postman_collection.json | 10 +++++----- scripts/generate_openapi.py | 15 +++++++++++++-- tests/unit/test_provider_file_openapi.py | 8 ++++++++ web/src/client/schema.ts | 10 +++++----- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 9bdbc1e269..c33516ecf7 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -21216,7 +21216,7 @@ }, "/api/v1/files": { "get": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI listing supports purpose filtering, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "operationId": "files-list_files", "parameters": [ { @@ -21394,7 +21394,7 @@ ] }, "post": { - "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uploads require purpose and return the OpenAI file metadata envelope. Hosted mode does not serve public file bytes.", "operationId": "files-create_file", "parameters": [ { @@ -21486,7 +21486,7 @@ }, "/api/v1/files/{file_id}": { "delete": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI deletion returns the OpenAI deletion envelope. Hosted mode does not serve public file bytes.", "operationId": "files-delete_file", "parameters": [ { @@ -21591,7 +21591,7 @@ ] }, "get": { - "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI retrieval returns the OpenAI file metadata envelope. Hosted mode does not serve public file bytes.", "operationId": "files-get_file", "parameters": [ { @@ -21698,7 +21698,7 @@ }, "/api/v1/files/{file_id}/content": { "get": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. Downloads return raw file bytes, not a JSON envelope. Hosted mode does not serve public file bytes.", "operationId": "files-get_file_content", "parameters": [ { diff --git a/docs/public/otari.postman_collection.json b/docs/public/otari.postman_collection.json index 63f4314003..f2c547ab0a 100644 --- a/docs/public/otari.postman_collection.json +++ b/docs/public/otari.postman_collection.json @@ -1763,7 +1763,7 @@ { "name": "List Files", "request": { - "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "List the authenticated user's uploaded files in the request's workspace.\n\n``workspace_id`` narrows a master-key listing to one workspace; a keyed\nrequest is already confined to its key's own and cannot widen or move it.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI listing supports purpose filtering, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1832,7 +1832,7 @@ { "name": "Create File", "request": { - "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "OpenAI-compatible file upload endpoint.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uploads require purpose and return the OpenAI file metadata envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "POST", "url": { @@ -1851,7 +1851,7 @@ { "name": "Get File", "request": { - "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Retrieve metadata for a single file.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI retrieval returns the OpenAI file metadata envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { @@ -1886,7 +1886,7 @@ { "name": "Delete File", "request": { - "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Soft-delete a file's metadata and remove its bytes from the backend.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI deletion returns the OpenAI deletion envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "DELETE", "url": { @@ -1921,7 +1921,7 @@ { "name": "Get File Content", "request": { - "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes.", + "description": "Download the raw bytes of a file, streamed rather than buffered whole.\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. Downloads return raw file bytes, not a JSON envelope. Hosted mode does not serve public file bytes.", "header": [], "method": "GET", "url": { diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 0c51b15be5..3fc300c84f 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -82,6 +82,17 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: hybrid.include_router(routes) native = hybrid.openapi() spec["components"]["schemas"].update(native.get("components", {}).get("schemas", {})) + openai_descriptions = { + (f"{API_ROOT}/files", "get"): ( + "OpenAI listing supports purpose filtering, after/before pagination, and the OpenAI response envelope. " + ), + (f"{API_ROOT}/files", "post"): ( + "OpenAI uploads require purpose and return the OpenAI file metadata envelope. " + ), + (f"{API_ROOT}/files/{{file_id}}", "get"): "OpenAI retrieval returns the OpenAI file metadata envelope. ", + (f"{API_ROOT}/files/{{file_id}}", "delete"): "OpenAI deletion returns the OpenAI deletion envelope. ", + (f"{API_ROOT}/files/{{file_id}}/content", "get"): "Downloads return raw file bytes, not a JSON envelope. ", + } for path, methods in native["paths"].items(): for method, operation in methods.items(): target = spec["paths"][path][method] @@ -89,8 +100,8 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: "\n\nHybrid mode stores files at the authorized provider with uploader/workspace bindings. " "X-Otari-Files-Provider selects anthropic (default) or openai. " "The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. " - "OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. " - "Hosted mode does not serve public file bytes." + + openai_descriptions[(path, method)] + + "Hosted mode does not serve public file bytes." ) schema = operation["responses"].get("200", {}).get("content", {}).get("application/json", {}).get("schema") if schema: diff --git a/tests/unit/test_provider_file_openapi.py b/tests/unit/test_provider_file_openapi.py index 63a874acd1..811e9ea7d6 100644 --- a/tests/unit/test_provider_file_openapi.py +++ b/tests/unit/test_provider_file_openapi.py @@ -23,6 +23,14 @@ def test_public_file_contracts_are_published() -> None: assert "X-Otari-Files-Provider" in names schema = json.dumps(operation["responses"]["200"]["content"]["application/json"]["schema"]) assert "AnthropicFile" in schema and "OpenAIFile" in schema + for suffix in ("/files", "/files/{file_id}", "/files/{file_id}/content"): + for method, operation in paths[API_ROOT + suffix].items(): + description = operation["description"] + assert ("pagination" in description) == (suffix == "/files" and method == "get") + assert "uploads require purpose" in paths[API_ROOT + "/files"]["post"]["description"] + download_description = paths[API_ROOT + "/files/{file_id}/content"]["get"]["description"] + assert "raw file bytes, not a JSON envelope" in download_description + assert "OpenAI response envelope" not in download_description listing = paths[API_ROOT + "/files"]["get"] assert {"after", "before", "order", "purpose", "page", "ids[]"} <= { parameter["name"] for parameter in listing["parameters"] diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts index fd3a23c289..e4ffceed4f 100644 --- a/web/src/client/schema.ts +++ b/web/src/client/schema.ts @@ -1102,7 +1102,7 @@ export interface paths { * ``workspace_id`` narrows a master-key listing to one workspace; a keyed * request is already confined to its key's own and cannot widen or move it. * - * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI listing supports purpose filtering, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. */ get: operations["files-list_files"]; put?: never; @@ -1110,7 +1110,7 @@ export interface paths { * Create File * @description OpenAI-compatible file upload endpoint. * - * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uploads require purpose and return the OpenAI file metadata envelope. Hosted mode does not serve public file bytes. */ post: operations["files-create_file"]; delete?: never; @@ -1130,7 +1130,7 @@ export interface paths { * Get File * @description Retrieve metadata for a single file. * - * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI retrieval returns the OpenAI file metadata envelope. Hosted mode does not serve public file bytes. */ get: operations["files-get_file"]; put?: never; @@ -1139,7 +1139,7 @@ export interface paths { * Delete File * @description Soft-delete a file's metadata and remove its bytes from the backend. * - * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI deletion returns the OpenAI deletion envelope. Hosted mode does not serve public file bytes. */ delete: operations["files-delete_file"]; options?: never; @@ -1158,7 +1158,7 @@ export interface paths { * Get File Content * @description Download the raw bytes of a file, streamed rather than buffered whole. * - * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. OpenAI uses purpose, after/before pagination, and the OpenAI response envelope. Hosted mode does not serve public file bytes. + * Hybrid mode stores files at the authorized provider with uploader/workspace bindings. X-Otari-Files-Provider selects anthropic (default) or openai. The Anthropic envelope requires anthropic-version and rejects the legacy Files beta. Downloads return raw file bytes, not a JSON envelope. Hosted mode does not serve public file bytes. */ get: operations["files-get_file_content"]; put?: never; From 8bc53bb6872dd250bf80820d0f29dee8f01661f8 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 12:42:52 +0200 Subject: [PATCH 07/21] fix(files): reject native outputs when Files are disabled --- src/gateway/api/routes/messages.py | 2 +- .../integration/test_hybrid_mode_messages.py | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 262cbc21f7..1429351a69 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -852,7 +852,7 @@ async def _normalize( ) or request.container is not None ) - if references or (native_outputs and config.files_provider_native_enabled): + if references or native_outputs: if not config.files_provider_native_enabled: raise FilesError(400, "Hybrid provider file references and native outputs are not enabled") assert ctx.route is not None and ctx.user_token is not None diff --git a/tests/integration/test_hybrid_mode_messages.py b/tests/integration/test_hybrid_mode_messages.py index 41acfb29fa..c501e52a7c 100644 --- a/tests/integration/test_hybrid_mode_messages.py +++ b/tests/integration/test_hybrid_mode_messages.py @@ -1284,11 +1284,22 @@ def test_container_is_refused_on_a_managed_credential( assert calls == [], "the provider must not be called at all" -def test_container_reaches_a_byo_credential( +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + "native_fields", + [ + {"container": "container_01ABC"}, + {"tools": [{"type": "code_execution_20250522", "name": "code_execution"}]}, + ], + ids=["container", "code_execution"], +) +def test_native_outputs_are_refused_when_files_are_disabled( platform_client: TestClient, monkeypatch: pytest.MonkeyPatch, + native_fields: dict[str, Any], + stream: bool, ) -> None: - """On the workspace's own key the container is already the caller's.""" + """Native outputs need Files enabled even on a BYO credential without file references.""" calls: list[str] = [] _container_route(monkeypatch, managed=False, calls=calls) @@ -1298,13 +1309,15 @@ def test_container_reaches_a_byo_credential( "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 100, - "container": "container_01ABC", + "stream": stream, + **native_fields, }, headers={"Authorization": "Bearer user_test_token"}, ) - assert response.status_code == 200, response.text - assert calls == ["container_01ABC"] + assert response.status_code == 400, response.text + assert "native outputs are not enabled" in response.json()["detail"]["error"]["message"] + assert calls == [], "the provider must not be called when Files are disabled" def test_a_managed_attempt_anywhere_on_the_route_refuses_the_container( From e424d41bd19cfadeb7d5ab101cffa34888e80cac Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 13:20:35 +0200 Subject: [PATCH 08/21] fix(files): preserve errors and gate retention validation Map FilesError statuses to Anthropic error types and preserve response headers in both Messages error handlers. Apply the 90-day retention ceiling only when provider-native Files is enabled. Add coverage for mapped errors, Retry-After preservation, and enabled/disabled retention boundaries. --- src/gateway/api/routes/messages.py | 14 +++++- src/gateway/core/config.py | 2 +- .../integration/test_hybrid_files_messages.py | 43 +++++++++++++------ tests/unit/test_provider_file_config.py | 20 +++++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 1429351a69..8492769eff 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -876,7 +876,12 @@ async def _normalize( ctx.route.attempts, ctx.route.fallback_enabled = [selected], False adapter = _FileMessagesAdapter(client, ctx.route.request_id, references) except FilesError as exc: - raise _anthropic_error(_ERR_API, exc.detail, exc.status_code) from None + raise _anthropic_error( + _STATUS_TO_ANTHROPIC_TYPE.get(exc.status_code, _ERR_API), + exc.detail, + exc.status_code, + headers=exc.headers, + ) from None tool_ctx = await prepare_gateway_tools( adapter=adapter, @@ -997,7 +1002,12 @@ async def _normalize( try: await adapter.finalize_outputs(result) except FilesError as exc: - raise _anthropic_error(_ERR_API, exc.detail, exc.status_code) from None + raise _anthropic_error( + _STATUS_TO_ANTHROPIC_TYPE.get(exc.status_code, _ERR_API), + exc.detail, + exc.status_code, + headers=exc.headers, + ) from None return result.model_dump(exclude_none=True) # Standalone non-stream path diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index 6db00945f3..df8ee4e117 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -894,7 +894,7 @@ class GatewayConfig(BudgetSettings, PricingSettings, BaseSettings): @model_validator(mode="after") def validate_provider_file_limits(self) -> "GatewayConfig": if ( - (self.is_hybrid_mode or self.files_provider_native_enabled) + self.files_provider_native_enabled and self.files_retention_hours is not None and self.files_retention_hours > 2160 ): diff --git a/tests/integration/test_hybrid_files_messages.py b/tests/integration/test_hybrid_files_messages.py index 993654323e..0320f13ba1 100644 --- a/tests/integration/test_hybrid_files_messages.py +++ b/tests/integration/test_hybrid_files_messages.py @@ -19,9 +19,27 @@ @pytest.mark.parametrize( - "outcome", ["success", "foreign", "wrong_generation", "wrong_provider", "registration_failure"] + "outcome, status_code, error_type", + [ + ("success", 200, None), + ("foreign", 404, "not_found_error"), + ("wrong_generation", 403, "permission_error"), + ("wrong_provider", 502, "api_error"), + ("registration_failure", 502, "api_error"), + ("reference_failure", 400, "invalid_request_error"), + ("reference_failure", 401, "authentication_error"), + ("reference_failure", 403, "permission_error"), + ("reference_failure", 429, "rate_limit_error"), + ("registration_failure", 400, "invalid_request_error"), + ("registration_failure", 401, "authentication_error"), + ("registration_failure", 403, "permission_error"), + ("registration_failure", 404, "not_found_error"), + ("registration_failure", 429, "rate_limit_error"), + ], ) -def test_file_reference_dispatch_and_accounting(monkeypatch: pytest.MonkeyPatch, outcome: str) -> None: +def test_file_reference_dispatch_and_accounting( + monkeypatch: pytest.MonkeyPatch, outcome: str, status_code: int, error_type: str | None +) -> None: monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") generation = uuid.uuid4() account = FileAccount( @@ -61,6 +79,8 @@ async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> assert body == {"ids": ["file_history"], "provider": "anthropic"} if outcome == "foreign": raise FilesError(404, "File not found") + if outcome == "reference_failure": + raise FilesError(status_code, "File reference rejected", headers={"Retry-After": "30"}) return account if path == "outputs/prepare": assert body["attempt_id"] == attempts[1]["attempt_id"] @@ -84,7 +104,7 @@ async def register(self: Any, value: Any) -> None: events.append("register") assert "usage" in events if outcome == "registration_failure": - raise FilesError(502, "Unable to register generated files") + raise FilesError(status_code, "Unable to register generated files", headers={"Retry-After": "30"}) monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) monkeypatch.setattr("gateway.api.routes.messages.amessages", provider) @@ -114,17 +134,12 @@ async def register(self: Any, value: Any) -> None: ], }, ) - assert ( - response.status_code - == { - "success": 200, - "foreign": 404, - "wrong_generation": 403, - "wrong_provider": 502, - "registration_failure": 502, - }[outcome] - ), response.text - if outcome in {"foreign", "wrong_generation", "wrong_provider"}: + assert response.status_code == status_code, response.text + if error_type is not None: + assert response.json()["detail"]["error"]["type"] == error_type + if outcome in {"reference_failure", "registration_failure"}: + assert response.headers["Retry-After"] == "30" + if outcome in {"foreign", "wrong_generation", "wrong_provider", "reference_failure"}: assert "provider" not in events else: assert events.count("provider") == 1 diff --git a/tests/unit/test_provider_file_config.py b/tests/unit/test_provider_file_config.py index d1aa126f62..c62a3470f2 100644 --- a/tests/unit/test_provider_file_config.py +++ b/tests/unit/test_provider_file_config.py @@ -19,3 +19,23 @@ def test_native_file_limits(monkeypatch: pytest.MonkeyPatch, overrides: dict[str monkeypatch.delenv("OTARI_AI_TOKEN", raising=False) with pytest.raises(ValidationError, match=message): GatewayConfig.model_validate({"files_provider_native_enabled": True, **overrides}) + + +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("retention_hours", [None, 2160, 2161, 2880]) +def test_hybrid_retention_limit_requires_native_files( + monkeypatch: pytest.MonkeyPatch, enabled: bool, retention_hours: int | None +) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + settings = { + "mode": "hybrid", + "files_provider_native_enabled": enabled, + "files_retention_hours": retention_hours, + } + if enabled and retention_hours is not None and retention_hours > 2160: + with pytest.raises(ValidationError, match="90 days"): + GatewayConfig.model_validate(settings) + else: + config = GatewayConfig.model_validate(settings) + assert config.is_hybrid_mode + assert config.files_retention_hours == retention_hours From 5eadba5f359fffb3732ae6db0e75a9ecb3c24581 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:23:18 +0200 Subject: [PATCH 09/21] fix(files): refuse oversized downloads before the response starts The size cap was enforced only inside the streaming generator, after the 200 and any earlier chunks had been sent. Reject the download first from the platform-stored size, then from the provider's Content-Length once the upstream response opens; the in-stream cap stays as a backstop. Forward a validated Content-Length when the body was not encoded, so a client can detect a truncated transfer. Spooling the body before the headers was rejected: the default cap is 512 MB, and a generator abort already leaves the chunked body unterminated rather than a clean 200. Co-Authored-By: Claude Fable 5.1 --- src/gateway/api/routes/hybrid_files.py | 35 +++++++++--- tests/integration/test_hybrid_files_routes.py | 54 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 3ce317f858..0c3320364f 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -2,7 +2,7 @@ import asyncio import uuid -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Mapping from contextlib import AsyncExitStack from typing import Annotated, Any from urllib.parse import quote @@ -256,6 +256,25 @@ async def retrieve_file(file_id: str, request: Request, config: Config) -> Anthr return envelope.metadata(resolved.metadata) +def _download_headers(upstream: Mapping[str, str], max_bytes: int) -> dict[str, str]: + """Reject an oversized download before the 200 is committed; keep the descriptive headers.""" + lowered = {name.lower(): value for name, value in upstream.items()} + headers = {name: lowered[name] for name in ("content-type", "content-disposition") if name in lowered} + declared = lowered.get("content-length") + if declared is None: + return headers + try: + length = int(declared) + except ValueError: + raise FilesError(502, "Provider returned an invalid download") from None + if length > max_bytes: + raise FilesError(413, "File size limit exceeded") + # The SDK decodes the body, so the length only describes it when nothing was encoded. + if lowered.get("content-encoding", "identity") == "identity": + headers["content-length"] = declared + return headers + + @router.get("/files/{file_id}/content") async def download_file(file_id: str, request: Request, config: Config) -> Response: client = files_client(request, config) @@ -267,6 +286,8 @@ async def download_file(file_id: str, request: Request, config: Config) -> Respo raise FilesError(502, "Authorization service returned an invalid file account") check_file_account(resolved.account, envelope.provider) require_download(envelope.provider, resolved.metadata) + if resolved.metadata.size_bytes is not None and resolved.metadata.size_bytes > config.files_max_bytes: + raise FilesError(413, "File size limit exceeded") track_request(request, endpoint="/files", model="files", provider=resolved.account.provider) stack = AsyncExitStack() deadline = asyncio.get_running_loop().time() + config.files_transfer_timeout_seconds @@ -278,16 +299,16 @@ async def download_file(file_id: str, request: Request, config: Config) -> Respo download = await stack.enter_async_context( provider.adownload_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) ) - headers = { - name: value - for name, value in download.headers.items() - if name.lower() in {"content-type", "content-disposition"} - } except BaseException as exc: await stack.aclose() if not isinstance(exc, Exception): raise raise provider_error(exc) from None + try: + headers = _download_headers(download.headers, config.files_max_bytes) + except BaseException: + await stack.aclose() + raise async def chunks() -> AsyncIterator[bytes]: total = 0 @@ -303,6 +324,8 @@ async def chunks() -> AsyncIterator[bytes]: break total += len(chunk) if total > config.files_max_bytes: + # Past the headers by now; the abort leaves the body + # unterminated so the client sees a failed transfer. raise FilesError(413, "File size limit exceeded") yield chunk finally: diff --git a/tests/integration/test_hybrid_files_routes.py b/tests/integration/test_hybrid_files_routes.py index 778bc2a5d4..0ebe0b5083 100644 --- a/tests/integration/test_hybrid_files_routes.py +++ b/tests/integration/test_hybrid_files_routes.py @@ -7,6 +7,7 @@ from typing import Any import pytest +from any_llm.types.files import AsyncFileDownload from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import SecretStr @@ -157,3 +158,56 @@ async def fail(self: Any, path: str, body: dict[str, Any], result_type: type[Any assert events.count("finalize-failed") == 3 assert "provider-delete" in events assert events[-1].endswith("/abandon") + + +@pytest.mark.parametrize("oversized", ["metadata", "content-length", None]) +def test_oversized_download_is_refused_before_the_body( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, oversized: str | None +) -> None: + """The 413 must precede the 200, since a streaming abort cannot take one back.""" + client, events = file_client + payload = b"x" * (2048 if oversized else 4) + data = FileMetadata( + id="file_provider", + filename="example.csv", + mime_type="text/csv", + size_bytes=len(payload) if oversized == "metadata" else None, + created_at=datetime.now(UTC), + downloadable=True, + ) + account = FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("provider-secret")) + + async def post(self: object, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + events.append(path) + return ResolvedFile(metadata=data, account=account) + + class Provider: + @asynccontextmanager + async def adownload_file(self, file_id: str, **kwargs: Any) -> AsyncIterator[AsyncFileDownload]: + events.append("provider-download") + + async def chunks() -> AsyncIterator[bytes]: + events.append("provider-chunk") + yield payload + + yield AsyncFileDownload( + status_code=200, + headers={"Content-Type": "text/csv", "Content-Length": str(len(payload))}, + chunks=chunks(), + ) + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + yield Provider() + + monkeypatch.setattr(PlatformFilesClient, "post", post) + monkeypatch.setattr(hybrid_files, "provider_client", provider) + response = client.get(API_ROOT + "/files/file_provider/content", headers=HEADERS) + if oversized is None: + assert response.status_code == 200, response.text + assert response.content == payload + assert response.headers["content-length"] == "4" + return + assert response.status_code == 413, response.text + assert "provider-chunk" not in events + assert ("provider-download" in events) == (oversized == "content-length") From 010093ffa92f4f414ad52c945bc758d937821b76 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:23:18 +0200 Subject: [PATCH 10/21] fix(files): keep the Files status when output preparation fails A FilesError raised by the output binder before the first chunk fell through to the attempt runners, which read its status_code and mapped it as a provider failure: 403 became a 502 credentials error, 409 a 500, and multiple attempts a generic 502. The non-streaming runner wraps the exception before the route sees it, so a route-level catch would only have covered streams. Convert in the binder instead, so both paths return the original status, message and headers through the existing HTTPException handling. Co-Authored-By: Claude Fable 5.1 --- src/gateway/api/routes/messages.py | 29 +++++---- .../integration/test_hybrid_files_messages.py | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 8492769eff..9da9afcaf1 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -304,6 +304,13 @@ def _anthropic_error( } +def _files_error(exc: FilesError) -> HTTPException: + """Carry a Files failure into the Anthropic envelope with its status and headers intact.""" + return _anthropic_error( + _STATUS_TO_ANTHROPIC_TYPE.get(exc.status_code, _ERR_API), exc.detail, exc.status_code, headers=exc.headers + ) + + def _ensure_anthropic_error(exc: HTTPException) -> HTTPException: """Re-wrap a plain-string ``HTTPException`` in the Anthropic error envelope, preserving the status code and headers (e.g. a 429's ``Retry-After``). @@ -638,6 +645,14 @@ def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str return result async def _binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: + # Raised as an HTTPException so the attempt runners, which map any other + # exception to a generic provider failure, hand it back unchanged. + try: + return await self._prepare_binder(kwargs) + except FilesError as exc: + raise _files_error(exc) from None + + async def _prepare_binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: attempt = kwargs.pop("_file_attempt") if attempt.provider != "anthropic" or not attempt.provider_account_generation_id: raise FilesError(403, "Provider file outputs require an authorized Anthropic account") @@ -876,12 +891,7 @@ async def _normalize( ctx.route.attempts, ctx.route.fallback_enabled = [selected], False adapter = _FileMessagesAdapter(client, ctx.route.request_id, references) except FilesError as exc: - raise _anthropic_error( - _STATUS_TO_ANTHROPIC_TYPE.get(exc.status_code, _ERR_API), - exc.detail, - exc.status_code, - headers=exc.headers, - ) from None + raise _files_error(exc) from None tool_ctx = await prepare_gateway_tools( adapter=adapter, @@ -1002,12 +1012,7 @@ async def _normalize( try: await adapter.finalize_outputs(result) except FilesError as exc: - raise _anthropic_error( - _STATUS_TO_ANTHROPIC_TYPE.get(exc.status_code, _ERR_API), - exc.detail, - exc.status_code, - headers=exc.headers, - ) from None + raise _files_error(exc) from None return result.model_dump(exclude_none=True) # Standalone non-stream path diff --git a/tests/integration/test_hybrid_files_messages.py b/tests/integration/test_hybrid_files_messages.py index 0320f13ba1..f0598ff79e 100644 --- a/tests/integration/test_hybrid_files_messages.py +++ b/tests/integration/test_hybrid_files_messages.py @@ -144,3 +144,63 @@ async def register(self: Any, value: Any) -> None: else: assert events.count("provider") == 1 assert events.index("usage") < events.index("register") + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("attempt_count", [1, 2]) +@pytest.mark.parametrize( + "status_code, error_type", + [(403, "permission_error"), (409, "api_error"), (429, "rate_limit_error")], +) +def test_output_preparation_failure_keeps_its_status( + monkeypatch: pytest.MonkeyPatch, stream: bool, attempt_count: int, status_code: int, error_type: str +) -> None: + """A Files refusal before dispatch reaches the caller as itself, not as a provider failure.""" + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + attempts = [_attempt(index, str(uuid.uuid4()), "owned-model", "owned-key") for index in range(attempt_count)] + for attempt in attempts: + attempt["managed"] = False + attempt["provider_account_generation_id"] = str(uuid.uuid4()) + events: list[str] = [] + + async def platform(url: str, **kwargs: Any) -> httpx.Response: + if url.endswith("/resolve"): + return httpx.Response(200, json=_resolve_payload(attempts)) + events.append("usage") + return httpx.Response(200, json={"correlation_id": kwargs["body"]["correlation_id"], "status": "completed"}) + + async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> Any: + events.append(path) + assert path == "outputs/prepare" + raise FilesError(status_code, "Provider file outputs unavailable", headers={"Retry-After": "30"}) + + async def provider(**kwargs: Any) -> Any: + raise AssertionError("the provider must not be called when output preparation fails") + + monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) + monkeypatch.setattr("gateway.api.routes.messages.amessages", provider) + monkeypatch.setattr(PlatformFilesClient, "post", files) + app = app_for( + GatewayConfig( + mode="hybrid", + platform={"base_url": "http://platform.test/api/v1"}, + files_provider_native_enabled=True, + ) + ) + with TestClient(app) as client: + response = client.post( + f"{API_ROOT}/messages", + headers={"Authorization": "Bearer user-token"}, + json={ + "model": "routed-model", + "max_tokens": 100, + "stream": stream, + "container": "container_01ABC", + "messages": [{"role": "user", "content": "Run it."}], + }, + ) + assert response.status_code == status_code, response.text + body = response.json()["detail"]["error"] + assert body == {"type": error_type, "message": "Provider file outputs unavailable"} + assert response.headers["Retry-After"] == "30" + assert events.count("outputs/prepare") == (attempt_count if stream else 1) From 1aa5e12ac0d9073da31167b2d10f835ac1ddc824 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:23:18 +0200 Subject: [PATCH 11/21] fix(files): finalize a blocked BYO retirement once the account drains A secret replacement refused while files were live left the generation retiring, and nothing moved it on: selection answered 409 until an admin repeated the same edit. Add finalize_retirement and run it from cleanup completion and from account selection. Selection is needed as well as cleanup because an account busy only with an output operation never gets a cleanup lease; it drains when the deadline passes. Co-Authored-By: Claude Fable 5.1 --- .../services/provider_files/accounts.py | 23 ++++- .../services/provider_files/cleanup.py | 3 +- .../test_provider_file_transactions.py | 91 ++++++++++++++++++- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/gateway/services/provider_files/accounts.py b/src/gateway/services/provider_files/accounts.py index e5a83bb03a..b031e9f98f 100644 --- a/src/gateway/services/provider_files/accounts.py +++ b/src/gateway/services/provider_files/accounts.py @@ -117,6 +117,8 @@ async def _select_byo(self, scope: FileScope, *, provider: str) -> FileAccount | if key is None: return None generation = await self.repo.latest_account("organization_key", str(key.id), scope.organization_id) + if generation is not None: + await finalize_retirement(self.repo, generation, datetime.now(UTC)) if generation is None or generation.status == "retired": number = generation.generation + 1 if generation is not None else 1 generation = ProviderAccountGeneration( @@ -194,8 +196,19 @@ async def retire_account_generation( now = datetime.now(UTC) row.status = "retiring" await repo.revoke(now, "credential_retirement", organization_id=row.organization_id, generation_id=row.id) - if release_secret and await repo.account_busy(row.id, now): - return True - if release_secret: - row.status, row.retired_at = "retired", now - return False + if not release_secret: + return False + return not await finalize_retirement(repo, row, now) + + +async def finalize_retirement(repo: ProviderFileRepository, row: ProviderAccountGeneration, now: datetime) -> bool: + """Retire a draining generation once nothing on it is live; return whether it did. + + A retirement that was refused because files were still live is finished + here, by cleanup completion and by the next account selection, so the + credential does not stay unusable until an admin repeats the refused edit. + """ + if row.status != "retiring" or await repo.account_busy(row.id, now): + return False + row.status, row.retired_at = "retired", now + return True diff --git a/src/gateway/services/provider_files/cleanup.py b/src/gateway/services/provider_files/cleanup.py index 3eae41ce30..24bfef6743 100644 --- a/src/gateway/services/provider_files/cleanup.py +++ b/src/gateway/services/provider_files/cleanup.py @@ -9,7 +9,7 @@ from pydantic import SecretStr -from gateway.services.provider_files.accounts import FileAccountResolver +from gateway.services.provider_files.accounts import FileAccountResolver, finalize_retirement from gateway.services.provider_files.contracts import CleanupItem, CleanupLease, FileAccount, FilesError, LeaseResult from gateway.services.provider_files.lifecycle import ProviderFileService @@ -87,3 +87,4 @@ async def complete( self.service.apply_cleanup(row, result.results.get(row.id, False)) row.lease_id = None account.lease_id = account.lease_token_hash = account.lease_gateway_id = account.lease_deadline = None + await finalize_retirement(self.repo, account, datetime.now(UTC)) diff --git a/tests/integration/test_provider_file_transactions.py b/tests/integration/test_provider_file_transactions.py index 332002d98a..5528d96374 100644 --- a/tests/integration/test_provider_file_transactions.py +++ b/tests/integration/test_provider_file_transactions.py @@ -1,19 +1,29 @@ """Files transaction boundaries preserve revocation without partially replacing secrets.""" import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock import pytest from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from gateway.core.unit_of_work import UnitOfWork +from gateway.models.provider_files import ProviderFileOutputOperation from gateway.models.provider_keys import OrgProviderKey, OrgProviderKeyUpdateRequest from gateway.models.tenancy import Organization from gateway.models.users import User from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.services.provider_files.accounts import FileAccountResolver -from gateway.services.provider_files.contracts import FileAccount, FileScope, FilesError, OutputPrepare, PrepareUpload +from gateway.services.provider_files.cleanup import ProviderFileCleanup +from gateway.services.provider_files.contracts import ( + FileAccount, + FileScope, + FilesError, + LeaseResult, + OutputPrepare, + PrepareUpload, +) from gateway.services.provider_files.lifecycle import ProviderFileService from gateway.services.provider_files.outputs import ProviderFileOutputs from gateway.services.secret_box import encrypt_secret @@ -160,3 +170,80 @@ async def test_rejected_output_registration_commits_cleanup( await async_db.rollback() binding = await repo.by_provider_id(account.generation_id, "file_late") assert binding is not None and binding.state == "pending_cleanup" + + +async def _blocked_replacement( + async_db: AsyncSession, files: ProviderFileService, scope: FileScope, account: FileAccount +) -> uuid.UUID: + """Refuse a secret replacement while the account is busy; return the key id.""" + repo = ProviderFileRepository(async_db) + organization = await async_db.get(Organization, scope.organization_id) + assert organization is not None + owner = await _member(async_db, organization, role="owner", full_name="Owner") + generation = await repo.account(account.generation_id) + assert generation is not None + key_id = uuid.UUID(generation.credential_ref) + await repo.save( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("original-credential"), + ) + ) + await async_db.commit() + with pytest.raises(TenancyConflictError, match="cleanup must finish"): + await OrgProviderKeyService(async_db).update_key_for_user( + user=owner, key_id=key_id, request=OrgProviderKeyUpdateRequest(api_key="replacement") + ) + await async_db.rollback() + with pytest.raises(FilesError, match="retiring"): + await FileAccountResolver(UnitOfWork(async_db)).select_byo(scope) + return key_id + + +async def test_blocked_retirement_finalizes_when_cleanup_completes( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + files, scope, account = files_setup + operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await files.finalize(scope, operation.id, metadata()) + await _blocked_replacement(async_db, files, scope, account) + + async def resolve(generation_id: uuid.UUID) -> FileAccount: + return account + + cleanup = ProviderFileCleanup(files) + lease = await cleanup.claim(scope.organization_id, scope.gateway_id, resolve_account=resolve) + assert lease is not None and len(lease.items) == 1 + await cleanup.complete( + scope.organization_id, scope.gateway_id, lease.id, LeaseResult(token=lease.token, results={operation.id: True}) + ) + retired = await ProviderFileRepository(async_db).account(account.generation_id) + assert retired is not None and retired.status == "retired" and retired.retired_at is not None + selected = await FileAccountResolver(UnitOfWork(async_db)).select_byo(scope) + assert selected is not None and selected.generation_id != account.generation_id + + +async def test_blocked_retirement_finalizes_on_selection_after_outputs_expire( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + """An account busy only with an output operation never gets a cleanup lease, so selection finishes it.""" + files, scope, account = files_setup + operation = await ProviderFileOutputs(files).prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + await _blocked_replacement(async_db, files, scope, account) + row = await async_db.get(ProviderFileOutputOperation, operation.id) + assert row is not None + row.deadline = datetime.now(UTC) - timedelta(seconds=1) + await async_db.commit() + selected = await FileAccountResolver(UnitOfWork(async_db)).select_byo(scope) + assert selected is not None and selected.generation_id != account.generation_id + retired = await ProviderFileRepository(async_db).account(account.generation_id) + assert retired is not None and retired.status == "retired" From 77c6c363f83515b0e3bd8b064a12328f39133750 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:30:50 +0200 Subject: [PATCH 12/21] fix(files): stop compensating an upload once finalize has committed Finalize commits the binding on the platform before it returns. A failure after that, in the envelope conversion or in a context manager exit, still reached the compensation handler, which deleted the provider file and abandoned the operation: a finished upload was destroyed and the caller got an error. Record the finalize boundary and skip compensation past it. Co-Authored-By: Claude Fable 5.1 --- src/gateway/api/routes/hybrid_files.py | 7 +++-- tests/integration/test_hybrid_files_routes.py | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 0c3320364f..02f96b0895 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -128,7 +128,7 @@ async def upload_file(request: Request, config: Config) -> AnthropicFileMetadata headers = envelope.headers(request) operation: Operation | None = None metadata: FileMetadata | None = None - started = False + started = committed = False try: async with asyncio.timeout(config.files_transfer_timeout_seconds): operation = await client.retry( @@ -179,9 +179,12 @@ async def upload_file(request: Request, config: Config) -> AnthropicFileMetadata }, FileMetadata, ) + committed = True return envelope.metadata(finalized) except BaseException as exc: - if operation is not None: + # Past finalize the binding is committed, so a failure here is only the + # response's; compensating would delete an upload that succeeded. + if operation is not None and not committed: await _compensate_upload(client, operation, metadata, headers, started, exc) if isinstance(exc, (FilesError, asyncio.CancelledError, TimeoutError)): raise diff --git a/tests/integration/test_hybrid_files_routes.py b/tests/integration/test_hybrid_files_routes.py index 0ebe0b5083..8814041139 100644 --- a/tests/integration/test_hybrid_files_routes.py +++ b/tests/integration/test_hybrid_files_routes.py @@ -13,7 +13,7 @@ from pydantic import SecretStr from gateway.api.deps import get_config -from gateway.api.routes import hybrid_files +from gateway.api.routes import _file_formats, hybrid_files from gateway.core.config import API_ROOT, GatewayConfig from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import ( @@ -25,6 +25,7 @@ ResolvedFile, WireModel, ) +from gateway.services.provider_files.transfers import receive_upload @pytest.fixture @@ -160,6 +161,34 @@ async def fail(self: Any, path: str, body: dict[str, Any], result_type: type[Any assert events[-1].endswith("/abandon") +@pytest.mark.parametrize("failure", ["conversion", "context-exit"]) +def test_failure_after_finalize_does_not_compensate( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + """Finalize commits the binding, so a later failure must not delete the upload.""" + client, events = file_client + if failure == "conversion": + + def convert(self: Any, value: Any) -> Any: + raise FilesError(502, "Provider returned invalid file metadata") + + monkeypatch.setattr(_file_formats.AnthropicFilesFormat, "metadata", convert) + else: + original = receive_upload + + @asynccontextmanager + async def leaky(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: + async with original(*args, **kwargs) as value: + yield value + raise RuntimeError("upload context failed on exit") + + monkeypatch.setattr(hybrid_files, "receive_upload", leaky) + response = client.post(API_ROOT + "/files", headers=HEADERS, files={"file": ("example.csv", b"data")}) + assert response.status_code == 502, response.text + assert events[-1].endswith("/finalize") + assert "provider-delete" not in events + + @pytest.mark.parametrize("oversized", ["metadata", "content-length", None]) def test_oversized_download_is_refused_before_the_body( file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, oversized: str | None From de0a159b37eae57e038ea7798d859025549d2249 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:45:03 +0200 Subject: [PATCH 13/21] fix(files): align storage and operator contracts Use 64-bit database columns for provider file byte accounting, publish the OpenAI expiry bounds in the generated schema, and describe all operator-visible native Files settings.\n\nAdd regression coverage for database types, OpenAPI constraints, and settings metadata. --- .../c3e5a7b9d1f4_add_provider_files.py | 4 +- docs/public/openapi.json | 3 +- scripts/generate_openapi.py | 3 +- src/gateway/core/config.py | 40 +++++++++++++++---- src/gateway/models/provider_files.py | 6 +-- tests/unit/test_provider_file_models.py | 14 +++++++ tests/unit/test_provider_file_openapi.py | 8 ++++ tests/unit/test_settings_endpoint.py | 10 +++++ 8 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_provider_file_models.py diff --git a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py index 2317abc20b..79ff7a39ca 100644 --- a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py +++ b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py @@ -80,7 +80,7 @@ def upgrade() -> None: sa.Column("deadline", sa.DateTime(timezone=True), nullable=False), sa.Column("state", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False), sa.Column("reserved_files", sa.Integer(), nullable=False), - sa.Column("reserved_bytes", sa.Integer(), nullable=False), + sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), sa.ForeignKeyConstraint( ["provider_account_generation_id"], ["provider_account_generations.id"], ondelete="RESTRICT" ), @@ -121,7 +121,7 @@ def upgrade() -> None: sa.Column("encrypted_metadata", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), sa.Column("provider_created_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), sa.Column("downloadable", sa.Boolean(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("provider_expires_at", sa.DateTime(timezone=True), nullable=True), diff --git a/docs/public/openapi.json b/docs/public/openapi.json index c33516ecf7..c9fbfa362e 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1796,7 +1796,8 @@ }, "expires_after[seconds]": { "description": "OpenAI hybrid retention, capped by the control-plane maximum.", - "minimum": 1, + "maximum": 2592000, + "minimum": 3600, "type": "integer" }, "expires_in_seconds": { diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 3fc300c84f..190d45c242 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -175,7 +175,8 @@ def _merge_hybrid_files(spec: dict[str, Any]) -> None: } properties["expires_after[seconds]"] = { "type": "integer", - "minimum": 1, + "minimum": 3600, + "maximum": 2592000, "description": "OpenAI hybrid retention, capped by the control-plane maximum.", } diff --git a/src/gateway/core/config.py b/src/gateway/core/config.py index df8ee4e117..af42464899 100644 --- a/src/gateway/core/config.py +++ b/src/gateway/core/config.py @@ -880,15 +880,41 @@ class GatewayConfig(BudgetSettings, PricingSettings, BaseSettings): default=False, description="Enable hybrid provider-native Files after provider and control-plane contract verification.", ) - files_transfer_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=300, ge=1) - files_idle_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=30, ge=1) - files_rate_limit_rpm: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=60, ge=1) - files_max_count: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field(default=None, ge=1) - files_max_outstanding_bytes: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field(default=None, ge=1) + files_transfer_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field( + default=300, + ge=1, + description="Maximum total seconds allowed for each provider file upload or download transfer.", + ) + files_idle_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field( + default=30, + ge=1, + description="Maximum seconds a provider file transfer may remain idle between chunks.", + ) + files_rate_limit_rpm: Annotated[int, Shown(SettingsGroup.FILES)] = Field( + default=60, + ge=1, + description="Maximum provider file operations per uploader and workspace per minute.", + ) + files_max_count: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field( + default=None, + ge=1, + description="Maximum active provider files per uploader and workspace; required in hosted mode.", + ) + files_max_outstanding_bytes: Annotated[int | None, Shown(SettingsGroup.FILES)] = Field( + default=None, + ge=1, + description="Maximum bytes reserved or stored per uploader and workspace; required in hosted mode.", + ) files_temporary_capacity_bytes: Annotated[int, Shown(SettingsGroup.FILES)] = Field( - default=2 * 1024 * 1024 * 1024, ge=1 + default=2 * 1024 * 1024 * 1024, + ge=1, + description="Shared byte capacity for in-progress provider file uploads on this gateway.", + ) + files_operation_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field( + default=600, + ge=1, + description="Seconds before an unfinished provider file operation expires and becomes eligible for cleanup.", ) - files_operation_timeout_seconds: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=600, ge=1) files_diagnostic_retention_days: Annotated[int, Shown(SettingsGroup.FILES)] = Field(default=30, ge=1, le=365) @model_validator(mode="after") diff --git a/src/gateway/models/provider_files.py b/src/gateway/models/provider_files.py index c026b9dec1..b859c89cc8 100644 --- a/src/gateway/models/provider_files.py +++ b/src/gateway/models/provider_files.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import Index, UniqueConstraint +from sqlalchemy import BigInteger, Index, UniqueConstraint from sqlmodel import Field, SQLModel from gateway.models.base import CreatedAtMixin, PrimaryKeyMixin, UpdatedAtMixin, UtcDateTime @@ -57,7 +57,7 @@ class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMi encrypted_metadata: str | None = None purpose: str | None = Field(default=None, max_length=255) provider_created_at: datetime | None = Field(default=None, sa_type=UtcDateTime) - size_bytes: int = 0 + size_bytes: int = Field(default=0, sa_type=BigInteger) downloadable: bool = False expires_at: datetime = Field(sa_type=UtcDateTime) provider_expires_at: datetime | None = Field(default=None, sa_type=UtcDateTime) @@ -89,7 +89,7 @@ class ProviderFileOutputOperation(SQLModel, PrimaryKeyMixin, CreatedAtMixin, tab deadline: datetime = Field(sa_type=UtcDateTime) state: str = Field(default="active", max_length=16) reserved_files: int - reserved_bytes: int + reserved_bytes: int = Field(sa_type=BigInteger) class ProviderFileRateWindow(SQLModel, table=True): diff --git a/tests/unit/test_provider_file_models.py b/tests/unit/test_provider_file_models.py new file mode 100644 index 0000000000..a6673b367f --- /dev/null +++ b/tests/unit/test_provider_file_models.py @@ -0,0 +1,14 @@ +"""Provider file byte counters use database types that hold configured quotas.""" + +from sqlalchemy import BigInteger +from sqlmodel import SQLModel + +from gateway.models import provider_files # noqa: F401 + + +def test_provider_file_byte_columns_use_big_integers() -> None: + bindings = SQLModel.metadata.tables["provider_file_bindings"] + outputs = SQLModel.metadata.tables["provider_file_output_operations"] + + assert isinstance(bindings.c.size_bytes.type, BigInteger) + assert isinstance(outputs.c.reserved_bytes.type, BigInteger) diff --git a/tests/unit/test_provider_file_openapi.py b/tests/unit/test_provider_file_openapi.py index 811e9ea7d6..c2bd7ca00b 100644 --- a/tests/unit/test_provider_file_openapi.py +++ b/tests/unit/test_provider_file_openapi.py @@ -35,3 +35,11 @@ def test_public_file_contracts_are_published() -> None: assert {"after", "before", "order", "purpose", "page", "ids[]"} <= { parameter["name"] for parameter in listing["parameters"] } + upload_schema = paths[API_ROOT + "/files"]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] + body_schema = spec["components"]["schemas"][upload_schema["$ref"].split("/")[-1]] + assert body_schema["properties"]["expires_after[seconds]"] == { + "type": "integer", + "minimum": 3600, + "maximum": 2592000, + "description": "OpenAI hybrid retention, capped by the control-plane maximum.", + } diff --git a/tests/unit/test_settings_endpoint.py b/tests/unit/test_settings_endpoint.py index 113010607a..e084df5bcc 100644 --- a/tests/unit/test_settings_endpoint.py +++ b/tests/unit/test_settings_endpoint.py @@ -235,6 +235,16 @@ def test_settings_includes_full_config_view(tmp_path: Path) -> None: # Fields carry a group, description, and a display type. assert by_key["require_pricing"]["group"] == "Metering & budgets" assert by_key["require_pricing"]["description"] + for name in ( + "files_transfer_timeout_seconds", + "files_idle_timeout_seconds", + "files_rate_limit_rpm", + "files_max_count", + "files_max_outstanding_bytes", + "files_temporary_capacity_bytes", + "files_operation_timeout_seconds", + ): + assert by_key[name]["description"], name assert by_key["port"]["type"] == "int" assert by_key["cors_allow_origins"]["type"] == "list" assert by_key["stream_missing_usage_policy"]["type"] == "str" From 9539bb66aa169fae34ab4c8bad55a8bd502399b8 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 14:57:28 +0200 Subject: [PATCH 14/21] fix(files): keep active keys unchanged on restore Return an already-active provider key before retiring its file account generation. Add regression coverage proving active bindings and account state remain unchanged while archived restore behavior stays intact. --- .../tenancy/org_provider_key_service.py | 2 ++ .../test_provider_file_transactions.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/gateway/services/tenancy/org_provider_key_service.py b/src/gateway/services/tenancy/org_provider_key_service.py index f3a12cc8b7..1ebc44e52f 100644 --- a/src/gateway/services/tenancy/org_provider_key_service.py +++ b/src/gateway/services/tenancy/org_provider_key_service.py @@ -544,6 +544,8 @@ async def restore_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro key = await self.keys.get_in_organization(key_id, organization.id) if key is None: raise OrgProviderKeyNotFoundError(key_id) + if key.archived_at is None: + return key.to_public(usable=key_is_usable(key)) async with self.uow: blocked = await retire_byo_account(self.uow, key, release_secret=True) diff --git a/tests/integration/test_provider_file_transactions.py b/tests/integration/test_provider_file_transactions.py index 5528d96374..f93e9bc5da 100644 --- a/tests/integration/test_provider_file_transactions.py +++ b/tests/integration/test_provider_file_transactions.py @@ -83,6 +83,39 @@ async def test_refused_secret_release_commits_revocation( assert binding is not None and binding.state == "pending_cleanup" +async def test_restore_active_key_preserves_provider_file_account( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + files, scope, account = files_setup + repo = ProviderFileRepository(async_db) + organization = await async_db.get(Organization, scope.organization_id) + assert organization is not None + owner = await _member(async_db, organization, role="owner", full_name="Owner") + generation = await repo.account(account.generation_id) + assert generation is not None + key_id = uuid.UUID(generation.credential_ref) + await repo.save( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("original-credential"), + ) + ) + await async_db.commit() + operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await files.finalize(scope, operation.id, metadata()) + + restored = await OrgProviderKeyService(async_db).restore_key_for_user(user=owner, key_id=key_id) + + stored_generation = await repo.account(account.generation_id) + binding = await repo.get(operation.id) + assert restored.archived_at is None + assert stored_generation is not None and stored_generation.status == "active" + assert binding is not None and binding.state == "active" + + async def test_secret_update_failure_rolls_back_retirement( async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount], From e5d3688a58c22e203f3ec5cacb3e19ec2b34fdf5 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 15:14:40 +0200 Subject: [PATCH 15/21] fix(files): use public credential extractor --- src/gateway/api/routes/hybrid_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 02f96b0895..88fb03476a 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -13,7 +13,7 @@ from fastapi.routing import APIRoute from starlette.types import Receive, Scope, Send -from gateway.api.deps import _extract_bearer_token, get_config +from gateway.api.deps import extract_credential_token, get_config from gateway.api.routes._file_formats import ( AnthropicFileDeleted, AnthropicFileMetadata, @@ -102,7 +102,7 @@ def files_client(request: Request, config: GatewayConfig) -> PlatformFilesClient if not config.files_enabled or not config.files_provider_native_enabled: raise FilesError(404, "Provider-native Files are not enabled") files_format(request) - token = _extract_bearer_token(request, config) + token = extract_credential_token(request) base = config.platform.get("base_url") if not base or not config.platform_token: raise FilesError(502, "Authorization service unavailable") From d4ae70b871ad0ace6ff6b814ff09133770909531 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 15:49:53 +0200 Subject: [PATCH 16/21] test(files): align contracts with native file support --- tests/unit/test_operator_gate_declarations.py | 1 + tests/unit/test_setting_names.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/unit/test_operator_gate_declarations.py b/tests/unit/test_operator_gate_declarations.py index ae5c636372..9b4115eed6 100644 --- a/tests/unit/test_operator_gate_declarations.py +++ b/tests/unit/test_operator_gate_declarations.py @@ -109,6 +109,7 @@ "chat.router": _DATA_PLANE, "embeddings.router": _DATA_PLANE, "files.router": _DATA_PLANE, + "hybrid_files.router": _DATA_PLANE, "images.router": _DATA_PLANE, "mcp.router": _DATA_PLANE, "messages.router": _DATA_PLANE, diff --git a/tests/unit/test_setting_names.py b/tests/unit/test_setting_names.py index 45d698412b..4643e57157 100644 --- a/tests/unit/test_setting_names.py +++ b/tests/unit/test_setting_names.py @@ -47,13 +47,22 @@ "enable_metrics", "file_understanding_enabled", "files_backend", + "files_diagnostic_retention_days", "files_enabled", + "files_idle_timeout_seconds", "files_local_dir", "files_max_bytes", + "files_max_count", + "files_max_outstanding_bytes", + "files_operation_timeout_seconds", + "files_provider_native_enabled", + "files_rate_limit_rpm", "files_retention_hours", "files_s3_bucket", "files_s3_endpoint_url", "files_s3_region", + "files_temporary_capacity_bytes", + "files_transfer_timeout_seconds", "guardrails_url", "host", "invitation_expiry_hours", From fa4609276e1b0ca0942c25c65ad22a27eab9d690 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 17:43:17 +0200 Subject: [PATCH 17/21] fix(files): preserve authorized input-only dispatch Reject Files authority credentials that differ from the authorized inference attempt. Reserve and register outputs only for native-output requests, preserving account pinning and trusted workspace headers for input-only inference. Cover credential mismatches and full-quota input-only dispatch across streaming and tool-loop paths. --- src/gateway/api/routes/messages.py | 45 +++++-- .../integration/test_hybrid_files_messages.py | 119 +++++++++++++++++- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 9da9afcaf1..bb16ad3648 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -621,16 +621,28 @@ def _reject_container_on_managed_credential(ctx: RequestContext) -> None: class _FileMessagesAdapter(_MessagesAdapter): - def __init__(self, client: PlatformFilesClient, request_id: str, references: list[str]) -> None: + def __init__( + self, + client: PlatformFilesClient, + request_id: str, + references: list[str], + *, + native_outputs: bool, + reference_account: FileAccount | None, + ) -> None: self.files_client = client self.files_request_id = request_id self.file_references = references + self.native_outputs = native_outputs + self.reference_account = reference_account self.pending_binder: AnthropicFileOutputBinder | None = None def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str, Any]) -> dict[str, Any]: result = super().attempt_kwargs(attempt, base_request_fields) result["api_key"], result["api_base"] = attempt.api_key, attempt.api_base result["client_args"] = {"max_retries": 0} + if self.reference_account is not None and self.reference_account.workspace is not None: + result["client_args"]["default_headers"] = {"anthropic-workspace-id": self.reference_account.workspace} supplied = result.get("extra_headers") result["extra_headers"] = ( { @@ -644,7 +656,7 @@ def attempt_kwargs(self, attempt: ResolvedAttempt, base_request_fields: dict[str result["_file_attempt"] = attempt return result - async def _binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: + async def _binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder | None: # Raised as an HTTPException so the attempt runners, which map any other # exception to a generic provider failure, hand it back unchanged. try: @@ -652,10 +664,12 @@ async def _binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: except FilesError as exc: raise _files_error(exc) from None - async def _prepare_binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder: + async def _prepare_binder(self, kwargs: dict[str, Any]) -> AnthropicFileOutputBinder | None: attempt = kwargs.pop("_file_attempt") if attempt.provider != "anthropic" or not attempt.provider_account_generation_id: raise FilesError(403, "Provider file outputs require an authorized Anthropic account") + if not self.native_outputs: + return None operation = await self.files_client.post( "outputs/prepare", { @@ -682,7 +696,8 @@ async def call_provider(self, kwargs: dict[str, Any]) -> MessageResponse: try: result = await super().call_provider(kwargs) except BaseException: - await binder.complete() + if binder is not None: + await binder.complete() raise self.pending_binder = binder return result @@ -700,9 +715,10 @@ async def open_provider_stream(self, kwargs: dict[str, Any]) -> AsyncIterator[Me try: stream = await super().open_provider_stream(kwargs) except BaseException: - await binder.complete() + if binder is not None: + await binder.complete() raise - return binder.stream(stream) + return binder.stream(stream) if binder is not None else stream async def run_tool_loop( self, @@ -725,7 +741,8 @@ async def run_tool_loop( web_search_budget=web_search_budget, ) except BaseException: - await binder.complete() + if binder is not None: + await binder.complete() raise self.pending_binder = binder return result @@ -748,7 +765,7 @@ async def stream() -> AsyncIterator[MessageStreamEvent]: emit_native_web_search=emit_native_web_search, web_search_budget=web_search_budget, ) - async for event in binder.stream(source): + async for event in binder.stream(source) if binder is not None else source: yield event return stream() @@ -872,6 +889,7 @@ async def _normalize( raise FilesError(400, "Hybrid provider file references and native outputs are not enabled") assert ctx.route is not None and ctx.user_token is not None client = PlatformFilesClient(config.platform["base_url"], config.platform_token or "", ctx.user_token) + account = None if references: account = await client.post( "references/resolve", {"ids": references, "provider": "anthropic"}, FileAccount @@ -886,10 +904,17 @@ async def _normalize( if not attempts: raise FilesError(403, "File account is not authorized by the requested model policy") selected = attempts[0] - selected.api_key, selected.api_base = account.api_key.get_secret_value(), account.api_base + if selected.api_key != account.api_key.get_secret_value() or selected.api_base != account.api_base: + raise FilesError(409, "Inference provider account changed before dispatch") selected.extra_params = None ctx.route.attempts, ctx.route.fallback_enabled = [selected], False - adapter = _FileMessagesAdapter(client, ctx.route.request_id, references) + adapter = _FileMessagesAdapter( + client, + ctx.route.request_id, + references, + native_outputs=native_outputs, + reference_account=account, + ) except FilesError as exc: raise _files_error(exc) from None diff --git a/tests/integration/test_hybrid_files_messages.py b/tests/integration/test_hybrid_files_messages.py index f0598ff79e..a4699b7052 100644 --- a/tests/integration/test_hybrid_files_messages.py +++ b/tests/integration/test_hybrid_files_messages.py @@ -1,11 +1,14 @@ """File ownership gates dispatch and output failures preserve inference accounting.""" import uuid +from collections.abc import AsyncIterator from datetime import UTC, datetime, timedelta from typing import Any +from unittest.mock import AsyncMock import httpx import pytest +from any_llm.types.messages import MessageDelta, MessageDeltaEvent, MessageDeltaUsage from fastapi.testclient import TestClient from pydantic import SecretStr @@ -25,6 +28,8 @@ ("foreign", 404, "not_found_error"), ("wrong_generation", 403, "permission_error"), ("wrong_provider", 502, "api_error"), + ("wrong_key", 409, "api_error"), + ("wrong_base", 409, "api_error"), ("registration_failure", 502, "api_error"), ("reference_failure", 400, "invalid_request_error"), ("reference_failure", 401, "authentication_error"), @@ -44,13 +49,16 @@ def test_file_reference_dispatch_and_accounting( generation = uuid.uuid4() account = FileAccount( generation_id=generation, - api_key=SecretStr("owned-key"), + api_key=SecretStr("different-key" if outcome == "wrong_key" else "owned-key"), + api_base="https://different.example" if outcome == "wrong_base" else None, provider="openai" if outcome == "wrong_provider" else "anthropic", ) attempts = [ _attempt(0, str(uuid.uuid4()), "other-model", "other-key"), _attempt(1, str(uuid.uuid4()), "owned-model", "owned-key"), ] + for attempt in attempts: + attempt["managed"] = False attempts[1]["provider_account_generation_id"] = str(uuid.uuid4() if outcome == "wrong_generation" else generation) events: list[str] = [] @@ -74,6 +82,8 @@ async def platform(url: str, **kwargs: Any) -> httpx.Response: ) async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> Any: + if path == "cleanup/claim": + return {"lease": None} events.append(path) if path == "references/resolve": assert body == {"ids": ["file_history"], "provider": "anthropic"} @@ -124,6 +134,7 @@ async def register(self: Any, value: Any) -> None: json={ "model": "routed-model", "max_tokens": 100, + **({"container": "container_01ABC"} if outcome == "registration_failure" else {}), "messages": [ { "role": "user", @@ -139,11 +150,113 @@ async def register(self: Any, value: Any) -> None: assert response.json()["detail"]["error"]["type"] == error_type if outcome in {"reference_failure", "registration_failure"}: assert response.headers["Retry-After"] == "30" - if outcome in {"foreign", "wrong_generation", "wrong_provider", "reference_failure"}: + if outcome in {"foreign", "wrong_generation", "wrong_provider", "wrong_key", "wrong_base", "reference_failure"}: assert "provider" not in events + assert "outputs/prepare" not in events else: assert events.count("provider") == 1 - assert events.index("usage") < events.index("register") + if outcome == "registration_failure": + assert events.index("usage") < events.index("register") + else: + assert events == ["references/resolve", "provider", "usage"] + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("tool_loop", [False, True]) +def test_input_only_dispatch_skips_full_output_quota( + monkeypatch: pytest.MonkeyPatch, stream: bool, tool_loop: bool +) -> None: + monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token") + generation = uuid.uuid4() + account = FileAccount(generation_id=generation, api_key=SecretStr("owned-key"), workspace="trusted-workspace") + attempt = _attempt(0, str(uuid.uuid4()), "owned-model", "owned-key") + attempt["provider_account_generation_id"] = str(generation) + events: list[str] = [] + + async def platform(url: str, **kwargs: Any) -> httpx.Response: + if url.endswith("/resolve"): + return httpx.Response(200, json=_resolve_payload([attempt])) + events.append("usage") + return httpx.Response(200, json={"correlation_id": kwargs["body"]["correlation_id"], "status": "completed"}) + + async def files(self: Any, path: str, body: dict[str, Any], result_type: Any) -> Any: + if path == "cleanup/claim": + return {"lease": None} + events.append(path) + if path == "references/resolve": + return account + if path == "outputs/prepare": + raise FilesError(429, "File capacity exceeded") + raise AssertionError(f"Unexpected Files call: {path}") + + def check_dispatch(kwargs: dict[str, Any]) -> None: + events.append("provider") + assert kwargs["api_key"] == "owned-key" + assert kwargs["model"] == "anthropic:owned-model" + assert kwargs["client_args"] == { + "max_retries": 0, + "default_headers": {"anthropic-workspace-id": "trusted-workspace"}, + } + assert "_file_attempt" not in kwargs + assert not {"anthropic-workspace-id", "x-api-key"} & kwargs.get("extra_headers", {}).keys() + + async def chunks() -> AsyncIterator[MessageDeltaEvent]: + yield MessageDeltaEvent( + type="message_delta", + delta=MessageDelta(stop_reason="end_turn", stop_sequence=None), + usage=MessageDeltaUsage(input_tokens=3, output_tokens=5), + ) + + async def provider(**kwargs: Any) -> Any: + check_dispatch(kwargs) + return chunks() if stream else _message_response() + + async def loop(**kwargs: Any) -> Any: + check_dispatch(kwargs["completion_kwargs"]) + return _message_response() + + async def loop_stream(**kwargs: Any) -> AsyncIterator[MessageDeltaEvent]: + check_dispatch(kwargs["completion_kwargs"]) + async for event in chunks(): + yield event + + monkeypatch.setattr("gateway.api.routes._platform._post_platform", platform) + monkeypatch.setattr(PlatformFilesClient, "post", files) + monkeypatch.setattr("gateway.api.routes.messages.amessages", provider) + monkeypatch.setattr("gateway.api.routes.messages.anthropic_tool_loop", loop) + monkeypatch.setattr("gateway.api.routes.messages.anthropic_tool_loop_stream", loop_stream) + monkeypatch.setattr( + "gateway.services.mcp_client.MCPClientPool.__aenter__", + AsyncMock(return_value=AsyncMock(purpose_hints=lambda: [])), + ) + monkeypatch.setattr("gateway.services.mcp_client.MCPClientPool.__aexit__", AsyncMock(return_value=None)) + app = app_for( + GatewayConfig( + mode="hybrid", + platform={"base_url": "http://platform.test/api/v1"}, + files_provider_native_enabled=True, + ) + ) + with TestClient(app) as client: + response = client.post( + f"{API_ROOT}/messages", + headers={"Authorization": "Bearer user-token"}, + json={ + "model": "routed-model", + "max_tokens": 100, + "stream": stream, + "messages": [ + { + "role": "user", + "content": [{"type": "document", "source": {"type": "file", "file_id": "file_history"}}], + } + ], + "extra_headers": {"anthropic-workspace-id": "foreign", "x-api-key": "foreign"}, + **({"mcp_servers": [{"name": "test", "url": "http://127.0.0.1:18080/mcp"}]} if tool_loop else {}), + }, + ) + assert response.status_code == 200, response.text + assert events == ["references/resolve", "provider", "usage"] @pytest.mark.parametrize("stream", [False, True]) From e28ad2a8e76f5e7bdd55d6d1a6de7e535d45abed Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 17:43:17 +0200 Subject: [PATCH 18/21] fix(files): recheck workspace credentials on resolution Check the current workspace override under the organization lock before returning a BYO credential. Preserve cleanup access after disablement and cover an override change between reference authorization and credential resolution. --- .../tenancy/provider_file_repository.py | 11 ++++ .../services/provider_files/accounts.py | 4 ++ .../test_provider_file_transactions.py | 61 ++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/gateway/repositories/tenancy/provider_file_repository.py b/src/gateway/repositories/tenancy/provider_file_repository.py index fec3c1c447..ce50a1771d 100644 --- a/src/gateway/repositories/tenancy/provider_file_repository.py +++ b/src/gateway/repositories/tenancy/provider_file_repository.py @@ -52,6 +52,17 @@ async def key_candidates( async def provider_key(self, key_id: uuid.UUID) -> OrgProviderKey | None: return await self.db.get(OrgProviderKey, key_id) + async def workspace_key_disabled(self, workspace_id: uuid.UUID, key_id: uuid.UUID) -> bool: + disabled = ( + await self.db.execute( + select(col(WorkspaceProviderKeyOverride.disabled)).where( + col(WorkspaceProviderKeyOverride.workspace_id) == workspace_id, + col(WorkspaceProviderKeyOverride.org_provider_key_id) == key_id, + ) + ) + ).scalar_one_or_none() + return disabled is True + async def expire_bindings(self, organization_id: uuid.UUID, now: datetime, diagnostic_seconds: int) -> None: await self.db.execute( update(ProviderFileBinding) diff --git a/src/gateway/services/provider_files/accounts.py b/src/gateway/services/provider_files/accounts.py index b031e9f98f..8232e991ff 100644 --- a/src/gateway/services/provider_files/accounts.py +++ b/src/gateway/services/provider_files/accounts.py @@ -93,6 +93,10 @@ async def resolve( raise FilesError(404, "Provider account unavailable") provider = row.provider if row.credential_source == "organization_key": + if not cleanup and await self.repo.workspace_key_disabled( + scope.workspace_id, uuid.UUID(row.credential_ref) + ): + raise FilesError(404, "Provider account unavailable") return await self.resolve_byo(generation_id, scope.organization_id, cleanup=cleanup) if resolve_hosted is None: raise FilesError(404, "Provider account unavailable") diff --git a/tests/integration/test_provider_file_transactions.py b/tests/integration/test_provider_file_transactions.py index f93e9bc5da..eddae0bf71 100644 --- a/tests/integration/test_provider_file_transactions.py +++ b/tests/integration/test_provider_file_transactions.py @@ -10,7 +10,7 @@ from gateway.core.unit_of_work import UnitOfWork from gateway.models.provider_files import ProviderFileOutputOperation -from gateway.models.provider_keys import OrgProviderKey, OrgProviderKeyUpdateRequest +from gateway.models.provider_keys import OrgProviderKey, OrgProviderKeyUpdateRequest, WorkspaceProviderKeyOverride from gateway.models.tenancy import Organization from gateway.models.users import User from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository @@ -181,6 +181,65 @@ async def test_account_resolution_rechecks_revoked_scope( assert cleanup.generation_id == account.generation_id +@pytest.mark.parametrize("disabled", [False, True]) +async def test_account_resolution_rechecks_workspace_override_after_references( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], + disabled: bool, +) -> None: + files, scope, account = files_setup + repo = ProviderFileRepository(async_db) + generation = await repo.account(account.generation_id) + assert generation is not None + key_id = uuid.UUID(generation.credential_ref) + await repo.save( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("original-credential"), + ) + ) + await async_db.commit() + operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + data = metadata() + await files.finalize(scope, operation.id, data) + authorized_generation = await files.references(scope, [data.id]) + + # An override mutation commits between binding authorization and credential resolution. + await repo.lock_organization(scope.organization_id) + await repo.save( + WorkspaceProviderKeyOverride( + organization_id=scope.organization_id, + workspace_id=scope.workspace_id, + org_provider_key_id=key_id, + disabled=disabled, + ) + ) + if disabled: + await repo.revoke( + datetime.now(UTC), + "workspace_credential_disabled", + organization_id=scope.organization_id, + workspace_id=scope.workspace_id, + generation_id=authorized_generation, + ) + await async_db.commit() + + resolver = FileAccountResolver(files.uow) + if disabled: + with pytest.raises(FilesError, match="unavailable") as failure: + await resolver.resolve(scope, authorized_generation) + assert failure.value.status_code == 404 + else: + resolved = await resolver.resolve(scope, authorized_generation) + assert resolved.api_key.get_secret_value() == "original-credential" + cleanup = await resolver.resolve(scope, authorized_generation, cleanup=True) + assert cleanup.generation_id == authorized_generation + assert cleanup.api_key.get_secret_value() == "original-credential" + + async def test_rejected_output_registration_commits_cleanup( async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] ) -> None: From 3e771f269d6ba6f512a4cc50ae9c2a2a8cd1583a Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 17:43:17 +0200 Subject: [PATCH 19/21] fix(files): reserve time for upload abandonment Give provider deletion and abandonment independent ten-second deadlines within shielded compensation. Report known file metadata even when deletion times out, and cover both phase timeouts with handler cancellation. --- src/gateway/api/routes/hybrid_files.py | 43 ++++++------ tests/unit/test_hybrid_file_compensation.py | 74 ++++++++++++++------- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index 88fb03476a..c8f317c077 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -205,37 +205,32 @@ async def compensate() -> None: deleted = False if metadata is not None and not (isinstance(failure, FilesError) and failure.status_code == 409): try: - async with provider_client(operation.account) as provider: + async with asyncio.timeout(10), provider_client(operation.account) as provider: await provider.adelete_file(metadata.id, max_retries=0, extra_headers=headers) deleted = True except Exception as exc: deleted = provider_error(exc).status_code == 404 try: - await client.retry( - f"uploads/{operation.id}/abandon", - { - "cleanup_token": operation.cleanup_token.get_secret_value(), - "metadata": metadata.model_dump(mode="json", exclude_unset=True) if metadata else None, - "deleted": deleted, - "outcome_unknown": started - and metadata is None - and ( - not isinstance(failure, Exception) - or provider_error(failure).status_code not in {400, 404, 413, 429} - ), - }, - WireModel, - ) - except FilesError: - pass - - async def bounded_compensate() -> None: - try: - await asyncio.wait_for(compensate(), timeout=20) - except TimeoutError: + async with asyncio.timeout(10): + await client.retry( + f"uploads/{operation.id}/abandon", + { + "cleanup_token": operation.cleanup_token.get_secret_value(), + "metadata": metadata.model_dump(mode="json", exclude_unset=True) if metadata else None, + "deleted": deleted, + "outcome_unknown": started + and metadata is None + and ( + not isinstance(failure, Exception) + or provider_error(failure).status_code not in {400, 404, 413, 429} + ), + }, + WireModel, + ) + except (FilesError, TimeoutError): pass - await asyncio.shield(asyncio.create_task(bounded_compensate())) + await asyncio.shield(asyncio.create_task(compensate())) @router.get("/files", response_model=AnthropicFilePage | OpenAIFilePage, response_model_exclude_unset=True) diff --git a/tests/unit/test_hybrid_file_compensation.py b/tests/unit/test_hybrid_file_compensation.py index 27d3fcdcc0..a5a6dfb8e7 100644 --- a/tests/unit/test_hybrid_file_compensation.py +++ b/tests/unit/test_hybrid_file_compensation.py @@ -1,8 +1,8 @@ -"""Upload compensation survives handler cancellation but remains time-bounded.""" +"""Upload compensation survives cancellation and reserves time for abandonment.""" import asyncio import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Coroutine from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from typing import Any @@ -17,12 +17,15 @@ @pytest.mark.asyncio @pytest.mark.parametrize("cancel_handler", [False, True]) -@pytest.mark.parametrize("expire", [False, True]) +@pytest.mark.parametrize("expire", [None, "delete", "report"]) async def test_upload_compensation_survives_cancellation_and_honors_timeout( - monkeypatch: pytest.MonkeyPatch, cancel_handler: bool, expire: bool + monkeypatch: pytest.MonkeyPatch, cancel_handler: bool, expire: str | None ) -> None: - entered, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event() + entered, release = asyncio.Event(), asyncio.Event() + reporting, report_release = asyncio.Event(), asyncio.Event() events: list[str] = [] + timers: list[asyncio.Timeout] = [] + tasks: list[asyncio.Task[None]] = [] operation = Operation( id=uuid.uuid4(), cleanup_token=SecretStr("cleanup"), @@ -50,25 +53,35 @@ async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: assert path == f"uploads/{operation.id}/abandon" assert body["metadata"] == {"id": metadata.id} - assert body["deleted"] is True - events.append("reported") + assert body["deleted"] is (expire != "delete") + assert body["outcome_unknown"] is False + reporting.set() + try: + await report_release.wait() + events.append("reported") + except asyncio.CancelledError: + events.append("report-cancelled") + raise return result_type() - real_wait_for = asyncio.wait_for - timer = asyncio.timeout(None) + real_timeout, real_create_task = asyncio.timeout, asyncio.create_task - async def controlled_wait_for(awaitable: Any, timeout: float) -> Any: - assert timeout == 20 - try: - async with timer: - return await awaitable - finally: - finished.set() + def controlled_timeout(delay: float | None) -> asyncio.Timeout: + assert delay == 10 + timer = real_timeout(None) + timers.append(timer) + return timer + + def capture_task(coroutine: Coroutine[Any, Any, None]) -> asyncio.Task[None]: + task = real_create_task(coroutine) + tasks.append(task) + return task monkeypatch.setattr(hybrid_files, "provider_client", provider) monkeypatch.setattr(PlatformFilesClient, "retry", retry) - monkeypatch.setattr(asyncio, "wait_for", controlled_wait_for) - handler = asyncio.create_task( + monkeypatch.setattr(asyncio, "timeout", controlled_timeout) + monkeypatch.setattr(asyncio, "create_task", capture_task) + handler = real_create_task( hybrid_files._compensate_upload( PlatformFilesClient("https://authority", "gateway", "user"), operation, @@ -79,21 +92,34 @@ async def controlled_wait_for(awaitable: Any, timeout: float) -> Any: ) ) try: - await real_wait_for(entered.wait(), timeout=1) + await asyncio.wait_for(entered.wait(), timeout=1) if cancel_handler: handler.cancel() with pytest.raises(asyncio.CancelledError): await handler assert events == [] - if expire: - timer.reschedule(asyncio.get_running_loop().time()) + if expire == "delete": + timers[0].reschedule(asyncio.get_running_loop().time()) else: release.set() - await real_wait_for(finished.wait(), timeout=1) + await asyncio.wait_for(reporting.wait(), timeout=1) + assert len(timers) == 2 + if expire == "report": + timers[1].reschedule(asyncio.get_running_loop().time()) + else: + report_release.set() + await asyncio.wait_for(tasks[0], timeout=1) if not cancel_handler: await handler - assert events == (["delete-cancelled"] if expire else ["deleted", "reported"]) + assert events == [ + "delete-cancelled" if expire == "delete" else "deleted", + "report-cancelled" if expire == "report" else "reported", + ] finally: release.set() + report_release.set() if not handler.done(): - await real_wait_for(handler, timeout=1) + await asyncio.wait_for(handler, timeout=1) + for task in tasks: + if not task.done(): + await asyncio.wait_for(task, timeout=1) From e7cff3d655a38da7d0bbe874711aaac32ee499ec Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 17:53:23 +0200 Subject: [PATCH 20/21] fix(files): preserve error headers and transfer deadlines Return fixed errors for invalid OpenAI metadata and preserve Files protocol headers on authentication failures. Gate hybrid Files only on the native-files flag. Apply the absolute download deadline during provider setup and retain 504 responses for download and deletion timeouts, reporting failed deletion cleanup before returning. Add regression coverage for review comments 6 through 10. Verified with 217 Files/Messages tests, lint, type checking, and OpenAPI/Postman drift checks. --- src/gateway/api/routes/_file_formats.py | 5 +- src/gateway/api/routes/hybrid_files.py | 29 +++- tests/integration/test_hybrid_files_routes.py | 156 ++++++++++++++++++ .../test_provider_files_protocol.py | 42 ++++- 4 files changed, 222 insertions(+), 10 deletions(-) diff --git a/src/gateway/api/routes/_file_formats.py b/src/gateway/api/routes/_file_formats.py index 1d371644d7..045ea3eb96 100644 --- a/src/gateway/api/routes/_file_formats.py +++ b/src/gateway/api/routes/_file_formats.py @@ -162,7 +162,10 @@ def metadata(self, value: FileMetadata) -> OpenAIFileMetadata: for key in ("created_at", "expires_at"): if data.get(key) is not None: data[key] = int(data[key].timestamp()) - return OpenAIFileMetadata.model_validate({**data, "object": "file"}) + try: + return OpenAIFileMetadata.model_validate({**data, "object": "file"}) + except ValidationError: + raise FilesError(502, "Provider returned invalid file metadata") from None def page(self, value: FilePage) -> OpenAIFilePage: return OpenAIFilePage( diff --git a/src/gateway/api/routes/hybrid_files.py b/src/gateway/api/routes/hybrid_files.py index c8f317c077..ba835fc3eb 100644 --- a/src/gateway/api/routes/hybrid_files.py +++ b/src/gateway/api/routes/hybrid_files.py @@ -78,6 +78,16 @@ async def handle(request: Request) -> Response: **exc.headers, }, ) from None + except HTTPException as exc: + raise HTTPException( + exc.status_code, + exc.detail, + headers={ + **{name.lower(): value for name, value in (exc.headers or {}).items()}, + "cache-control": "private, no-store", + "x-otari-files-protocol": FILES_PROTOCOL_VERSION, + }, + ) from None except TimeoutError: raise HTTPException( 504, @@ -99,7 +109,7 @@ async def handle(request: Request) -> Response: def files_client(request: Request, config: GatewayConfig) -> PlatformFilesClient: - if not config.files_enabled or not config.files_provider_native_enabled: + if not config.files_provider_native_enabled: raise FilesError(404, "Provider-native Files are not enabled") files_format(request) token = extract_credential_token(request) @@ -290,16 +300,17 @@ async def download_file(file_id: str, request: Request, config: Config) -> Respo stack = AsyncExitStack() deadline = asyncio.get_running_loop().time() + config.files_transfer_timeout_seconds try: - provider = await stack.enter_async_context( - provider_client(resolved.account, idle_timeout=config.files_idle_timeout_seconds) - ) - async with asyncio.timeout(config.files_idle_timeout_seconds): - download = await stack.enter_async_context( - provider.adownload_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) + async with asyncio.timeout_at(deadline): + provider = await stack.enter_async_context( + provider_client(resolved.account, idle_timeout=config.files_idle_timeout_seconds) ) + async with asyncio.timeout(config.files_idle_timeout_seconds): + download = await stack.enter_async_context( + provider.adownload_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) + ) except BaseException as exc: await stack.aclose() - if not isinstance(exc, Exception): + if isinstance(exc, TimeoutError) or not isinstance(exc, Exception): raise raise provider_error(exc) from None try: @@ -351,6 +362,8 @@ async def delete_file(file_id: str, request: Request, config: Config) -> Anthrop provider_client(resolved.account) as provider, ): await provider.adelete_file(file_id, max_retries=0, extra_headers=envelope.headers(request)) + except TimeoutError: + failure = FilesError(504, "File transfer timed out") except Exception as exc: failure = provider_error(exc) if failure.status_code == 404: diff --git a/tests/integration/test_hybrid_files_routes.py b/tests/integration/test_hybrid_files_routes.py index 8814041139..8efb096eb8 100644 --- a/tests/integration/test_hybrid_files_routes.py +++ b/tests/integration/test_hybrid_files_routes.py @@ -1,5 +1,6 @@ """Public Files routing is scoped, GA-only, and never exposes unfinalized IDs.""" +import asyncio import uuid from collections.abc import AsyncIterator, Generator from contextlib import asynccontextmanager @@ -240,3 +241,158 @@ async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: assert response.status_code == 413, response.text assert "provider-chunk" not in events assert ("provider-download" in events) == (oversized == "content-length") + + +@pytest.mark.parametrize("path", ["/files", "/files/file_provider"]) +def test_invalid_openai_metadata_returns_fixed_error( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, path: str +) -> None: + client, _ = file_client + data = FileMetadata.model_validate({"id": "file_provider", "bytes": "private-invalid-value"}) + + async def post(self: object, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + if path == "list": + return FilePage(data=[data]) + return ResolvedFile(metadata=data) + + monkeypatch.setattr(PlatformFilesClient, "post", post) + response = client.get( + API_ROOT + path, headers={"Authorization": "Bearer caller-token", "X-Otari-Files-Provider": "openai"} + ) + assert response.status_code == 502 + assert response.json() == {"detail": "Provider returned invalid file metadata"} + assert response.headers["cache-control"] == "private, no-store" + assert response.headers["x-otari-files-protocol"] == "2" + + +@pytest.mark.parametrize("authorization", [None, "Basic invalid", "Bearer "]) +@pytest.mark.parametrize( + "method,path", + [ + ("POST", "/files"), + ("GET", "/files"), + ("GET", "/files/file_provider"), + ("GET", "/files/file_provider/content"), + ("DELETE", "/files/file_provider"), + ], +) +def test_authentication_errors_include_files_headers( + file_client: tuple[TestClient, list[str]], method: str, path: str, authorization: str | None +) -> None: + client, events = file_client + headers = {"anthropic-version": "2023-06-01"} + if authorization is not None: + headers["Authorization"] = authorization + response = client.request(method, API_ROOT + path, headers=headers) + assert response.status_code == 401 + assert response.headers["cache-control"] == "private, no-store" + assert response.headers["x-otari-files-protocol"] == "2" + assert not events + + +@pytest.mark.parametrize("standalone_enabled", [False, True]) +@pytest.mark.parametrize("native_enabled", [False, True]) +def test_hybrid_files_only_uses_native_feature_flag( + file_client: tuple[TestClient, list[str]], standalone_enabled: bool, native_enabled: bool +) -> None: + client, events = file_client + app: Any = client.app + config = app.dependency_overrides[get_config]() + app.dependency_overrides[get_config] = lambda: config.model_copy( + update={"files_enabled": standalone_enabled, "files_provider_native_enabled": native_enabled} + ) + response = client.get(API_ROOT + "/files", headers=HEADERS) + assert response.status_code == (200 if native_enabled else 404) + assert events == (["list"] if native_enabled else []) + + +@pytest.mark.parametrize("stage,budget", [("setup", "transfer"), ("download", "transfer"), ("download", "idle")]) +def test_download_setup_timeout_returns_504_and_closes_resources( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, stage: str, budget: str +) -> None: + client, events = file_client + app: Any = client.app + config = app.dependency_overrides[get_config]() + app.dependency_overrides[get_config] = lambda: config.model_copy( + update={ + "files_transfer_timeout_seconds": 0.01 if budget == "transfer" else 1, + "files_idle_timeout_seconds": 0.01 if budget == "idle" else 1, + } + ) + + async def stall() -> None: + await asyncio.sleep(0.1) + raise RuntimeError("Transfer deadline did not interrupt provider setup") + + class Provider: + @asynccontextmanager + async def adownload_file(self, file_id: str, **kwargs: Any) -> AsyncIterator[AsyncFileDownload]: + try: + await stall() + yield AsyncFileDownload(status_code=200, headers={}, chunks=aiter_bytes()) + finally: + events.append("download-closed") + + async def aiter_bytes() -> AsyncIterator[bytes]: + yield b"data" + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + try: + if stage == "setup": + await stall() + yield Provider() + finally: + events.append("provider-closed") + + monkeypatch.setattr(hybrid_files, "provider_client", provider) + response = client.get(API_ROOT + "/files/file_provider/content", headers=HEADERS) + assert response.status_code == 504, response.text + assert response.json() == {"detail": "File transfer timed out"} + assert response.headers["cache-control"] == "private, no-store" + assert response.headers["x-otari-files-protocol"] == "2" + assert events[-1] == "provider-closed" + assert ("download-closed" in events) == (stage == "download") + + +@pytest.mark.parametrize("stage", ["setup", "delete"]) +def test_delete_timeout_reports_failed_cleanup_before_504( + file_client: tuple[TestClient, list[str]], monkeypatch: pytest.MonkeyPatch, stage: str +) -> None: + client, events = file_client + app: Any = client.app + config = app.dependency_overrides[get_config]() + app.dependency_overrides[get_config] = lambda: config.model_copy(update={"files_transfer_timeout_seconds": 0.01}) + original = PlatformFilesClient.post + + async def post(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + if path.endswith("/cleanup-result"): + assert body == {"cleanup_token": "operation-token", "deleted": False} + return await original(self, path, body, result_type) + + async def stall() -> None: + await asyncio.sleep(0.1) + raise RuntimeError("Transfer deadline did not interrupt provider deletion") + + class Provider: + async def adelete_file(self, file_id: str, **kwargs: Any) -> None: + await stall() + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + try: + if stage == "setup": + await stall() + yield Provider() + finally: + events.append("provider-closed") + + monkeypatch.setattr(PlatformFilesClient, "post", post) + monkeypatch.setattr(hybrid_files, "provider_client", provider) + response = client.delete(API_ROOT + "/files/file_provider", headers=HEADERS) + assert response.status_code == 504, response.text + assert response.json() == {"detail": "File transfer timed out"} + assert response.headers["cache-control"] == "private, no-store" + assert response.headers["x-otari-files-protocol"] == "2" + assert events[-2] == "provider-closed" + assert events[-1].endswith("/cleanup-result") diff --git a/tests/integration/test_provider_files_protocol.py b/tests/integration/test_provider_files_protocol.py index 9cc1aff197..47ec2b95ac 100644 --- a/tests/integration/test_provider_files_protocol.py +++ b/tests/integration/test_provider_files_protocol.py @@ -5,7 +5,7 @@ import httpx import pytest -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request from gateway.api.deps import get_config, get_unit_of_work from gateway.api.routes.provider_files import create_provider_files_router @@ -148,3 +148,43 @@ async def authorize(scope: FileScope, body: OutputPrepare, uow: UnitOfWork) -> F async with service.uow: stored = await service.repo.get(uuid.UUID(prepared.json()["id"])) assert stored is not None and stored.user_id == scope.user_id and stored.workspace_id == scope.workspace_id + + +@pytest.mark.parametrize("gateway_only", [False, True]) +async def test_authentication_http_errors_preserve_safe_headers( + files_setup: tuple[ProviderFileService, FileScope, FileAccount], gateway_only: bool +) -> None: + service, _, _ = files_setup + + async def authenticate(request: Request, uow: UnitOfWork) -> FileScope: + raise HTTPException( + 401, + "Invalid authentication", + headers={ + "WWW-Authenticate": "Bearer", + "Retry-After": "60", + "cache-control": "public", + "x-otari-files-protocol": "1", + }, + ) + + async def authorize(scope: FileScope, body: OutputPrepare, uow: UnitOfWork) -> FileAccount: + pytest.fail("Rejected authentication must not reach authorization") + + app = FastAPI() + app.dependency_overrides[get_unit_of_work] = lambda: service.uow + app.include_router( + create_provider_files_router( + authenticate=authenticate, authenticate_gateway=authenticate, authorize_attempt=authorize + ), + prefix=API_ROOT, + ) + path = f"outputs/{uuid.uuid4()}/complete" if gateway_only else "uploads/prepare" + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://control") as client: + response = await client.post(f"{API_ROOT}/gateway/files/{path}", json={}) + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid authentication"} + assert response.headers["www-authenticate"] == "Bearer" + assert response.headers["retry-after"] == "60" + assert response.headers["cache-control"] == "private, no-store" + assert response.headers["x-otari-files-protocol"] == "2" From 1694fe31cbe800fca7c6904fbb25a880dc232975 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Mon, 21 Sep 2026 19:28:58 +0200 Subject: [PATCH 21/21] fix(files): enforce lifecycle limits and isolate revocation Reuse provider clients and bound generated-file registration concurrency. Rate-limit new output reservations without charging idempotent retries, and constrain lifecycle vocabularies in the models and migration. Inject tenancy-owned revocation listeners for credential, workspace, and user mutations. Keep revocation transactional and preserve cleanup-blocked credential retirement. Add regression coverage for batching, quota windows, database constraints, and revocation rollback. Verified 527 focused tests, lint, type checking, and generated API artifacts. --- .../c3e5a7b9d1f4_add_provider_files.py | 8 + scripts/check_architecture.py | 2 - src/gateway/api/deps.py | 27 ++- src/gateway/api/routes/org_provider_keys.py | 8 +- src/gateway/api/routes/users.py | 25 +-- src/gateway/api/routes/workspaces.py | 9 +- src/gateway/models/provider_files.py | 26 ++- .../tenancy/attribution_user_repository.py | 37 ++++ .../services/provider_files/__init__.py | 4 + .../services/provider_files/_revocations.py | 39 ++++ .../services/provider_files/inference.py | 58 ++++-- .../services/provider_files/outputs.py | 1 + src/gateway/services/tenancy/__init__.py | 2 + .../tenancy/attribution_user_service.py | 26 +++ .../tenancy/org_provider_key_service.py | 37 ++-- .../services/tenancy/revocation_listener.py | 24 +++ .../services/tenancy/workspace_service.py | 21 +- tests/integration/test_membership_listener.py | 5 +- tests/integration/test_org_provider_keys.py | 21 +- .../test_provider_file_lifecycle.py | 4 +- .../test_provider_file_revocations.py | 183 ++++++++++++++++++ .../test_provider_file_transactions.py | 34 +++- tests/integration/test_tenancy_races.py | 9 +- tests/unit/test_provider_file_migration.py | 16 +- tests/unit/test_provider_file_models.py | 62 +++++- tests/unit/test_provider_file_outputs.py | 78 ++++++++ 26 files changed, 654 insertions(+), 112 deletions(-) create mode 100644 src/gateway/repositories/tenancy/attribution_user_repository.py create mode 100644 src/gateway/services/provider_files/_revocations.py create mode 100644 src/gateway/services/tenancy/attribution_user_service.py create mode 100644 src/gateway/services/tenancy/revocation_listener.py create mode 100644 tests/integration/test_provider_file_revocations.py diff --git a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py index 79ff7a39ca..6d9c60949e 100644 --- a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py +++ b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py @@ -33,6 +33,10 @@ def upgrade() -> None: sa.Column("lease_token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True), sa.Column("lease_gateway_id", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), sa.Column("lease_deadline", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "credential_source IN ('organization_key', 'hosted_backend')", name="ck_provider_account_source" + ), + sa.CheckConstraint("status IN ('active', 'retiring', 'retired')", name="ck_provider_account_status"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( "organization_id", @@ -81,6 +85,7 @@ def upgrade() -> None: sa.Column("state", sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False), sa.Column("reserved_files", sa.Integer(), nullable=False), sa.Column("reserved_bytes", sa.BigInteger(), nullable=False), + sa.CheckConstraint("state IN ('active', 'revoked', 'completed')", name="ck_provider_file_output_state"), sa.ForeignKeyConstraint( ["provider_account_generation_id"], ["provider_account_generations.id"], ondelete="RESTRICT" ), @@ -135,6 +140,9 @@ def upgrade() -> None: sa.Column("cleanup_after", sa.DateTime(timezone=True), nullable=True), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("lease_id", sa.Uuid(), nullable=True), + sa.CheckConstraint( + "state IN ('pending_upload', 'active', 'pending_cleanup', 'deleted')", name="ck_provider_file_state" + ), sa.ForeignKeyConstraint(["output_operation_id"], ["provider_file_output_operations.id"], ondelete="RESTRICT"), sa.ForeignKeyConstraint( ["provider_account_generation_id"], ["provider_account_generations.id"], ondelete="RESTRICT" diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 73f40768d5..6dde6ebed4 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -398,7 +398,6 @@ def check_file(file_path: Path, src_root: Path) -> list[tuple[int, str, str]]: "gateway/api/routes/messages.py", "gateway/api/routes/models.py", "gateway/api/routes/moderations.py", - "gateway/api/routes/org_provider_keys.py", "gateway/api/routes/organization_budgets.py", "gateway/api/routes/organization_guardrails.py", "gateway/api/routes/organization_keys.py", @@ -426,7 +425,6 @@ def check_file(file_path: Path, src_root: Path) -> list[tuple[int, str, str]]: "gateway/api/routes/workspace_mcp_servers.py", "gateway/api/routes/workspace_member_budget_policies.py", "gateway/api/routes/workspace_web_search.py", - "gateway/api/routes/workspaces.py", ) diff --git a/src/gateway/api/deps.py b/src/gateway/api/deps.py index aeafb6dd2a..5239cff839 100644 --- a/src/gateway/api/deps.py +++ b/src/gateway/api/deps.py @@ -27,13 +27,16 @@ from gateway.ports.model_provider_port import ModelProviderPort from gateway.ports.telemetry_storage_port import TelemetryStoragePort from gateway.repositories.overview.overview_repository import OverviewRepository +from gateway.repositories.tenancy.attribution_user_repository import AttributionUserRepository +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.services.dashboard_session_service import SESSION_COOKIE_NAME, resolve_dashboard_session from gateway.services.file_store import FileStore from gateway.services.log_writer import LogWriter from gateway.services.master_key_service import hash_master_key, is_generated_master_key, load_master_key_hash from gateway.services.overview.overview_service import OverviewService +from gateway.services.provider_files import ProviderFileRevocations from gateway.services.routing import clear_router_backend_cache -from gateway.services.tenancy import OrganizationService +from gateway.services.tenancy import AttributionUserService, OrganizationService, OrgProviderKeyService from gateway.services.tenancy.deployment_user_service import DeploymentUserService from gateway.services.tenancy.provisioning_service import ensure_bootstrap_identity from gateway.services.tenancy.workspace_budget_default_service import WorkspaceBudgetDefaultService @@ -53,6 +56,28 @@ ) +def get_org_provider_key_service(db: Annotated[AsyncSession, Depends(get_db)]) -> OrgProviderKeyService: + uow = UnitOfWork(db) + return OrgProviderKeyService( + db, uow=uow, revocation_listener=ProviderFileRevocations(ProviderFileRepository(db), uow) + ) + + +def get_workspace_service(db: Annotated[AsyncSession, Depends(get_db)]) -> WorkspaceService: + return WorkspaceService( + db, + membership_listener=WorkspaceBudgetDefaultService(db), + revocation_listener=ProviderFileRevocations(ProviderFileRepository(db), UnitOfWork(db)), + ) + + +def get_attribution_user_service(db: Annotated[AsyncSession, Depends(get_db)]) -> AttributionUserService: + uow = UnitOfWork(db) + return AttributionUserService( + AttributionUserRepository(uow), uow, ProviderFileRevocations(ProviderFileRepository(uow), uow) + ) + + def record_auth_failure(reason: str) -> None: """Record an authentication failure.""" AUTH_FAILURES.labels(reason=reason).inc() diff --git a/src/gateway/api/routes/org_provider_keys.py b/src/gateway/api/routes/org_provider_keys.py index 9d1d7e2dd5..6fff5df73a 100644 --- a/src/gateway/api/routes/org_provider_keys.py +++ b/src/gateway/api/routes/org_provider_keys.py @@ -16,9 +16,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, status -from sqlalchemy.ext.asyncio import AsyncSession -from gateway.api.deps import CurrentIdentity, get_db, verify_master_key +from gateway.api.deps import CurrentIdentity, get_org_provider_key_service, verify_master_key from gateway.api.routes.organizations import Message from gateway.core.surface import Surface from gateway.models.provider_keys import ( @@ -53,11 +52,6 @@ ) -def get_org_provider_key_service(db: Annotated[AsyncSession, Depends(get_db)]) -> OrgProviderKeyService: - """Build the org provider key service on the request's session.""" - return OrgProviderKeyService(db) - - OrgProviderKeyServiceDep = Annotated[OrgProviderKeyService, Depends(get_org_provider_key_service)] diff --git a/src/gateway/api/routes/users.py b/src/gateway/api/routes/users.py index 9f677d93f8..d4f6e5b83e 100644 --- a/src/gateway/api/routes/users.py +++ b/src/gateway/api/routes/users.py @@ -5,29 +5,30 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from gateway.api.deps import ( CallerOrganization, TelemetryStoragePortDep, + get_attribution_user_service, get_config, get_db, require_deployment_operator, ) from gateway.core.config import GatewayConfig +from gateway.core.database import DATABASE_ERRORS from gateway.core.surface import Surface from gateway.log_config import logger -from gateway.models.api_keys import APIKey from gateway.models.budgets import Budget from gateway.models.money import as_float from gateway.models.usage import UsageLog from gateway.models.users import User -from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.repositories.users_repository import in_organization from gateway.services.budget_periods import budget_window from gateway.services.model_access import validate_allowed_models +from gateway.services.tenancy import AttributionUserService router = APIRouter( prefix="/users", @@ -368,9 +369,10 @@ async def delete_user( db: Annotated[AsyncSession, Depends(get_db)], storage: TelemetryStoragePortDep, organization_id: CallerOrganization, + service: Annotated[AttributionUserService, Depends(get_attribution_user_service)], ) -> None: """Delete a user in the caller's organization, and erase their telemetry.""" - user = await _load_user_in_organization(db, user_id, organization_id) + await _load_user_in_organization(db, user_id, organization_id) # Explicit erasure, not a database ON DELETE cascade: this endpoint # soft-deletes the user (deleted_at), so the users row is never hard-deleted @@ -400,20 +402,9 @@ async def delete_user( detail="Could not erase this user's telemetry; the user was not deleted", ) from None - await ProviderFileRepository(db).lock_user(user_id) - await db.execute( - update(APIKey) - .where(APIKey.user_id == user_id) - .values(is_active=False) - .execution_options(synchronize_session=False) - ) - await ProviderFileRepository(db).revoke_user(user_id, datetime.now(UTC)) - user.deleted_at = datetime.now(UTC) - try: - await db.commit() - except SQLAlchemyError: - await db.rollback() + await service.delete(user_id, organization_id) + except DATABASE_ERRORS: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error", diff --git a/src/gateway/api/routes/workspaces.py b/src/gateway/api/routes/workspaces.py index 395cd73eb7..da3edff5ec 100644 --- a/src/gateway/api/routes/workspaces.py +++ b/src/gateway/api/routes/workspaces.py @@ -9,9 +9,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, status -from sqlalchemy.ext.asyncio import AsyncSession -from gateway.api.deps import CurrentIdentity, get_db, verify_master_key +from gateway.api.deps import CurrentIdentity, get_workspace_service, verify_master_key from gateway.api.routes.organizations import Message from gateway.core.surface import Surface from gateway.models.tenancy import ( @@ -24,7 +23,6 @@ WorkspaceUpdate, ) from gateway.services.tenancy import WorkspaceService -from gateway.services.tenancy.workspace_budget_default_service import WorkspaceBudgetDefaultService # Auth is declared on the router, not left to arrive through `CurrentIdentity`: # every handler here happens to take one today, and a future handler that did @@ -40,11 +38,6 @@ WORKSPACE_ROLE_DESCRIPTION = "Role to assign in this workspace." -def get_workspace_service(db: Annotated[AsyncSession, Depends(get_db)]) -> WorkspaceService: - """Build the workspace service on the request's session.""" - return WorkspaceService(db, membership_listener=WorkspaceBudgetDefaultService(db)) - - WorkspaceServiceDep = Annotated[WorkspaceService, Depends(get_workspace_service)] diff --git a/src/gateway/models/provider_files.py b/src/gateway/models/provider_files.py index b859c89cc8..f1d230a034 100644 --- a/src/gateway/models/provider_files.py +++ b/src/gateway/models/provider_files.py @@ -2,16 +2,26 @@ import uuid from datetime import datetime +from typing import Literal -from sqlalchemy import BigInteger, Index, UniqueConstraint +from sqlalchemy import BigInteger, CheckConstraint, Column, Index, String, UniqueConstraint from sqlmodel import Field, SQLModel from gateway.models.base import CreatedAtMixin, PrimaryKeyMixin, UpdatedAtMixin, UtcDateTime +CredentialSource = Literal["organization_key", "hosted_backend"] +AccountStatus = Literal["active", "retiring", "retired"] +BindingState = Literal["pending_upload", "active", "pending_cleanup", "deleted"] +OutputOperationState = Literal["active", "revoked", "completed"] + class ProviderAccountGeneration(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table=True): __tablename__ = "provider_account_generations" __table_args__ = ( + CheckConstraint( + "credential_source IN ('organization_key', 'hosted_backend')", name="ck_provider_account_source" + ), + CheckConstraint("status IN ('active', 'retiring', 'retired')", name="ck_provider_account_status"), UniqueConstraint( "organization_id", "credential_source", @@ -22,12 +32,12 @@ class ProviderAccountGeneration(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table ) provider: str = Field(default="anthropic", max_length=32) - credential_source: str = Field(max_length=32) + credential_source: CredentialSource = Field(sa_column=Column(String(32), nullable=False)) credential_ref: str = Field(max_length=255, index=True) organization_id: uuid.UUID = Field(index=True) upstream_identity_ciphertext: str | None = None generation: int = Field(default=1) - status: str = Field(default="active", max_length=16, index=True) + status: AccountStatus = Field(default="active", sa_column=Column(String(16), nullable=False, index=True)) retired_at: datetime | None = Field(default=None, sa_type=UtcDateTime) lease_id: uuid.UUID | None = None lease_token_hash: str | None = Field(default=None, max_length=64) @@ -38,6 +48,9 @@ class ProviderAccountGeneration(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMixin, table=True): __tablename__ = "provider_file_bindings" __table_args__ = ( + CheckConstraint( + "state IN ('pending_upload', 'active', 'pending_cleanup', 'deleted')", name="ck_provider_file_state" + ), UniqueConstraint("provider_account_generation_id", "provider_file_id", name="uq_provider_file_account_id"), Index("ix_provider_files_owner_page", "workspace_id", "user_id", "state", "created_at", "id"), Index("ix_provider_files_cleanup", "state", "cleanup_after"), @@ -65,7 +78,7 @@ class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMi initiating_gateway_id: str = Field(max_length=255) cleanup_token_hash: str = Field(max_length=64) provider_outcome_unknown: bool = False - state: str = Field(default="pending_upload", max_length=32) + state: BindingState = Field(default="pending_upload", sa_column=Column(String(32), nullable=False)) cleanup_reason: str | None = Field(default=None, max_length=32) cleanup_attempts: int = 0 cleanup_after: datetime | None = Field(default=None, sa_type=UtcDateTime) @@ -75,6 +88,9 @@ class ProviderFileBinding(SQLModel, PrimaryKeyMixin, CreatedAtMixin, UpdatedAtMi class ProviderFileOutputOperation(SQLModel, PrimaryKeyMixin, CreatedAtMixin, table=True): __tablename__ = "provider_file_output_operations" + __table_args__ = ( + CheckConstraint("state IN ('active', 'revoked', 'completed')", name="ck_provider_file_output_state"), + ) provider_account_generation_id: uuid.UUID = Field( foreign_key="provider_account_generations.id", ondelete="RESTRICT", index=True @@ -87,7 +103,7 @@ class ProviderFileOutputOperation(SQLModel, PrimaryKeyMixin, CreatedAtMixin, tab attempt_id: str = Field(max_length=255) cleanup_token_hash: str = Field(max_length=64) deadline: datetime = Field(sa_type=UtcDateTime) - state: str = Field(default="active", max_length=16) + state: OutputOperationState = Field(default="active", sa_column=Column(String(16), nullable=False)) reserved_files: int reserved_bytes: int = Field(sa_type=BigInteger) diff --git a/src/gateway/repositories/tenancy/attribution_user_repository.py b/src/gateway/repositories/tenancy/attribution_user_repository.py new file mode 100644 index 0000000000..e7078fc5db --- /dev/null +++ b/src/gateway/repositories/tenancy/attribution_user_repository.py @@ -0,0 +1,37 @@ +"""Persistence for atomic request-plane user revocation.""" + +import uuid +from datetime import datetime + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.core.unit_of_work import UnitOfWork, session_for +from gateway.models.api_keys import APIKey +from gateway.models.users import User +from gateway.repositories.users_repository import in_organization + + +class AttributionUserRepository: + def __init__(self, uow: UnitOfWork) -> None: + self.uow = uow + + @property + def db(self) -> AsyncSession: + return session_for(self.uow) + + async def lock_active(self, user_id: str, organization_id: uuid.UUID | None) -> User | None: + statement = select(User).where(User.user_id == user_id, User.deleted_at.is_(None)).with_for_update() + if organization_id is not None: + statement = statement.where(in_organization(organization_id)) + return (await self.db.execute(statement)).scalar_one_or_none() + + async def soft_delete(self, user: User, now: datetime) -> None: + await self.db.execute( + update(APIKey) + .where(APIKey.user_id == user.user_id) + .values(is_active=False) + .execution_options(synchronize_session=False) + ) + user.deleted_at = now + await self.db.flush() diff --git a/src/gateway/services/provider_files/__init__.py b/src/gateway/services/provider_files/__init__.py index 386cc129ce..e99380b703 100644 --- a/src/gateway/services/provider_files/__init__.py +++ b/src/gateway/services/provider_files/__init__.py @@ -1 +1,5 @@ """Provider-native file authorization and lifecycle.""" + +from gateway.services.provider_files._revocations import ProviderFileRevocations + +__all__ = ["ProviderFileRevocations"] diff --git a/src/gateway/services/provider_files/_revocations.py b/src/gateway/services/provider_files/_revocations.py new file mode 100644 index 0000000000..ea24c30fbf --- /dev/null +++ b/src/gateway/services/provider_files/_revocations.py @@ -0,0 +1,39 @@ +"""Provider-file reactions within the tenant mutation's transaction.""" + +import uuid +from datetime import UTC, datetime + +from gateway.core.unit_of_work import UnitOfWork +from gateway.models.provider_keys import OrgProviderKey +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.accounts import retire_byo_account + + +class ProviderFileRevocations: + def __init__(self, repo: ProviderFileRepository, uow: UnitOfWork) -> None: + self.repo = repo + self.uow = uow + + async def retire_key(self, key: OrgProviderKey, *, release_secret: bool) -> bool: + return await retire_byo_account(self.uow, key, release_secret=release_secret) + + async def workspace_key_disabled( + self, organization_id: uuid.UUID, workspace_id: uuid.UUID, key_id: uuid.UUID + ) -> None: + generation = await self.repo.latest_account("organization_key", str(key_id), organization_id) + if generation is not None: + await self.repo.revoke( + datetime.now(UTC), + "workspace_credential_disabled", + organization_id=organization_id, + workspace_id=workspace_id, + generation_id=generation.id, + ) + + async def workspace_deleted(self, organization_id: uuid.UUID, workspace_id: uuid.UUID) -> None: + await self.repo.revoke( + datetime.now(UTC), "workspace_deletion", organization_id=organization_id, workspace_id=workspace_id + ) + + async def user_deleted(self, user_id: str) -> None: + await self.repo.revoke_user(user_id, datetime.now(UTC)) diff --git a/src/gateway/services/provider_files/inference.py b/src/gateway/services/provider_files/inference.py index 1017e30cde..89dae6db0b 100644 --- a/src/gateway/services/provider_files/inference.py +++ b/src/gateway/services/provider_files/inference.py @@ -1,6 +1,10 @@ """Withhold provider output references until their durable binding is active.""" +import asyncio from collections.abc import Iterable +from contextlib import AsyncExitStack +from itertools import batched +from typing import Any from gateway.services.provider_files.client import PlatformFilesClient from gateway.services.provider_files.contracts import FileMetadata, FilesError, Operation, OutputCleanup, WireModel @@ -14,29 +18,45 @@ def __init__(self, client: PlatformFilesClient, operation: Operation, existing_i self.bound = set(existing_ids) async def register_ids(self, ids: Iterable[str]) -> None: - for file_id in ids: - if file_id in self.bound: - continue + pending = [file_id for file_id in dict.fromkeys(ids) if file_id not in self.bound] + if not pending: + return + async with AsyncExitStack() as stack: try: - async with provider_client(self.operation.account) as provider: - result = await provider.aretrieve_file(file_id, max_retries=0) - metadata = FileMetadata.model_validate(result.model_dump(exclude_unset=True)) + provider = await stack.enter_async_context(provider_client(self.operation.account)) except Exception: - await self.compensate(None, file_id) + for file_id in pending: + await self.compensate(None, file_id) raise FilesError(502, "Provider file metadata could not be registered") from None - try: - await self.client.retry( - "outputs/register", - { - "operation_id": str(self.operation.id), - "metadata": metadata.model_dump(mode="json", exclude_unset=True), - }, - FileMetadata, + for batch in batched(pending, 4): + # Finish every sibling's registration or compensation before releasing the client. + results = await asyncio.gather( + *(self._register_id(provider, file_id) for file_id in batch), return_exceptions=True ) - except FilesError: - await self.compensate(metadata) - raise - self.bound.add(file_id) + for result in results: + if isinstance(result, BaseException): + raise result + + async def _register_id(self, provider: Any, file_id: str) -> None: + try: + result = await provider.aretrieve_file(file_id, max_retries=0) + metadata = FileMetadata.model_validate(result.model_dump(exclude_unset=True)) + except Exception: + await self.compensate(None, file_id) + raise FilesError(502, "Provider file metadata could not be registered") from None + try: + await self.client.retry( + "outputs/register", + { + "operation_id": str(self.operation.id), + "metadata": metadata.model_dump(mode="json", exclude_unset=True), + }, + FileMetadata, + ) + except FilesError: + await self.compensate(metadata) + raise + self.bound.add(file_id) async def complete(self) -> None: try: diff --git a/src/gateway/services/provider_files/outputs.py b/src/gateway/services/provider_files/outputs.py index ed855f4ad9..99544d971b 100644 --- a/src/gateway/services/provider_files/outputs.py +++ b/src/gateway/services/provider_files/outputs.py @@ -70,6 +70,7 @@ async def _prepare(self, scope: FileScope, account: FileAccount, request: Output ): raise FilesError(409, "Output operation conflict") else: + await self.service._rate_limit(scope, now) count, size = await self.repo.capacity(scope.workspace_id, scope.user_id, now) reserved = min(20, self.service.max_files - count) available = min(reserved * self.service.max_bytes, self.service.max_outstanding_bytes - size) diff --git a/src/gateway/services/tenancy/__init__.py b/src/gateway/services/tenancy/__init__.py index 40c0a06390..6fbc27be4a 100644 --- a/src/gateway/services/tenancy/__init__.py +++ b/src/gateway/services/tenancy/__init__.py @@ -5,6 +5,7 @@ files under `gateway.api.routes` stay thin composition over these services. """ +from gateway.services.tenancy.attribution_user_service import AttributionUserService from gateway.services.tenancy.deployment_user_service import DeploymentUserService from gateway.services.tenancy.org_provider_key_service import OrgProviderKeyService from gateway.services.tenancy.organization_domain_service import OrganizationDomainService @@ -13,6 +14,7 @@ from gateway.services.tenancy.workspace_service import WorkspaceService __all__ = [ + "AttributionUserService", "DeploymentUserService", "OrgProviderKeyService", "OrganizationDomainService", diff --git a/src/gateway/services/tenancy/attribution_user_service.py b/src/gateway/services/tenancy/attribution_user_service.py new file mode 100644 index 0000000000..98572fdf5a --- /dev/null +++ b/src/gateway/services/tenancy/attribution_user_service.py @@ -0,0 +1,26 @@ +"""Revoke an attribution user's keys and dependent resources atomically.""" + +import uuid +from datetime import UTC, datetime + +from gateway.core.unit_of_work import UnitOfWork +from gateway.repositories.tenancy.attribution_user_repository import AttributionUserRepository +from gateway.services.tenancy.errors import TenancyNotFoundError +from gateway.services.tenancy.revocation_listener import RevocationListener + + +class AttributionUserService: + def __init__( + self, repo: AttributionUserRepository, uow: UnitOfWork, revocation_listener: RevocationListener + ) -> None: + self.repo = repo + self.uow = uow + self.revocation_listener = revocation_listener + + async def delete(self, user_id: str, organization_id: uuid.UUID | None) -> None: + async with self.uow: + user = await self.repo.lock_active(user_id, organization_id) + if user is None: + raise TenancyNotFoundError("User not found") + await self.revocation_listener.user_deleted(user_id) + await self.repo.soft_delete(user, datetime.now(UTC)) diff --git a/src/gateway/services/tenancy/org_provider_key_service.py b/src/gateway/services/tenancy/org_provider_key_service.py index 1ebc44e52f..15458d0849 100644 --- a/src/gateway/services/tenancy/org_provider_key_service.py +++ b/src/gateway/services/tenancy/org_provider_key_service.py @@ -78,8 +78,6 @@ WorkspaceRepository, resolve_active_key, ) -from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository -from gateway.services.provider_files.accounts import retire_byo_account from gateway.services.secret_box import ( SecretBoxUnavailableError, SecretDecryptionError, @@ -102,6 +100,7 @@ WorkspaceProviderKeyOverrideConflictError, ) from gateway.services.tenancy.organization_service import OrganizationService +from gateway.services.tenancy.revocation_listener import RevocationListener from gateway.services.url_safety import UnsafeURLError, validate_provider_api_base # Same value as provider_store_service.PROVIDER_CACHE_TTL_SECONDS, defined @@ -381,15 +380,24 @@ async def _gate_api_base(api_base: str | None) -> None: class OrgProviderKeyService: """Business logic for the organization provider key surface.""" - def __init__(self, db: AsyncSession): + def __init__( + self, db: AsyncSession, *, revocation_listener: RevocationListener | None = None, uow: UnitOfWork | None = None + ): self.db = db - self.uow = UnitOfWork(db) + self.uow = uow or UnitOfWork(db) + self._revocation_listener = revocation_listener self.keys = OrgProviderKeyRepository(db) self.overrides = WorkspaceProviderKeyOverrideRepository(db) self.restrictions = WorkspaceProviderModelRestrictionRepository(db) self.workspaces = WorkspaceRepository(db) self.organizations = OrganizationService(db, membership_listener=None) + @property + def _revocations(self) -> RevocationListener: + if self._revocation_listener is None: + raise RuntimeError("Credential mutations require a revocation listener") + return self._revocation_listener + # ------------------------------------------------------------------ # Organization-scoped keys # ------------------------------------------------------------------ @@ -506,7 +514,7 @@ async def update_key_for_user( try: async with self.uow: if {"encrypted_api_key", "api_base", "client_args"} & update_data.keys(): - blocked = await retire_byo_account(self.uow, key, release_secret=True) + blocked = await self._revocations.retire_key(key, release_secret=True) if not blocked: await self.keys.update_key(key, update_data) except IntegrityError: @@ -531,7 +539,7 @@ async def archive_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro raise OrgProviderKeyNotFoundError(key_id) async with self.uow: - await retire_byo_account(self.uow, key, release_secret=False) + await self._revocations.retire_key(key, release_secret=False) updated = await self.keys.update_key(key, {"archived_at": datetime.now(UTC), "is_org_default": False}) await refresh_org_provider_cache(self.db) return updated.to_public(usable=key_is_usable(updated)) @@ -548,7 +556,7 @@ async def restore_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro return key.to_public(usable=key_is_usable(key)) async with self.uow: - blocked = await retire_byo_account(self.uow, key, release_secret=True) + blocked = await self._revocations.retire_key(key, release_secret=True) if not blocked: await self.keys.update_key(key, {"archived_at": None}) self._raise_if_file_cleanup_pending(blocked) @@ -570,7 +578,7 @@ async def delete_key_for_user(self, *, user: User, key_id: uuid.UUID) -> None: raise OrgProviderKeyNotArchivedError(key_id) async with self.uow: - blocked = await retire_byo_account(self.uow, key, release_secret=True) + blocked = await self._revocations.retire_key(key, release_secret=True) if not blocked: await self.keys.delete_key(key) self._raise_if_file_cleanup_pending(blocked) @@ -689,7 +697,7 @@ async def set_workspace_override_for_user( # spans the variable set of override rows a "pin" can land in (unlike # `set_org_default`, which is a single row the partial unique index # already arbitrates). - await ProviderFileRepository(self.db).lock_organization(workspace.organization_id) + await self.organizations.organizations.lock(workspace.organization_id) await WorkspaceRepository(self.db).lock(workspace.id) existing = await self.overrides.get(workspace_id=workspace.id, org_provider_key_id=key.id) @@ -732,16 +740,7 @@ async def set_workspace_override_for_user( result_default, result_disabled = created.is_default, created.disabled if new_disabled and not current_disabled: - files = ProviderFileRepository(self.db) - generation = await files.latest_account("organization_key", str(key.id), workspace.organization_id) - if generation is not None: - await files.revoke( - datetime.now(UTC), - "workspace_credential_disabled", - organization_id=workspace.organization_id, - workspace_id=workspace.id, - generation_id=generation.id, - ) + await self._revocations.workspace_key_disabled(workspace.organization_id, workspace.id, key.id) await self.restrictions.delete_for_workspace_key(workspace_id=workspace.id, org_provider_key_id=key.id) await self.db.commit() diff --git a/src/gateway/services/tenancy/revocation_listener.py b/src/gateway/services/tenancy/revocation_listener.py new file mode 100644 index 0000000000..ee883bff34 --- /dev/null +++ b/src/gateway/services/tenancy/revocation_listener.py @@ -0,0 +1,24 @@ +"""Transaction-local reactions to credential and tenant revocation.""" + +import uuid +from typing import Protocol + +from gateway.models.provider_keys import OrgProviderKey + + +class RevocationListener(Protocol): + """The caller owns locks and commit; implementations never commit or roll back.""" + + async def retire_key(self, key: OrgProviderKey, *, release_secret: bool) -> bool: + """Revoke key-backed resources; return whether cleanup blocks secret release.""" + + async def workspace_key_disabled( + self, organization_id: uuid.UUID, workspace_id: uuid.UUID, key_id: uuid.UUID + ) -> None: + """Revoke resources using this key in the workspace.""" + + async def workspace_deleted(self, organization_id: uuid.UUID, workspace_id: uuid.UUID) -> None: + """Revoke resources before workspace deletion.""" + + async def user_deleted(self, user_id: str) -> None: + """Revoke resources while the caller holds the attribution user's lock.""" diff --git a/src/gateway/services/tenancy/workspace_service.py b/src/gateway/services/tenancy/workspace_service.py index 689b60f7b7..8bbd020730 100644 --- a/src/gateway/services/tenancy/workspace_service.py +++ b/src/gateway/services/tenancy/workspace_service.py @@ -16,7 +16,6 @@ """ import uuid -from datetime import UTC, datetime from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -36,7 +35,6 @@ WorkspaceUpdate, ) from gateway.repositories.tenancy import WorkspaceMemberRepository, WorkspaceRepository -from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository from gateway.services.tenancy import authorization from gateway.services.tenancy.errors import ( InvalidRoleError, @@ -50,17 +48,25 @@ ) from gateway.services.tenancy.membership_listener import MembershipListener from gateway.services.tenancy.organization_service import OrganizationService +from gateway.services.tenancy.revocation_listener import RevocationListener class WorkspaceService: """Business logic for the workspace surface.""" - def __init__(self, db: AsyncSession, *, membership_listener: MembershipListener): + def __init__( + self, + db: AsyncSession, + *, + membership_listener: MembershipListener, + revocation_listener: RevocationListener | None = None, + ): self.db = db self.workspaces = WorkspaceRepository(db) self.members = WorkspaceMemberRepository(db) self.organizations = OrganizationService(db, membership_listener=None) self._membership_listener = membership_listener + self._revocation_listener = revocation_listener # ------------------------------------------------------------------ # Scoping and authorization @@ -297,15 +303,12 @@ async def delete_workspace(self, *, user: User, workspace_id: uuid.UUID) -> None # leaves an orphaned ceiling behind. await self.workspaces.lock(workspace_id) + if self._revocation_listener is None: + raise RuntimeError("Workspace deletion requires a revocation listener") try: member_ids = await self.members.ids_for_workspace(workspace_id) await self._membership_listener.workspace_deleted(workspace_id, member_ids) - await ProviderFileRepository(self.db).revoke( - datetime.now(UTC), - "workspace_deletion", - organization_id=organization.id, - workspace_id=workspace_id, - ) + await self._revocation_listener.workspace_deleted(organization.id, workspace_id) await self.workspaces.delete_workspace(workspace) await self.db.commit() except IntegrityError: diff --git a/tests/integration/test_membership_listener.py b/tests/integration/test_membership_listener.py index 26bc519b6d..9452d38753 100644 --- a/tests/integration/test_membership_listener.py +++ b/tests/integration/test_membership_listener.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col +from gateway.api.deps import get_workspace_service from gateway.models.budgets import ScopedBudget, WorkspaceBudgetDefault from gateway.models.tenancy import ( ActiveOrganizationMemberCreateRequest, @@ -181,7 +182,9 @@ async def test_delete_workspace_announces_the_workspace_and_its_members(async_db doomed_membership = await _membership_id(async_db, doomed.id, owner.id) listener = RecordingListener() - await WorkspaceService(async_db, membership_listener=listener).delete_workspace(user=owner, workspace_id=doomed.id) + service = get_workspace_service(async_db) + service._membership_listener = listener + await service.delete_workspace(user=owner, workspace_id=doomed.id) assert listener.deleted == [(doomed.id, [doomed_membership])] diff --git a/tests/integration/test_org_provider_keys.py b/tests/integration/test_org_provider_keys.py index 50a7d80824..ff581b9db9 100644 --- a/tests/integration/test_org_provider_keys.py +++ b/tests/integration/test_org_provider_keys.py @@ -17,6 +17,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from gateway.api.deps import get_org_provider_key_service from gateway.core.config import GatewayConfig from gateway.models.provider_keys import ( OrgProviderKey, @@ -104,7 +105,7 @@ def _create_request( async def test_crud_round_trip(async_db: AsyncSession) -> None: organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) created = await service.create_key_for_user(user=owner, request=_create_request()) assert created.last4 == "1234" @@ -282,7 +283,7 @@ async def test_set_org_default_clears_the_previous_default(async_db: AsyncSessio async def test_set_org_default_refuses_an_archived_key(async_db: AsyncSession) -> None: organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) await service.archive_key_for_user(user=owner, key_id=key.id) @@ -398,7 +399,7 @@ async def test_archiving_the_default_falls_through_to_earliest_fallback(async_db organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) fallback = await service.create_key_for_user(user=owner, request=_create_request(name="fallback")) default_key = await service.create_key_for_user(user=owner, request=_create_request(name="default")) @@ -415,7 +416,7 @@ async def test_pinning_reenables_a_disabled_key_and_disabling_unpins(async_db: A organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) disabled = await service.set_workspace_override_for_user( @@ -449,7 +450,7 @@ async def test_repinning_an_already_pinned_key_stays_pinned(async_db: AsyncSessi organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) first = await service.set_workspace_override_for_user( @@ -489,7 +490,7 @@ async def test_disabling_a_key_cascades_deleting_its_model_restrictions(async_db organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) await service.add_model_restriction_for_user(user=owner, workspace_id=workspace.id, key_id=key.id, model="gpt-4o") @@ -554,7 +555,7 @@ async def test_an_override_write_answers_with_the_allow_list_it_left_behind(asyn organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) await service.add_model_restriction_for_user(user=owner, workspace_id=workspace.id, key_id=key.id, model="gpt-4o") @@ -576,7 +577,7 @@ async def test_restricting_models_on_a_disabled_key_is_refused(async_db: AsyncSe organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) await service.set_workspace_override_for_user( user=owner, workspace_id=workspace.id, key_id=key.id, request=WorkspaceProviderKeyOverrideRequest(disabled=True) @@ -595,7 +596,7 @@ async def test_model_restriction_is_cached_for_the_active_key(async_db: AsyncSes organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key = await service.create_key_for_user(user=owner, request=_create_request()) await service.set_org_default_for_user(user=owner, key_id=key.id) await service.add_model_restriction_for_user(user=owner, workspace_id=workspace.id, key_id=key.id, model="gpt-4o") @@ -648,7 +649,7 @@ async def test_model_restriction_follows_the_active_key_not_a_stale_one(async_db organization = await _organization(async_db) owner = await _member(async_db, organization, role="owner", full_name="Owner") workspace = await _workspace(async_db, organization, owner=owner) - service = OrgProviderKeyService(async_db) + service = get_org_provider_key_service(async_db) key_a = await service.create_key_for_user(user=owner, request=_create_request(name="a")) await service.set_org_default_for_user(user=owner, key_id=key_a.id) await service.add_model_restriction_for_user(user=owner, workspace_id=workspace.id, key_id=key_a.id, model="gpt-4o") diff --git a/tests/integration/test_provider_file_lifecycle.py b/tests/integration/test_provider_file_lifecycle.py index 05d306e496..84fd8d60ab 100644 --- a/tests/integration/test_provider_file_lifecycle.py +++ b/tests/integration/test_provider_file_lifecycle.py @@ -129,7 +129,7 @@ async def test_delete_revokes_before_provider_and_retries_survive( row = await ProviderFileRepository(async_db).get(operation.id) assert row is not None and row.state == "pending_cleanup" and row.cleanup_attempts == 1 await service.cleanup_result(operation.id, scope.gateway_id, resolved.cleanup_token.get_secret_value(), True) - assert row.state == "deleted" + assert str(row.state) == "deleted" async def test_expired_files_are_hidden( @@ -210,7 +210,7 @@ async def test_output_cleanup_survives_user_revocation( row = await ProviderFileRepository(async_db).get(cleanup.operation_id) assert row is not None and row.state == "pending_cleanup" and row.provider_file_id == "file_late" await service.cleanup_result(row.id, scope.gateway_id, cleanup.cleanup_token.get_secret_value(), True) - assert row.state == "deleted" + assert str(row.state) == "deleted" async def test_cursor_scope_and_snapshot(files_setup: tuple[ProviderFileService, FileScope, FileAccount]) -> None: diff --git a/tests/integration/test_provider_file_revocations.py b/tests/integration/test_provider_file_revocations.py new file mode 100644 index 0000000000..628aea8943 --- /dev/null +++ b/tests/integration/test_provider_file_revocations.py @@ -0,0 +1,183 @@ +"""Tenant mutation listeners revoke Files in the caller's transaction.""" + +import uuid +from datetime import datetime +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + +from gateway.api.deps import get_attribution_user_service, get_org_provider_key_service, get_workspace_service +from gateway.models.api_keys import APIKey +from gateway.models.provider_files import ProviderFileBinding, ProviderFileOutputOperation +from gateway.models.provider_keys import OrgProviderKey, WorkspaceProviderKeyOverrideRequest +from gateway.models.tenancy import Organization, Workspace +from gateway.models.users import User +from gateway.repositories.tenancy.provider_file_repository import ProviderFileRepository +from gateway.services.provider_files.contracts import FileAccount, FileScope, OutputPrepare, PrepareUpload +from gateway.services.provider_files.lifecycle import ProviderFileService +from gateway.services.provider_files.outputs import ProviderFileOutputs +from gateway.services.secret_box import encrypt_secret +from gateway.services.tenancy.errors import TenancyNotFoundError, WorkspaceInUseError + +from .test_org_provider_keys import _member, _workspace +from .test_provider_file_lifecycle import files_setup as files_setup +from .test_provider_file_lifecycle import metadata + +pytestmark = pytest.mark.asyncio + + +async def _live_files( + files: ProviderFileService, scope: FileScope, account: FileAccount +) -> tuple[uuid.UUID, uuid.UUID]: + upload = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) + await files.finalize(scope, upload.id, metadata()) + output = await ProviderFileOutputs(files).prepare( + scope, + account, + OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ), + ) + return upload.id, output.id + + +async def _assert_states(db: AsyncSession, binding_id: uuid.UUID, output_id: uuid.UUID, *, revoked: bool) -> None: + db.expire_all() + binding = await db.get(ProviderFileBinding, binding_id) + output = await db.get(ProviderFileOutputOperation, output_id) + assert binding is not None and output is not None + assert binding.state == ("pending_cleanup" if revoked else "active") + assert output.state == ("revoked" if revoked else "active") + + +@pytest.mark.parametrize("fail", [False, True]) +async def test_user_deletion_and_file_revocation_are_atomic( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], + monkeypatch: pytest.MonkeyPatch, + fail: bool, +) -> None: + files, scope, account = files_setup + binding_id, output_id = await _live_files(files, scope, account) + key_id = str(uuid.uuid4()) + async_db.add(APIKey(id=key_id, key_hash=key_id, user_id=scope.user_id, workspace_id=scope.workspace_id)) + await async_db.commit() + service = get_attribution_user_service(async_db) + if fail: + original = service.repo.soft_delete + + async def refuse(user: User, now: datetime) -> None: + # Fail after both domains have written, before the only commit. + await original(user, now) + raise RuntimeError("injected failure") + + monkeypatch.setattr(service.repo, "soft_delete", refuse) + with pytest.raises(RuntimeError, match="injected"): + await service.delete(scope.user_id, scope.organization_id) + else: + await service.delete(scope.user_id, scope.organization_id) + await _assert_states(async_db, binding_id, output_id, revoked=not fail) + user = await async_db.get(User, scope.user_id) + key = await async_db.get(APIKey, key_id) + assert user is not None and key is not None + assert (user.deleted_at is not None) is (not fail) + assert key.is_active is fail + + +async def test_user_deletion_rechecks_organization_scope( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + files, scope, account = files_setup + binding_id, output_id = await _live_files(files, scope, account) + key_id = str(uuid.uuid4()) + async_db.add(APIKey(id=key_id, key_hash=key_id, user_id=scope.user_id, workspace_id=scope.workspace_id)) + await async_db.commit() + with pytest.raises(TenancyNotFoundError): + await get_attribution_user_service(async_db).delete(scope.user_id, uuid.uuid4()) + await _assert_states(async_db, binding_id, output_id, revoked=False) + + +@pytest.mark.parametrize("fail", [False, True]) +async def test_workspace_deletion_and_file_revocation_are_atomic( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], + monkeypatch: pytest.MonkeyPatch, + fail: bool, +) -> None: + files, scope, account = files_setup + binding_id, output_id = await _live_files(files, scope, account) + organization = await async_db.get(Organization, scope.organization_id) + assert organization is not None + owner = await _member(async_db, organization, role="owner", full_name="Owner") + await _workspace(async_db, organization, name="Survivor", owner=owner) + await async_db.commit() + service = get_workspace_service(async_db) + if fail: + monkeypatch.setattr( + service.workspaces, "delete_workspace", AsyncMock(side_effect=IntegrityError("injected", {}, ValueError())) + ) + with pytest.raises(WorkspaceInUseError): + await service.delete_workspace(user=owner, workspace_id=scope.workspace_id) + else: + await service.delete_workspace(user=owner, workspace_id=scope.workspace_id) + await _assert_states(async_db, binding_id, output_id, revoked=not fail) + assert (await async_db.get(Workspace, scope.workspace_id) is not None) is fail + + +async def test_disabling_workspace_key_revokes_its_files( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], +) -> None: + files, scope, account = files_setup + binding_id, output_id = await _live_files(files, scope, account) + repo = ProviderFileRepository(async_db) + generation = await repo.account(account.generation_id) + organization = await async_db.get(Organization, scope.organization_id) + assert generation is not None and organization is not None + key_id = uuid.UUID(generation.credential_ref) + async_db.add( + OrgProviderKey( + id=key_id, + organization_id=scope.organization_id, + provider="anthropic", + name="Files", + encrypted_api_key=encrypt_secret("key"), + ) + ) + owner = await _member(async_db, organization, role="owner", full_name="Owner") + await async_db.commit() + await get_org_provider_key_service(async_db).set_workspace_override_for_user( + user=owner, + workspace_id=scope.workspace_id, + key_id=key_id, + request=WorkspaceProviderKeyOverrideRequest(disabled=True), + ) + await _assert_states(async_db, binding_id, output_id, revoked=True) + + +@pytest.mark.parametrize( + ("table_name", "column"), + [ + ("provider_account_generations", "credential_source"), + ("provider_account_generations", "status"), + ("provider_file_bindings", "state"), + ("provider_file_output_operations", "state"), + ], +) +async def test_postgres_rejects_invalid_file_lifecycle_values( + async_db: AsyncSession, + files_setup: tuple[ProviderFileService, FileScope, FileAccount], + table_name: str, + column: str, +) -> None: + files, scope, account = files_setup + await _live_files(files, scope, account) + table = SQLModel.metadata.tables[table_name] + with pytest.raises(IntegrityError): + await async_db.execute(update(table).values(**{column: "typo"})) + await async_db.rollback() diff --git a/tests/integration/test_provider_file_transactions.py b/tests/integration/test_provider_file_transactions.py index eddae0bf71..0102dcaa5c 100644 --- a/tests/integration/test_provider_file_transactions.py +++ b/tests/integration/test_provider_file_transactions.py @@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from gateway.api.deps import get_org_provider_key_service from gateway.core.unit_of_work import UnitOfWork from gateway.models.provider_files import ProviderFileOutputOperation from gateway.models.provider_keys import OrgProviderKey, OrgProviderKeyUpdateRequest, WorkspaceProviderKeyOverride @@ -28,7 +29,6 @@ from gateway.services.provider_files.outputs import ProviderFileOutputs from gateway.services.secret_box import encrypt_secret from gateway.services.tenancy.errors import OrgProviderKeyAlreadyExistsError, TenancyConflictError -from gateway.services.tenancy.org_provider_key_service import OrgProviderKeyService from .test_org_provider_keys import _member from .test_provider_file_lifecycle import files_setup as files_setup @@ -63,7 +63,7 @@ async def test_refused_secret_release_commits_revocation( await async_db.commit() operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) await files.finalize(scope, operation.id, metadata()) - keys = OrgProviderKeyService(async_db) + keys = get_org_provider_key_service(async_db) with pytest.raises(TenancyConflictError, match="cleanup must finish"): if action == "replace": await keys.update_key_for_user( @@ -107,7 +107,7 @@ async def test_restore_active_key_preserves_provider_file_account( operation = await files.prepare(scope, account, PrepareUpload(operation_id=uuid.uuid4(), size_bytes=20)) await files.finalize(scope, operation.id, metadata()) - restored = await OrgProviderKeyService(async_db).restore_key_for_user(user=owner, key_id=key_id) + restored = await get_org_provider_key_service(async_db).restore_key_for_user(user=owner, key_id=key_id) stored_generation = await repo.account(account.generation_id) binding = await repo.get(operation.id) @@ -140,7 +140,7 @@ async def test_secret_update_failure_rolls_back_retirement( ) ) await async_db.commit() - keys = OrgProviderKeyService(async_db) + keys = get_org_provider_key_service(async_db) monkeypatch.setattr( keys.keys, "update_key", AsyncMock(side_effect=IntegrityError("injected", {}, ValueError("injected conflict"))) ) @@ -286,7 +286,7 @@ async def _blocked_replacement( ) await async_db.commit() with pytest.raises(TenancyConflictError, match="cleanup must finish"): - await OrgProviderKeyService(async_db).update_key_for_user( + await get_org_provider_key_service(async_db).update_key_for_user( user=owner, key_id=key_id, request=OrgProviderKeyUpdateRequest(api_key="replacement") ) await async_db.rollback() @@ -339,3 +339,27 @@ async def test_blocked_retirement_finalizes_on_selection_after_outputs_expire( assert selected is not None and selected.generation_id != account.generation_id retired = await ProviderFileRepository(async_db).account(account.generation_id) assert retired is not None and retired.status == "retired" + + +async def test_output_preparation_rate_limits_only_new_operations( + async_db: AsyncSession, files_setup: tuple[ProviderFileService, FileScope, FileAccount] +) -> None: + files, scope, account = files_setup + files.rate_limit_rpm = 2 + outputs = ProviderFileOutputs(files) + request = OutputPrepare( + operation_id=uuid.uuid4(), request_id="request", attempt_id="attempt", generation_id=account.generation_id + ) + operation = await outputs.prepare(scope, account, request) + assert (await outputs.prepare(scope, account, request)).id == operation.id + await outputs.complete(operation.id, scope.gateway_id, operation.cleanup_token.get_secret_value()) + second = await outputs.prepare(scope, account, request.model_copy(update={"operation_id": uuid.uuid4()})) + await outputs.complete(second.id, scope.gateway_id, second.cleanup_token.get_secret_value()) + refused = request.model_copy(update={"operation_id": uuid.uuid4()}) + with pytest.raises(FilesError, match="rate limit") as failure: + await outputs.prepare(scope, account, refused) + assert failure.value.status_code == 429 + assert await ProviderFileRepository(async_db).output_operation(refused.operation_id) is None + await async_db.rollback() + other = scope.model_copy(update={"user_id": "other"}) + assert (await outputs.prepare(other, account, refused)).id == refused.operation_id diff --git a/tests/integration/test_tenancy_races.py b/tests/integration/test_tenancy_races.py index f5232bea1a..66020b50bc 100644 --- a/tests/integration/test_tenancy_races.py +++ b/tests/integration/test_tenancy_races.py @@ -20,6 +20,7 @@ from sqlmodel import col from gateway.adapters.api_key_format_adapter import DefaultApiKeyFormatAdapter +from gateway.api.deps import get_workspace_service from gateway.auth.models import hash_key from gateway.core.config import GatewayConfig from gateway.models.api_keys import APIKey @@ -414,7 +415,7 @@ async def run_one(workspace_id: uuid.UUID) -> object: user = await UserRepository(session).get(owner.id) assert user is not None try: - service = WorkspaceService(session, membership_listener=WorkspaceBudgetDefaultService(session)) + service = get_workspace_service(session) await service.delete_workspace(user=user, workspace_id=workspace_id) except Exception as exc: # noqa: BLE001 - the outcome is the assertion return exc @@ -511,10 +512,8 @@ async def let_the_join_run() -> None: async with sessions() as session: actor = await UserRepository(session).get(owner.id) assert actor is not None - deleter = WorkspaceService( - session, - membership_listener=_PausingListener(WorkspaceBudgetDefaultService(session), let_the_join_run), - ) + deleter = get_workspace_service(session) + deleter._membership_listener = _PausingListener(WorkspaceBudgetDefaultService(session), let_the_join_run) await deleter.delete_workspace(user=actor, workspace_id=target.id) await joining diff --git a/tests/unit/test_provider_file_migration.py b/tests/unit/test_provider_file_migration.py index 6dea18352f..53a44fe40c 100644 --- a/tests/unit/test_provider_file_migration.py +++ b/tests/unit/test_provider_file_migration.py @@ -5,7 +5,10 @@ from alembic import command from alembic.config import Config from alembic.script import ScriptDirectory -from sqlalchemy import create_engine, inspect +from sqlalchemy import CheckConstraint, create_engine, inspect +from sqlmodel import SQLModel + +from gateway.models import provider_files # noqa: F401 TABLES = { "provider_account_generations", @@ -40,5 +43,16 @@ def test_provider_file_migration_round_trip(tmp_path: Path) -> None: assert {"purpose", "provider_created_at"} <= { column["name"] for column in inspect(engine).get_columns("provider_file_bindings") } + for table_name in TABLES: + expected = { + constraint.name: str(constraint.sqltext) + for constraint in SQLModel.metadata.tables[table_name].constraints + if isinstance(constraint, CheckConstraint) + } + actual = { + constraint["name"]: constraint["sqltext"] + for constraint in inspect(engine).get_check_constraints(table_name) + } + assert actual == expected finally: engine.dispose() diff --git a/tests/unit/test_provider_file_models.py b/tests/unit/test_provider_file_models.py index a6673b367f..f3623960dc 100644 --- a/tests/unit/test_provider_file_models.py +++ b/tests/unit/test_provider_file_models.py @@ -1,6 +1,12 @@ """Provider file byte counters use database types that hold configured quotas.""" -from sqlalchemy import BigInteger +import uuid +from datetime import UTC, datetime +from typing import Any + +import pytest +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.exc import IntegrityError from sqlmodel import SQLModel from gateway.models import provider_files # noqa: F401 @@ -12,3 +18,57 @@ def test_provider_file_byte_columns_use_big_integers() -> None: assert isinstance(bindings.c.size_bytes.type, BigInteger) assert isinstance(outputs.c.reserved_bytes.type, BigInteger) + + +@pytest.mark.parametrize( + ("table_name", "column", "valid"), + [ + ("provider_account_generations", "credential_source", "organization_key"), + ("provider_account_generations", "status", "active"), + ("provider_file_bindings", "state", "pending_upload"), + ("provider_file_output_operations", "state", "active"), + ], +) +def test_database_rejects_unknown_lifecycle_values(table_name: str, column: str, valid: str) -> None: + engine = create_engine("sqlite://") + names = ("provider_account_generations", "provider_file_output_operations", "provider_file_bindings") + tables = [SQLModel.metadata.tables[name] for name in names] + SQLModel.metadata.create_all(engine, tables=tables) + now = datetime.now(UTC) + common: dict[str, Any] = { + "id": uuid.uuid4(), + "created_at": now, + "organization_id": uuid.uuid4(), + "workspace_id": uuid.uuid4(), + "user_id": "uploader", + "provider_account_generation_id": uuid.uuid4(), + "initiating_gateway_id": "gateway", + "cleanup_token_hash": "token", + "deadline": now, + "expires_at": now, + "operation_deadline": now, + "reserved_files": 1, + "reserved_bytes": 10, + "credential_source": "organization_key", + "credential_ref": "key", + "generation": 1, + "provider": "anthropic", + "status": "active", + "state": "active", + "size_bytes": 10, + "downloadable": False, + "provider_outcome_unknown": False, + "cleanup_attempts": 0, + "request_id": "request", + "attempt_id": "attempt", + } + table = SQLModel.metadata.tables[table_name] + values = {key: value for key, value in common.items() if key in table.c} + values[column] = valid + try: + with engine.begin() as connection: + connection.execute(table.insert().values(**values)) + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.execute(table.update().values(**{column: "typo"})) + finally: + engine.dispose() diff --git a/tests/unit/test_provider_file_outputs.py b/tests/unit/test_provider_file_outputs.py index 3cf6f35d6c..4d7e022dd1 100644 --- a/tests/unit/test_provider_file_outputs.py +++ b/tests/unit/test_provider_file_outputs.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from typing import Any +from unittest.mock import AsyncMock import pytest from pydantic import BaseModel, ConfigDict, SecretStr @@ -144,3 +145,80 @@ async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[An binder = inference.FileOutputBinder(PlatformFilesClient("https://authority", "gateway", "user"), operation, []) await binder.register_ids(["file_generated", "file_generated"]) assert calls == ["file_generated"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [None, "metadata", "registration"]) +async def test_output_batches_share_client_and_finish_siblings( + monkeypatch: pytest.MonkeyPatch, failure: str | None +) -> None: + operation = Operation( + id=uuid.uuid4(), + cleanup_token=SecretStr("cleanup"), + deadline=datetime.now(UTC) + timedelta(minutes=1), + account=FileAccount(generation_id=uuid.uuid4(), api_key=SecretStr("key")), + max_bytes=100, + expires_in_seconds=3600, + ) + opened = closed = active = maximum = 0 + registered: list[str] = [] + fetched: list[str] = [] + + class Provider: + async def aretrieve_file(self, file_id: str, **kwargs: Any) -> FileMetadata: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + fetched.append(file_id) + try: + await asyncio.sleep(0) + if failure == "metadata" and file_id == "file_0": + raise ValueError("invalid metadata") + return FileMetadata(id=file_id) + finally: + active -= 1 + + @asynccontextmanager + async def provider(*args: Any, **kwargs: Any) -> AsyncIterator[Provider]: + nonlocal opened, closed + opened += 1 + try: + yield Provider() + finally: + assert active == 0 + closed += 1 + + async def retry(self: Any, path: str, body: dict[str, Any], result_type: type[Any]) -> Any: + assert path == "outputs/register" + file_id = body["metadata"]["id"] + await asyncio.sleep(0) + if failure == "registration" and file_id == "file_0": + raise FilesError(502, "unavailable") + registered.append(file_id) + return FileMetadata(id=file_id) + + monkeypatch.setattr(inference, "provider_client", provider) + monkeypatch.setattr(PlatformFilesClient, "retry", retry) + binder = inference.FileOutputBinder( + PlatformFilesClient("https://authority", "gateway", "user"), operation, ["input"] + ) + compensate = AsyncMock() + monkeypatch.setattr(binder, "compensate", compensate) + ids = ["input", "file_0", *[f"file_{i}" for i in range(9)]] + if failure: + with pytest.raises(FilesError): + await binder.register_ids(ids) + assert set(registered) == {"file_1", "file_2", "file_3"} + if failure == "metadata": + compensate.assert_awaited_once_with(None, "file_0") + else: + compensate.assert_awaited_once_with(FileMetadata(id="file_0")) + else: + await binder.register_ids(ids) + assert len(registered) == 9 + compensate.assert_not_awaited() + await binder.register_ids(ids) + assert len(fetched) == len(set(fetched)) + assert binder.bound == {"input", *registered} + assert opened == closed == 1 + assert maximum == 4