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;