diff --git a/alembic/versions/c3e5a7b9d1f4_add_provider_files.py b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py
new file mode 100644
index 0000000000..6d9c60949e
--- /dev/null
+++ b/alembic/versions/c3e5a7b9d1f4_add_provider_files.py
@@ -0,0 +1,206 @@
+"""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.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",
+ "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.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"
+ ),
+ 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("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.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),
+ 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.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"
+ ),
+ 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..3cc2973177 100644
--- a/docs/files.md
+++ b/docs/files.md
@@ -125,3 +125,164 @@ 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 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`:
+
+```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.
+
+### 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 |
+| --- | --- |
+| `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; 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 |
+| `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 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
+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 dependency floor is any-llm-sdk 1.28.0, and the lockfile selects that published
+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 b9fb2a4582..5838216b2e 100644
--- a/docs/hybrid-mode-protocol.md
+++ b/docs/hybrid-mode-protocol.md
@@ -629,3 +629,177 @@ 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: 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
+
+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 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. 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.
+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 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
+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, 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. 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
+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.
+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.
+
+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..c9fbfa362e 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": {
@@ -1316,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.",
@@ -1635,6 +1787,25 @@
},
"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.",
+ "maximum": 2592000,
+ "minimum": 3600,
+ "type": "integer"
+ },
+ "expires_in_seconds": {
+ "description": "Anthropic hybrid retention, capped by the control-plane maximum.",
+ "maximum": 7776000,
+ "minimum": 3600,
+ "type": "integer"
+ },
"file": {
"contentMediaType": "application/octet-stream",
"title": "File",
@@ -1642,6 +1813,7 @@
},
"purpose": {
"default": "user_data",
+ "description": "Required for OpenAI hybrid uploads; unsupported for Anthropic hybrid uploads.",
"title": "Purpose",
"type": "string"
},
@@ -3364,6 +3536,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,71 +5684,324 @@
"title": "ExternalUsageEvent",
"type": "object"
},
- "GateResultResponse": {
+ "FileListRequest": {
"properties": {
- "detail": {
+ "after_id": {
"anyOf": [
{
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
},
{
"type": "null"
}
],
- "title": "Detail"
+ "title": "After Id"
},
- "enforcement": {
- "title": "Enforcement",
- "type": "string"
+ "before_id": {
+ "anyOf": [
+ {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[A-Za-z0-9_-]+$",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Before Id"
},
- "gate_id": {
- "title": "Gate Id",
+ "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"
+ },
+ "order": {
+ "default": "desc",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "title": "Order",
"type": "string"
},
- "message": {
- "title": "Message",
+ "page": {
+ "anyOf": [
+ {
+ "maxLength": 4096,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Page"
+ },
+ "provider": {
+ "default": "anthropic",
+ "maxLength": 32,
+ "minLength": 1,
+ "pattern": "^[a-z][a-z0-9_]*$",
+ "title": "Provider",
"type": "string"
},
- "outcome": {
- "title": "Outcome",
+ "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"
}
},
- "required": [
- "gate_id",
- "enforcement",
- "outcome",
- "message"
- ],
- "title": "GateResultResponse",
+ "title": "FileListRequest",
"type": "object"
},
- "GatewaySettings": {
- "description": "Operator-facing runtime settings surfaced to the dashboard.\n\nThe top-level flags are kept for the callers that read them directly\n(pricing warnings, the models/providers pages); ``config`` is the full\nread-only view with the settable/startup-only marking.",
+ "FileMetadata": {
+ "additionalProperties": true,
+ "description": "Bounded any-llm metadata; absent provider fields remain unknown.",
"properties": {
- "config": {
- "items": {
- "$ref": "#/components/schemas/ConfigField"
- },
- "title": "Config",
- "type": "array"
- },
- "default_pricing": {
- "title": "Default Pricing",
- "type": "boolean"
- },
- "master_key_source": {
- "description": "Whether the dashboard master key is configured at startup or generated and stored by Otari.",
- "enum": [
- "configured",
- "generated"
+ "created_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Master Key Source",
- "type": "string"
- },
- "mode": {
- "title": "Mode",
- "type": "string"
+ "title": "Created At"
+ },
+ "downloadable": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Downloadable"
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At"
+ },
+ "filename": {
+ "anyOf": [
+ {
+ "maxLength": 1024,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Filename"
+ },
+ "id": {
+ "maxLength": 255,
+ "minLength": 1,
+ "pattern": "^[A-Za-z0-9_-]+$",
+ "title": "Id",
+ "type": "string"
+ },
+ "mime_type": {
+ "anyOf": [
+ {
+ "maxLength": 255,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mime Type"
+ },
+ "purpose": {
+ "anyOf": [
+ {
+ "maxLength": 255,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Purpose"
+ },
+ "size_bytes": {
+ "anyOf": [
+ {
+ "minimum": 0.0,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Size Bytes"
+ },
+ "status": {
+ "anyOf": [
+ {
+ "maxLength": 255,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "title": "FileMetadata",
+ "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": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Detail"
+ },
+ "enforcement": {
+ "title": "Enforcement",
+ "type": "string"
+ },
+ "gate_id": {
+ "title": "Gate Id",
+ "type": "string"
+ },
+ "message": {
+ "title": "Message",
+ "type": "string"
+ },
+ "outcome": {
+ "title": "Outcome",
+ "type": "string"
+ }
+ },
+ "required": [
+ "gate_id",
+ "enforcement",
+ "outcome",
+ "message"
+ ],
+ "title": "GateResultResponse",
+ "type": "object"
+ },
+ "GatewaySettings": {
+ "description": "Operator-facing runtime settings surfaced to the dashboard.\n\nThe top-level flags are kept for the callers that read them directly\n(pricing warnings, the models/providers pages); ``config`` is the full\nread-only view with the settable/startup-only marking.",
+ "properties": {
+ "config": {
+ "items": {
+ "$ref": "#/components/schemas/ConfigField"
+ },
+ "title": "Config",
+ "type": "array"
+ },
+ "default_pricing": {
+ "title": "Default Pricing",
+ "type": "boolean"
+ },
+ "master_key_source": {
+ "description": "Whether the dashboard master key is configured at startup or generated and stored by Otari.",
+ "enum": [
+ "configured",
+ "generated"
+ ],
+ "title": "Master Key Source",
+ "type": "string"
+ },
+ "mode": {
+ "title": "Mode",
+ "type": "string"
},
"model_discovery": {
"title": "Model Discovery",
@@ -6553,6 +7011,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": {
@@ -7854,66 +8339,68 @@
"title": "OfferingUsage",
"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.",
+ "OpenAIFileDeleted": {
"properties": {
- "api_base": {
+ "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": [
{
- "maxLength": 1024,
- "type": "string"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Api Base"
+ "title": "Bytes"
},
- "api_key": {
+ "created_at": {
"anyOf": [
{
- "type": "string"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Api Key"
+ "title": "Created At"
},
- "client_args": {
+ "expires_at": {
"anyOf": [
{
- "additionalProperties": true,
- "type": "object"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Client Args"
- },
- "name": {
- "maxLength": 255,
- "title": "Name",
- "type": "string"
+ "title": "Expires At"
},
- "provider": {
- "maxLength": 255,
- "title": "Provider",
- "type": "string"
- }
- },
- "required": [
- "provider",
- "name"
- ],
- "title": "OrgProviderKeyCreateRequest",
- "type": "object"
- },
- "OrgProviderKeyPublic": {
- "description": "The API-facing shape. Never carries the key, only whether one is set.",
- "properties": {
- "api_base": {
+ "filename": {
"anyOf": [
{
"type": "string"
@@ -7922,13 +8409,173 @@
"type": "null"
}
],
- "title": "Api Base"
+ "title": "Filename"
},
- "archived_at": {
- "anyOf": [
- {
- "format": "date-time",
- "type": "string"
+ "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": {
+ "api_base": {
+ "anyOf": [
+ {
+ "maxLength": 1024,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Api Base"
+ },
+ "api_key": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Api Key"
+ },
+ "client_args": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Client Args"
+ },
+ "name": {
+ "maxLength": 255,
+ "title": "Name",
+ "type": "string"
+ },
+ "provider": {
+ "maxLength": 255,
+ "title": "Provider",
+ "type": "string"
+ }
+ },
+ "required": [
+ "provider",
+ "name"
+ ],
+ "title": "OrgProviderKeyCreateRequest",
+ "type": "object"
+ },
+ "OrgProviderKeyPublic": {
+ "description": "The API-facing shape. Never carries the key, only whether one is set.",
+ "properties": {
+ "api_base": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Api Base"
+ },
+ "archived_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
},
{
"type": "null"
@@ -9690,6 +10337,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,6 +11304,47 @@
"title": "PoolStatus",
"type": "object"
},
+ "PrepareUpload": {
+ "properties": {
+ "expires_in_seconds": {
+ "anyOf": [
+ {
+ "maximum": 7776000.0,
+ "minimum": 3600.0,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires In Seconds"
+ },
+ "operation_id": {
+ "format": "uuid",
+ "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",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "operation_id",
+ "size_bytes"
+ ],
+ "title": "PrepareUpload",
+ "type": "object"
+ },
"PricingDriftRow": {
"description": "A stored deployment rate beside the default it shadows.",
"properties": {
@@ -11366,6 +12104,35 @@
"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"
+ },
+ "provider": {
+ "default": "anthropic",
+ "maxLength": 32,
+ "minLength": 1,
+ "pattern": "^[a-z][a-z0-9_]*$",
+ "title": "Provider",
+ "type": "string"
+ }
+ },
+ "required": [
+ "ids"
+ ],
+ "title": "References",
+ "type": "object"
+ },
"RegisterPasskeyRequest": {
"description": "A completed registration ceremony, with the label to file it under.",
"properties": {
@@ -11575,6 +12342,32 @@
"title": "ResetPasswordRequest",
"type": "object"
},
+ "ResolveFile": {
+ "properties": {
+ "operation": {
+ "enum": [
+ "metadata",
+ "download",
+ "delete"
+ ],
+ "title": "Operation",
+ "type": "string"
+ },
+ "provider": {
+ "default": "anthropic",
+ "maxLength": 32,
+ "minLength": 1,
+ "pattern": "^[a-z][a-z0-9_]*$",
+ "title": "Provider",
+ "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 +21217,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 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": [
{
@@ -20475,18 +21268,105 @@
],
"title": "Workspace Id"
}
- }
- ],
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "additionalProperties": true,
- "title": "Response Files-List Files",
- "type": "object"
- }
- }
+ },
+ {
+ "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"
+ }
+ },
+ {
+ "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"
+ }
+ },
+ {
+ "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": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "title": "Response Files-List Files",
+ "type": "object"
+ },
+ {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AnthropicFilePage"
+ },
+ {
+ "$ref": "#/components/schemas/OpenAIFilePage"
+ }
+ ],
+ "title": "Response List Files Api V1 Files Get"
+ }
+ ]
+ }
+ }
},
"description": "Successful Response"
},
@@ -20515,8 +21395,33 @@
]
},
"post": {
- "description": "OpenAI-compatible file upload endpoint.",
+ "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": [
+ {
+ "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": {
"content": {
"multipart/form-data": {
@@ -20532,9 +21437,24 @@
"content": {
"application/json": {
"schema": {
- "additionalProperties": true,
- "title": "Response Files-Create File",
- "type": "object"
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "title": "Response Files-Create File",
+ "type": "object"
+ },
+ {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AnthropicFileMetadata"
+ },
+ {
+ "$ref": "#/components/schemas/OpenAIFileMetadata"
+ }
+ ],
+ "title": "Response Upload File Api V1 Files Post"
+ }
+ ]
}
}
},
@@ -20567,7 +21487,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 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": [
{
@@ -20594,6 +21514,29 @@
],
"title": "User"
}
+ },
+ {
+ "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": {
@@ -20601,9 +21544,24 @@
"content": {
"application/json": {
"schema": {
- "additionalProperties": true,
- "title": "Response Files-Delete File",
- "type": "object"
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "title": "Response Files-Delete File",
+ "type": "object"
+ },
+ {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AnthropicFileDeleted"
+ },
+ {
+ "$ref": "#/components/schemas/OpenAIFileDeleted"
+ }
+ ],
+ "title": "Response Delete File Api V1 Files File Id Delete"
+ }
+ ]
}
}
},
@@ -20634,7 +21592,7 @@
]
},
"get": {
- "description": "Retrieve metadata for a single file.",
+ "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": [
{
@@ -20661,6 +21619,29 @@
],
"title": "User"
}
+ },
+ {
+ "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": {
@@ -20668,9 +21649,24 @@
"content": {
"application/json": {
"schema": {
- "additionalProperties": true,
- "title": "Response Files-Get File",
- "type": "object"
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "title": "Response Files-Get File",
+ "type": "object"
+ },
+ {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AnthropicFileMetadata"
+ },
+ {
+ "$ref": "#/components/schemas/OpenAIFileMetadata"
+ }
+ ],
+ "title": "Response Retrieve File Api V1 Files File Id Get"
+ }
+ ]
}
}
},
@@ -20703,7 +21699,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 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": [
{
@@ -20730,6 +21726,29 @@
],
"title": "User"
}
+ },
+ {
+ "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": {
@@ -20783,6 +21802,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..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.",
+ "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": {
@@ -1793,16 +1793,46 @@
"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": ""
+ },
+ {
+ "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="
+ "raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=&page=&ids[]=&after=&before=&order="
}
}
},
{
"name": "Create File",
"request": {
- "description": "OpenAI-compatible file upload endpoint.",
+ "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": {
@@ -1821,7 +1851,7 @@
{
"name": "Get File",
"request": {
- "description": "Retrieve metadata for a single file.",
+ "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": {
@@ -1856,7 +1886,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 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": {
@@ -1891,7 +1921,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 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": {
@@ -1927,6 +1957,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 \"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": [
+ {
+ "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 }\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 }\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/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/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/scripts/generate_openapi.py b/scripts/generate_openapi.py
index f3e97eb703..190d45c242 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 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
def generate_openapi_spec() -> dict[str, object]:
@@ -47,7 +53,132 @@ 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, uow: UnitOfWork) -> FileScope:
+ raise RuntimeError("Schema-only authentication dependency")
+
+
+async def _schema_attempt(scope: FileScope, request: OutputPrepare, uow: UnitOfWork) -> 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", {}))
+ 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]
+ target["description"] = target.get("description", "") + (
+ "\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_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:
+ 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 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"]
+ 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.",
+ },
+ ]
+ )
+ 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": "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": 3600,
+ "maximum": 2592000,
+ "description": "OpenAI hybrid 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/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/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/_file_formats.py b/src/gateway/api/routes/_file_formats.py
new file mode 100644
index 0000000000..045ea3eb96
--- /dev/null
+++ b/src/gateway/api/routes/_file_formats.py
@@ -0,0 +1,210 @@
+"""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())
+ 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(
+ 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/_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..7f0d65b582 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 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
@@ -468,6 +470,14 @@ 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:
+ reject_openai_file_state(request.model_dump(exclude_unset=True))
+ 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..ba835fc3eb
--- /dev/null
+++ b/src/gateway/api/routes/hybrid_files.py
@@ -0,0 +1,381 @@
+"""Provider-native Files with thin native API envelopes on a stateless gateway."""
+
+import asyncio
+import uuid
+from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
+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 starlette.types import Receive, Scope, Send
+
+from gateway.api.deps import extract_credential_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 (
+ FILES_PROTOCOL_VERSION,
+ FileMetadata,
+ FilePage,
+ FilesError,
+ 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": 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": FILES_PROTOCOL_VERSION,
+ **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,
+ "File transfer timed out",
+ headers={
+ "Cache-Control": "private, no-store",
+ "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION,
+ },
+ ) from None
+ result.headers["Cache-Control"] = "private, no-store"
+ result.headers["X-Otari-Files-Protocol"] = FILES_PROTOCOL_VERSION
+ return result
+
+ return handle
+
+
+router = APIRouter(tags=["files"], route_class=FilesRoute)
+Config = Annotated[GatewayConfig, Depends(get_config)]
+
+
+def files_client(request: Request, config: GatewayConfig) -> PlatformFilesClient:
+ if not config.files_provider_native_enabled:
+ raise FilesError(404, "Provider-native Files are not enabled")
+ files_format(request)
+ 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")
+ 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=AnthropicFileMetadata | OpenAIFileMetadata, response_model_exclude_unset=True)
+async def upload_file(request: Request, config: Config) -> AnthropicFileMetadata | OpenAIFileMetadata:
+ client = files_client(request, config)
+ 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 = committed = 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,
+ "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=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,
+ 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:
+ started = True
+ result = await provider.aupload_file(
+ upload.file,
+ 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))
+ finalized = await client.retry(
+ f"uploads/{operation.id}/finalize",
+ {
+ "metadata": metadata.model_dump(mode="json", exclude_unset=True),
+ "expires_in_seconds": retention,
+ },
+ FileMetadata,
+ )
+ committed = True
+ return envelope.metadata(finalized)
+ except BaseException as exc:
+ # 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
+ 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 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:
+ 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(compensate()))
+
+
+@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)
+ 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=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 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)
+ 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)
+ 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
+ try:
+ 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 isinstance(exc, TimeoutError) or 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
+ 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:
+ # 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:
+ await stack.aclose()
+
+ return FileDownloadResponse(chunks(), stack=stack, headers=headers, media_type="application/octet-stream")
+
+
+@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)
+ 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")
+ 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=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:
+ 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 envelope.deleted(file_id)
diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py
index 115d243c72..bb16ad3648 100644
--- a/src/gateway/api/routes/messages.py
+++ b/src/gateway/api/routes/messages.py
@@ -68,6 +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.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
@@ -229,9 +234,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
@@ -301,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``).
@@ -342,12 +352,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 +620,157 @@ def _reject_container_on_managed_credential(ctx: RequestContext) -> None:
)
+class _FileMessagesAdapter(_MessagesAdapter):
+ 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"] = (
+ {
+ 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]) -> 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:
+ 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 | 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",
+ {
+ "operation_id": str(uuid.uuid4()),
+ "request_id": self.files_request_id,
+ "attempt_id": attempt.attempt_id,
+ "generation_id": attempt.provider_account_generation_id,
+ },
+ 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
+ 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 AnthropicFileOutputBinder(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:
+ if binder is not None:
+ 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:
+ if binder is not None:
+ await binder.complete()
+ raise
+ return binder.stream(stream) if binder is not None else 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:
+ if binder is not None:
+ 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) if binder is not None else source:
+ yield event
+
+ return stream()
+
+
_ADAPTER = _MessagesAdapter()
@@ -636,6 +793,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 +807,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 +835,7 @@ async def _normalize(
try:
ctx = await resolve_request_context(
- adapter=_ADAPTER,
+ adapter=adapter,
raw_request=raw_request,
response=response,
db=db,
@@ -713,8 +874,52 @@ async def _normalize(
await release_reservation(ctx)
raise
+ if ctx.hybrid_mode:
+ try:
+ references = collect_anthropic_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:
+ 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)
+ account = None
+ if references:
+ account = await client.post(
+ "references/resolve", {"ids": references, "provider": "anthropic"}, FileAccount
+ )
+ check_file_account(account, "anthropic")
+ 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]
+ 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,
+ native_outputs=native_outputs,
+ reference_account=account,
+ )
+ except FilesError as exc:
+ raise _files_error(exc) from None
+
tool_ctx = await prepare_gateway_tools(
- adapter=_ADAPTER,
+ adapter=adapter,
ctx=ctx,
response=response,
guardrails=request.guardrails,
@@ -762,7 +967,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 +986,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 +1013,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 +1033,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 _files_error(exc) 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/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/provider_files.py b/src/gateway/api/routes/provider_files.py
new file mode 100644
index 0000000000..fe77847233
--- /dev/null
+++ b/src/gateway/api/routes/provider_files.py
@@ -0,0 +1,218 @@
+"""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 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,
+ 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
+
+# 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:
+ """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)
+ Uow = Annotated[UnitOfWork, Depends(get_unit_of_work)]
+ Config = Annotated[GatewayConfig, Depends(get_config)]
+
+ async def principal(request: Request, uow: Uow, response: Response) -> FileScope:
+ response.headers["Cache-Control"] = "private, no-store"
+ 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, uow: Uow, response: Response) -> FileScope:
+ response.headers["Cache-Control"] = "private, no-store"
+ 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(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(
+ uow,
+ 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,
+ uow: UnitOfWork,
+ generation_id: uuid.UUID | None = None,
+ *,
+ provider: str | None = None,
+ cleanup: bool = False,
+ ) -> FileAccount:
+ 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, 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, 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, lifecycle: Service
+ ) -> dict[str, bool]:
+ await lifecycle.abandon(binding_id, scope.gateway_id, body)
+ return {"ok": True}
+
+ @router.post("/list")
+ 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, 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, uow: Uow, lifecycle: Service) -> Any:
+ # The authorizer intersects the original model plan, prices, and workspace tool policy.
+ 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, 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, lifecycle: Service) -> Any:
+ return _wire(
+ await ProviderFileOutputs(lifecycle).abandon(
+ operation_id, scope.gateway_id, body.cleanup_token.get_secret_value(), body.metadata, body.file_id
+ )
+ )
+
+ @router.post("/outputs/{operation_id}/complete")
+ async def complete_output(
+ operation_id: uuid.UUID, body: CleanupResult, scope: Gateway, lifecycle: Service
+ ) -> dict[str, bool]:
+ 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, lifecycle: Service) -> dict[str, int]:
+ return await lifecycle.backlog(scope.organization_id)
+
+ @router.post("/cleanup/claim")
+ async def claim(body: CleanupClaim, scope: Gateway, uow: Uow, lifecycle: Service) -> Any:
+ async def credential(generation_id: uuid.UUID) -> FileAccount:
+ return await account(scope, uow, generation_id, cleanup=True)
+
+ lease = await ProviderFileCleanup(lifecycle).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, lifecycle: Service
+ ) -> dict[str, bool]:
+ 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, uow: Uow, lifecycle: Service) -> Any:
+ selected = None
+ if body.operation != "metadata":
+ 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, lifecycle: Service
+ ) -> dict[str, bool]:
+ await lifecycle.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..edba0e4a5e 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 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
@@ -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,14 @@ 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:
+ reject_openai_file_state(request_body.model_dump(exclude_unset=True))
+ 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..d4f6e5b83e 100644
--- a/src/gateway/api/routes/users.py
+++ b/src/gateway/api/routes/users.py
@@ -5,21 +5,22 @@
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
@@ -27,6 +28,7 @@
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",
@@ -367,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
@@ -399,18 +402,9 @@ async def delete_user(
detail="Could not erase this user's telemetry; the user was not deleted",
) from None
- await db.execute(
- update(APIKey)
- .where(APIKey.user_id == user_id)
- .values(is_active=False)
- .execution_options(synchronize_session=False)
- )
- 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/core/config.py b/src/gateway/core/config.py
index 9134fda1d1..af42464899 100644
--- a/src/gateway/core/config.py
+++ b/src/gateway/core/config.py
@@ -876,6 +876,66 @@ 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 provider-native Files after provider and control-plane contract verification.",
+ )
+ 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,
+ 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_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.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 +1172,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 +1597,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 +1690,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 +2123,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..f1d230a034
--- /dev/null
+++ b/src/gateway/models/provider_files.py
@@ -0,0 +1,117 @@
+"""Durable provider accounts and file operations; no file bytes or provider secrets."""
+
+import uuid
+from datetime import datetime
+from typing import Literal
+
+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",
+ "credential_ref",
+ "generation",
+ name="uq_provider_account_generation",
+ ),
+ )
+
+ provider: str = Field(default="anthropic", 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: 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)
+ 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__ = (
+ 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"),
+ )
+
+ 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
+ purpose: str | None = Field(default=None, max_length=255)
+ provider_created_at: datetime | None = Field(default=None, sa_type=UtcDateTime)
+ 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)
+ 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: 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)
+ 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"
+ __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
+ )
+ 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: OutputOperationState = Field(default="active", sa_column=Column(String(16), nullable=False))
+ reserved_files: int
+ reserved_bytes: int = Field(sa_type=BigInteger)
+
+
+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/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/repositories/tenancy/provider_file_repository.py b/src/gateway/repositories/tenancy/provider_file_repository.py
new file mode 100644
index 0000000000..ce50a1771d
--- /dev/null
+++ b/src/gateway/repositories/tenancy/provider_file_repository.py
@@ -0,0 +1,429 @@
+"""Scoped persistence and locking for provider-native file operations."""
+
+import uuid
+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.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 | 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 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)
+ .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(
+ 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,
+ *,
+ provider: str = "anthropic",
+ purpose: str | None = None,
+ ascending: bool = False,
+ reverse_cursor: bool = False,
+ provider_order: bool = False,
+ ) -> 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",
+ 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_(
+ 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(*(column.asc() if ascending else column.desc() for column in ordering)).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..e99380b703
--- /dev/null
+++ b/src/gateway/services/provider_files/__init__.py
@@ -0,0 +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/accounts.py b/src/gateway/services/provider_files/accounts.py
new file mode 100644
index 0000000000..8232e991ff
--- /dev/null
+++ b/src/gateway/services/provider_files/accounts.py
@@ -0,0 +1,218 @@
+"""Conservative account selection and credential retirement."""
+
+import uuid
+from collections.abc import Awaitable, Callable, Sequence
+from datetime import UTC, datetime
+
+from pydantic import SecretStr
+
+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.provider_file_repository import ProviderFileRepository
+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
+
+
+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, "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, "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, "Provider account is ambiguous")
+ return live[0] if live else None
+
+
+class FileAccountResolver:
+ def __init__(self, uow: UnitOfWork) -> None:
+ self.uow = uow
+ self.repo = ProviderFileRepository(uow)
+
+ 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":
+ 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")
+ 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, "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 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(
+ provider=provider,
+ generation=number,
+ credential_source="organization_key",
+ credential_ref=str(key.id),
+ organization_id=scope.organization_id,
+ )
+ await self.repo.save(generation)
+ elif generation.status != "active":
+ 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 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 credential")
+ return FileAccount(
+ 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
+ or row.organization_id != organization_id
+ or row.credential_source != "organization_key"
+ or (not cleanup and row.status != "active")
+ ):
+ 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, "Provider account unavailable")
+ return self._credential(key, row)
+
+
+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 repo.refresh(key)
+ row = await repo.latest_account("organization_key", str(key.id), key.organization_id)
+ if row is None:
+ return False
+ return await retire_account_generation(uow, row, release_secret=release_secret)
+
+
+async def retire_account_generation(
+ uow: UnitOfWork,
+ row: ProviderAccountGeneration,
+ *,
+ release_secret: bool,
+) -> 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 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 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/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
new file mode 100644
index 0000000000..24bfef6743
--- /dev/null
+++ b/src/gateway/services/provider_files/cleanup.py
@@ -0,0 +1,90 @@
+"""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 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
+
+
+class ProviderFileCleanup:
+ def __init__(self, service: ProviderFileService) -> None:
+ self.service = service
+ self.uow = service.uow
+ 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."""
+ 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
+ )
+ 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)
+ )
+ 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
+ ],
+ )
+
+ async def complete(
+ self, organization_id: uuid.UUID, gateway_id: str, lease_id: uuid.UUID, result: LeaseResult
+ ) -> None:
+ 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
+ await finalize_retirement(self.repo, account, datetime.now(UTC))
diff --git a/src/gateway/services/provider_files/client.py b/src/gateway/services/provider_files/client.py
new file mode 100644
index 0000000000..dc5f4778d5
--- /dev/null
+++ b/src/gateway/services/provider_files/client.py
@@ -0,0 +1,62 @@
+"""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 FILES_PROTOCOL_VERSION, 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, "X-Otari-Files-Protocol": FILES_PROTOCOL_VERSION}
+ 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.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:
+ 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..9a4f4fec7b
--- /dev/null
+++ b/src/gateway/services/provider_files/contracts.py
@@ -0,0 +1,186 @@
+"""Additive gateway/control-plane contracts for provider-native files."""
+
+import uuid
+from datetime import datetime
+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
+
+
+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_-]+$")]
+
+
+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
+ 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
+ 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:
+ 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):
+ 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 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
+
+
+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: ProviderName = "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):
+ 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)
+
+
+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):
+ provider: ProviderName = "anthropic"
+ 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):
+ provider: ProviderName = "anthropic"
+ 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
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..89dae6db0b
--- /dev/null
+++ b/src/gateway/services/provider_files/inference.py
@@ -0,0 +1,99 @@
+"""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
+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_ids(self, ids: Iterable[str]) -> None:
+ 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:
+ provider = await stack.enter_async_context(provider_client(self.operation.account))
+ except Exception:
+ for file_id in pending:
+ await self.compensate(None, file_id)
+ raise FilesError(502, "Provider file metadata could not be registered") from None
+ 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
+ )
+ 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:
+ 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
diff --git a/src/gateway/services/provider_files/lifecycle.py b/src/gateway/services/provider_files/lifecycle.py
new file mode 100644
index 0000000000..7d7de1baf3
--- /dev/null
+++ b/src/gateway/services/provider_files/lifecycle.py
@@ -0,0 +1,462 @@
+"""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 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,
+ 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,
+ uow: UnitOfWork,
+ *,
+ 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.uow = uow
+ self.repo = ProviderFileRepository(uow)
+ 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.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
+
+ 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"
+ 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")
+ 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:
+ 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)
+ 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),
+ )
+ await self.repo.save(row)
+ return self._operation(row, account)
+
+ async def finalize(
+ self,
+ scope: FileScope,
+ binding_id: uuid.UUID,
+ 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)
+ 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)
+ 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 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 = 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")
+ 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
+ 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.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")
+ 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"
+
+ 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 = 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))
+ 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
+ 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,
+ scope.user_id,
+ now,
+ ids=request.ids,
+ 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": timestamp.isoformat(),
+ "id": str(last.id),
+ }
+ )
+ )
+ return FilePage(data=[self._metadata(row) for row in rows], next_page=next_page)
+
+ async def resolve(
+ self,
+ scope: FileScope,
+ 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], provider=provider
+ )
+ if len(rows) != 1:
+ raise FilesError(404, "File unavailable")
+ row = rows[0]
+ 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.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":
+ 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,
+ )
+ return result
+
+ 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)),
+ provider=provider,
+ )
+ 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 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.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)
+
+ @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..99544d971b
--- /dev/null
+++ b/src/gateway/services/provider_files/outputs.py
@@ -0,0 +1,251 @@
+"""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.uow = service.uow
+ 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."""
+ 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.repo.output_operation(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:
+ 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)
+ 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,
+ )
+ await self.repo.save(row)
+ 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:
+ 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.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,
+ 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)
+ 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 >= 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)
+ )
+ 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=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,
+ 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,
+ )
+ await self.repo.save(binding)
+ row.reserved_files = max(0, row.reserved_files - 1)
+ 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:
+ 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.repo.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
+
+ async def abandon(
+ self,
+ operation_id: uuid.UUID,
+ gateway_id: str,
+ token: str,
+ metadata: FileMetadata | None,
+ file_id: str | None = None,
+ ) -> OutputCleanup:
+ 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.repo.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 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),
+ )
+ await self.repo.save(existing)
+ existing.state = "pending_cleanup"
+ existing.cleanup_reason, existing.cleanup_after = "output_abandoned", datetime.now(UTC)
+ 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..c2549006da
--- /dev/null
+++ b/src/gateway/services/provider_files/references.py
@@ -0,0 +1,105 @@
+"""Bounded, envelope-specific inspection of structured 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_anthropic_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", "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")
+ 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)
+
+
+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: list[tuple[Any, int]] = [(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")
+ 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",
+ "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
new file mode 100644
index 0000000000..639601827a
--- /dev/null
+++ b/src/gateway/services/provider_files/transfers.py
@@ -0,0 +1,153 @@
+"""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,
+ 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)
+ 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=len(allowed_fields), max_part_size=_ENVELOPE_BYTES
+ )
+ form: FormData | None = None
+ try:
+ form = await parser.parse()
+ if set(form.keys()) - {"file", *allowed_fields}:
+ raise FilesError(400, "Unsupported upload field")
+ 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")
+ 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:
+ 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..fa79e754b8
--- /dev/null
+++ b/src/gateway/services/provider_files/transport.py
@@ -0,0 +1,74 @@
+"""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]:
+ 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"
+ 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(
+ account.provider,
+ 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 {},
+ )
+ 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/__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 1489cb9e47..15458d0849 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,
@@ -95,9 +96,11 @@
OrgProviderKeyUnknownProviderError,
OrgProviderKeyUnsafeApiBaseError,
SecretBoxUnavailableTenancyError,
+ TenancyConflictError,
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
@@ -377,14 +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 = 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
# ------------------------------------------------------------------
@@ -496,15 +509,20 @@ async def update_key_for_user(
update_data["encrypted_api_key"] = encrypted_api_key
update_data["last4"] = last4
+ 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 self._revocations.retire_key(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.
@@ -520,8 +538,9 @@ async def archive_key_for_user(self, *, user: User, key_id: uuid.UUID) -> OrgPro
if key is None:
raise OrgProviderKeyNotFoundError(key_id)
- 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 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))
@@ -533,11 +552,16 @@ 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))
- updated = await self.keys.update_key(key, {"archived_at": None})
- await self.db.commit()
+ async with self.uow:
+ 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)
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.
@@ -553,10 +577,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 self.keys.delete_key(key)
- await self.db.commit()
+ async with self.uow:
+ 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)
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)
@@ -665,6 +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 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)
@@ -707,6 +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:
+ 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 f880842d1f..8bbd020730 100644
--- a/src/gateway/services/tenancy/workspace_service.py
+++ b/src/gateway/services/tenancy/workspace_service.py
@@ -48,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
@@ -295,9 +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 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_hybrid_files_inference_guards.py b/tests/integration/test_hybrid_files_inference_guards.py
new file mode 100644
index 0000000000..b117db5b05
--- /dev/null
+++ b/tests/integration/test_hybrid_files_inference_guards.py
@@ -0,0 +1,66 @@
+"""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"]}]},
+ {
+ "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"}}]},
+ {"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
new file mode 100644
index 0000000000..a4699b7052
--- /dev/null
+++ b/tests/integration/test_hybrid_files_messages.py
@@ -0,0 +1,319 @@
+"""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
+
+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 .conftest import app_for
+from .test_hybrid_mode_messages import _attempt, _message_response, _resolve_payload
+
+
+@pytest.mark.parametrize(
+ "outcome, status_code, error_type",
+ [
+ ("success", 200, None),
+ ("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"),
+ ("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, status_code: int, error_type: str | None
+) -> None:
+ monkeypatch.setenv("OTARI_AI_TOKEN", "gateway-token")
+ generation = uuid.uuid4()
+ account = FileAccount(
+ generation_id=generation,
+ 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] = []
+
+ 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:
+ if path == "cleanup/claim":
+ return {"lease": None}
+ events.append(path)
+ if path == "references/resolve":
+ 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"]
+ 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(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)
+ monkeypatch.setattr(PlatformFilesClient, "post", files)
+ monkeypatch.setattr(AnthropicFileOutputBinder, "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,
+ **({"container": "container_01ABC"} if outcome == "registration_failure" else {}),
+ "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 == 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", "wrong_key", "wrong_base", "reference_failure"}:
+ assert "provider" not in events
+ assert "outputs/prepare" not in events
+ else:
+ assert events.count("provider") == 1
+ 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])
+@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)
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_routes.py b/tests/integration/test_hybrid_files_routes.py
new file mode 100644
index 0000000000..8efb096eb8
--- /dev/null
+++ b/tests/integration/test_hybrid_files_routes.py
@@ -0,0 +1,398 @@
+"""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
+from datetime import UTC, datetime, timedelta
+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
+
+from gateway.api.deps import get_config
+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 (
+ FileAccount,
+ FileMetadata,
+ FilePage,
+ FilesError,
+ Operation,
+ ResolvedFile,
+ WireModel,
+)
+from gateway.services.provider_files.transfers import receive_upload
+
+
+@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")
+
+
+@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
+) -> 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")
+
+
+@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_hybrid_files_sdk_contract.py b/tests/integration/test_hybrid_files_sdk_contract.py
new file mode 100644
index 0000000000..c413f70cf8
--- /dev/null
+++ b/tests/integration/test_hybrid_files_sdk_contract.py
@@ -0,0 +1,118 @@
+"""Official GA Anthropic client through Otari and the released any-llm Files transport."""
+
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import anthropic
+import httpx
+import pytest
+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
+
+
+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",
+ 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",
+ 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:
+ 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"):
+ calls.append(f"resolve:{body['operation']}")
+ 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]
+ 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)
+ assert deleted.id == uploaded.id and deleted.type == "file_deleted"
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(
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
new file mode 100644
index 0000000000..84fd8d60ab
--- /dev/null
+++ b/tests/integration/test_provider_file_lifecycle.py
@@ -0,0 +1,294 @@
+"""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.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,
+ 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(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"))
+
+
+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(
+ 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))
+ data = metadata()
+ assert await service.finalize(scope, operation.id, data) == data
+ assert await service.finalize(scope, operation.id, data) == data
+ 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"):
+ 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(
+ 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 ProviderFileRepository(async_db).account(account.generation_id)
+ assert generation is not None
+ generation.status = "retiring"
+ await async_db.commit()
+ with pytest.raises(FilesError, match="revoked"):
+ await service.finalize(scope, operation.id, metadata())
+ 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
+ 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 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 str(row.state) == "deleted"
+
+
+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 ProviderFileRepository(async_db).get(operation.id)
+ assert row is not None
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
+ await async_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(
+ async_db: AsyncSession,
+ 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 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 async_db.commit()
+ cleanup = await outputs.abandon(
+ operation.id, scope.gateway_id, operation.cleanup_token.get_secret_value(), None, "file_late"
+ )
+ 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 str(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(
+ 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
+
+ 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 ProviderFileRepository(async_db).get(operation.id)
+ assert row is not None and row.state == "deleted"
+ 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:
+ 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, "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_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
new file mode 100644
index 0000000000..0102dcaa5c
--- /dev/null
+++ b/tests/integration/test_provider_file_transactions.py
@@ -0,0 +1,365 @@
+"""Files transaction boundaries preserve revocation without partially replacing secrets."""
+
+import uuid
+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.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
+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.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
+from gateway.services.tenancy.errors import OrgProviderKeyAlreadyExistsError, TenancyConflictError
+
+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 = get_org_provider_key_service(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_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 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)
+ 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],
+ 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 = get_org_provider_key_service(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
+
+
+@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:
+ 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"
+
+
+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 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()
+ 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"
+
+
+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_provider_files_protocol.py b/tests/integration/test_provider_files_protocol.py
new file mode 100644
index 0000000000..47ec2b95ac
--- /dev/null
+++ b/tests/integration/test_provider_files_protocol.py
@@ -0,0 +1,190 @@
+"""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, HTTPException, Request
+
+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
+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
+from .test_provider_file_lifecycle import metadata
+
+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],
+ provider: str,
+) -> None:
+ service, scope, account = files_setup
+ 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, 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, 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,
+ )
+ 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"] == "2"
+ request: dict[str, Any] = {
+ "operation_id": str(uuid.uuid4()),
+ "provider": provider,
+ "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.json()["account"]["provider"] == provider
+ 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={"provider": provider}, headers=headers)
+ assert listing.json()["data"][0]["id"] == data.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
+
+
+@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"
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_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_hybrid_file_compensation.py b/tests/unit/test_hybrid_file_compensation.py
new file mode 100644
index 0000000000..a5a6dfb8e7
--- /dev/null
+++ b/tests/unit/test_hybrid_file_compensation.py
@@ -0,0 +1,125 @@
+"""Upload compensation survives cancellation and reserves time for abandonment."""
+
+import asyncio
+import uuid
+from collections.abc import AsyncIterator, Coroutine
+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", [None, "delete", "report"])
+async def test_upload_compensation_survives_cancellation_and_honors_timeout(
+ monkeypatch: pytest.MonkeyPatch, cancel_handler: bool, expire: str | None
+) -> None:
+ 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"),
+ 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 (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_timeout, real_create_task = asyncio.timeout, asyncio.create_task
+
+ 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, "timeout", controlled_timeout)
+ monkeypatch.setattr(asyncio, "create_task", capture_task)
+ handler = real_create_task(
+ hybrid_files._compensate_upload(
+ PlatformFilesClient("https://authority", "gateway", "user"),
+ operation,
+ metadata,
+ {},
+ True,
+ FilesError(502, "unavailable"),
+ )
+ )
+ try:
+ await asyncio.wait_for(entered.wait(), timeout=1)
+ if cancel_handler:
+ handler.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await handler
+ assert events == []
+ if expire == "delete":
+ timers[0].reschedule(asyncio.get_running_loop().time())
+ else:
+ release.set()
+ 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 == "delete" else "deleted",
+ "report-cancelled" if expire == "report" else "reported",
+ ]
+ finally:
+ release.set()
+ report_release.set()
+ if not handler.done():
+ await asyncio.wait_for(handler, timeout=1)
+ for task in tasks:
+ if not task.done():
+ await asyncio.wait_for(task, timeout=1)
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_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..c62a3470f2
--- /dev/null
+++ b/tests/unit/test_provider_file_config.py
@@ -0,0 +1,41 @@
+"""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})
+
+
+@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
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
new file mode 100644
index 0000000000..53a44fe40c
--- /dev/null
+++ b/tests/unit/test_provider_file_migration.py
@@ -0,0 +1,58 @@
+"""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 alembic.script import ScriptDirectory
+from sqlalchemy import CheckConstraint, create_engine, inspect
+from sqlmodel import SQLModel
+
+from gateway.models import provider_files # noqa: F401
+
+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"))
+ 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")
+ }
+ 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
new file mode 100644
index 0000000000..f3623960dc
--- /dev/null
+++ b/tests/unit/test_provider_file_models.py
@@ -0,0 +1,74 @@
+"""Provider file byte counters use database types that hold configured quotas."""
+
+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
+
+
+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)
+
+
+@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_openapi.py b/tests/unit/test_provider_file_openapi.py
new file mode 100644
index 0000000000..c2bd7ca00b
--- /dev/null
+++ b/tests/unit/test_provider_file_openapi.py
@@ -0,0 +1,45 @@
+"""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
+ 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"]
+ }
+ 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_provider_file_outputs.py b/tests/unit/test_provider_file_outputs.py
new file mode 100644
index 0000000000..4d7e022dd1
--- /dev/null
+++ b/tests/unit/test_provider_file_outputs.py
@@ -0,0 +1,224 @@
+"""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
+from unittest.mock import AsyncMock
+
+import pytest
+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
+
+
+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])
+@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(),
+ 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 = AnthropicFileOutputBinder(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": "bash_code_execution_result",
+ "content": [{"type": output_type, "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())
+ 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:
+ 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"]
+
+
+@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"]
+
+
+@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
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
new file mode 100644
index 0000000000..4144773e0f
--- /dev/null
+++ b/tests/unit/test_provider_file_references.py
@@ -0,0 +1,83 @@
+"""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_anthropic_file_references, reject_openai_file_state
+
+
+@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"}}]},
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "bash_code_execution_tool_result",
+ "content": {
+ "type": "bash_code_execution_result",
+ "content": [{"type": output_type, "file_id": "file_out"}],
+ },
+ }
+ ],
+ },
+ {"role": "user", "content": [{"type": "container_upload", "file_id": "file_old"}]},
+ ]
+ assert collect_anthropic_file_references(history) == ["file_old", "file_out"]
+
+
+def test_text_and_tool_input_are_not_references() -> None:
+ assert (
+ collect_anthropic_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_anthropic_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_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)
diff --git a/tests/unit/test_provider_file_transfers.py b/tests/unit/test_provider_file_transfers.py
new file mode 100644
index 0000000000..b0bff1c46a
--- /dev/null
+++ b/tests/unit/test_provider_file_transfers.py
@@ -0,0 +1,88 @@
+"""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._file_formats import AnthropicFilesFormat
+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"):
+ AnthropicFilesFormat().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 AnthropicFilesFormat().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,
+ fields,
+ ):
+ assert upload.file.read() == b"col\nvalue"
+ assert fields == {}
+ 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/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",
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"
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]]
diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts
index 0ac463d0f7..e4ffceed4f 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 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;
/**
* 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 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;
@@ -1125,6 +1129,8 @@ 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 retrieval returns the OpenAI file metadata envelope. 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 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;
@@ -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 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;
@@ -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 */
@@ -5699,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.
@@ -5857,10 +6172,20 @@ export interface components {
};
/** Body_files-create_file */
"Body_files-create_file": {
+ /**
+ * @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;
@@ -6622,6 +6947,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,17 +7949,81 @@ export interface components {
/** User Id */
user_id?: string | null;
};
- /** GateResultResponse */
- GateResultResponse: {
- /** Detail */
- detail?: string | null;
- /** Enforcement */
- enforcement: string;
- /** Gate Id */
- gate_id: string;
- /** Message */
- message: string;
- /** Outcome */
+ /** 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 Bounded any-llm metadata; absent provider fields remain unknown.
+ */
+ FileMetadata: {
+ /** Created At */
+ created_at?: string | null;
+ /** Downloadable */
+ downloadable?: boolean | null;
+ /** Expires At */
+ expires_at?: string | null;
+ /** Filename */
+ filename?: string | null;
+ /** Id */
+ id: string;
+ /** Mime Type */
+ mime_type?: string | null;
+ /** Purpose */
+ purpose?: string | null;
+ /** Size Bytes */
+ size_bytes?: number | null;
+ /** Status */
+ status?: string | null;
+ } & {
+ [key: string]: unknown;
+ };
+ /** FinalizeUpload */
+ FinalizeUpload: {
+ /** Expires In Seconds */
+ expires_in_seconds?: number | null;
+ metadata: components["schemas"]["FileMetadata"];
+ };
+ /** GateResultResponse */
+ GateResultResponse: {
+ /** Detail */
+ detail?: string | null;
+ /** Enforcement */
+ enforcement: string;
+ /** Gate Id */
+ gate_id: string;
+ /** Message */
+ message: string;
+ /** Outcome */
outcome: string;
};
/**
@@ -8134,6 +8541,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.
@@ -8776,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.
@@ -9633,6 +10111,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 +10661,23 @@ export interface components {
/** Warm */
warm: boolean;
};
+ /** PrepareUpload */
+ PrepareUpload: {
+ /** Expires In Seconds */
+ expires_in_seconds?: number | null;
+ /**
+ * Operation Id
+ * Format: uuid
+ */
+ operation_id: string;
+ /**
+ * Provider
+ * @default anthropic
+ */
+ provider: string;
+ /** Size Bytes */
+ size_bytes: number;
+ };
/**
* PricingDriftRow
* @description A stored deployment rate beside the default it shadows.
@@ -10507,6 +11028,16 @@ export interface components {
*/
unreadable: number;
};
+ /** References */
+ References: {
+ /** Ids */
+ ids: string[];
+ /**
+ * Provider
+ * @default anthropic
+ */
+ provider: string;
+ };
/**
* RegisterPasskeyRequest
* @description A completed registration ceremony, with the label to file it under.
@@ -10627,6 +11158,19 @@ export interface components {
*/
token: string;
};
+ /** ResolveFile */
+ ResolveFile: {
+ /**
+ * Operation
+ * @enum {string}
+ */
+ operation: "metadata" | "download" | "delete";
+ /**
+ * Provider
+ * @default anthropic
+ */
+ provider: string;
+ };
/**
* ResourceLink
* @description A resource that the server is capable of reading, included in a prompt or tool call result.
@@ -14530,8 +15074,23 @@ 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[];
+ /** @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 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";
};
- header?: never;
path?: never;
cookie?: never;
};
@@ -14545,7 +15104,7 @@ export interface operations {
content: {
"application/json": {
[key: string]: unknown;
- };
+ } | (components["schemas"]["AnthropicFilePage"] | components["schemas"]["OpenAIFilePage"]);
};
};
/** @description Validation Error */
@@ -14562,7 +15121,12 @@ export interface operations {
"files-create_file": {
parameters: {
query?: never;
- header?: never;
+ header?: {
+ /** @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;
};
@@ -14580,7 +15144,7 @@ export interface operations {
content: {
"application/json": {
[key: string]: unknown;
- };
+ } | (components["schemas"]["AnthropicFileMetadata"] | components["schemas"]["OpenAIFileMetadata"]);
};
};
/** @description Validation Error */
@@ -14599,7 +15163,12 @@ export interface operations {
query?: {
user?: string | null;
};
- header?: never;
+ header?: {
+ /** @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;
};
@@ -14615,7 +15184,7 @@ export interface operations {
content: {
"application/json": {
[key: string]: unknown;
- };
+ } | (components["schemas"]["AnthropicFileMetadata"] | components["schemas"]["OpenAIFileMetadata"]);
};
};
/** @description Validation Error */
@@ -14634,7 +15203,12 @@ export interface operations {
query?: {
user?: string | null;
};
- header?: never;
+ header?: {
+ /** @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;
};
@@ -14650,7 +15224,7 @@ export interface operations {
content: {
"application/json": {
[key: string]: unknown;
- };
+ } | (components["schemas"]["AnthropicFileDeleted"] | components["schemas"]["OpenAIFileDeleted"]);
};
};
/** @description Validation Error */
@@ -14669,7 +15243,12 @@ export interface operations {
query?: {
user?: string | null;
};
- header?: never;
+ header?: {
+ /** @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;
};
@@ -14700,6 +15279,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;