Skip to content
Merged
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
13 changes: 12 additions & 1 deletion docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5075,7 +5075,7 @@
"type": "object"
},
"MessagesRequest": {
"description": "Anthropic Messages API-compatible request.\n\nThe wire fields are derived from any-llm's ``MessagesParams`` (see\n``_schema_derive``) so the schema cannot silently drop a param any-llm\nforwards. Gateway-internal fields (``mcp_servers``, ``mcp_server_ids``,\n``guardrails``, ``tools_header``, ``max_tool_iterations``) opt the request\ninto gateway-managed MCP / sandbox / web_search / guardrails without\nchanging the upstream wire shape. They're stripped before the request is\nforwarded.",
"description": "Anthropic Messages API-compatible request.\n\nThe wire fields are derived from any-llm's ``MessagesParams`` (see\n``_schema_derive``) so the schema cannot silently drop a param any-llm\nforwards. ``container`` is an Anthropic wire param ``MessagesParams`` does\nnot model, declared here and forwarded as an any-llm ``**kwargs`` param.\nGateway-internal fields (``mcp_servers``, ``mcp_server_ids``,\n``guardrails``, ``tools_header``, ``max_tool_iterations``) opt the request\ninto gateway-managed MCP / sandbox / web_search / guardrails without\nchanging the upstream wire shape. They're stripped before the request is\nforwarded.",
"properties": {
"betas": {
"anyOf": [
Expand Down Expand Up @@ -5103,6 +5103,17 @@
],
"title": "Cache Control"
},
"container": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Container"
},
"context_management": {
"anyOf": [
{
Expand Down
19 changes: 19 additions & 0 deletions src/gateway/api/routes/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
_resolve_sandbox_purpose_hint,
_web_search_intercept_enabled,
declares_native_web_search,
has_provider_code_execution_tool,
web_search_max_results_baseline,
)
from gateway.core.config import GatewayConfig
Expand Down Expand Up @@ -253,6 +254,12 @@
"otari_code_execution and mcp_servers cannot be combined in the same request yet; "
"pick one. Multi-backend dispatch is a planned refinement."
)
SANDBOX_PROVIDER_TOOL_CONFLICT_DETAIL = (
"otari_code_execution cannot be combined with a provider-native code-execution tool "
"(code_execution, code_interpreter, code_execution_<date>) in the same request; pick one. "
"The gateway sandbox and the provider's own are separate environments, and a request "
"addressing both has no single place its files and state live."
)
WEB_SEARCH_NOT_CONFIGURED_DETAIL = (
"otari_web_search tool requested but no search backend is configured on this gateway. "
"Set OTARI_WEB_SEARCH_URL on the gateway, or remove otari_web_search from `tools`."
Expand Down Expand Up @@ -381,6 +388,11 @@ def _redacted_caller_fault_detail(message: str, fallback: str) -> str:
# Declared on the chat request rather than derived, and forwarded through
# any-llm's ``**kwargs`` (see ``chat.ChatCompletionRequest.service_tier``).
| {"service_tier"}
# Same shape on the Messages request (see
# ``messages.MessagesRequest.container``). A provider that bridges
# Messages through Chat Completions has no parameter for it, so it
# arrives here as the unknown-keyword TypeError.
| {"container"}
)
- SENSITIVE_PARAM_FIELDS
)
Expand Down Expand Up @@ -2396,6 +2408,13 @@ async def prepare_gateway_tools(
raise adapter.error(400, SANDBOX_NOT_CONFIGURED_DETAIL, ErrorKind.INVALID_REQUEST)
if mcp_servers:
raise adapter.error(400, SANDBOX_MCP_CONFLICT_DETAIL, ErrorKind.INVALID_REQUEST)
# Two sandboxes, one request. Whichever way the gateway resolved it
# silently, half the caller's state would live somewhere they cannot
# address: the gateway sandbox's session is per-request and never
# named on the wire, the provider's is named by a handle the gateway
# would then have to route around. Refuse instead of picking.
if has_provider_code_execution_tool(tools_after_sandbox):
raise adapter.error(400, SANDBOX_PROVIDER_TOOL_CONFLICT_DETAIL, ErrorKind.INVALID_REQUEST)
use_sandbox = True

# Forwarded to the sandbox backend as `Authorization: Bearer`. Only set in
Expand Down
30 changes: 30 additions & 0 deletions src/gateway/api/routes/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,36 @@ def _is_code_execution_tool_type(type_value: Any) -> bool:
return type_value == Tool.CODE_EXECUTION


# The provider-named code-execution keywords: OpenAI's ``code_interpreter``, the
# bare short form, and any dated/preview variant. The prefix match keeps future
# Anthropic versions (``code_execution_20991231``) working without a release
# here, mirroring the web-search keywords above.
_BARE_CODE_EXECUTION_TYPES = frozenset({"code_execution", "code_interpreter"})
_VERSIONED_CODE_EXECUTION_PREFIX = "code_execution_"


def has_provider_code_execution_tool(tools: list[dict[str, Any]] | None) -> bool:
"""Whether ``tools`` still asks the provider to run code in its own sandbox.

Matched on the tool ``type`` alone, never on a caller's ``function`` named
``code_execution``: that is the caller's own tool, the same carve-out
:func:`_is_web_search_tool_type` makes for a function named ``web_search``.

Called on what is left after :func:`_extract_code_execution_tool` has taken
the gateway-managed entry, so a true answer means the request asks two
separate sandboxes to run code.
"""
if not tools:
return False
for entry in tools:
type_value = entry.get("type") if isinstance(entry, dict) else None
if not isinstance(type_value, str):
continue
if type_value in _BARE_CODE_EXECUTION_TYPES or type_value.startswith(_VERSIONED_CODE_EXECUTION_PREFIX):
return True
return False


# Gateway-internal fields the provider SDKs (any-llm, anthropic, openai, …)
# don't accept as ``acompletion`` kwargs. Strip these from the model_dump
# before forwarding to upstream — Anthropic in particular rejects unknown
Expand Down
75 changes: 74 additions & 1 deletion src/gateway/api/routes/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
DB_UNAVAILABLE_DETAIL,
NO_RESOLVABLE_PROVIDER_DETAIL,
ErrorKind,
RequestContext,
classify_provider_error,
default_attempt_kwargs,
prepare_gateway_tools,
raise_all_streaming_attempts_failed,
release_reservation,
resolve_dispatch_provider,
resolve_request_context,
run_platform_non_stream,
Expand Down Expand Up @@ -74,14 +76,27 @@ class MessagesRequest(derive_request_base(MessagesParams)): # type: ignore[misc

The wire fields are derived from any-llm's ``MessagesParams`` (see
``_schema_derive``) so the schema cannot silently drop a param any-llm
forwards. Gateway-internal fields (``mcp_servers``, ``mcp_server_ids``,
forwards. ``container`` is an Anthropic wire param ``MessagesParams`` does
not model, declared here and forwarded as an any-llm ``**kwargs`` param.
Gateway-internal fields (``mcp_servers``, ``mcp_server_ids``,
``guardrails``, ``tools_header``, ``max_tool_iterations``) opt the request
into gateway-managed MCP / sandbox / web_search / guardrails without
changing the upstream wire shape. They're stripped before the request is
forwarded.
"""

messages: list[dict[str, Any]] = Field(min_length=1)
# Anthropic's top-level container id, for continuing a code-execution
# container across turns. ``MessagesParams`` does not model it, so the
# derived base would drop a caller's value before the provider call. It
# rides any-llm's ``**kwargs``, which is why it is also registered in
# ``_pipeline._FORWARDED_PARAMS``: without that, a bridged (non-Anthropic)
# provider's rejection reads as an upstream outage instead of a 400.
#
# Stopgap: remove this declaration once the SDK pin carries the param
# (mozilla-ai/any-llm#1329, merged after 1.26.0; tracked in #924). Until
# then it also shadows whatever annotation any-llm picks for it.
container: str | None = None
Comment thread
njbrake marked this conversation as resolved.
Resolved
Comment thread
njbrake marked this conversation as resolved.
Resolved
Comment thread
njbrake marked this conversation as resolved.
Resolved
# any-llm types ``stream`` as ``bool | None``; keep the Anthropic wire
# contract (a non-nullable boolean defaulting to false) for stable SDK
# generation.
Expand Down Expand Up @@ -480,6 +495,46 @@ def prepare_platform_call_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]
return kwargs


CONTAINER_ON_MANAGED_CREDENTIAL_DETAIL = (
"container cannot be used on this route: it resolves to a provider account this gateway "
"manages on behalf of many workspaces, and a container id addresses state on that account "
"rather than on your workspace. Use a model served by your own provider key."
)


def _reject_container_on_managed_credential(ctx: RequestContext) -> None:
"""Refuse a caller-chosen container id when the upstream account is not the caller's.

A container id names an execution environment and the files uploaded into it,
scoped to the *provider account* that minted it, not to an otari tenant. A
managed attempt runs on a credential the platform owns and many workspaces
share, so forwarding an id the caller picked would let anyone holding one
resume another tenant's container and read its workspace.

This is the container-shaped case of what :func:`scope_prompt_cache_key`
solves two calls later by namespacing the key. A container id is minted by
the provider and carries the caller's claim on it, so it cannot be
namespaced: forward or refuse are the only answers, and on a shared account
the answer is refuse.

Refused when *any* attempt on the route is managed, not only the first: which
attempt serves the request is decided during fallback, past this point. A
BYO-only route keeps the field, because there the account, and so the
container, is already the caller's own. Standalone never reaches this: its
credentials are the deployment operator's own, and the managed rung
(``_serve_from_hosted_credential``) answers ``None`` in every build that
mounts this route.
"""
route = ctx.route
if route is None or not any(attempt.managed for attempt in route.attempts):
return
raise _anthropic_error(
_ERR_INVALID_REQUEST,
CONTAINER_ON_MANAGED_CREDENTIAL_DETAIL,
status.HTTP_400_BAD_REQUEST,
)


_ADAPTER = _MessagesAdapter()


Expand Down Expand Up @@ -559,6 +614,16 @@ async def _normalize(
# them in the Anthropic envelope so /v1/messages errors stay structured.
raise _ensure_anthropic_error(exc) from exc

if request.container is not None and ctx.hybrid_mode:
try:
_reject_container_on_managed_credential(ctx)
except HTTPException:
# A no-op in hybrid, the only mode that reaches this gate, since
# hybrid reserves nothing locally. Kept so this exit already settles
# if the gate ever covers a mode that does pre-debit the estimate.
await release_reservation(ctx)
raise

tool_ctx = await prepare_gateway_tools(
adapter=_ADAPTER,
ctx=ctx,
Expand All @@ -585,6 +650,14 @@ async def _normalize(
request_fields["tools"] = openai_to_anthropic_tools(request_fields["tools"])
if tool_ctx.intercepts_web_search and request_fields.get("messages"):
request_fields["messages"] = _strip_gateway_minted_blocks(request_fields["messages"])
if tool_ctx.use_sandbox:
# ``container`` addresses Anthropic's own code-execution container, and
# the gateway sandbox owns execution for this request, so the provider
# would be asked to attach a container no tool call will reach.
# ``prepare_gateway_tools`` has already refused the one shape where a
# provider-native code-execution tool survives alongside the sandbox, so
# dropping it here cannot strand a container the provider would have used.
request_fields.pop("container", None)

# ------------------------------------------------------------------
# Streaming path
Expand Down
146 changes: 146 additions & 0 deletions tests/integration/test_hybrid_mode_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,3 +1142,149 @@ async def fake_loop_stream(**kwargs: Any) -> Any:
error_reports = [r for r in usage_reports if r.get("status") == "error"]
assert len(error_reports) == 1
assert error_reports[0]["correlation_id"] == "tool-att-primary"


# ---------- container continuity on a shared upstream account ----------


def _container_route(monkeypatch: pytest.MonkeyPatch, *, managed: bool, calls: list[str]) -> None:
"""Wire a single-attempt route whose credential is managed or BYO."""

async def fake_post_platform(
url: str,
headers: dict[str, str],
body: dict[str, Any],
timeout_seconds: float,
) -> httpx.Response:
if url.endswith("/gateway/provider-keys/resolve"):
attempt = _attempt(0, "3f1b6a1e-0000-4000-8000-0000000000c1", "claude-3-5-sonnet-20241022", "sk-upstream")
attempt["managed"] = managed
return httpx.Response(200, json=_resolve_payload([attempt]))
return httpx.Response(
200,
json={
"correlation_id": body["correlation_id"],
"status": "completed",
"outcome": "success",
"cost_usd": "0.010000",
"currency": "USD",
"usage_status": "reported",
"pricing": {"source": "managed"},
},
)

async def fake_amessages(**kwargs: Any) -> MessageResponse:
calls.append(str(kwargs.get("container")))
return _message_response()

monkeypatch.setattr("gateway.api.routes._platform._post_platform", fake_post_platform)
monkeypatch.setattr("gateway.api.routes.messages.amessages", fake_amessages)


def test_container_is_refused_on_a_managed_credential(
platform_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A managed attempt runs on an account many workspaces share, so a
caller-chosen container id would address another tenant's state."""
calls: list[str] = []
_container_route(monkeypatch, managed=True, calls=calls)

response = platform_client.post(
"/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"container": "container_01ABC",
},
headers={"Authorization": "Bearer user_test_token"},
)

assert response.status_code == 400, response.text
body = response.json()["detail"]
assert body["error"]["type"] == "invalid_request_error"
assert "container cannot be used on this route" in body["error"]["message"]
assert calls == [], "the provider must not be called at all"


def test_container_reaches_a_byo_credential(
platform_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On the workspace's own key the container is already the caller's."""
calls: list[str] = []
_container_route(monkeypatch, managed=False, calls=calls)

response = platform_client.post(
"/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"container": "container_01ABC",
},
headers={"Authorization": "Bearer user_test_token"},
)

assert response.status_code == 200, response.text
assert calls == ["container_01ABC"]


def test_a_managed_attempt_anywhere_on_the_route_refuses_the_container(
platform_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Which attempt serves the request is decided during fallback, past the
gate, so a managed fallback behind a BYO primary still refuses."""

async def fake_post_platform(
url: str,
headers: dict[str, str],
body: dict[str, Any],
timeout_seconds: float,
) -> httpx.Response:
assert url.endswith("/gateway/provider-keys/resolve")
byo = _attempt(0, "3f1b6a1e-0000-4000-8000-0000000000d1", "claude-3-5-sonnet-20241022", "sk-byo")
byo["managed"] = False
managed = _attempt(1, "3f1b6a1e-0000-4000-8000-0000000000d2", "claude-3-5-sonnet-20241022", "sk-managed")
managed["managed"] = True
return httpx.Response(200, json=_resolve_payload([byo, managed]))

monkeypatch.setattr("gateway.api.routes._platform._post_platform", fake_post_platform)

response = platform_client.post(
"/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"container": "container_01ABC",
},
headers={"Authorization": "Bearer user_test_token"},
)

assert response.status_code == 400, response.text
assert "container cannot be used on this route" in response.json()["detail"]["error"]["message"]


def test_a_request_without_a_container_is_untouched_by_the_gate(
platform_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The gate reads a field the overwhelming majority of requests never set."""
calls: list[str] = []
_container_route(monkeypatch, managed=True, calls=calls)

response = platform_client.post(
"/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
},
headers={"Authorization": "Bearer user_test_token"},
)

assert response.status_code == 200, response.text
assert calls == ["None"]
Loading