Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ providers:
# File and image normalization. See docs/files.md.
# files_enabled: true
# files_local_dir: "./otari-files"
# Any fsspec filesystem instead of a local directory or boto3 S3:
# files_backend: fsspec
# files_url: "gcs://my-bucket/otari-files"
# files_storage_options: { project: "my-project" }
# files_max_bytes: 536870912
# files_retention_hours: 168
# files_sweep_interval_sec: 3600
# vision_strategy: describe
# vision_describe_model: "ollama:qwen2-vl"
# model_capabilities:
Expand Down
5 changes: 4 additions & 1 deletion docs/code-execution-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ below is unchanged either way, which is what lets the same backend serve both.

Six operations, of which the first three are the whole execution path. A
backend MUST implement those three; the file operations are OPTIONAL and are
used only by clients that move files in or out of a session.
used only by clients that move files in or out of a session. Otari is such a
client when a request attaches uploaded files: it seeds them with `PutFile`
before the first call and fetches what the result block's file references name
with `GetFile` (see `docs/files.md`, "Files and code execution").

| Operation | Purpose | Request | Response |
|---|---|---|---|
Expand Down
61 changes: 59 additions & 2 deletions docs/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,55 @@ Otari also requires pricing for that model key by default: add pricing, enable
an intentionally unpriced backend.

You can also inline a file as a base64 `data:` URL (`file.file_data`) or send an
`image_url` block, with or without uploading first.
`image_url` block, with or without uploading first. On the Responses API a
`input_file` or `input_image` item may sit directly in `input` as well as inside
a message.

### Using the OpenAI or Anthropic SDK

The five routes (`POST`/`GET /v1/files`, `GET`/`DELETE /v1/files/{id}`,
`GET /v1/files/{id}/content`) share their paths and verbs with both vendors'
Files APIs, so either official SDK works against Otari with only its base URL
changed. The response shape follows the caller: a request carrying Anthropic's
`anthropic-version` header, which its SDK sends on every call, gets Anthropic's
`FileMetadata` (`type`, `size_bytes`, `mime_type`, `downloadable`, an RFC 3339
`created_at`); everything else gets the OpenAI file object (`object`, `bytes`,
`purpose`, an epoch `created_at`).

```python
from anthropic import Anthropic
client = Anthropic(base_url="http://localhost:8000", api_key="<your-api-key>")
meta = client.beta.files.upload(file=("report.pdf", open("report.pdf", "rb"), "application/pdf"))
client.beta.files.download(meta.id) # Otari serves every stored file's bytes back
```

Listings are cursor-paged: `limit` (default 100, at most 1000), `after`
(OpenAI) or `after_id` (Anthropic) naming the last file of the previous page,
`order` (`desc` by default), and `has_more`, `first_id`, `last_id` on the page.

## Files and code execution

When a request declares the `otari_code_execution` tool, every uploaded file it
references is also seeded into the sandbox session's working directory under its
own filename, so the code the model writes can open it. An Anthropic
`container_upload` block (`{"type": "container_upload", "file_id": "..."}`) is
for the sandbox only: the model is told the file is there and never sees its
contents. A `document`, `file`, or `input_file` block with a `file_id` is both
shown to the model (extracted or passed through as usual) and seeded. Without a
sandbox in the request, a `container_upload` block is read as a document.

A file the code writes into the working directory comes back as a new stored
file owned by the same user and workspace, with purpose `code_execution_output`.
The model sees it in the tool result as `chart.png (file_id: file-...)` and is
asked to pass that id on, and the caller downloads it with
`GET /v1/files/{id}/content`. Both directions need a sandbox backend that
implements the protocol's optional `PutFile` and `GetFile` operations; a seed the
backend refuses fails the request rather than running code over a missing input,
while an output that cannot be fetched is named without an id and the run stands.

> The reference `otari-sandbox-container` implements the file operations but
> does not yet populate the result block's file-reference list, so with it
> inputs are seeded and outputs are not collected until that lands.

### Who can see an uploaded file

Expand Down Expand Up @@ -109,7 +157,16 @@ in order:
See [config.example.yml](../config.example.yml) for the full list. Key knobs:

- `files_enabled`, `files_backend`, `files_local_dir`, `files_max_bytes`,
`files_retention_hours`: upload storage.
`files_retention_hours`: upload storage. `files_backend` is `local` (a
directory), `s3` (boto3, `files_s3_*`), or `fsspec`: any filesystem
[fsspec](https://filesystem-spec.readthedocs.io) has an implementation for,
named by `files_url` (`gcs://bucket/prefix`, `abfs://container/prefix`,
`s3://bucket/prefix`, `sftp://host/path`, `file:///path`, ...) with the
implementation's own keyword arguments in `files_storage_options`. Install the
implementation package for the protocol (`gcsfs`, `adlfs`, `s3fs`, `paramiko`);
most read their standard credential environment variables on their own. An expired file answers 404 at once,
and the background sweep (`files_sweep_interval_sec`, hourly by default, `0` to
disable) then reclaims its bytes and row along with those of deleted files.
- `file_understanding_enabled`: master switch for content normalization.
- `vision_strategy` (`describe` | `ocr` | `off`) and `vision_describe_model`:
how images are handled for text-only models. The describe model may be a local
Expand Down
62 changes: 60 additions & 2 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -20152,7 +20152,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\nPages are cursor-based: ``after`` (OpenAI) or ``after_id`` (Anthropic) names\nthe last file of the previous page, and ``has_more`` says whether to ask\nagain. A cursor the caller cannot see (another user's file, a deleted one)\nis a 404, the same answer a direct read of it gets.",
"operationId": "files-list_files",
"parameters": [
{
Expand Down Expand Up @@ -20203,6 +20203,64 @@
],
"title": "Workspace Id"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 100,
"maximum": 1000,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "after",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "After"
}
},
{
"in": "query",
"name": "after_id",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "After Id"
}
},
{
"in": "query",
"name": "order",
"required": false,
"schema": {
"default": "desc",
"enum": [
"asc",
"desc"
],
"title": "Order",
"type": "string"
}
}
],
"responses": {
Expand Down Expand Up @@ -20243,7 +20301,7 @@
]
},
"post": {
"description": "OpenAI-compatible file upload endpoint.",
"description": "Upload a file. Answers in the OpenAI or Anthropic file shape, following the caller's headers.",
"operationId": "files-create_file",
"requestBody": {
"content": {
Expand Down
30 changes: 27 additions & 3 deletions docs/public/otari.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -1745,7 +1745,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\nPages are cursor-based: ``after`` (OpenAI) or ``after_id`` (Anthropic) names\nthe last file of the previous page, and ``has_more`` says whether to ask\nagain. A cursor the caller cannot see (another user's file, a deleted one)\nis a 404, the same answer a direct read of it gets.",
"header": [],
"method": "GET",
"url": {
Expand Down Expand Up @@ -1775,16 +1775,40 @@
"disabled": true,
"key": "workspace_id",
"value": ""
},
{
"description": "",
"disabled": true,
"key": "limit",
"value": ""
},
{
"description": "",
"disabled": true,
"key": "after",
"value": ""
},
{
"description": "",
"disabled": true,
"key": "after_id",
"value": ""
},
{
"description": "",
"disabled": true,
"key": "order",
"value": ""
}
],
"raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id="
"raw": "{{baseUrl}}/api/v1/files?user=&purpose=&workspace_id=&limit=&after=&after_id=&order="
}
}
},
{
"name": "Create File",
"request": {
"description": "OpenAI-compatible file upload endpoint.",
"description": "Upload a file. Answers in the OpenAI or Anthropic file shape, following the caller's headers.",
"header": [],
"method": "POST",
"url": {
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ dependencies = [
# blocking resolver would hold the event loop for it.
"dnspython>=2.7.0",
"fastapi>=0.115.0",
# The generic files backend (`FsspecFileStore`): one URL reaches whichever
# filesystem the operator has an fsspec implementation installed for.
# Already in the tree through any-llm, declared because the gateway imports
# it directly.
"fsspec>=2024.6.0",
"genai-prices>=0.1.0",
# Direct dependency for gateway-managed HTTP clients and the public
# transport API used by pinned web retrieval.
Expand Down Expand Up @@ -225,6 +230,11 @@ ignore_missing_imports = true
module = ["trafilatura.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
# fsspec ships no stubs; the file store drives it through a handful of calls.
module = ["fsspec", "fsspec.*"]
ignore_missing_imports = true

[[tool.mypy.overrides]]
# Optional/untyped extraction deps imported at the root (e.g. `import pypdfium2`,
# `from markitdown import ...`). Bare root names are required — a `foo.*` pattern
Expand Down
36 changes: 36 additions & 0 deletions src/gateway/api/routes/_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,42 @@
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession

from gateway.api.routes._tools import _extract_code_execution_tool
from gateway.core.config import GatewayConfig
from gateway.log_config import logger
from gateway.services.content_normalizer import NormalizationStats, WireFormat, normalize_messages
from gateway.services.file_service import SandboxFileBridge, StagedFile
from gateway.services.model_capabilities import resolve_capabilities


def sandbox_requested(tools: list[dict[str, Any]] | None) -> bool:
"""Whether the request declared the gateway's own code-execution tool."""
entry, _remaining = _extract_code_execution_tool(tools)
return entry is not None


def build_sandbox_file_bridge(
*,
config: GatewayConfig,
raw_request: Request,
hybrid_mode: bool,
user_id: str | None,
workspace_id: uuid.UUID | None,
inputs: list[StagedFile],
) -> SandboxFileBridge | None:
"""The file bridge a sandbox session gets, or ``None`` where files are unavailable.

Hybrid mode has no local file store or database to hold what a run produces,
so its sandbox runs without one, exactly as before.
"""
file_store = getattr(raw_request.app.state, "file_store", None)
if hybrid_mode or not config.files_enabled or file_store is None or user_id is None or workspace_id is None:
return None
return SandboxFileBridge(
file_store=file_store, config=config, user_id=user_id, workspace_id=workspace_id, inputs=inputs
)


async def normalize_request_messages(
messages: list[dict[str, Any]],
*,
Expand All @@ -37,9 +67,14 @@ async def normalize_request_messages(
user_id: str | None,
instance: str | None = None,
workspace_id: uuid.UUID | None = None,
sandbox_requested: bool = False,
) -> tuple[list[dict[str, Any]], NormalizationStats]:
"""Normalize ``messages`` for the resolved ``provider/model``.

``sandbox_requested`` is whether the request declared the gateway's
code-execution tool; the normalizer then records referenced uploads on the
stats for the sandbox backend to seed (see ``NormalizationStats.sandbox_inputs``).

No-ops (returns the input untouched) when file understanding is disabled or
the provider couldn't be parsed — the downstream provider call surfaces an
unknown model with its own status code.
Expand All @@ -63,6 +98,7 @@ async def normalize_request_messages(
file_store=file_store,
user_id=user_id,
workspace_id=workspace_id,
sandbox_requested=sandbox_requested,
)
except Exception as exc: # noqa: BLE001 — never fail the request / leak the reservation
logger.warning("content normalization failed; forwarding messages unchanged: %s", exc)
Expand Down
8 changes: 8 additions & 0 deletions src/gateway/api/routes/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
refund_reservation,
reserve_budget,
)
from gateway.services.file_service import SandboxFileBridge
from gateway.services.log_writer import LogWriter
from gateway.services.mcp_client import MCPClientPool
from gateway.services.mcp_loop import (
Expand Down Expand Up @@ -2177,10 +2178,14 @@ def __init__(
use_web_fetch: bool = False,
web_fetch_tool_entry: dict[str, Any] | None = None,
web_fetch_policy: DomainPolicy | None = None,
sandbox_files: SandboxFileBridge | None = None,
) -> None:
self.config = config
self.mcp_server_configs = mcp_server_configs
self.use_sandbox = use_sandbox
# The uploads a sandbox session is seeded with and the store its outputs
# land in. None in hybrid mode and when files are disabled.
self.sandbox_files = sandbox_files
self.sandbox_tool_entry = sandbox_tool_entry
self.sandbox_url = sandbox_url
self.sandbox_auth_token = sandbox_auth_token
Expand Down Expand Up @@ -2236,6 +2241,7 @@ def build_sandbox_backend(self) -> SandboxBackend:
image=self.sandbox_session_image,
allowed_tools=self.sandbox_allowed_tools,
tally=self.tally,
files=self.sandbox_files,
)

@property
Expand Down Expand Up @@ -2678,6 +2684,7 @@ async def prepare_gateway_tools(
mcp_server_ids: list[uuid.UUID] | None,
max_tool_iterations: int | None,
tools_header: str | None,
sandbox_files: SandboxFileBridge | None = None,
) -> ToolContext:
"""Guardrails, MCP server-id resolution, and gateway-tool extraction.

Expand Down Expand Up @@ -3076,6 +3083,7 @@ async def prepare_gateway_tools(
sandbox_max_iterations or MAX_TOOL_ITERATIONS_CAP,
),
tools_header=tools_header,
sandbox_files=sandbox_files if use_sandbox else None,
)


Expand Down
Loading
Loading