From aab65a99147d213de114d8f5375cf14b7bfb23d6 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Thu, 23 Oct 2025 13:08:10 +0200 Subject: [PATCH 01/22] upd. gitignore, ignore coverage --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 18bf11a..b8f267c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ storage dist docs .aico -!.aico/project.json \ No newline at end of file +!.aico/project.json +.coverage +coverage.xml \ No newline at end of file From 936c8dddbe2e8399be598c310004f175c2ef3c44 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Thu, 23 Oct 2025 13:09:00 +0200 Subject: [PATCH 02/22] pyproject: add testpaths --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c9eff74..16e7b0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,6 @@ lm-proxy = "lm_proxy.app:cli_app" [tool.pytest.ini_options] asyncio_mode = "auto" +testpaths = [ + "tests", +] From cf1b97ab5f64e80094d8829693482878505bd34a Mon Sep 17 00:00:00 2001 From: Nayjest Date: Fri, 24 Oct 2025 14:17:43 +0200 Subject: [PATCH 03/22] README.md: Debugging section added --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index b64fcde..51edffe 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ It works as a drop-in replacement for OpenAI's API, allowing you to switch betwe - [Advanced Usage](#%EF%B8%8F-advanced-usage) - [Dynamic Model Routing](#dynamic-model-routing) - [Load Balancing Example](#load-balancing-example) +- [Debugging](#-debugging) - [Contributing](#-contributing) - [License](#-license) @@ -463,6 +464,29 @@ The routing section allows flexible pattern matching with wildcards: This example demonstrates how to set up a load balancer that randomly distributes requests across multiple language model servers using the lm_proxy. +## 🔍 Debugging + +### Overview +When **debugging mode** is enabled, +LM-Proxy provides detailed logging information to help diagnose issues: +- Stack traces for exceptions are shown in the console +- logging level is set to DEBUG instead of INFO + +> **Warning** ⚠️ +>Never enable debugging mode in production environments, as it may expose sensitive information to the application logs. +### Enabling Debugging Mode +To enable debugging, set the `LM_PROXY_DEBUG` environment variable to a truthy value (e.g., "1", "true", "yes"). +> **Tip** 💡 +>Environment variables can also be defined in a `.env` file. + +Alternatively, you can enable or disable debugging via the command line-arguments: +- `--debug` to enable debugging +- `--no-debug` to disable debugging + +> **Note** ℹ️ +> CLI arguments override environment variable settings. + + ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. From 34f504384644eca0636eeb216ec7d0392132f3f9 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sat, 25 Oct 2025 00:11:11 +0200 Subject: [PATCH 04/22] v2: - Major refactoring - Components support - Config now do not instantiate logger instances, that's done in bootstrap - LogEntry is now RequestContext and includes additional fields like 'model', 'user_info', 'extra' - config.check_api_key renamed to config.api_key_check - Config loaders: toml, yaml, json, py; Custom config loaders may be registered via "config.loaders" entry point - Bootstrap messaging reworked - Substituting env:vars now is done via utils.replace_env_strings_recursive - lm_proxy.loggers.log_writers.JsonLogWriter moved to lm_proxy.loggers.JsonLogWriter - API check functions now can return additional data in a tuple (treated as user_info) --- config.toml | 2 +- lm_proxy/__main__.py | 1 + lm_proxy/api_key_check/__init__.py | 6 ++ lm_proxy/api_key_check/in_config.py | 18 +++++ lm_proxy/api_key_check/with_request.py | 61 +++++++++++++++ lm_proxy/app.py | 17 ++-- lm_proxy/base_types.py | 54 +++++++++++++ lm_proxy/bootstrap.py | 43 ++++++++--- lm_proxy/config.py | 69 +++++++++-------- lm_proxy/config_loaders/__init__.py | 12 +++ lm_proxy/config_loaders/json.py | 6 ++ lm_proxy/config_loaders/python.py | 9 +++ lm_proxy/config_loaders/toml.py | 6 ++ lm_proxy/config_loaders/yaml.py | 12 +++ lm_proxy/core.py | 65 ++++++++++------ lm_proxy/loggers.py | 77 +++++++++++++++++++ lm_proxy/loggers/__init__.py | 11 --- lm_proxy/loggers/base_logger.py | 56 -------------- lm_proxy/loggers/core.py | 53 ------------- lm_proxy/loggers/log_writers.py | 24 ------ lm_proxy/{models.py => models_endpoint.py} | 13 ++-- lm_proxy/utils.py | 61 +++++++++++---- pyproject.toml | 12 ++- tests/configs/__init__.py | 0 tests/configs/config_fn.py | 4 +- tests/configs/test_config.json | 30 ++++++++ tests/configs/test_config.toml | 1 - tests/configs/test_config.yml | 24 ++++++ tests/test_config_loaders.py | 25 ++++++ tests/test_integration.py | 8 +- tests/test_loggers.py | 11 ++- ...test_models.py => test_models_endpoint.py} | 5 +- tests/test_utils.py | 58 ++++++++++++++ 33 files changed, 591 insertions(+), 263 deletions(-) create mode 100644 lm_proxy/api_key_check/__init__.py create mode 100644 lm_proxy/api_key_check/in_config.py create mode 100644 lm_proxy/api_key_check/with_request.py create mode 100644 lm_proxy/base_types.py create mode 100644 lm_proxy/config_loaders/__init__.py create mode 100644 lm_proxy/config_loaders/json.py create mode 100644 lm_proxy/config_loaders/python.py create mode 100644 lm_proxy/config_loaders/toml.py create mode 100644 lm_proxy/config_loaders/yaml.py create mode 100644 lm_proxy/loggers.py delete mode 100644 lm_proxy/loggers/__init__.py delete mode 100644 lm_proxy/loggers/base_logger.py delete mode 100644 lm_proxy/loggers/core.py delete mode 100644 lm_proxy/loggers/log_writers.py rename lm_proxy/{models.py => models_endpoint.py} (81%) create mode 100644 tests/configs/__init__.py create mode 100644 tests/configs/test_config.json create mode 100644 tests/configs/test_config.yml create mode 100644 tests/test_config_loaders.py rename tests/{test_models.py => test_models_endpoint.py} (93%) create mode 100644 tests/test_utils.py diff --git a/config.toml b/config.toml index 87e1af3..c72c4de 100644 --- a/config.toml +++ b/config.toml @@ -40,7 +40,7 @@ api_keys = [ [[loggers]] class = 'lm_proxy.loggers.BaseLogger' [loggers.log_writer] -class = 'lm_proxy.loggers.log_writers.JsonLogWriter' +class = 'lm_proxy.loggers.JsonLogWriter' file_name = 'storage/json.log' [loggers.entry_transformer] class = 'lm_proxy.loggers.LogEntryTransformer' diff --git a/lm_proxy/__main__.py b/lm_proxy/__main__.py index 9bbc6a3..9837177 100644 --- a/lm_proxy/__main__.py +++ b/lm_proxy/__main__.py @@ -1,3 +1,4 @@ +"""Provides the CLI entry point when the package is executed as a Python module.""" from .app import cli_app diff --git a/lm_proxy/api_key_check/__init__.py b/lm_proxy/api_key_check/__init__.py new file mode 100644 index 0000000..09c35b4 --- /dev/null +++ b/lm_proxy/api_key_check/__init__.py @@ -0,0 +1,6 @@ +"""Collection of built-in API-key checkers for usage in the configuration.""" +from .in_config import check_api_key_in_config +from .with_request import CheckAPIKeyWithRequest + + +__all__ = ["check_api_key_in_config", "CheckAPIKeyWithRequest"] diff --git a/lm_proxy/api_key_check/in_config.py b/lm_proxy/api_key_check/in_config.py new file mode 100644 index 0000000..d75a0f3 --- /dev/null +++ b/lm_proxy/api_key_check/in_config.py @@ -0,0 +1,18 @@ +from typing import Optional +from ..bootstrap import env + + +def check_api_key_in_config(api_key: Optional[str]) -> Optional[str]: + """ + Validates a Client API key against configured groups and returns the matching group name. + + Args: + api_key (Optional[str]): The Virtual / Client API key to validate. + Returns: + Optional[str]: The group name if the API key is valid and found in a group, + None otherwise. + """ + for group_name, group in env.config.groups.items(): + if api_key in group.api_keys: + return group_name + return None diff --git a/lm_proxy/api_key_check/with_request.py b/lm_proxy/api_key_check/with_request.py new file mode 100644 index 0000000..12dd666 --- /dev/null +++ b/lm_proxy/api_key_check/with_request.py @@ -0,0 +1,61 @@ +from typing import Optional +from dataclasses import dataclass, field +import requests + +@dataclass(slots=True) +class CheckAPIKeyWithRequest: + url: str = field() + method: str = field(default="get") + headers: dict = field(default_factory=dict) + response_as_user_info: bool = field(default=False) + group_field: Optional[str] = field(default=None) + default_group: str = field(default="default") + key_placeholder: str = field(default="{api_key}") + use_cache: bool = field(default=False) + cache_size: int = field(default=1024 * 16) + cache_ttl: int = field(default=60 * 5) # 5 minutes + timeout: int = field(default=5) # seconds + _func: callable = field(init=False, repr=False) + + def __post_init__(self): + def check_func(api_key: str) -> dict | None: + try: + url = self.url.replace(self.key_placeholder, api_key) + headers = { + k: str(v).replace(self.key_placeholder, api_key) + for k, v in self.headers.items() + } + response = requests.request( + method=self.method, + url=url, + headers=headers, + timeout=self.timeout + ) + response.raise_for_status() + group = self.default_group + user_info = None + if self.response_as_user_info: + user_info = response.json() + if self.group_field: + group = user_info.get(self.group_field, self.default_group) + return group, user_info + except requests.exceptions.RequestException: + return None + + if self.use_cache: + try: + import cachetools + except ImportError as e: + raise ImportError( + "Missing optional dependency 'cachetools'. " + "Using 'lm_proxy.api_key_check.CheckAPIKeyWithRequest' with 'use_cache = true' " + "requires installing 'cachetools' package. " + "\nPlease install it with following command: 'pip install cachetools'" + ) from e + cache = cachetools.TTLCache(maxsize=self.cache_size, ttl=self.cache_ttl) + self._func = cachetools.cached(cache)(check_func) + else: + self._func = check_func + + def __call__(self, api_key: str) -> dict | None: + return self._func(api_key) diff --git a/lm_proxy/app.py b/lm_proxy/app.py index 80628f8..67162cf 100644 --- a/lm_proxy/app.py +++ b/lm_proxy/app.py @@ -1,3 +1,6 @@ +""" +LM-Proxy Application Entrypoint +""" import logging from typing import Optional from fastapi import FastAPI @@ -6,12 +9,11 @@ from .bootstrap import env, bootstrap from .core import chat_completions -from .models import models +from .models_endpoint import models cli_app = typer.Typer() -# run-server is a default command of cli-app @cli_app.callback(invoke_without_command=True) def run_server( config: Optional[str] = typer.Option(None, help="Path to the configuration file"), @@ -26,6 +28,9 @@ def run_server( help="Set the .env file to load ENV vars from", ), ): + """ + Default command for CLI application: Run LM-Proxy web server + """ try: bootstrap(config=config or "config.toml", env_file=env_file, debug=debug) uvicorn.run( @@ -38,12 +43,14 @@ def run_server( except Exception as e: if env.debug: raise - else: - logging.error(e) - raise typer.Exit(code=1) + logging.error(e) + raise typer.Exit(code=1) def web_app(): + """ + Entrypoint for ASGI server + """ app = FastAPI( title="LM-Proxy", description="OpenAI-compatible proxy server for LLM inference" ) diff --git a/lm_proxy/base_types.py b/lm_proxy/base_types.py new file mode 100644 index 0000000..5ae4a4d --- /dev/null +++ b/lm_proxy/base_types.py @@ -0,0 +1,54 @@ +"""Base types used in LM-Proxy.""" +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional, TYPE_CHECKING + +import microcore as mc +from pydantic import BaseModel + +if TYPE_CHECKING: + from .config import Group + + +class ChatCompletionRequest(BaseModel): + """ + Request model for chat/completions endpoint. + """ + model: str + messages: List[mc.Msg] + stream: Optional[bool] = None + max_tokens: Optional[int] = None + temperature: Optional[float] = None + top_p: Optional[float] = None + n: Optional[int] = None + stop: Optional[List[str]] = None + presence_penalty: Optional[float] = None + frequency_penalty: Optional[float] = None + user: Optional[str] = None + + +@dataclass +class RequestContext: + """ + Stores information about a single LLM request/response cycle for usage in middleware. + """ + id: Optional[str] = field(default_factory=lambda: str(uuid.uuid4())) + request: Optional[ChatCompletionRequest] = field(default=None) + response: Optional[mc.LLMResponse] = field(default=None) + error: Optional[Exception] = field(default=None) + group: Optional["Group"] = field(default=None) + connection: Optional[str] = field(default=None) + model: Optional[str] = field(default=None) + api_key_id: Optional[str] = field(default=None) + remote_addr: Optional[str] = field(default=None) + created_at: Optional[datetime] = field(default_factory=datetime.now) + duration: Optional[float] = field(default=None) + user_info: Optional[dict] = field(default=None) + extra: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + data = self.__dict__.copy() + if self.request: + data["request"] = self.request.model_dump(mode="json") + return data diff --git a/lm_proxy/bootstrap.py b/lm_proxy/bootstrap.py index 92a2f6d..e46bca1 100644 --- a/lm_proxy/bootstrap.py +++ b/lm_proxy/bootstrap.py @@ -1,8 +1,9 @@ +"""Initialization and bootstrapping.""" import sys import logging import inspect from datetime import datetime - +from typing import TYPE_CHECKING import microcore as mc from microcore import ui @@ -10,9 +11,14 @@ from dotenv import load_dotenv from .config import Config +from .utils import resolve_instance_or_callable + +if TYPE_CHECKING: + from .loggers import TLogger def setup_logging(log_level: int = logging.INFO): + """Setup logging format and level.""" class CustomFormatter(logging.Formatter): def format(self, record): dt = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") @@ -31,20 +37,33 @@ def format(self, record): class Env: + """Runtime environment singleton.""" config: Config connections: dict[str, mc.types.LLMAsyncFunctionType] debug: bool + components: dict + loggers: list["TLogger"] + + def _init_components(self): + self.components = dict() + for name, component_data in self.config.components.items(): + self.components[name] = resolve_instance_or_callable(component_data) + logging.info(f"Loaded component '{name}'") @staticmethod def init(config: Config | str, debug: bool = False): env.debug = debug - if isinstance(config, Config): - env.config = config - elif isinstance(config, str): - env.config = Config.load(config) - else: - raise ValueError("config must be a string (file path) or Config instance") + if not isinstance(config, Config): + if isinstance(config, str): + config = Config.load(config) + else: + raise ValueError("config must be a string (file path) or Config instance") + env.config = config + + env._init_components() + + env.loggers = [resolve_instance_or_callable(logger) for logger in env.config.loggers] # initialize connections env.connections = dict() @@ -77,10 +96,10 @@ def bootstrap(config: str | Config = "config.toml", env_file: str = ".env", debu setup_logging(logging.DEBUG if debug else logging.INFO) mc.logging.LoggingConfig.OUTPUT_METHOD = logging.info logging.info( - f"Bootstrapping {ui.yellow('lm_proxy')}: " - f"config_file={'dynamic' if isinstance(config, Config) else ui.blue(config)}" - f"{' debug=on' if debug else ''}" - f"{' env_file=' + ui.blue(env_file) if env_file else ''}" - f"..." + f"Bootstrapping {ui.magenta('LM-Proxy')}..." + f"\n - Config{ui.gray('......')}" + f"[ {'dynamic' if isinstance(config, Config) else ui.blue(config)} ]" + f"{'\n - Env. File' + ui.gray('...') + '[ ' + ui.blue(env_file)+' ]' if env_file else ''}" + f"{'\n - Debug' + ui.gray('.......') + '[ ' + ui.yellow('On')+' ]' if debug else ''}" ) Env.init(config, debug=debug) diff --git a/lm_proxy/config.py b/lm_proxy/config.py index 42e5b83..e2ecd8f 100644 --- a/lm_proxy/config.py +++ b/lm_proxy/config.py @@ -5,14 +5,13 @@ import os from enum import StrEnum -from typing import Union, Callable -import tomllib -import importlib.util +from typing import Union, Callable, Dict +from importlib.metadata import entry_points from pydantic import BaseModel, Field, ConfigDict -from microcore.utils import resolve_callable -from .utils import resolve_instance_or_callable +from .utils import resolve_instance_or_callable, replace_env_strings_recursive +from .loggers import TLogger class ModelListingMode(StrEnum): @@ -44,7 +43,10 @@ def allows_connecting_to(self, connection_name: str) -> bool: class Config(BaseModel): """Main configuration model matching config.toml structure.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict( + extra="forbid", + arbitrary_types_allowed=True, + ) enabled: bool = True host: str = "0.0.0.0" port: int = 8000 @@ -56,9 +58,9 @@ class Config(BaseModel): ) routing: dict[str, str] = Field(default_factory=dict) """ model_name_pattern* => connection_name.< model | * >, example: {"gpt-*": "oai.*"} """ - groups: dict[str, Group] = Field(default_factory=dict) - check_api_key: Union[str, Callable] = Field(default="lm_proxy.core.check_api_key") - loggers: list[Union[str, Callable, dict]] = Field(default_factory=list) + groups: dict[str, Group] = Field(default_factory=lambda: {"default": Group()}) + api_key_check: Union[str, Callable, dict] = Field(default="lm_proxy.core.check_api_key") + loggers: list[Union[str, dict, TLogger]] = Field(default_factory=list) encryption_key: str = Field( default="Eclipse", description="Key for encrypting sensitive data (must be explicitly set)", @@ -67,19 +69,30 @@ class Config(BaseModel): default=ModelListingMode.AS_IS, description="How to handle wildcard models in /v1/models endpoint", ) + components: dict[str, Union[str, Callable, dict]] = Field(default_factory=dict) def __init__(self, **data): super().__init__(**data) - self.check_api_key = resolve_callable(self.check_api_key) - self.loggers = [resolve_instance_or_callable(logger) for logger in self.loggers] - if not self.groups: - # Default group with no restrictions - self.groups = {"default": Group()} + self.api_key_check = resolve_instance_or_callable( + self.api_key_check, + debug_name="check_api_key", + ) + + @staticmethod + def _load_raw(config_path: str = "config.toml") -> Union["Config", Dict]: + config_ext = os.path.splitext(config_path)[1].lower().lstrip(".") + for entry_point in entry_points(group="config.loaders"): + if config_ext == entry_point.name: + loader = entry_point.load() + config_data = loader(config_path) + return config_data + + raise ValueError(f"No loader found for configuration file extension: {config_ext}") @staticmethod def load(config_path: str = "config.toml") -> "Config": """ - Load configuration from a TOML file. + Load configuration from a TOML or Python file. Args: config_path: Path to the config.toml file @@ -87,22 +100,10 @@ def load(config_path: str = "config.toml") -> "Config": Returns: Config object with parsed configuration """ - if config_path.endswith(".py"): - spec = importlib.util.spec_from_file_location("config_module", config_path) - config_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(config_module) - return config_module.config - elif config_path.endswith(".toml"): - with open(config_path, "rb") as f: - config_data = tomllib.load(f) - else: - raise ValueError(f"Unsupported configuration file extension: {config_path}") - - # Process environment variables in api_key fields - for conn_name, conn_config in config_data.get("connections", {}).items(): - for key, value in conn_config.items(): - if isinstance(value, str) and value.startswith("env:"): - env_var = value.split(":", 1)[1] - conn_config[key] = os.environ.get(env_var, "") - - return Config(**config_data) + config = Config._load_raw(config_path) + if isinstance(config, dict): + config = replace_env_strings_recursive(config) + config = Config(**config) + elif not isinstance(config, Config): + raise TypeError("Loaded configuration must be a dict or Config instance") + return config diff --git a/lm_proxy/config_loaders/__init__.py b/lm_proxy/config_loaders/__init__.py new file mode 100644 index 0000000..ea2fdc8 --- /dev/null +++ b/lm_proxy/config_loaders/__init__.py @@ -0,0 +1,12 @@ +"""Built-in configuration loaders for different file formats.""" +from .python import load_python_config +from .toml import load_toml_config +from .yaml import load_yaml_config +from .json import load_json_config + +__all__ = [ + "load_python_config", + "load_toml_config", + "load_yaml_config", + "load_json_config", +] diff --git a/lm_proxy/config_loaders/json.py b/lm_proxy/config_loaders/json.py new file mode 100644 index 0000000..f167c9e --- /dev/null +++ b/lm_proxy/config_loaders/json.py @@ -0,0 +1,6 @@ +import json + + +def load_json_config(config_path: str) -> dict: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/lm_proxy/config_loaders/python.py b/lm_proxy/config_loaders/python.py new file mode 100644 index 0000000..6cae3a7 --- /dev/null +++ b/lm_proxy/config_loaders/python.py @@ -0,0 +1,9 @@ +import importlib.util +from ..config import Config + + +def load_python_config(config_path: str) -> Config: + spec = importlib.util.spec_from_file_location("config_module", config_path) + config_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(config_module) + return config_module.config diff --git a/lm_proxy/config_loaders/toml.py b/lm_proxy/config_loaders/toml.py new file mode 100644 index 0000000..864a635 --- /dev/null +++ b/lm_proxy/config_loaders/toml.py @@ -0,0 +1,6 @@ +import tomllib + + +def load_toml_config(config_path: str) -> dict: + with open(config_path, "rb") as f: + return tomllib.load(f) diff --git a/lm_proxy/config_loaders/yaml.py b/lm_proxy/config_loaders/yaml.py new file mode 100644 index 0000000..b1a7195 --- /dev/null +++ b/lm_proxy/config_loaders/yaml.py @@ -0,0 +1,12 @@ +def load_yaml_config(config_path: str) -> dict: + try: + import yaml + except ImportError as e: + raise ImportError( + "Missing optional dependency 'PyYAML'. " + "For using YAML configuration files with LM-Proxy, " + "please install it with following command: 'pip install pyyaml'." + ) from e + + with open(config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) diff --git a/lm_proxy/core.py b/lm_proxy/core.py index dcd45ef..9590cd8 100644 --- a/lm_proxy/core.py +++ b/lm_proxy/core.py @@ -5,35 +5,19 @@ import secrets import time import hashlib -from typing import List, Optional +from datetime import datetime +from typing import Optional -import microcore as mc from fastapi import HTTPException -from lm_proxy.loggers import LogEntry -from pydantic import BaseModel +from lm_proxy.base_types import ChatCompletionRequest, RequestContext from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse from .bootstrap import env from .config import Config -from .loggers import log_non_blocking from .utils import get_client_ip -class ChatCompletionRequest(BaseModel): - model: str - messages: List[mc.Msg] - stream: Optional[bool] = None - max_tokens: Optional[int] = None - temperature: Optional[float] = None - top_p: Optional[float] = None - n: Optional[int] = None - stop: Optional[List[str]] = None - presence_penalty: Optional[float] = None - frequency_penalty: Optional[float] = None - user: Optional[str] = None - - def parse_routing_rule(rule: str, config: Config) -> tuple[str, str]: """ Parses a routing rule in the format 'connection.model' or 'connection.*'. @@ -79,7 +63,7 @@ def resolve_connection_and_model( async def process_stream( - async_llm_func, request: ChatCompletionRequest, llm_params, log_entry: LogEntry + async_llm_func, request: ChatCompletionRequest, llm_params, log_entry: RequestContext ): prompt = request.messages queue = asyncio.Queue() @@ -173,7 +157,7 @@ def api_key_id(api_key: Optional[str]) -> str | None: ).hexdigest() -async def check(request: Request) -> tuple[str, str]: +async def check(request: Request) -> tuple[str, str, dict]: """ API key and service availability check for endpoints. Args: @@ -196,7 +180,13 @@ async def check(request: Request) -> tuple[str, str]: }, ) api_key = read_api_key(request) - group: str | bool | None = (env.config.check_api_key)(api_key) + result = (env.config.api_key_check)(api_key) + if isinstance(result, tuple): + group, user_info = result + else: + group: str | bool | None = result + user_info = dict() + if not group: raise HTTPException( status_code=403, @@ -210,7 +200,7 @@ async def check(request: Request) -> tuple[str, str]: } }, ) - return group, api_key + return group, api_key, user_info async def chat_completions( @@ -220,17 +210,19 @@ async def chat_completions( Endpoint for chat completions that mimics OpenAI's API structure. Streams the response from the LLM using microcore. """ - group, api_key = await check(raw_request) + group, api_key, user_info = await check(raw_request) llm_params = request.model_dump(exclude={"messages"}, exclude_none=True) connection, llm_params["model"] = resolve_connection_and_model( env.config, llm_params.get("model", "default_model") ) - log_entry = LogEntry( + log_entry = RequestContext( request=request, api_key_id=api_key_id(api_key), group=group if isinstance(group, str) else None, remote_addr=get_client_ip(raw_request), connection=connection, + model=llm_params["model"], + user_info=user_info, ) logging.debug( "Resolved routing for [%s] --> connection: %s, model: %s", @@ -282,3 +274,26 @@ async def chat_completions( ] } ) + + +async def log(log_entry: RequestContext): + if log_entry.duration is None and log_entry.created_at: + log_entry.duration = (datetime.now() - log_entry.created_at).total_seconds() + for handler in env.loggers: + # check if it is async, then run both sync and async loggers in non-blocking way (sync too) + if asyncio.iscoroutinefunction(handler): + asyncio.create_task(handler(log_entry)) + else: + try: + handler(log_entry) + except Exception as e: + logging.error("Error in logger handler: %s", e) + raise e + + +async def log_non_blocking( + log_entry: RequestContext, +) -> Optional[asyncio.Task]: + if env.loggers: + task = asyncio.create_task(log(log_entry)) + return task diff --git a/lm_proxy/loggers.py b/lm_proxy/loggers.py new file mode 100644 index 0000000..b171223 --- /dev/null +++ b/lm_proxy/loggers.py @@ -0,0 +1,77 @@ +import abc +import json +import os +from dataclasses import dataclass, field +from typing import Union, Callable + +from .base_types import RequestContext +from .utils import CustomJsonEncoder, resolve_instance_or_callable, resolve_obj_path + + +class AbstractLogEntryTransformer(abc.ABC): + @abc.abstractmethod + def __call__(self, request_context: RequestContext) -> dict: + raise NotImplementedError() + + +class AbstractLogWriter(abc.ABC): + @abc.abstractmethod + def __call__(self, logged_data: dict) -> dict: + raise NotImplementedError() + + +class LogEntryTransformer(AbstractLogEntryTransformer): + def __init__(self, **kwargs): + self.mapping = kwargs + + def __call__(self, request_context: RequestContext) -> dict: + result = {} + for key, path in self.mapping.items(): + result[key] = resolve_obj_path(request_context, path) + return result + + +@dataclass +class BaseLogger: + log_writer: AbstractLogWriter | str | dict + entry_transformer: AbstractLogEntryTransformer | str | dict = field(default=None) + + def __post_init__(self): + self.entry_transformer = resolve_instance_or_callable( + self.entry_transformer, + debug_name="logging..entry_transformer", + ) + self.log_writer = resolve_instance_or_callable( + self.log_writer, + debug_name="logging..log_writer", + ) + + def _transform(self, request_context: RequestContext) -> dict: + return ( + self.entry_transformer(request_context) + if self.entry_transformer + else request_context.to_dict() + ) + + def __call__(self, request_context: RequestContext): + self.log_writer(self._transform(request_context)) + + +@dataclass +class JsonLogWriter(AbstractLogWriter): + file_name: str + + def __post_init__(self): + dir_path = os.path.dirname(self.file_name) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + # Create the file if it doesn't exist + with open(self.file_name, "a", encoding="utf-8"): + pass + + def __call__(self, logged_data: dict): + with open(self.file_name, "a", encoding="utf-8") as f: + f.write(json.dumps(logged_data, cls=CustomJsonEncoder) + "\n") + + +TLogger = Union[BaseLogger, Callable[[RequestContext], None]] diff --git a/lm_proxy/loggers/__init__.py b/lm_proxy/loggers/__init__.py deleted file mode 100644 index 3d8ab1c..0000000 --- a/lm_proxy/loggers/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .base_logger import BaseLogger, LogEntryTransformer -from .log_writers import JsonLogWriter -from .core import LogEntry, log_non_blocking - -__all__ = [ - "BaseLogger", - "LogEntryTransformer", - "JsonLogWriter", - "LogEntry", - "log_non_blocking", -] diff --git a/lm_proxy/loggers/base_logger.py b/lm_proxy/loggers/base_logger.py deleted file mode 100644 index 55241a0..0000000 --- a/lm_proxy/loggers/base_logger.py +++ /dev/null @@ -1,56 +0,0 @@ -import abc -from dataclasses import dataclass, field - -from lm_proxy.utils import resolve_instance_or_callable - -from ..utils import resolve_obj_path -from .core import LogEntry - - -class AbstractLogEntryTransformer(abc.ABC): - @abc.abstractmethod - def __call__(self, log_entry: LogEntry) -> dict: - raise NotImplementedError() - - -class LogEntryTransformer(AbstractLogEntryTransformer): - def __init__(self, **kwargs): - self.mapping = kwargs - - def __call__(self, log_entry: LogEntry) -> dict: - result = {} - for key, path in self.mapping.items(): - result[key] = resolve_obj_path(log_entry, path) - return result - - -class AbstractLogWriter(abc.ABC): - @abc.abstractmethod - def __call__(self, logged_data: dict) -> dict: - raise NotImplementedError() - - -@dataclass -class BaseLogger: - log_writer: AbstractLogWriter | str | dict - entry_transformer: AbstractLogEntryTransformer | str | dict = field(default=None) - - def __post_init__(self): - self.entry_transformer = resolve_instance_or_callable( - self.entry_transformer, - debug_name="logging..entry_transformer", - ) - self.log_writer = resolve_instance_or_callable( - self.log_writer, - debug_name="logging..log_writer", - ) - - def _transform(self, log_entry: LogEntry) -> dict: - return ( - self.entry_transformer(log_entry) - if self.entry_transformer - else log_entry.to_dict() - ) - - def __call__(self, log_entry: LogEntry): - self.log_writer(self._transform(log_entry)) diff --git a/lm_proxy/loggers/core.py b/lm_proxy/loggers/core.py deleted file mode 100644 index 95854fd..0000000 --- a/lm_proxy/loggers/core.py +++ /dev/null @@ -1,53 +0,0 @@ -import asyncio -import logging -from typing import Optional, TYPE_CHECKING -from dataclasses import dataclass, field -from datetime import datetime - -import microcore as mc -from ..bootstrap import env - -if TYPE_CHECKING: - from lm_proxy.core import ChatCompletionRequest, Group - - -@dataclass -class LogEntry: - request: "ChatCompletionRequest" = field() - response: Optional[mc.LLMResponse] = field(default=None) - error: Optional[Exception] = field(default=None) - group: "Group" = field(default=None) - connection: str = field(default=None) - api_key_id: Optional[str] = field(default=None) - remote_addr: Optional[str] = field(default=None) - created_at: Optional[datetime] = field(default_factory=datetime.now) - duration: Optional[float] = field(default=None) - - def to_dict(self) -> dict: - data = self.__dict__.copy() - if self.request: - data["request"] = self.request.model_dump(mode="json") - return data - - -async def log(log_entry: LogEntry): - if log_entry.duration is None and log_entry.created_at: - log_entry.duration = (datetime.now() - log_entry.created_at).total_seconds() - for handler in env.config.loggers: - # check if it is async, then run both sync and async loggers in non-blocking way (sync too) - if asyncio.iscoroutinefunction(handler): - asyncio.create_task(handler(log_entry)) - else: - try: - handler(log_entry) - except Exception as e: - logging.error("Error in logger handler: %s", e) - raise e - - -async def log_non_blocking( - log_entry: LogEntry, -) -> Optional[asyncio.Task]: - if env.config.loggers: - task = asyncio.create_task(log(log_entry)) - return task diff --git a/lm_proxy/loggers/log_writers.py b/lm_proxy/loggers/log_writers.py deleted file mode 100644 index 2b12df3..0000000 --- a/lm_proxy/loggers/log_writers.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -import json -from dataclasses import dataclass - -from .base_logger import AbstractLogWriter -from ..utils import CustomJsonEncoder - - -@dataclass -class JsonLogWriter(AbstractLogWriter): - - file_name: str - - def __post_init__(self): - dir_path = os.path.dirname(self.file_name) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - # Create the file if it doesn't exist - with open(self.file_name, "a", encoding="utf-8"): - pass - - def __call__(self, logged_data: dict): - with open(self.file_name, "a", encoding="utf-8") as f: - f.write(json.dumps(logged_data, cls=CustomJsonEncoder) + "\n") diff --git a/lm_proxy/models.py b/lm_proxy/models_endpoint.py similarity index 81% rename from lm_proxy/models.py rename to lm_proxy/models_endpoint.py index 27f6a3a..70272d5 100644 --- a/lm_proxy/models.py +++ b/lm_proxy/models_endpoint.py @@ -14,9 +14,9 @@ async def models(request: Request) -> JSONResponse: """ Lists available models based on routing rules and group permissions. """ - group_name, api_key = await check(request) + group_name, api_key, user_info = await check(request) group: Group = env.config.groups[group_name] - models = list() + models = [] for model_pattern, route in env.config.routing.items(): connection_name, _ = parse_routing_rule(route, env.config) if group.allows_connecting_to(connection_name): @@ -28,11 +28,10 @@ async def models(request: Request) -> JSONResponse: == ModelListingMode.IGNORE_WILDCARDS ): continue - else: - raise NotImplementedError( - f"'{env.config.model_listing_mode}' model listing mode " - f"is not implemented yet" - ) + raise NotImplementedError( + f"'{env.config.model_listing_mode}' model listing mode " + f"is not implemented yet" + ) models.append( dict( id=model_pattern, diff --git a/lm_proxy/utils.py b/lm_proxy/utils.py index cbbc7e1..eb41180 100644 --- a/lm_proxy/utils.py +++ b/lm_proxy/utils.py @@ -1,6 +1,9 @@ +"""Common usage utility functions.""" +import os import json import inspect -from typing import Union, Callable +import logging +from typing import Any, Callable, Union from datetime import datetime, date, time from microcore.utils import resolve_callable @@ -21,9 +24,12 @@ def resolve_obj_path(obj, path: str, default=None): def resolve_instance_or_callable( - item: Union[str, Callable, dict], class_key: str = "class", debug_name: str = None + item: Union[str, Callable, dict, object], + class_key: str = "class", + debug_name: str = None, + allow_types: list[type] = None, ) -> Callable: - if not item: + if item is None or item == "": return None if isinstance(item, dict): if class_key not in item: @@ -38,25 +44,27 @@ def resolve_instance_or_callable( return fn() if inspect.isclass(fn) else fn if callable(item): return item() if inspect.isclass(item) else item + if allow_types and any(isinstance(item, t) for t in allow_types): + return item else: raise ValueError(f"Invalid {debug_name or 'item'} config: {item}") class CustomJsonEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, datetime): - return obj.isoformat() - elif isinstance(obj, date): - return obj.isoformat() - elif isinstance(obj, time): - return obj.isoformat() - elif hasattr(obj, "__dict__"): - return obj.__dict__ - elif hasattr(obj, "model_dump"): - return obj.model_dump() - elif hasattr(obj, "dict"): - return obj.dict() - return super().default(obj) + def default(self, o): + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, date): + return o.isoformat() + if isinstance(o, time): + return o.isoformat() + if hasattr(o, "__dict__"): + return o.__dict__ + if hasattr(o, "model_dump"): + return o.model_dump() + if hasattr(o, "dict"): + return o.dict() + return super().default(o) def get_client_ip(request: Request) -> str: @@ -71,3 +79,22 @@ def get_client_ip(request: Request) -> str: # Fallback to direct client return request.client.host if request.client else "unknown" + + +def replace_env_strings_recursive(data: Any) -> Any: + """ + Recursively traverses dicts and lists, replacing all string values + that start with 'env:' with the corresponding environment variable. + For example, a string "env:VAR_NAME" will be replaced by the value of the + environment variable "VAR_NAME". + """ + if isinstance(data, dict): + return {k: replace_env_strings_recursive(v) for k, v in data.items()} + if isinstance(data, list): + return [replace_env_strings_recursive(i) for i in data] + if isinstance(data, str) and data.startswith("env:"): + env_var_name = data[4:] + if env_var_name not in os.environ: + logging.warning(f"Environment variable '{env_var_name}' not found") + return os.environ.get(env_var_name, "") + return data diff --git a/pyproject.toml b/pyproject.toml index 16e7b0f..28703f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lm-proxy" -version = "1.1.0" +version = "2.0.0" description = "\"LM-Proxy\" is OpenAI-compatible http proxy server for inferencing various LLMs capable of working with Google, Anthropic, OpenAI APIs, local PyTorch inference, etc." readme = "README.md" keywords = ["llm", "large language models", "ai", "gpt", "openai", "proxy", "http", "proxy-server"] @@ -18,6 +18,7 @@ dependencies = [ "fastapi~=0.116.1", "uvicorn>=0.22.0", "typer>=0.16.1", + "requests~=2.32.3", ] requires-python = ">=3.11,<4" @@ -33,6 +34,13 @@ license = { file = "LICENSE" } [project.urls] "Source Code" = "https://github.com/Nayjest/lm-proxy" +[project.entry-points."config.loaders"] +toml = "lm_proxy.config_loaders:load_toml_config" +py = "lm_proxy.config_loaders:load_python_config" +yml = "lm_proxy.config_loaders:load_yaml_config" +yaml = "lm_proxy.config_loaders:load_yaml_config" +json = "lm_proxy.config_loaders:load_json_config" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -53,4 +61,4 @@ lm-proxy = "lm_proxy.app:cli_app" asyncio_mode = "auto" testpaths = [ "tests", -] +] \ No newline at end of file diff --git a/tests/configs/__init__.py b/tests/configs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/configs/config_fn.py b/tests/configs/config_fn.py index fe9be9e..86812e3 100644 --- a/tests/configs/config_fn.py +++ b/tests/configs/config_fn.py @@ -8,7 +8,7 @@ from lm_proxy.config import Config, Group # noqa -def check_api_key(api_key: str) -> str | None: +def custom_api_key_check(api_key: str) -> str | None: return "default" if api_key == "py-test" else None @@ -20,7 +20,7 @@ def check_api_key(api_key: str) -> str | None: config = Config( port=8123, host="127.0.0.1", - check_api_key=check_api_key, + api_key_check=custom_api_key_check, connections={"py_oai": mc.env().llm_async_function}, routing={"*": "py_oai.gpt-3.5-turbo", "my-gpt": "py_oai.gpt-3.5-turbo"}, groups={"default": Group(connections="*")}, diff --git a/tests/configs/test_config.json b/tests/configs/test_config.json new file mode 100644 index 0000000..555c04e --- /dev/null +++ b/tests/configs/test_config.json @@ -0,0 +1,30 @@ +{ + "host": "127.0.0.1", + "port": 8787, + "connections": { + "test_openai": { + "api_type": "open_ai", + "api_base": "https://api.openai.com/v1/", + "api_key": "env:OPENAI_API_KEY" + }, + "test_google": { + "api_type": "google_ai_studio", + "api_key": "env:GOOGLE_API_KEY" + }, + "test_anthropic": { + "api_type": "anthropic", + "api_key": "env:ANTHROPIC_API_KEY" + } + }, + "routing": { + "gpt*": "test_openai.*", + "claude*": "test_anthropic.*", + "gemini*": "test_google.*", + "*": "test_openai.gpt-5" + }, + "groups": { + "default": { + "api_keys": [] + } + } +} \ No newline at end of file diff --git a/tests/configs/test_config.toml b/tests/configs/test_config.toml index 7efebe0..13591b3 100644 --- a/tests/configs/test_config.toml +++ b/tests/configs/test_config.toml @@ -1,6 +1,5 @@ host = "127.0.0.1" port = 8787 -check_api_key = "tests.conftest.check_api_function" [connections] [connections.test_openai] diff --git a/tests/configs/test_config.yml b/tests/configs/test_config.yml new file mode 100644 index 0000000..7c4bf38 --- /dev/null +++ b/tests/configs/test_config.yml @@ -0,0 +1,24 @@ +host: "127.0.0.1" +port: 8787 + +connections: + test_openai: + api_type: "open_ai" + api_base: "https://api.openai.com/v1/" + api_key: "env:OPENAI_API_KEY" + test_google: + api_type: "google_ai_studio" + api_key: "env:GOOGLE_API_KEY" + test_anthropic: + api_type: "anthropic" + api_key: "env:ANTHROPIC_API_KEY" + +routing: + "gpt*": "test_openai.*" + "claude*": "test_anthropic.*" + "gemini*": "test_google.*" + "*": "test_openai.gpt-5" + +groups: + default: + api_keys: [] diff --git a/tests/test_config_loaders.py b/tests/test_config_loaders.py new file mode 100644 index 0000000..743b482 --- /dev/null +++ b/tests/test_config_loaders.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path + +import dotenv +import pytest + +from lm_proxy.config import Config + + +def test_config_loaders(): + root = Path(__file__).resolve().parent + dotenv.load_dotenv(root.parent / ".env.template", override=True) + oai_key = os.getenv("OPENAI_API_KEY") + toml = Config.load(root / "configs" / "test_config.toml") + json = Config.load(root / "configs" / "test_config.json") + yaml = Config.load(root / "configs" / "test_config.yml") + + assert json.model_dump() == yaml.model_dump() == toml.model_dump() + assert json.connections["test_openai"]["api_key"] == oai_key + + py = Config.load(root / "configs" / "config_fn.py") + + # Expect an error for unsupported format + with pytest.raises(ValueError): + Config.load(root / "configs" / "test_config.xyz") diff --git a/tests/test_integration.py b/tests/test_integration.py index df81c65..de717e7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,17 +3,17 @@ from tests.conftest import ServerFixture -def configure_mc(cfg: ServerFixture): +def configure_mc_to_use_local_proxy(cfg: ServerFixture): mc.configure( LLM_API_TYPE="openai", LLM_API_BASE=f"http://127.0.0.1:{cfg.port}/v1", # Test server port LLM_API_KEY=cfg.api_key, # Not used but required - MODEL=cfg.model, # Will be routed according to test_config.toml + MODEL=cfg.model, ) def test_france_capital_query(server_config_fn: ServerFixture): - configure_mc(server_config_fn) + configure_mc_to_use_local_proxy(server_config_fn) response = mc.llm("What is the capital of France?\n (!) Respond with 1 word.") assert ( "paris" in response.lower().strip() @@ -51,7 +51,7 @@ def test_direct_api_call(server_config_fn: ServerFixture): def test_streaming_response(server_config_fn: ServerFixture): - configure_mc(server_config_fn) + configure_mc_to_use_local_proxy(server_config_fn) collected_text = [] mc.llm( "Count from 1 to 5, each number as english word (one, two, ...) on a new line", diff --git a/tests/test_loggers.py b/tests/test_loggers.py index a95c3ce..32945a9 100644 --- a/tests/test_loggers.py +++ b/tests/test_loggers.py @@ -2,9 +2,8 @@ import microcore as mc -from lm_proxy.core import ChatCompletionRequest -from lm_proxy.loggers import LogEntry -from lm_proxy.loggers.core import log_non_blocking +from lm_proxy.core import log_non_blocking +from lm_proxy.base_types import ChatCompletionRequest, RequestContext from lm_proxy.config import Config from lm_proxy.bootstrap import bootstrap from lm_proxy.utils import CustomJsonEncoder @@ -31,7 +30,7 @@ async def test_custom_config(): messages=[{"role": "user", "content": "Test request message"}], ) response = mc.LLMResponse("Test response message", dict(prompt=request.messages)) - task = await log_non_blocking(LogEntry(request=request, response=response)) + task = await log_non_blocking(RequestContext(request=request, response=response)) if task: await task assert len(logs) == 1 @@ -60,10 +59,10 @@ async def test_json(tmp_path): messages=[{"role": "user", "content": "Test request message"}], ) response = mc.LLMResponse("Test response message", dict(prompt=request.messages)) - task = await log_non_blocking(LogEntry(request=request, response=response)) + task = await log_non_blocking(RequestContext(request=request, response=response)) if task: await task - task = await log_non_blocking(LogEntry(request=request, response=response)) + task = await log_non_blocking(RequestContext(request=request, response=response)) if task: await task with open(tmp_path / "json_log.log", "r") as f: diff --git a/tests/test_models.py b/tests/test_models_endpoint.py similarity index 93% rename from tests/test_models.py rename to tests/test_models_endpoint.py index a80a407..41eb617 100644 --- a/tests/test_models.py +++ b/tests/test_models_endpoint.py @@ -3,9 +3,8 @@ from starlette.requests import Request from lm_proxy.config import Config, ModelListingMode -from lm_proxy.bootstrap import bootstrap -from lm_proxy.models import models -from lm_proxy.bootstrap import env +from lm_proxy.bootstrap import bootstrap, env +from lm_proxy.models_endpoint import models async def test_models_endpoint(): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7ae730b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,58 @@ +import os +import logging + +import pytest +from lm_proxy.utils import resolve_instance_or_callable, replace_env_strings_recursive + + +def test_resolve_instance_or_callable(): + assert resolve_instance_or_callable(None) is None + + obj1, obj2 = object(), object() + ins = resolve_instance_or_callable(obj1, allow_types=[object]) + assert ins is obj1 and ins is not obj2 + + with pytest.raises(ValueError): + resolve_instance_or_callable(123) + + with pytest.raises(ValueError): + resolve_instance_or_callable([]) + + with pytest.raises(ValueError): + resolve_instance_or_callable({}) + + assert resolve_instance_or_callable(lambda: 42)() == 42 + + class MyClass: + def __init__(self, value=0): + self.value = value + + res = resolve_instance_or_callable(lambda: MyClass(10), allow_types=[MyClass]) + assert not isinstance(res, MyClass) and res().value == 10 + + ins = resolve_instance_or_callable(MyClass(20), allow_types=[MyClass]) + assert isinstance(ins, MyClass) and ins.value == 20 + assert resolve_instance_or_callable( + "lm_proxy.utils.resolve_instance_or_callable" + ) is resolve_instance_or_callable + + ins = resolve_instance_or_callable({ + 'class': 'lm_proxy.loggers.JsonLogWriter', + 'file_name': 'test.log' + }) + assert ins.__class__.__name__ == 'JsonLogWriter' and ins.file_name == 'test.log' + + +def test_replace_env_strings_recursive(caplog): + os.environ['TEST_VAR1'] = 'env_value1' + os.environ['TEST_VAR2'] = 'env_value2' + assert replace_env_strings_recursive("env:TEST_VAR1") == 'env_value1' + + caplog.set_level(logging.WARNING) + assert replace_env_strings_recursive("env:NON_EXIST") == '' + assert len(caplog.records) == 1 + + assert replace_env_strings_recursive([["env:TEST_VAR1"]]) == [['env_value1']] + assert replace_env_strings_recursive( + {"data": {"field": "env:TEST_VAR1"}} + ) == {"data": {"field": "env_value1"}} From 2017e8517343a3668ad10806ea091b98b2f9305d Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sat, 25 Oct 2025 00:12:59 +0200 Subject: [PATCH 05/22] cs fixes --- lm_proxy/api_key_check/with_request.py | 1 + tests/test_config_loaders.py | 1 + 2 files changed, 2 insertions(+) diff --git a/lm_proxy/api_key_check/with_request.py b/lm_proxy/api_key_check/with_request.py index 12dd666..a458ca1 100644 --- a/lm_proxy/api_key_check/with_request.py +++ b/lm_proxy/api_key_check/with_request.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field import requests + @dataclass(slots=True) class CheckAPIKeyWithRequest: url: str = field() diff --git a/tests/test_config_loaders.py b/tests/test_config_loaders.py index 743b482..cf4e88f 100644 --- a/tests/test_config_loaders.py +++ b/tests/test_config_loaders.py @@ -19,6 +19,7 @@ def test_config_loaders(): assert json.connections["test_openai"]["api_key"] == oai_key py = Config.load(root / "configs" / "config_fn.py") + assert isinstance(py, Config) # Expect an error for unsupported format with pytest.raises(ValueError): From 8e5eca89f3658189b3902bf88bfc2cdfa6a91dd1 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sat, 25 Oct 2025 00:13:39 +0200 Subject: [PATCH 06/22] upd. poetry.lock --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index b3c4b28..edb7ba5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2007,4 +2007,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.11,<4" -content-hash = "20eaf54bdd65e34c48db3cdd92557f8bdf0f51f1e77cdd80ea2b689636b08f7f" +content-hash = "384cc8da11306adeca8a6ec7e6800fa2ad4c47196e0d50cd9dc523e29d3ab7f1" From b17e0108a09dc1fca711aabffb42a38835e8a622 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sat, 25 Oct 2025 00:15:01 +0200 Subject: [PATCH 07/22] .gitignore fix --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b8f267c..1ed6bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ venv storage dist docs -.aico +.aico/* !.aico/project.json .coverage coverage.xml \ No newline at end of file From d0d61f77fb334416c8f12184b60f72b9fb801661 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sat, 25 Oct 2025 00:16:32 +0200 Subject: [PATCH 08/22] fix README typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51edffe..196020b 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ To enable debugging, set the `LM_PROXY_DEBUG` environment variable to a truthy v > **Tip** 💡 >Environment variables can also be defined in a `.env` file. -Alternatively, you can enable or disable debugging via the command line-arguments: +Alternatively, you can enable or disable debugging via the command-line arguments: - `--debug` to enable debugging - `--no-debug` to disable debugging From 3d0925a9117455b3de8e5086593e3fb28fda5bd6 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 12:50:13 +0100 Subject: [PATCH 09/22] fix python 3.11 compatibility --- lm_proxy/bootstrap.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lm_proxy/bootstrap.py b/lm_proxy/bootstrap.py index e46bca1..a91bc9b 100644 --- a/lm_proxy/bootstrap.py +++ b/lm_proxy/bootstrap.py @@ -89,17 +89,19 @@ def init(config: Config | str, debug: bool = False): def bootstrap(config: str | Config = "config.toml", env_file: str = ".env", debug=None): + """Bootstraps the LM-Proxy environment.""" + def log_bootstrap(): + cfg_val = 'dynamic' if isinstance(config, Config) else ui.blue(config) + cfg_line = f"\n - Config{ui.gray('......')}[ {cfg_val} ]" + env_line = f"\n - Env. File{ui.gray('...')}[ {ui.blue(env_file)} ]" if env_file else "" + dbg_line = f"\n - Debug{ui.gray('.......')}[ {ui.yellow('On')} ]" if debug else "" + logging.info(f"Bootstrapping {ui.magenta('LM-Proxy')}...{cfg_line}{env_line}{dbg_line}") + if env_file: load_dotenv(env_file, override=True) if debug is None: debug = "--debug" in sys.argv or get_bool_from_env("LM_PROXY_DEBUG", False) setup_logging(logging.DEBUG if debug else logging.INFO) mc.logging.LoggingConfig.OUTPUT_METHOD = logging.info - logging.info( - f"Bootstrapping {ui.magenta('LM-Proxy')}..." - f"\n - Config{ui.gray('......')}" - f"[ {'dynamic' if isinstance(config, Config) else ui.blue(config)} ]" - f"{'\n - Env. File' + ui.gray('...') + '[ ' + ui.blue(env_file)+' ]' if env_file else ''}" - f"{'\n - Debug' + ui.gray('.......') + '[ ' + ui.yellow('On')+' ]' if debug else ''}" - ) + log_bootstrap() Env.init(config, debug=debug) From 740e8d7f3913c5a1f377b48f09142c2bf7f74641 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 12:50:39 +0100 Subject: [PATCH 10/22] fix lm-proxy configuration example --- config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.toml b/config.toml index c72c4de..39d7c84 100644 --- a/config.toml +++ b/config.toml @@ -5,7 +5,7 @@ port=8000 # dev_autoreload=true # Validates a Client API key against configured groups and returns the matching group. -check_api_key="lm_proxy.core.check_api_key" +api_key_check="lm_proxy.api_key_check.check_api_key_in_config" model_listing_mode = "as_is" From 7eb824fabf84cf8c4d678ef521fa023d37202bde Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 26 Oct 2025 11:51:05 +0000 Subject: [PATCH 11/22] Update coverage badge [skip ci] --- coverage.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coverage.svg b/coverage.svg index eb83f3f..9f708fe 100644 --- a/coverage.svg +++ b/coverage.svg @@ -9,13 +9,13 @@ - + coverage coverage - 59% - 59% + 60% + 60% From 24894bcc51d191c7ab0152ee35abe5846031dcce Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:08:56 +0100 Subject: [PATCH 12/22] minor fixes --- .gitignore | 2 +- README.md | 9 +++++---- lm_proxy/api_key_check/with_request.py | 8 +++++--- lm_proxy/config.py | 11 +++++++++-- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1ed6bd2..3cccf15 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ docs .aico/* !.aico/project.json .coverage -coverage.xml \ No newline at end of file +coverage.xml diff --git a/README.md b/README.md index 196020b..6b09eec 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ api_key = "env:OPENAI_API_KEY" At runtime, LM-Proxy automatically retrieves the value of the target variable (OPENAI_API_KEY) from your operating system’s environment or from a .env file, if present. -### .env Files +### .env Files By default, LM-Proxy looks for a `.env` file in the current working directory and loads environment variables from it. @@ -470,14 +470,15 @@ distributes requests across multiple language model servers using the lm_proxy. When **debugging mode** is enabled, LM-Proxy provides detailed logging information to help diagnose issues: - Stack traces for exceptions are shown in the console -- logging level is set to DEBUG instead of INFO +- Logging level is set to DEBUG instead of INFO > **Warning** ⚠️ ->Never enable debugging mode in production environments, as it may expose sensitive information to the application logs. +> Never enable debugging mode in production environments, as it may expose sensitive information to the application logs. + ### Enabling Debugging Mode To enable debugging, set the `LM_PROXY_DEBUG` environment variable to a truthy value (e.g., "1", "true", "yes"). > **Tip** 💡 ->Environment variables can also be defined in a `.env` file. +> Environment variables can also be defined in a `.env` file. Alternatively, you can enable or disable debugging via the command-line arguments: - `--debug` to enable debugging diff --git a/lm_proxy/api_key_check/with_request.py b/lm_proxy/api_key_check/with_request.py index a458ca1..a66310b 100644 --- a/lm_proxy/api_key_check/with_request.py +++ b/lm_proxy/api_key_check/with_request.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field import requests +from ..config import TApiKeyCheckFunc + @dataclass(slots=True) class CheckAPIKeyWithRequest: @@ -16,10 +18,10 @@ class CheckAPIKeyWithRequest: cache_size: int = field(default=1024 * 16) cache_ttl: int = field(default=60 * 5) # 5 minutes timeout: int = field(default=5) # seconds - _func: callable = field(init=False, repr=False) + _func: TApiKeyCheckFunc = field(init=False, repr=False) def __post_init__(self): - def check_func(api_key: str) -> dict | None: + def check_func(api_key: str) -> Optional[tuple[str, dict]]: try: url = self.url.replace(self.key_placeholder, api_key) headers = { @@ -58,5 +60,5 @@ def check_func(api_key: str) -> dict | None: else: self._func = check_func - def __call__(self, api_key: str) -> dict | None: + def __call__(self, api_key: str) -> Optional[tuple[str, dict]]: return self._func(api_key) diff --git a/lm_proxy/config.py b/lm_proxy/config.py index e2ecd8f..9ed4577 100644 --- a/lm_proxy/config.py +++ b/lm_proxy/config.py @@ -5,7 +5,7 @@ import os from enum import StrEnum -from typing import Union, Callable, Dict +from typing import Union, Callable, Dict, Optional, Union from importlib.metadata import entry_points from pydantic import BaseModel, Field, ConfigDict @@ -40,6 +40,10 @@ def allows_connecting_to(self, connection_name: str) -> bool: return connection_name in allowed +TApiKeyCheckResult = Optional[Union[str, tuple[str, dict]]] +TApiKeyCheckFunc = Callable[[str | None], TApiKeyCheckResult] + + class Config(BaseModel): """Main configuration model matching config.toml structure.""" @@ -59,7 +63,10 @@ class Config(BaseModel): routing: dict[str, str] = Field(default_factory=dict) """ model_name_pattern* => connection_name.< model | * >, example: {"gpt-*": "oai.*"} """ groups: dict[str, Group] = Field(default_factory=lambda: {"default": Group()}) - api_key_check: Union[str, Callable, dict] = Field(default="lm_proxy.core.check_api_key") + api_key_check: Union[str, TApiKeyCheckFunc, dict] = Field( + default="lm_proxy.api_key_check.check_api_key_in_config", + description="Function to check Virtual API keys", + ) loggers: list[Union[str, dict, TLogger]] = Field(default_factory=list) encryption_key: str = Field( default="Eclipse", From 9d616b008ca18924f0aa919294d87bfb1b395e93 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 26 Oct 2025 12:09:53 +0000 Subject: [PATCH 13/22] Update coverage badge [skip ci] --- coverage.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coverage.svg b/coverage.svg index 9f708fe..d79e240 100644 --- a/coverage.svg +++ b/coverage.svg @@ -15,7 +15,7 @@ coverage coverage - 60% - 60% + 65% + 65% From 33139a5829fd169333313516c2334d2abad89045 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:12:12 +0100 Subject: [PATCH 14/22] minor fixes --- lm_proxy/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lm_proxy/config.py b/lm_proxy/config.py index 9ed4577..aae67aa 100644 --- a/lm_proxy/config.py +++ b/lm_proxy/config.py @@ -5,7 +5,7 @@ import os from enum import StrEnum -from typing import Union, Callable, Dict, Optional, Union +from typing import Union, Callable, Dict, Optional from importlib.metadata import entry_points from pydantic import BaseModel, Field, ConfigDict From 76380e99ca34c906680eedd44a145147317711e7 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:37:16 +0100 Subject: [PATCH 15/22] add inline doc. --- lm_proxy/base_types.py | 1 + lm_proxy/config_loaders/json.py | 1 + lm_proxy/config_loaders/python.py | 1 + lm_proxy/config_loaders/toml.py | 1 + lm_proxy/config_loaders/yaml.py | 3 +++ lm_proxy/loggers.py | 6 ++++++ 6 files changed, 13 insertions(+) diff --git a/lm_proxy/base_types.py b/lm_proxy/base_types.py index 5ae4a4d..eef477a 100644 --- a/lm_proxy/base_types.py +++ b/lm_proxy/base_types.py @@ -48,6 +48,7 @@ class RequestContext: extra: dict = field(default_factory=dict) def to_dict(self) -> dict: + """Export to serializeable dictionary.""" data = self.__dict__.copy() if self.request: data["request"] = self.request.model_dump(mode="json") diff --git a/lm_proxy/config_loaders/json.py b/lm_proxy/config_loaders/json.py index f167c9e..6505a23 100644 --- a/lm_proxy/config_loaders/json.py +++ b/lm_proxy/config_loaders/json.py @@ -1,3 +1,4 @@ +"""JSON configuration loader.""" import json diff --git a/lm_proxy/config_loaders/python.py b/lm_proxy/config_loaders/python.py index 6cae3a7..5f89597 100644 --- a/lm_proxy/config_loaders/python.py +++ b/lm_proxy/config_loaders/python.py @@ -1,3 +1,4 @@ +"""Loader for Python configuration files.""" import importlib.util from ..config import Config diff --git a/lm_proxy/config_loaders/toml.py b/lm_proxy/config_loaders/toml.py index 864a635..323b3b5 100644 --- a/lm_proxy/config_loaders/toml.py +++ b/lm_proxy/config_loaders/toml.py @@ -1,3 +1,4 @@ +"""TOML configuration loader.""" import tomllib diff --git a/lm_proxy/config_loaders/yaml.py b/lm_proxy/config_loaders/yaml.py index b1a7195..cc630df 100644 --- a/lm_proxy/config_loaders/yaml.py +++ b/lm_proxy/config_loaders/yaml.py @@ -1,3 +1,6 @@ +"""YAML configuration loader.""" + + def load_yaml_config(config_path: str) -> dict: try: import yaml diff --git a/lm_proxy/loggers.py b/lm_proxy/loggers.py index b171223..9dec5d1 100644 --- a/lm_proxy/loggers.py +++ b/lm_proxy/loggers.py @@ -1,3 +1,4 @@ +"""LLM Request logging.""" import abc import json import os @@ -9,18 +10,21 @@ class AbstractLogEntryTransformer(abc.ABC): + """Transform RequestContext into a dictionary of logged attributes.""" @abc.abstractmethod def __call__(self, request_context: RequestContext) -> dict: raise NotImplementedError() class AbstractLogWriter(abc.ABC): + """Writes the logged data to a destination.""" @abc.abstractmethod def __call__(self, logged_data: dict) -> dict: raise NotImplementedError() class LogEntryTransformer(AbstractLogEntryTransformer): + """Transforms RequestContext into a dictionary of logged attributes""" def __init__(self, **kwargs): self.mapping = kwargs @@ -33,6 +37,7 @@ def __call__(self, request_context: RequestContext) -> dict: @dataclass class BaseLogger: + """Base LLM request logger.""" log_writer: AbstractLogWriter | str | dict entry_transformer: AbstractLogEntryTransformer | str | dict = field(default=None) @@ -59,6 +64,7 @@ def __call__(self, request_context: RequestContext): @dataclass class JsonLogWriter(AbstractLogWriter): + """Writes logged data to a JSON file.""" file_name: str def __post_init__(self): From 8973251aba190922c3320aa0b5c1a7e9884819cc Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:42:56 +0100 Subject: [PATCH 16/22] minor fixes --- lm_proxy/config_loaders/yaml.py | 2 +- lm_proxy/loggers.py | 2 +- lm_proxy/utils.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lm_proxy/config_loaders/yaml.py b/lm_proxy/config_loaders/yaml.py index cc630df..825ad26 100644 --- a/lm_proxy/config_loaders/yaml.py +++ b/lm_proxy/config_loaders/yaml.py @@ -8,7 +8,7 @@ def load_yaml_config(config_path: str) -> dict: raise ImportError( "Missing optional dependency 'PyYAML'. " "For using YAML configuration files with LM-Proxy, " - "please install it with following command: 'pip install pyyaml'." + "please install it with the following command: 'pip install pyyaml'." ) from e with open(config_path, "r", encoding="utf-8") as f: diff --git a/lm_proxy/loggers.py b/lm_proxy/loggers.py index 9dec5d1..7bb25cf 100644 --- a/lm_proxy/loggers.py +++ b/lm_proxy/loggers.py @@ -19,7 +19,7 @@ def __call__(self, request_context: RequestContext) -> dict: class AbstractLogWriter(abc.ABC): """Writes the logged data to a destination.""" @abc.abstractmethod - def __call__(self, logged_data: dict) -> dict: + def __call__(self, logged_data: dict): raise NotImplementedError() diff --git a/lm_proxy/utils.py b/lm_proxy/utils.py index eb41180..7b1e29e 100644 --- a/lm_proxy/utils.py +++ b/lm_proxy/utils.py @@ -36,9 +36,10 @@ def resolve_instance_or_callable( raise ValueError( f"'{class_key}' key is missing in {debug_name or 'item'} config: {item}" ) - class_name = item.pop(class_key) + args = dict(item) + class_name = args.pop(class_key) constructor = resolve_callable(class_name) - return constructor(**item) + return constructor(**args) if isinstance(item, str): fn = resolve_callable(item) return fn() if inspect.isclass(fn) else fn From 0ea2eb7cb4d5c736f6dbb361cc0bb4187440132f Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:49:43 +0100 Subject: [PATCH 17/22] cs fixes --- lm_proxy/api_key_check/with_request.py | 2 +- lm_proxy/config_loaders/json.py | 1 + lm_proxy/config_loaders/python.py | 1 + lm_proxy/config_loaders/toml.py | 1 + lm_proxy/config_loaders/yaml.py | 3 ++- lm_proxy/core.py | 3 ++- 6 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lm_proxy/api_key_check/with_request.py b/lm_proxy/api_key_check/with_request.py index a66310b..260f889 100644 --- a/lm_proxy/api_key_check/with_request.py +++ b/lm_proxy/api_key_check/with_request.py @@ -47,7 +47,7 @@ def check_func(api_key: str) -> Optional[tuple[str, dict]]: if self.use_cache: try: - import cachetools + import cachetools # pylint: disable=import-outside-toplevel except ImportError as e: raise ImportError( "Missing optional dependency 'cachetools'. " diff --git a/lm_proxy/config_loaders/json.py b/lm_proxy/config_loaders/json.py index 6505a23..70b27b6 100644 --- a/lm_proxy/config_loaders/json.py +++ b/lm_proxy/config_loaders/json.py @@ -3,5 +3,6 @@ def load_json_config(config_path: str) -> dict: + """Loads configuration from a JSON file.""" with open(config_path, "r", encoding="utf-8") as f: return json.load(f) diff --git a/lm_proxy/config_loaders/python.py b/lm_proxy/config_loaders/python.py index 5f89597..3a92c0c 100644 --- a/lm_proxy/config_loaders/python.py +++ b/lm_proxy/config_loaders/python.py @@ -4,6 +4,7 @@ def load_python_config(config_path: str) -> Config: + """Load configuration from a Python file.""" spec = importlib.util.spec_from_file_location("config_module", config_path) config_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(config_module) diff --git a/lm_proxy/config_loaders/toml.py b/lm_proxy/config_loaders/toml.py index 323b3b5..d9202d4 100644 --- a/lm_proxy/config_loaders/toml.py +++ b/lm_proxy/config_loaders/toml.py @@ -3,5 +3,6 @@ def load_toml_config(config_path: str) -> dict: + """Loads configuration from a TOML file.""" with open(config_path, "rb") as f: return tomllib.load(f) diff --git a/lm_proxy/config_loaders/yaml.py b/lm_proxy/config_loaders/yaml.py index 825ad26..4473b97 100644 --- a/lm_proxy/config_loaders/yaml.py +++ b/lm_proxy/config_loaders/yaml.py @@ -2,8 +2,9 @@ def load_yaml_config(config_path: str) -> dict: + """Loads a YAML configuration file and returns its contents as a dictionary.""" try: - import yaml + import yaml # pylint: disable=import-outside-toplevel except ImportError as e: raise ImportError( "Missing optional dependency 'PyYAML'. " diff --git a/lm_proxy/core.py b/lm_proxy/core.py index 9590cd8..6ac5fc9 100644 --- a/lm_proxy/core.py +++ b/lm_proxy/core.py @@ -1,3 +1,4 @@ +"""Core LM-Proxy logic""" import asyncio import fnmatch import json @@ -9,10 +10,10 @@ from typing import Optional from fastapi import HTTPException -from lm_proxy.base_types import ChatCompletionRequest, RequestContext from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse +from .base_types import ChatCompletionRequest, RequestContext from .bootstrap import env from .config import Config from .utils import get_client_ip From edd410cad0c4e2c128f038f11d82dd210cc36cb8 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:52:00 +0100 Subject: [PATCH 18/22] cs fixes --- lm_proxy/base_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lm_proxy/base_types.py b/lm_proxy/base_types.py index eef477a..4a79745 100644 --- a/lm_proxy/base_types.py +++ b/lm_proxy/base_types.py @@ -48,7 +48,7 @@ class RequestContext: extra: dict = field(default_factory=dict) def to_dict(self) -> dict: - """Export to serializeable dictionary.""" + """Export as dictionary.""" data = self.__dict__.copy() if self.request: data["request"] = self.request.model_dump(mode="json") From ae4b11ab898f45ac6a82cd41d76d8db76c452e4a Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 13:53:43 +0100 Subject: [PATCH 19/22] minor fix --- tests/test_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index de717e7..1740167 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -33,6 +33,7 @@ def test_direct_api_call(server_config_fn: ServerFixture): "Content-Type": "application/json", "authorization": f"bearer {cfg.api_key}", }, + timeout=120, ) assert ( From 3718374cc0507131b5c551dd79ce55c84c09eca1 Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 14:01:58 +0100 Subject: [PATCH 20/22] minor fix --- config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.toml b/config.toml index 39d7c84..e9707dc 100644 --- a/config.toml +++ b/config.toml @@ -1,4 +1,4 @@ -# This is a lm-proxy configuration example +# This is an lm-proxy configuration example host="0.0.0.0" port=8000 From 34fe6ef1549e69ac86c6aefb9df86d636805ddfb Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 18:27:37 +0100 Subject: [PATCH 21/22] Readme fixes --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b09eec..9c3abfa 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,37 @@ This allows fine-grained control over which users can access which AI providers, - Implementing usage quotas per group - Billing and cost allocation by user group -### Custom API Key Validation +### Virtual API Key Validation + +#### Overview + +LM-proxy includes 2 built-in methods for validating Virtual API keys: + - `lm_proxy.api_key_check.check_api_key_in_config` - verifies API keys against those defined in the config file; used by default + - `lm_proxy.api_key_check.CheckAPIKeyWithRequest` - validates API keys via an external HTTP service + +The API key check method can be configured using the `api_key_check` configuration key. +Its value can be either a reference to a Python function in the format `my_module.sub_module1.sub_module2.fn_name`, +or a object containing parameters for a class-based validator. + +In the .py config representation, the validator function can be passed directly as a callable. + +#### Example configuration for external API key validation using HTTP request to Keycloak / OpenID Connect + +This example shows how to validate API keys against an external service (e.g., Keycloak): + +```toml +[api_key_check] +class = "lm_proxy.api_key_check.CheckAPIKeyWithRequest" +method = "POST" +url = "http://keycloak:8080/realms/master/protocol/openid-connect/userinfo" +response_as_user_info = true # interpret response JSON as user info object for further processing / logging +use_cache = true # requires installing cachetools if True: pip install cachetools +cache_ttl = 60 # Cahe duration in seconds + +[api_key_check.headers] +Authorization = "Bearer {api_key}" +``` +#### Custom API Key Validation / Extending functionality For more advanced authentication needs, you can implement a custom validator function: @@ -438,7 +468,7 @@ def validate_api_key(api_key: str) -> str | None: Then reference it in your config: ```toml -check_api_key = "my_validators.validate_api_key" +api_key_check = "my_validators.validate_api_key" ``` > **NOTE** > In this case, the `api_keys` lists in groups are ignored, and the custom function is responsible for all validation logic. @@ -458,6 +488,9 @@ The routing section allows flexible pattern matching with wildcards: "custom*" = "local.llama-7b" # Map any "custom*" to a specific local model "*" = "openai.gpt-3.5-turbo" # Default fallback for unmatched models ``` +Keys are model name patterns (with `*` wildcard support), and values are connection/model mappings. +Connection names reference those defined in the `[connections]` section. + ### Load Balancing Example - [Simple load-balancer configuration](https://github.com/Nayjest/lm-proxy/blob/main/examples/load_balancer_config.py) From 56540ba464bd366e83ab1d639afed9964e7d4f7a Mon Sep 17 00:00:00 2001 From: Nayjest Date: Sun, 26 Oct 2025 18:35:24 +0100 Subject: [PATCH 22/22] Readme fixes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9c3abfa..2263fbf 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ It works as a drop-in replacement for OpenAI's API, allowing you to switch betwe - [Basic Group Definition](#basic-group-definition) - [Group-based Access Control](#group-based-access-control) - [Connection Restrictions](#connection-restrictions) - - [Custom API Key Validation](#custom-api-key-validation) + - [Virtual API Key Validation](#virtual-api-key-validation) - [Advanced Usage](#%EF%B8%8F-advanced-usage) - [Dynamic Model Routing](#dynamic-model-routing) - [Load Balancing Example](#load-balancing-example) @@ -139,7 +139,7 @@ port = 8000 # Port to listen on dev_autoreload = false # Enable for development # API key validation function (optional) -check_api_key = "lm_proxy.core.check_api_key" +api_key_check = "lm_proxy.api_key_check.check_api_key_in_config" # LLM Provider Connections [connections] @@ -421,7 +421,7 @@ LM-proxy includes 2 built-in methods for validating Virtual API keys: The API key check method can be configured using the `api_key_check` configuration key. Its value can be either a reference to a Python function in the format `my_module.sub_module1.sub_module2.fn_name`, -or a object containing parameters for a class-based validator. +or an object containing parameters for a class-based validator. In the .py config representation, the validator function can be passed directly as a callable. @@ -436,7 +436,7 @@ method = "POST" url = "http://keycloak:8080/realms/master/protocol/openid-connect/userinfo" response_as_user_info = true # interpret response JSON as user info object for further processing / logging use_cache = true # requires installing cachetools if True: pip install cachetools -cache_ttl = 60 # Cahe duration in seconds +cache_ttl = 60 # Cache duration in seconds [api_key_check.headers] Authorization = "Bearer {api_key}"