mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* fix(bedrock): list the account's invocable models behind bedrock/* when check_provider_endpoint is on * fix(bedrock): sign the listing query RFC 3986 style and raise BedrockError on a failed listing * test(utils): keep test_utils.py at main's formatting, only the Bedrock discovery test is new * fix(bedrock): chain BedrockModelInfo's constructor so the passthrough config keeps its event parser * fix(bedrock): list discovered ids without the provider prefix so partial wildcards filter get_valid_models returns bare vendor ids for every other provider and for the static bedrock catalog, and the proxy's wildcard expansion adds the provider prefix itself. The lister prefixed its ids, so a partial wildcard like bedrock/anthropic.* never matched the proxy's filter and listed uncallable bedrock/anthropic.bedrock/<id> entries * test(bedrock): integration cells for wildcard discovery through the proxy and the SDK * fix(proxy): keep a partial wildcard a filter when its deployment repeats the prefix The wildcard expansion guessed filter-or-alias by whether any provider id carried the prefix. A deployment such as model_name bedrock/anthropic.* over model bedrock/anthropic.* can only ever route names that share the prefix, so when the account has no on-demand anthropic.* id (every Anthropic model behind an inference profile) the guess fell into the alias branch and listed bedrock/anthropic.<every id>, none of them callable. A deployment whose model repeats the suffix now always filters, and lists nothing when nothing matches * fix(proxy): prefix every discovered id under a custom wildcard prefix that starts a vendor id * test(bedrock): Final and read-only annotations in the wildcard discovery tests --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
281 lines
11 KiB
Python
281 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import threading
|
|
from collections.abc import Callable, Generator, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from queue import SimpleQueue
|
|
from types import MappingProxyType
|
|
from typing import Final, TypeAlias
|
|
from urllib.parse import parse_qsl, urlsplit
|
|
|
|
from integration._support import tls
|
|
from integration._support.sigv4 import canonical_query, signature
|
|
from integration._support.wire import Reply
|
|
from pydantic import JsonValue
|
|
|
|
_AUTHORIZATION: Final = re.compile(
|
|
r"^AWS4-HMAC-SHA256 Credential=(?P<key>[^/]+)/(?P<scope>[^,]+), "
|
|
r"SignedHeaders=(?P<signed>[^,]+), Signature=(?P<signature>[0-9a-f]{64})$"
|
|
)
|
|
_BEARER: Final = re.compile(r"^Bearer (?P<token>.+)$")
|
|
UNKNOWN_CREDENTIAL: Final = "none"
|
|
NO_PROXY_HOSTS: Final = "127.0.0.1,localhost"
|
|
INFERENCE_PROFILES: Final = "/inference-profiles"
|
|
FOUNDATION_MODELS: Final = "/foundation-models"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ControlPlaneRequest:
|
|
host: str
|
|
method: str
|
|
target: str
|
|
headers: Mapping[str, str]
|
|
body: bytes
|
|
|
|
@property
|
|
def path(self) -> str:
|
|
return urlsplit(self.target).path
|
|
|
|
@property
|
|
def query(self) -> Mapping[str, str]:
|
|
return MappingProxyType(dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True)))
|
|
|
|
@property
|
|
def credential(self) -> str:
|
|
"""The access key id of a SigV4 request, the token of a bearer one, UNKNOWN_CREDENTIAL otherwise."""
|
|
authorization: Final = self.headers.get("authorization", "")
|
|
signed: Final = _AUTHORIZATION.match(authorization)
|
|
if signed is not None:
|
|
return signed.group("key")
|
|
bearer: Final = _BEARER.match(authorization)
|
|
return bearer.group("token") if bearer is not None else UNKNOWN_CREDENTIAL
|
|
|
|
|
|
def verified_scope(request: ControlPlaneRequest, secret: str) -> str:
|
|
"""Recompute the SigV4 signature with `secret`; returns the credential scope the request was signed under."""
|
|
found: Final = _AUTHORIZATION.match(request.headers.get("authorization", ""))
|
|
assert found is not None, dict(request.headers)
|
|
parts: Final = urlsplit(request.target)
|
|
_, expected = signature(
|
|
request.method,
|
|
parts.path,
|
|
request.headers,
|
|
found.group("signed"),
|
|
request.body,
|
|
secret,
|
|
found.group("scope"),
|
|
canonical_query(parts.query),
|
|
)
|
|
assert found.group("signature") == expected, request
|
|
return found.group("scope")
|
|
|
|
|
|
Responder: TypeAlias = Callable[[ControlPlaneRequest], Reply]
|
|
|
|
|
|
def json_reply(status: int, payload: Mapping[str, JsonValue]) -> Reply:
|
|
return Reply(status=status, body=json.dumps(payload).encode())
|
|
|
|
|
|
def _unregistered(request: ControlPlaneRequest) -> Reply:
|
|
return json_reply(403, {"message": f"no scripted catalog for credential {request.credential}"})
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Catalog:
|
|
"""What the scripted control plane advertises for one credential; `page_size` splits the profiles."""
|
|
|
|
active_profiles: tuple[str, ...] = ()
|
|
inactive_profiles: tuple[str, ...] = ()
|
|
on_demand_models: tuple[str, ...] = ()
|
|
page_size: int | None = None
|
|
|
|
def vendor_ids(self) -> frozenset[str]:
|
|
return frozenset((*self.active_profiles, *self.on_demand_models))
|
|
|
|
def invocable_ids(self) -> frozenset[str]:
|
|
return frozenset(f"bedrock/{model_id}" for model_id in self.vendor_ids())
|
|
|
|
def pages(self) -> tuple[tuple[Mapping[str, JsonValue], ...], ...]:
|
|
summaries: Final[tuple[Mapping[str, JsonValue], ...]] = (
|
|
*({"inferenceProfileId": name, "status": "ACTIVE"} for name in self.active_profiles),
|
|
*({"inferenceProfileId": name, "status": "INACTIVE"} for name in self.inactive_profiles),
|
|
)
|
|
size: Final = len(summaries) if self.page_size is None else self.page_size
|
|
return tuple(summaries[start : start + size] for start in range(0, len(summaries), size)) or ((),)
|
|
|
|
def respond(self, request: ControlPlaneRequest) -> Reply:
|
|
if request.method != "GET":
|
|
return json_reply(405, {"message": f"{request.method} is not a listing"})
|
|
if request.path == FOUNDATION_MODELS:
|
|
if request.query != {"byInferenceType": "ON_DEMAND"}:
|
|
return json_reply(400, {"message": f"unexpected foundation-models query {dict(request.query)}"})
|
|
return json_reply(200, {"modelSummaries": [{"modelId": name} for name in self.on_demand_models]})
|
|
if request.path != INFERENCE_PROFILES:
|
|
return json_reply(404, {"message": f"no scripted listing at {request.path}"})
|
|
pages: Final = self.pages()
|
|
index: Final = int(request.query.get("nextToken", "page-0").removeprefix("page-"))
|
|
expected_query: Final = {"maxResults": "1000", "typeEquals": "SYSTEM_DEFINED"} | (
|
|
{"nextToken": f"page-{index}"} if index else {}
|
|
)
|
|
if dict(request.query) != expected_query or index >= len(pages):
|
|
return json_reply(400, {"message": f"unexpected inference-profiles query {dict(request.query)}"})
|
|
continuation: Final = {"nextToken": f"page-{index + 1}"} if index + 1 < len(pages) else {}
|
|
return json_reply(200, {"inferenceProfileSummaries": list(pages[index]), **continuation})
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ControlPlane:
|
|
"""An owned HTTP CONNECT proxy terminating TLS for the hosted Bedrock control-plane names.
|
|
|
|
The proxy under test gets it as ``HTTPS_PROXY`` and trusts its certificate through ``SSL_VERIFY``, so the
|
|
lister's own ``https://bedrock.<region>.<suffix>`` URLs reach a scripted catalog without a DNS override.
|
|
A CONNECT to any other host is refused with 403 and recorded in `refused`."""
|
|
|
|
url: str
|
|
certificate: Path
|
|
received: SimpleQueue[ControlPlaneRequest]
|
|
refused: SimpleQueue[str]
|
|
responders: dict[str, Responder] # mutable-ok: answering() adds a responder per test and removes it
|
|
|
|
def environment(self) -> Mapping[str, str]:
|
|
return MappingProxyType(
|
|
{
|
|
"HTTPS_PROXY": self.url,
|
|
"https_proxy": self.url,
|
|
"NO_PROXY": NO_PROXY_HOSTS,
|
|
"no_proxy": NO_PROXY_HOSTS,
|
|
"SSL_VERIFY": str(self.certificate),
|
|
}
|
|
)
|
|
|
|
def drain(self) -> tuple[ControlPlaneRequest, ...]:
|
|
return tuple(self.received.get_nowait() for _ in range(self.received.qsize()))
|
|
|
|
def refusals(self) -> tuple[str, ...]:
|
|
return tuple(self.refused.get_nowait() for _ in range(self.refused.qsize()))
|
|
|
|
@contextmanager
|
|
def answering(self, credential: str, respond: Responder) -> Iterator[None]:
|
|
self.responders[credential] = respond
|
|
try:
|
|
yield
|
|
finally:
|
|
del self.responders[credential]
|
|
|
|
|
|
def _header_map(handler: BaseHTTPRequestHandler) -> Mapping[str, str]:
|
|
return MappingProxyType({name.lower(): value for name, value in handler.headers.items()})
|
|
|
|
|
|
def _answer(request: ControlPlaneRequest, responders: Mapping[str, Responder], errors: SimpleQueue[Exception]) -> Reply:
|
|
try:
|
|
return responders.get(request.credential, _unregistered)(request)
|
|
except Exception as error:
|
|
errors.put(error)
|
|
return Reply(status=500)
|
|
|
|
|
|
def _tunneled_handler(
|
|
host: str,
|
|
received: SimpleQueue[ControlPlaneRequest],
|
|
responders: Mapping[str, Responder],
|
|
errors: SimpleQueue[Exception],
|
|
) -> type[BaseHTTPRequestHandler]:
|
|
class Tunneled(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
timeout = 60
|
|
|
|
def respond(self) -> None:
|
|
request: Final = ControlPlaneRequest(
|
|
host,
|
|
self.command,
|
|
self.path,
|
|
_header_map(self),
|
|
self.rfile.read(int(self.headers.get("content-length", "0"))),
|
|
)
|
|
received.put(request)
|
|
reply: Final = _answer(request, responders, errors)
|
|
if reply.drop_connection:
|
|
self.close_connection = True
|
|
return
|
|
try:
|
|
self.send_response(reply.status)
|
|
self.send_header("content-type", reply.content_type)
|
|
for name, value in reply.headers.items():
|
|
self.send_header(name, value)
|
|
self.send_header("content-length", str(len(reply.body)))
|
|
self.end_headers()
|
|
self.wfile.write(reply.body)
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
self.close_connection = True
|
|
|
|
do_GET = respond
|
|
do_POST = respond
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
return Tunneled
|
|
|
|
|
|
@contextmanager
|
|
def control_plane(directory: Path, hosts: tuple[str, ...]) -> Generator[ControlPlane, None, None]:
|
|
cert_file, key_file = tls.write_self_signed_cert(directory, names=hosts)
|
|
context: Final = tls.server_context(cert_file, key_file)
|
|
trusted: Final = frozenset(hosts)
|
|
received: Final[SimpleQueue[ControlPlaneRequest]] = SimpleQueue()
|
|
refused: Final[SimpleQueue[str]] = SimpleQueue()
|
|
errors: Final[SimpleQueue[Exception]] = SimpleQueue()
|
|
responders: Final[dict[str, Responder]] = {} # mutable-ok: cells register and remove their catalogs
|
|
|
|
class Proxy(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
timeout = 60
|
|
|
|
def do_CONNECT(self) -> None:
|
|
host: Final = self.path.rsplit(":", 1)[0]
|
|
self.close_connection = True
|
|
if host not in trusted:
|
|
refused.put(self.path)
|
|
self.send_response(403)
|
|
self.send_header("content-length", "0")
|
|
self.end_headers()
|
|
return
|
|
self.send_response(200, "Connection established")
|
|
self.end_headers()
|
|
self.wfile.flush()
|
|
try:
|
|
secured: Final = context.wrap_socket(self.connection, server_side=True)
|
|
except OSError as error:
|
|
errors.put(error)
|
|
return
|
|
with secured:
|
|
_tunneled_handler(host, received, responders, errors)(secured, self.client_address, self.server)
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
class Server(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
block_on_close = False
|
|
request_queue_size = 128
|
|
|
|
with Server(("127.0.0.1", 0), Proxy) as server:
|
|
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
|
|
thread.start()
|
|
try:
|
|
yield ControlPlane(f"http://127.0.0.1:{server.server_port}", cert_file, received, refused, responders)
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=6)
|
|
assert not thread.is_alive(), "Owned control plane survived cleanup"
|
|
server.server_close()
|
|
failure: Final = None if errors.empty() else errors.get_nowait()
|
|
assert failure is None, f"Owned control plane failed: {failure!r}"
|