Coverage for packages/client/src/langgate/client/http.py: 45%
67 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-04-09 21:23 +0000
« prev ^ index » next coverage.py v7.7.1, created at 2025-04-09 21:23 +0000
1"""HTTP client for LangGate API."""
3from contextlib import asynccontextmanager
4from datetime import timedelta
5from typing import Generic, get_args
7import httpx
8from pydantic import SecretStr
10from langgate.client.protocol import BaseRegistryClient, LLMInfoT
11from langgate.core.logging import get_logger
12from langgate.core.models import LLMInfo
14logger = get_logger(__name__)
17def create_registry_http_client(
18 base_url: str,
19 api_key: SecretStr | None = None,
20 timeout: float | httpx.Timeout | None = 10.0,
21 **kwargs,
22) -> httpx.AsyncClient:
23 """
24 Creates and configures an httpx.AsyncClient for the Registry API.
25 """
26 headers = kwargs.pop("headers", {})
27 if api_key:
28 headers["X-API-Key"] = api_key.get_secret_value()
30 processed_base_url = base_url.rstrip("/")
32 return httpx.AsyncClient(
33 base_url=processed_base_url,
34 headers=headers,
35 timeout=timeout,
36 **kwargs,
37 )
40class BaseHTTPRegistryClient(BaseRegistryClient[LLMInfoT], Generic[LLMInfoT]):
41 """
42 Base HTTP client for the Model Registry API.
43 Supports LLMInfo-derived schemas for response parsing and httpx client injection.
45 Handles infrequent HTTP requests via temporary clients by default or uses an
46 injected client as the request engine. Configuration (base_url, api_key)
47 stored in this instance is always used for requests.
49 Type Parameters:
50 LLMInfoT: The LLMInfo-derived model class for response parsing
51 """
53 __orig_bases__: tuple
54 model_info_cls: type[LLMInfoT]
56 def __init__(
57 self,
58 base_url: str,
59 api_key: SecretStr | None = None,
60 cache_ttl: timedelta | None = None,
61 model_info_cls: type[LLMInfoT] | None = None,
62 http_client: httpx.AsyncClient | None = None,
63 ):
64 """Initialize the client.
65 Args:
66 base_url: The base URL of the registry service
67 api_key: Registry server API key for authentication
68 cache_ttl: Cache time-to-live
69 model_info_cls: Override for model info class
70 http_client: Optional HTTP client for making requests
71 """
72 super().__init__(cache_ttl=cache_ttl)
73 self.base_url = base_url.rstrip("/")
74 self.api_key = api_key
75 self._http_client = http_client
77 # Set model_info_cls if provided, otherwise it is inferred from the class
78 if model_info_cls is not None:
79 self.model_info_cls = model_info_cls
81 logger.debug(
82 "initialized_base_http_registry_client",
83 base_url=self.base_url,
84 api_key=self.api_key,
85 model_info_cls=self.model_info_cls,
86 )
88 def __init_subclass__(cls, **kwargs):
89 """Set up model class when this class is subclassed."""
90 super().__init_subclass__(**kwargs)
92 # Extract the model class from generic parameters
93 if not hasattr(cls, "model_info_cls"):
94 cls.model_info_cls = cls._get_model_info_class()
96 @classmethod
97 def _get_model_info_class(cls) -> type[LLMInfoT]:
98 """Extract the model class from generic type parameters."""
99 return get_args(cls.__orig_bases__[0])[0]
101 @asynccontextmanager
102 async def _get_client_for_request(self):
103 """Provides the httpx client to use (injected or temporary)."""
104 if self._http_client:
105 yield self._http_client
106 else:
107 async with httpx.AsyncClient() as temp_client:
108 yield temp_client
110 async def _request(self, method: str, url_path: str, **kwargs) -> httpx.Response:
111 """Makes an HTTP request using the appropriate client engine."""
112 url = f"{self.base_url}{url_path}"
113 headers = kwargs.pop("headers", {})
114 if self.api_key:
115 headers["X-API-Key"] = self.api_key.get_secret_value()
117 async with self._get_client_for_request() as client:
118 response = await client.request(method, url, headers=headers, **kwargs)
119 return response
121 async def _fetch_model_info(self, model_id: str) -> LLMInfoT:
122 """Fetch model info from the source via HTTP."""
123 response = await self._request("GET", f"/models/{model_id}")
124 response.raise_for_status()
125 return self.model_info_cls.model_validate(response.json())
127 async def _fetch_all_models(self) -> list[LLMInfoT]:
128 """Fetch all models from the source via HTTP."""
129 response = await self._request("GET", "/models")
130 response.raise_for_status()
131 return [self.model_info_cls.model_validate(model) for model in response.json()]
134class HTTPRegistryClient(BaseHTTPRegistryClient[LLMInfo]):
135 """HTTP client singleton for the Model Registry API using the default LLMInfo schema."""
137 _instance = None
139 def __new__(cls, *args, **kwargs):
140 if cls._instance is None:
141 cls._instance = super().__new__(cls)
142 logger.debug("creating_http_registry_client_singleton")
143 return cls._instance
145 def __init__(
146 self,
147 base_url: str,
148 api_key: SecretStr | None = None,
149 cache_ttl: timedelta | None = None,
150 http_client: httpx.AsyncClient | None = None,
151 ):
152 if not hasattr(self, "_initialized"):
153 super().__init__(base_url, api_key, cache_ttl, http_client=http_client)
154 self._initialized = True
155 logger.debug("initialized_http_registry_client_singleton")