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
4 changes: 4 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7298,6 +7298,10 @@
{
"type": "string"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ version = "0.0.0"
description = "otari, an OpenAI-compatible LLM gateway"
requires-python = ">=3.13"
dependencies = [
"any-llm-sdk[all]>=1.28.0",
"any-llm-sdk[all]>=1.29.0",
# The built-in guardrail catalog (`services/guardrail_catalog.py`) and the
# runner that builds an organization's definitions from it
# (`services/tenancy/organization_guardrail_runner.py`). The extras are the
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/api/routes/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ def prepare_platform_call_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]
)


def _reject_container_on_managed_credential(ctx: RequestContext, container: str) -> None:
def _reject_container_on_managed_credential(ctx: RequestContext, container: str | dict[str, Any]) -> 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,
Expand Down
13 changes: 11 additions & 2 deletions src/gateway/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,20 @@ def _get_platform_token_from_env() -> str | None:
return token or None


# Self-hosted backends any-llm calls without a key although each declares a
# credential variable: each tolerates a missing key in ``_verify_and_set_api_key``
# and defaults to a localhost or LAN base URL, so a bare ``vllm:my-model`` reaches
# a local server with nothing configured. The declaration alone cannot tell them
# from a keyed provider, so they are listed by hand and drift-guarded in
# ``tests/unit/test_provider_instances.py``.
KEYLESS_SELF_HOSTED_PROVIDERS = frozenset({"cascadia", "llamacpp", "lmstudio", "otari", "vllm"})


def provider_credential_env_names(provider_type: str) -> tuple[str, ...] | None:
"""Environment variables any-llm reads for a provider's credential.

Returns an empty tuple when the provider needs no API key: the keyless local
backends (ollama, llamacpp, llamafile) declare the literal string ``"None"``,
backends ollama and llamafile declare the literal string ``"None"``,
and a provider authenticating through a cloud SDK (Vertex AI) declares an
empty name. Returns ``None`` when the provider cannot be inspected at all
(not a known implementation, or an optional SDK dependency that is not
Expand Down Expand Up @@ -1773,7 +1782,7 @@ def _warn_on_uncredentialed_bare_entry(self, instance: str) -> None:
env_names = provider_credential_env_names(instance)
# Empty: a keyless backend, nothing to warn about. None: a provider we
# cannot inspect, so we do not know that a credential is needed.
if not env_names:
if not env_names or instance in KEYLESS_SELF_HOSTED_PROVIDERS:
return
if any(os.getenv(name) for name in env_names):
return
Expand Down
24 changes: 9 additions & 15 deletions src/gateway/services/provider_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from any_llm.exceptions import AnyLLMError

from gateway.auth.vertex_auth import setup_vertex_environment
from gateway.core.config import GatewayConfig, provider_credential_env_names
from gateway.core.config import KEYLESS_SELF_HOSTED_PROVIDERS, GatewayConfig, provider_credential_env_names
from gateway.services.alias_service import resolve_effective_alias
from gateway.services.catalog_selectors import resolve_catalog_selector
from gateway.services.policy_store import resolve_effective_policy
Expand All @@ -59,19 +59,13 @@
# tolerance (mozilla-ai/any-llm#1198).
_KEYLESS_PLACEHOLDER_API_KEY = "otari-no-key-required"

# Two sets of providers any-llm calls without an otari-visible credential, even
# though each *declares* a credential environment variable.
# ``provider_credential_env_names`` sees only the declaration, so it cannot tell
# them from a keyed provider the way it can ollama/llamacpp/llamafile (which
# Providers any-llm calls without an otari-visible credential, even though each
# *declares* a credential environment variable, so ``provider_credential_env_names``
# cannot tell them from a keyed provider the way it can ollama/llamafile (which
# declare the literal ``"None"``) or Vertex AI (which declares an empty name).
# Both are written out by hand and both are drift-guarded in
# ``tests/unit/test_provider_instances.py``.
# The self-hosted ones are ``KEYLESS_SELF_HOSTED_PROVIDERS``; this set is drift-guarded
# alongside it in ``tests/unit/test_provider_instances.py``.
#
# Local and LAN backends that never require a key: each overrides
# ``_verify_and_set_api_key`` to return without raising and defaults to a
# localhost or LAN base URL, so a bare ``vllm:my-model`` reaches a self-hosted
# server today with nothing configured in otari at all.
_KEYLESS_SELF_HOSTED_PROVIDERS = frozenset({"vllm", "lmstudio", "cascadia", "otari"})
# Providers authenticating from cloud SDK credentials this gateway cannot see:
# an EC2 instance profile, an SSO session, or an ambient boto3 chain. They are
# the same category as Vertex AI's application default credentials, which
Expand Down Expand Up @@ -154,9 +148,9 @@ def credential_ladder_exhausted(provider: LLMProvider, kwargs: dict[str, Any]) -
caller that might serve it from somewhere else. A deployment pointing at its
own backends is served upstream of anything reading this. They come in two
shapes: those declaring no credential variable at all (the keyless local
backends ollama, llamacpp and llamafile, and Vertex AI, which authenticates
backends ollama and llamafile, and Vertex AI, which authenticates
through the cloud SDK), and those declaring one any-llm does not insist on
(``_KEYLESS_SELF_HOSTED_PROVIDERS`` and ``_AMBIENT_CREDENTIAL_PROVIDERS``).
(``KEYLESS_SELF_HOSTED_PROVIDERS`` and ``_AMBIENT_CREDENTIAL_PROVIDERS``).

``provider_credential_env_names`` returns ``None`` rather than ``()`` for a
provider it cannot inspect at all, and that stays exhausted: nothing is known
Expand All @@ -165,7 +159,7 @@ def credential_ladder_exhausted(provider: LLMProvider, kwargs: dict[str, Any]) -
"""
if _kwargs_carry_a_credential(kwargs):
return False
if provider.value in _KEYLESS_SELF_HOSTED_PROVIDERS or provider.value in _AMBIENT_CREDENTIAL_PROVIDERS:
if provider.value in KEYLESS_SELF_HOSTED_PROVIDERS or provider.value in _AMBIENT_CREDENTIAL_PROVIDERS:
return False
if provider_credential_env_names(provider.value) == ():
return False
Expand Down
14 changes: 12 additions & 2 deletions tests/integration/test_hybrid_mode_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,12 +1254,22 @@ async def fake_amessages(**kwargs: Any) -> MessageResponse:
monkeypatch.setattr("gateway.api.routes.messages.amessages", fake_amessages)


@pytest.mark.parametrize(
"container",
[
"container_01ABC",
{"id": "container_01ABC"},
{"skills": [{"type": "anthropic", "skill_id": "pptx"}]},
],
ids=["id", "object-with-id", "object-with-skills-only"],
)
def test_container_is_refused_on_a_managed_credential(
platform_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
container: str | dict[str, Any],
) -> None:
"""A managed attempt runs on an account many workspaces share, so a
caller-chosen container id would address another tenant's state."""
caller-chosen container would address or create state on that account."""
calls: list[str] = []
_container_route(monkeypatch, managed=True, calls=calls)

Expand All @@ -1269,7 +1279,7 @@ def test_container_is_refused_on_a_managed_credential(
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100,
"container": "container_01ABC",
"container": container,
},
headers={"Authorization": "Bearer user_test_token"},
)
Expand Down
20 changes: 12 additions & 8 deletions tests/unit/test_provider_instances.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
from any_llm.exceptions import AnyLLMError

from gateway.api.routes.pricing import _candidate_model_keys
from gateway.core.config import GatewayConfig, ModelCapabilityConfig, provider_credential_env_names
from gateway.core.config import (
KEYLESS_SELF_HOSTED_PROVIDERS,
GatewayConfig,
ModelCapabilityConfig,
provider_credential_env_names,
)
from gateway.log_config import logger as gateway_logger
from gateway.services.model_capabilities import resolve_capabilities
from gateway.services.provider_kwargs import (
_AMBIENT_CREDENTIAL_PROVIDERS,
_KEYLESS_PLACEHOLDER_API_KEY,
_KEYLESS_SELF_HOSTED_PROVIDERS,
get_provider_kwargs,
keyless_placeholder_api_key,
normalize_pricing_key,
Expand Down Expand Up @@ -221,7 +225,7 @@ def test_credential_env_names_splits_alternatives() -> None:

def test_credential_env_names_empty_for_keyless_backends() -> None:
# any-llm spells "no credential" as the literal string "None".
for keyless in ("ollama", "llamacpp", "llamafile"):
for keyless in ("ollama", "llamafile"):
assert provider_credential_env_names(keyless) == ()


Expand All @@ -248,7 +252,7 @@ def test_the_uncredentialed_provider_roster_has_not_drifted() -> None:
routes a working self-hosted or IAM-authenticated request to somebody else's
fleet. `provider_credential_env_names` cannot answer it, because it sees the
*declaration* and these providers declare a variable they do not insist on,
so the two sets are written out by hand in `provider_kwargs.py`.
so the two sets are written out by hand in `config.py` and `provider_kwargs.py`.

This is the drift guard for both directions, which is why it asserts on the
whole roster rather than only on the names already listed. A provider that
Expand All @@ -270,17 +274,17 @@ def test_the_uncredentialed_provider_roster_has_not_drifted() -> None:
# itself and need no hand-written entry, so they are expected here but not in
# either set.
declares_nothing = {name for name in overriding if provider_credential_env_names(name) == ()}
classified = _KEYLESS_SELF_HOSTED_PROVIDERS | _AMBIENT_CREDENTIAL_PROVIDERS | _KEYED_DESPITE_OVERRIDING
classified = KEYLESS_SELF_HOSTED_PROVIDERS | _AMBIENT_CREDENTIAL_PROVIDERS | _KEYED_DESPITE_OVERRIDING

assert overriding - declares_nothing == classified, (
"any-llm's uncredentialed-provider roster changed. Every provider overriding "
"_verify_and_set_api_key must be classified in provider_kwargs.py "
"(_KEYLESS_SELF_HOSTED_PROVIDERS / _AMBIENT_CREDENTIAL_PROVIDERS) or here "
"_verify_and_set_api_key must be classified in config.py "
"(KEYLESS_SELF_HOSTED_PROVIDERS), provider_kwargs.py (_AMBIENT_CREDENTIAL_PROVIDERS) or here "
"(_KEYED_DESPITE_OVERRIDING). Unclassified names are treated as keyed, which is "
"the direction that hands a working request to a hosted fleet."
)
# And nothing in either set has quietly started demanding a key.
for name in _KEYLESS_SELF_HOSTED_PROVIDERS | _AMBIENT_CREDENTIAL_PROVIDERS:
for name in KEYLESS_SELF_HOSTED_PROVIDERS | _AMBIENT_CREDENTIAL_PROVIDERS:
assert provider_credential_env_names(name), f"{name} no longer declares a credential variable"
assert name in overriding, f"any-llm now unconditionally requires a credential for {name}"

Expand Down
19 changes: 17 additions & 2 deletions tests/unit/test_request_schema_derivation.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,28 @@ def test_messages_request_preserves_container_for_upstream() -> None:
assert request.model_dump(exclude_unset=True)["container"] == "container_01ABC"


def test_messages_request_preserves_a_container_object_for_upstream() -> None:
"""Anthropic's object form, an id and the skills to load, survives validation unchanged."""
container = {"id": "container_01ABC", "skills": [{"type": "anthropic", "skill_id": "pptx"}]}
request = MessagesRequest.model_validate(
{
"model": "anthropic:claude-sonnet-4-5",
"messages": [{"role": "user", "content": "Continue"}],
"max_tokens": 100,
"container": container,
}
)

assert request.model_dump(exclude_unset=True)["container"] == container


def test_messages_openapi_request_schema_includes_container() -> None:
"""The published Messages request contract advertises container continuity."""
"""The published Messages request contract advertises container continuity, as an id or an object."""
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"}
assert {variant.get("type") for variant in container["anyOf"]} == {"string", "object", "null"}


def test_derived_and_hand_written_endpoints_are_disjoint() -> None:
Expand Down
20 changes: 10 additions & 10 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion web/src/client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8907,7 +8907,9 @@ export interface components {
[key: string]: unknown;
} | null;
/** Container */
container?: string | null;
container?: string | {
[key: string]: unknown;
} | null;
/** Context Management */
context_management?: {
[key: string]: unknown;
Expand Down
Loading