diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 93ef1d7f69..a5726dcb8c 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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": [ @@ -5103,6 +5103,17 @@ ], "title": "Cache Control" }, + "container": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Container" + }, "context_management": { "anyOf": [ { diff --git a/src/gateway/api/routes/_pipeline.py b/src/gateway/api/routes/_pipeline.py index 28804ff13a..4cb44e0e46 100644 --- a/src/gateway/api/routes/_pipeline.py +++ b/src/gateway/api/routes/_pipeline.py @@ -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 @@ -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_) 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`." @@ -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 ) @@ -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 diff --git a/src/gateway/api/routes/_tools.py b/src/gateway/api/routes/_tools.py index 821217a448..5e7125c40c 100644 --- a/src/gateway/api/routes/_tools.py +++ b/src/gateway/api/routes/_tools.py @@ -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 diff --git a/src/gateway/api/routes/messages.py b/src/gateway/api/routes/messages.py index 9c18c90d7a..353091419a 100644 --- a/src/gateway/api/routes/messages.py +++ b/src/gateway/api/routes/messages.py @@ -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, @@ -74,7 +76,9 @@ 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 @@ -82,6 +86,17 @@ class MessagesRequest(derive_request_base(MessagesParams)): # type: ignore[misc """ 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 # any-llm types ``stream`` as ``bool | None``; keep the Anthropic wire # contract (a non-nullable boolean defaulting to false) for stable SDK # generation. @@ -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() @@ -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, @@ -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 diff --git a/tests/integration/test_hybrid_mode_messages.py b/tests/integration/test_hybrid_mode_messages.py index 32d654b14b..3dbb24fa1f 100644 --- a/tests/integration/test_hybrid_mode_messages.py +++ b/tests/integration/test_hybrid_mode_messages.py @@ -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"] diff --git a/tests/integration/test_messages_route_dispatch.py b/tests/integration/test_messages_route_dispatch.py index 484a00ae8f..feeebd6b23 100644 --- a/tests/integration/test_messages_route_dispatch.py +++ b/tests/integration/test_messages_route_dispatch.py @@ -143,6 +143,108 @@ async def fake_amessages(**kwargs: Any) -> MessageResponse: assert "tools" not in captured +def test_container_reaches_plain_amessages_unchanged( + client: TestClient, + api_key_header: dict[str, str], +) -> None: + """Top-level container continuity reaches the upstream provider call unchanged.""" + captured: dict[str, Any] = {} + + async def fake_amessages(**kwargs: Any) -> MessageResponse: + captured.update(kwargs) + return _text_response() + + with patch("gateway.api.routes.messages.amessages", new=fake_amessages): + resp = client.post( + "/v1/messages", + json={ + "model": "anthropic:claude-sonnet-4-5", + "messages": [{"role": "user", "content": "Continue"}], + "max_tokens": 100, + "container": "container_01ABC", + }, + headers=api_key_header, + ) + + assert resp.status_code == 200, resp.text + assert captured["container"] == "container_01ABC" + + +def test_container_is_dropped_when_the_gateway_runs_code_execution( + client: TestClient, + api_key_header: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``otari_code_execution`` means the sandbox runs the code, so Anthropic is + never asked to stand up a container this request could reach.""" + monkeypatch.setenv("OTARI_SANDBOX_URL", "http://127.0.0.1:9999/sandbox") + forwarded: dict[str, Any] = {} + + async def fake_loop( + *, completion_kwargs: Any, pool: Any, max_iterations: int, emit_native_web_search: bool = False + ) -> MessageResponse: + forwarded.update(completion_kwargs) + return _text_response() + + fake_backend = AsyncMock() + fake_backend.purpose_hints = lambda: [] + + with ( + patch("gateway.api.routes.messages.anthropic_tool_loop", new=fake_loop), + patch( + "gateway.api.routes._pipeline.SandboxBackend", + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=fake_backend), + __aexit__=AsyncMock(return_value=None), + ), + ), + ): + resp = client.post( + "/v1/messages", + json={ + "model": "anthropic:claude-sonnet-4-5", + "messages": [{"role": "user", "content": "compute"}], + "max_tokens": 100, + "tools": [{"type": "otari_code_execution"}], + "container": "container_01ABC", + }, + headers=api_key_header, + ) + + assert resp.status_code == 200, resp.text + assert "container" not in forwarded + + +def test_container_survives_provider_native_code_execution( + client: TestClient, + api_key_header: dict[str, str], +) -> None: + """The provider-named keyword leaves execution with the provider, which is + the case the container id is for.""" + captured: dict[str, Any] = {} + + async def fake_amessages(**kwargs: Any) -> MessageResponse: + captured.update(kwargs) + return _text_response() + + with patch("gateway.api.routes.messages.amessages", new=fake_amessages): + resp = client.post( + "/v1/messages", + json={ + "model": "anthropic:claude-sonnet-4-5", + "messages": [{"role": "user", "content": "compute"}], + "max_tokens": 100, + "tools": [{"type": "code_execution_20250825", "name": "code_execution"}], + "betas": ["code-execution-2025-08-25"], + "container": "container_01ABC", + }, + headers=api_key_header, + ) + + assert resp.status_code == 200, resp.text + assert captured["container"] == "container_01ABC" + + def test_cache_control_and_non_stream_usage_round_trip( client: TestClient, api_key_header: dict[str, str], @@ -641,6 +743,77 @@ def test_code_execution_combined_with_mcp_servers_returns_400( ) +@pytest.mark.parametrize("native_type", ["code_execution", "code_interpreter", "code_execution_20250825"]) +def test_code_execution_combined_with_a_provider_native_tool_returns_400( + client: TestClient, + api_key_header: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + native_type: str, +) -> None: + """Two sandboxes in one request have no single home for the caller's state.""" + monkeypatch.setenv("OTARI_SANDBOX_URL", "http://127.0.0.1:9999/sandbox") + resp = client.post( + "/v1/messages", + json={ + "model": "anthropic:claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + "tools": [{"type": "otari_code_execution"}, {"type": native_type}], + }, + headers=api_key_header, + ) + assert resp.status_code == 400 + _assert_anthropic_error( + resp.json(), + error_type="invalid_request_error", + message_substr="cannot be combined with a provider-native code-execution tool", + ) + + +def test_code_execution_allows_an_unrelated_caller_function( + client: TestClient, + api_key_header: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The conflict keys on the tool ``type``. A caller's own function named + ``code_execution`` is theirs to dispatch and never claimed by the gateway.""" + monkeypatch.setenv("OTARI_SANDBOX_URL", "http://127.0.0.1:9999/sandbox") + + async def fake_loop( + *, completion_kwargs: Any, pool: Any, max_iterations: int, emit_native_web_search: bool = False + ) -> MessageResponse: + return _text_response() + + fake_backend = AsyncMock() + fake_backend.purpose_hints = lambda: [] + + with ( + patch("gateway.api.routes.messages.anthropic_tool_loop", new=fake_loop), + patch( + "gateway.api.routes._pipeline.SandboxBackend", + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=fake_backend), + __aexit__=AsyncMock(return_value=None), + ), + ), + ): + resp = client.post( + "/v1/messages", + json={ + "model": "anthropic:claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + "tools": [ + {"type": "otari_code_execution"}, + {"type": "function", "function": {"name": "code_execution"}}, + ], + }, + headers=api_key_header, + ) + + assert resp.status_code == 200, resp.text + + def test_web_search_combined_with_sandbox_returns_400( client: TestClient, api_key_header: dict[str, str], diff --git a/tests/unit/test_provider_error_classification.py b/tests/unit/test_provider_error_classification.py index 32f632c0c4..7572fbf2a3 100644 --- a/tests/unit/test_provider_error_classification.py +++ b/tests/unit/test_provider_error_classification.py @@ -359,6 +359,19 @@ def test_rejected_param_maps_to_400_naming_the_param() -> None: assert "'seed'" in mapping.detail +def test_rejected_container_maps_to_400_naming_the_param() -> None: + """``container`` is hand-declared on the Messages request and rides any-llm's + ``**kwargs``, so a provider that bridges Messages through Chat Completions + hands it to an SDK method with no such parameter. Without the registration it + misses the caller-fault gate and a permanent, caller-fixable rejection is + reported as a generic upstream failure.""" + exc = TypeError("AsyncCompletions.create() got an unexpected keyword argument 'container'") + mapping = classify_provider_error(exc) + assert mapping is not None + assert mapping.status_code == 400 + assert "'container'" in mapping.detail + + def test_rejected_param_detail_does_not_name_the_sdk_internals() -> None: """Only the param name crosses the boundary: the SDK's class and method are the gateway's implementation, not something a caller can act on.""" diff --git a/tests/unit/test_request_schema_derivation.py b/tests/unit/test_request_schema_derivation.py index 295d323946..7a3b362397 100644 --- a/tests/unit/test_request_schema_derivation.py +++ b/tests/unit/test_request_schema_derivation.py @@ -20,6 +20,9 @@ ``NO_TYPED_PARAMS_SOURCE`` for the record. """ +import json +from pathlib import Path + import pytest from any_llm.types.audio import AudioSpeechParams from any_llm.types.completion import CompletionParams @@ -96,6 +99,30 @@ def test_renamed_params_are_exposed_under_the_wire_name(endpoint: str) -> None: ) +def test_messages_request_preserves_container_for_upstream() -> None: + """The compatibility field survives validation and request serialization unchanged.""" + request = MessagesRequest.model_validate( + { + "model": "anthropic:claude-sonnet-4-5", + "messages": [{"role": "user", "content": "Continue"}], + "max_tokens": 100, + "container": "container_01ABC", + } + ) + + assert request.container == "container_01ABC" + assert request.model_dump(exclude_unset=True)["container"] == "container_01ABC" + + +def test_messages_openapi_request_schema_includes_container() -> None: + """The published Messages request contract advertises container continuity.""" + spec_path = Path(__file__).resolve().parents[2] / "docs/public/openapi.json" + spec = json.loads(spec_path.read_text()) + + container = spec["components"]["schemas"]["MessagesRequest"]["properties"]["container"] + assert {variant.get("type") for variant in container["anyOf"]} == {"string", "null"} + + def test_derived_and_hand_written_endpoints_are_disjoint() -> None: """An endpoint is either derived from a typed ``*Params`` or documented as hand-written, not both.""" overlap = set(DERIVED_SCHEMAS) & NO_TYPED_PARAMS_SOURCE diff --git a/web/src/client/schema.ts b/web/src/client/schema.ts index a7474a12e6..804d424293 100644 --- a/web/src/client/schema.ts +++ b/web/src/client/schema.ts @@ -6572,7 +6572,9 @@ export interface components { * * 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 @@ -6585,6 +6587,8 @@ export interface components { cache_control?: { [key: string]: unknown; } | null; + /** Container */ + container?: string | null; /** Context Management */ context_management?: { [key: string]: unknown;