llmock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmock/__init__.py +5 -0
- llmock/chaos.py +187 -0
- llmock/cli.py +275 -0
- llmock/errors.py +91 -0
- llmock/main.py +53 -0
- llmock/routers/__init__.py +0 -0
- llmock/routers/ai21.py +219 -0
- llmock/routers/anthropic.py +212 -0
- llmock/routers/base.py +45 -0
- llmock/routers/batch.py +678 -0
- llmock/routers/cohere.py +218 -0
- llmock/routers/gemini.py +208 -0
- llmock/routers/groq.py +254 -0
- llmock/routers/mistral.py +250 -0
- llmock/routers/openai.py +313 -0
- llmock/routers/perplexity.py +293 -0
- llmock/routers/registry.py +36 -0
- llmock/routers/together.py +245 -0
- llmock/routers/xai.py +254 -0
- llmock/simulation.py +355 -0
- llmock-0.1.0.dist-info/METADATA +378 -0
- llmock-0.1.0.dist-info/RECORD +25 -0
- llmock-0.1.0.dist-info/WHEEL +4 -0
- llmock-0.1.0.dist-info/entry_points.txt +2 -0
- llmock-0.1.0.dist-info/licenses/LICENSE +21 -0
llmock/__init__.py
ADDED
llmock/chaos.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Chaos Engineering middleware for LLMock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
11
|
+
|
|
12
|
+
from llmock.simulation import (
|
|
13
|
+
ERROR_RATE_ENV_PREFIX,
|
|
14
|
+
SUPPORTED_ERROR_STATUS_CODES,
|
|
15
|
+
build_error_response,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChaosSettings:
|
|
20
|
+
"""Runtime settings for latency and injected HTTP errors."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
latency_ms: int = 0,
|
|
26
|
+
error_rates: dict[int, float] | None = None,
|
|
27
|
+
error_rate_429: float = 0.0,
|
|
28
|
+
error_rate_500: float = 0.0,
|
|
29
|
+
error_rate_503: float = 0.0,
|
|
30
|
+
**named_error_rates: float,
|
|
31
|
+
) -> None:
|
|
32
|
+
object.__setattr__(self, "latency_ms", latency_ms)
|
|
33
|
+
object.__setattr__(self, "_error_rates", {})
|
|
34
|
+
|
|
35
|
+
combined = dict(error_rates or {})
|
|
36
|
+
if error_rate_429 > 0.0:
|
|
37
|
+
combined[429] = error_rate_429
|
|
38
|
+
if error_rate_500 > 0.0:
|
|
39
|
+
combined[500] = error_rate_500
|
|
40
|
+
if error_rate_503 > 0.0:
|
|
41
|
+
combined[503] = error_rate_503
|
|
42
|
+
|
|
43
|
+
for key, value in named_error_rates.items():
|
|
44
|
+
status_code = self._status_code_from_attr(key)
|
|
45
|
+
if status_code is None:
|
|
46
|
+
raise TypeError(f"Unexpected chaos setting '{key}'.")
|
|
47
|
+
combined[status_code] = value
|
|
48
|
+
|
|
49
|
+
self.error_rates = combined
|
|
50
|
+
|
|
51
|
+
def __getattr__(self, name: str) -> float:
|
|
52
|
+
status_code = self._status_code_from_attr(name)
|
|
53
|
+
if status_code is not None:
|
|
54
|
+
return self._error_rates.get(status_code, 0.0)
|
|
55
|
+
raise AttributeError(name)
|
|
56
|
+
|
|
57
|
+
def __setattr__(self, name: str, value: int | float) -> None:
|
|
58
|
+
status_code = self._status_code_from_attr(name)
|
|
59
|
+
if status_code is not None:
|
|
60
|
+
self._error_rates[status_code] = float(value)
|
|
61
|
+
return
|
|
62
|
+
object.__setattr__(self, name, value)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def error_rates(self) -> dict[int, float]:
|
|
66
|
+
return dict(self._error_rates)
|
|
67
|
+
|
|
68
|
+
@error_rates.setter
|
|
69
|
+
def error_rates(self, rates: dict[int, float]) -> None:
|
|
70
|
+
normalized: dict[int, float] = {}
|
|
71
|
+
for status, rate in rates.items():
|
|
72
|
+
normalized[int(status)] = float(rate)
|
|
73
|
+
object.__setattr__(self, "_error_rates", normalized)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _status_code_from_attr(name: str) -> int | None:
|
|
77
|
+
if not name.startswith("error_rate_"):
|
|
78
|
+
return None
|
|
79
|
+
suffix = name.removeprefix("error_rate_")
|
|
80
|
+
if not suffix.isdigit():
|
|
81
|
+
return None
|
|
82
|
+
return int(suffix)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_env(cls) -> "ChaosSettings":
|
|
86
|
+
parsed_rates: dict[int, float] = {}
|
|
87
|
+
for key, value in os.environ.items():
|
|
88
|
+
if not key.startswith(ERROR_RATE_ENV_PREFIX):
|
|
89
|
+
continue
|
|
90
|
+
suffix = key[len(ERROR_RATE_ENV_PREFIX) :]
|
|
91
|
+
if not suffix.isdigit():
|
|
92
|
+
continue
|
|
93
|
+
parsed_rates[int(suffix)] = float(value)
|
|
94
|
+
return cls(
|
|
95
|
+
latency_ms=int(os.getenv("LLMOCK_LATENCY_MS", "0")),
|
|
96
|
+
error_rates=parsed_rates,
|
|
97
|
+
).validated()
|
|
98
|
+
|
|
99
|
+
def with_overrides(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
latency_ms: int | None = None,
|
|
103
|
+
error_rates: dict[int, float] | None = None,
|
|
104
|
+
error_rate_429: float | None = None,
|
|
105
|
+
error_rate_500: float | None = None,
|
|
106
|
+
error_rate_503: float | None = None,
|
|
107
|
+
) -> "ChaosSettings":
|
|
108
|
+
combined = dict(self.error_rates)
|
|
109
|
+
if error_rates:
|
|
110
|
+
combined.update(error_rates)
|
|
111
|
+
if error_rate_429 is not None:
|
|
112
|
+
combined[429] = error_rate_429
|
|
113
|
+
if error_rate_500 is not None:
|
|
114
|
+
combined[500] = error_rate_500
|
|
115
|
+
if error_rate_503 is not None:
|
|
116
|
+
combined[503] = error_rate_503
|
|
117
|
+
return type(self)(
|
|
118
|
+
latency_ms=self.latency_ms if latency_ms is None else latency_ms,
|
|
119
|
+
error_rates=combined,
|
|
120
|
+
).validated()
|
|
121
|
+
|
|
122
|
+
def validated(self) -> "ChaosSettings":
|
|
123
|
+
if self.latency_ms < 0:
|
|
124
|
+
raise ValueError("LLMOCK_LATENCY_MS must be greater than or equal to 0.")
|
|
125
|
+
|
|
126
|
+
total_probability = 0.0
|
|
127
|
+
for status, rate in self.error_rates.items():
|
|
128
|
+
if status not in SUPPORTED_ERROR_STATUS_CODES:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"LLMOCK_ERROR_RATE_{status} must target an HTTP error code between 400 and 599."
|
|
131
|
+
)
|
|
132
|
+
if not 0.0 <= rate <= 1.0:
|
|
133
|
+
raise ValueError(f"LLMOCK_ERROR_RATE_{status} must be between 0.0 and 1.0.")
|
|
134
|
+
total_probability += rate
|
|
135
|
+
|
|
136
|
+
if total_probability > 1.0:
|
|
137
|
+
raise ValueError("The sum of simulated error probabilities must be <= 1.0.")
|
|
138
|
+
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def as_env(self) -> dict[str, str]:
|
|
142
|
+
env = {"LLMOCK_LATENCY_MS": str(self.latency_ms)}
|
|
143
|
+
for status, rate in self.error_rates.items():
|
|
144
|
+
env[f"{ERROR_RATE_ENV_PREFIX}{status}"] = str(rate)
|
|
145
|
+
return env
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
chaos_settings = ChaosSettings.from_env()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ChaosMiddleware(BaseHTTPMiddleware):
|
|
152
|
+
"""Middleware that injects latency and random errors based on ChaosSettings."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, app, settings: ChaosSettings | None = None) -> None:
|
|
155
|
+
super().__init__(app)
|
|
156
|
+
self.settings = (settings or ChaosSettings.from_env()).validated()
|
|
157
|
+
|
|
158
|
+
async def dispatch(self, request: Request, call_next):
|
|
159
|
+
cfg = self.settings
|
|
160
|
+
|
|
161
|
+
if request.url.path == "/health":
|
|
162
|
+
return await call_next(request)
|
|
163
|
+
|
|
164
|
+
forced_status = request.headers.get("x-llmock-force-status")
|
|
165
|
+
if forced_status:
|
|
166
|
+
status_code = int(forced_status)
|
|
167
|
+
if 400 <= status_code < 600:
|
|
168
|
+
return build_error_response(request.url.path, status_code)
|
|
169
|
+
|
|
170
|
+
if cfg.latency_ms > 0:
|
|
171
|
+
await asyncio.sleep(cfg.latency_ms / 1000.0)
|
|
172
|
+
|
|
173
|
+
sampled_status = _sample_error_status(cfg)
|
|
174
|
+
if sampled_status is not None:
|
|
175
|
+
return build_error_response(request.url.path, sampled_status)
|
|
176
|
+
|
|
177
|
+
return await call_next(request)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _sample_error_status(settings: ChaosSettings) -> int | None:
|
|
181
|
+
r = random.random()
|
|
182
|
+
cumulative = 0.0
|
|
183
|
+
for status, rate in sorted(settings.error_rates.items()):
|
|
184
|
+
cumulative += rate
|
|
185
|
+
if r < cumulative:
|
|
186
|
+
return status
|
|
187
|
+
return None
|
llmock/cli.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Typer CLI entry point for LLMock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import uvicorn
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from llmock.chaos import ChaosSettings
|
|
14
|
+
from llmock.simulation import ERROR_RATE_ENV_PREFIX, MockResponseSettings, SUPPORTED_ERROR_STATUS_CODES
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="llmock",
|
|
18
|
+
help="OpenAI-compatible mock server for LLM API resilience testing.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ERROR_RATE_OPTION_HELP = (
|
|
23
|
+
"Inject any HTTP error with STATUS=PROBABILITY. Repeat the option for as many 4xx/5xx "
|
|
24
|
+
"statuses as needed, for example: --error-rate 400=0.05 --error-rate 429=0.2 --error-rate 503=0.1."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_config_path(*, config: str | None) -> str | None:
|
|
29
|
+
return config or os.getenv("LLMOCK_CONFIG")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_config_file(path: str | None) -> dict[str, Any]:
|
|
33
|
+
if not path:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
38
|
+
raw = handle.read()
|
|
39
|
+
|
|
40
|
+
lower_path = path.lower()
|
|
41
|
+
if lower_path.endswith(".json"):
|
|
42
|
+
data = json.loads(raw)
|
|
43
|
+
elif lower_path.endswith((".yaml", ".yml")):
|
|
44
|
+
data = yaml.safe_load(raw) or {}
|
|
45
|
+
else:
|
|
46
|
+
raise typer.BadParameter(
|
|
47
|
+
"Unsupported config file format. Use a .json, .yaml, or .yml file.",
|
|
48
|
+
param_hint="--config",
|
|
49
|
+
)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise typer.BadParameter(
|
|
52
|
+
f"Could not read config file: {exc}",
|
|
53
|
+
param_hint="--config",
|
|
54
|
+
) from exc
|
|
55
|
+
except (json.JSONDecodeError, yaml.YAMLError) as exc:
|
|
56
|
+
raise typer.BadParameter(
|
|
57
|
+
f"Could not parse config file: {exc}",
|
|
58
|
+
param_hint="--config",
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
raise typer.BadParameter(
|
|
63
|
+
"Config file root must be a JSON/YAML object.",
|
|
64
|
+
param_hint="--config",
|
|
65
|
+
)
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _config_scalar(config: dict[str, Any], key: str, *, section: str | None = None) -> Any:
|
|
70
|
+
if section:
|
|
71
|
+
nested = config.get(section)
|
|
72
|
+
if isinstance(nested, dict) and key in nested:
|
|
73
|
+
return nested[key]
|
|
74
|
+
return config.get(key)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _config_error_rates(config: dict[str, Any]) -> dict[int, float]:
|
|
78
|
+
parsed: dict[int, float] = {}
|
|
79
|
+
|
|
80
|
+
for source in (config, config.get("chaos", {})):
|
|
81
|
+
if not isinstance(source, dict):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
mapping = source.get("error_rates")
|
|
85
|
+
if mapping is not None:
|
|
86
|
+
if not isinstance(mapping, dict):
|
|
87
|
+
raise typer.BadParameter(
|
|
88
|
+
"Config key 'error_rates' must be an object mapping status codes to probabilities.",
|
|
89
|
+
param_hint="--config",
|
|
90
|
+
)
|
|
91
|
+
for status, rate in mapping.items():
|
|
92
|
+
parsed[int(status)] = float(rate)
|
|
93
|
+
|
|
94
|
+
for key, value in source.items():
|
|
95
|
+
if not isinstance(key, str) or not key.startswith("error_rate_"):
|
|
96
|
+
continue
|
|
97
|
+
suffix = key.removeprefix("error_rate_")
|
|
98
|
+
if suffix.isdigit():
|
|
99
|
+
parsed[int(suffix)] = float(value)
|
|
100
|
+
|
|
101
|
+
return parsed
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_error_rate_options(values: list[str] | None) -> dict[int, float]:
|
|
105
|
+
parsed: dict[int, float] = {}
|
|
106
|
+
for value in values or []:
|
|
107
|
+
try:
|
|
108
|
+
status_str, rate_str = value.split("=", 1)
|
|
109
|
+
status_code = int(status_str)
|
|
110
|
+
rate = float(rate_str)
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise typer.BadParameter(
|
|
113
|
+
"Each --error-rate value must use the form STATUS=PROBABILITY, e.g. 429=0.4."
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
if status_code not in SUPPORTED_ERROR_STATUS_CODES:
|
|
117
|
+
raise typer.BadParameter(
|
|
118
|
+
f"Unsupported status code {status_code}. Use an HTTP error code between 400 and 599."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
parsed[status_code] = rate
|
|
122
|
+
return parsed
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _set_server_env(*, chaos: ChaosSettings, responses: MockResponseSettings) -> None:
|
|
126
|
+
for key in list(os.environ):
|
|
127
|
+
if key.startswith(ERROR_RATE_ENV_PREFIX):
|
|
128
|
+
del os.environ[key]
|
|
129
|
+
|
|
130
|
+
for key, value in chaos.as_env().items():
|
|
131
|
+
os.environ[key] = value
|
|
132
|
+
for key, value in responses.as_env().items():
|
|
133
|
+
os.environ[key] = value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resolve_chaos_settings(
|
|
137
|
+
*,
|
|
138
|
+
config: dict[str, Any],
|
|
139
|
+
latency_ms: int | None,
|
|
140
|
+
error_rates: list[str] | None = None,
|
|
141
|
+
error_rate_429: float | None,
|
|
142
|
+
error_rate_500: float | None,
|
|
143
|
+
error_rate_503: float | None,
|
|
144
|
+
) -> ChaosSettings:
|
|
145
|
+
base = ChaosSettings(
|
|
146
|
+
latency_ms=int(_config_scalar(config, "latency_ms", section="chaos") or 0),
|
|
147
|
+
error_rates=_config_error_rates(config),
|
|
148
|
+
).validated()
|
|
149
|
+
|
|
150
|
+
env_settings = ChaosSettings.from_env()
|
|
151
|
+
env_latency = os.getenv("LLMOCK_LATENCY_MS")
|
|
152
|
+
return base.with_overrides(
|
|
153
|
+
latency_ms=int(env_latency) if env_latency is not None else None,
|
|
154
|
+
error_rates=env_settings.error_rates,
|
|
155
|
+
).with_overrides(
|
|
156
|
+
latency_ms=latency_ms,
|
|
157
|
+
error_rates=_parse_error_rate_options(error_rates),
|
|
158
|
+
error_rate_429=error_rate_429,
|
|
159
|
+
error_rate_500=error_rate_500,
|
|
160
|
+
error_rate_503=error_rate_503,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_mock_response_settings(*, config: dict[str, Any], response_style: str | None) -> MockResponseSettings:
|
|
165
|
+
settings = MockResponseSettings(
|
|
166
|
+
response_style=str(
|
|
167
|
+
_config_scalar(config, "response_style")
|
|
168
|
+
or _config_scalar(config, "response_style", section="responses")
|
|
169
|
+
or _config_scalar(config, "style", section="responses")
|
|
170
|
+
or "varied"
|
|
171
|
+
)
|
|
172
|
+
).validated()
|
|
173
|
+
env_style = os.getenv("LLMOCK_RESPONSE_STYLE")
|
|
174
|
+
if env_style is not None:
|
|
175
|
+
settings.response_style = env_style
|
|
176
|
+
if response_style is not None:
|
|
177
|
+
settings.response_style = response_style
|
|
178
|
+
return settings.validated()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_server_host(*, config: dict[str, Any], host: str | None) -> str:
|
|
182
|
+
return str(host or os.getenv("LLMOCK_HOST") or _config_scalar(config, "host", section="server") or "127.0.0.1")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resolve_server_port(*, config: dict[str, Any], port: int | None) -> int:
|
|
186
|
+
if port is not None:
|
|
187
|
+
return port
|
|
188
|
+
return int(os.getenv("LLMOCK_PORT") or _config_scalar(config, "port", section="server") or "8000")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _format_error_rates(settings: ChaosSettings) -> str:
|
|
192
|
+
return ", ".join(
|
|
193
|
+
f"{status}={rate:.0%}" for status, rate in sorted(settings.error_rates.items()) if rate
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.callback()
|
|
198
|
+
def main() -> None:
|
|
199
|
+
"""LLMock command group."""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def serve(
|
|
204
|
+
config: str | None = typer.Option(
|
|
205
|
+
None,
|
|
206
|
+
"--config",
|
|
207
|
+
help="Load startup settings from a JSON or YAML file. CLI flags override env vars, and env vars override the config file.",
|
|
208
|
+
),
|
|
209
|
+
host: str | None = typer.Option(None, "--host", "-h", help="Override LLMOCK_HOST."),
|
|
210
|
+
port: int | None = typer.Option(None, "--port", "-p", help="Override LLMOCK_PORT."),
|
|
211
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload (dev mode)"),
|
|
212
|
+
log_level: str = typer.Option("info", "--log-level", help="Uvicorn log level"),
|
|
213
|
+
latency_ms: int | None = typer.Option(None, "--latency-ms", help="Override LLMOCK_LATENCY_MS."),
|
|
214
|
+
error_rates: list[str] | None = typer.Option(
|
|
215
|
+
None,
|
|
216
|
+
"--error-rate",
|
|
217
|
+
help=ERROR_RATE_OPTION_HELP,
|
|
218
|
+
),
|
|
219
|
+
error_rate_429: float | None = typer.Option(
|
|
220
|
+
None,
|
|
221
|
+
"--error-rate-429",
|
|
222
|
+
help="Shortcut for --error-rate 429=RATE.",
|
|
223
|
+
),
|
|
224
|
+
error_rate_500: float | None = typer.Option(
|
|
225
|
+
None,
|
|
226
|
+
"--error-rate-500",
|
|
227
|
+
help="Shortcut for --error-rate 500=RATE.",
|
|
228
|
+
),
|
|
229
|
+
error_rate_503: float | None = typer.Option(
|
|
230
|
+
None,
|
|
231
|
+
"--error-rate-503",
|
|
232
|
+
help="Shortcut for --error-rate 503=RATE.",
|
|
233
|
+
),
|
|
234
|
+
response_style: str | None = typer.Option(
|
|
235
|
+
None,
|
|
236
|
+
"--response-style",
|
|
237
|
+
help="Override LLMOCK_RESPONSE_STYLE (static, hello, echo, varied).",
|
|
238
|
+
),
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Start the LLMock server."""
|
|
241
|
+
config_path = _resolve_config_path(config=config)
|
|
242
|
+
loaded_config = _load_config_file(config_path)
|
|
243
|
+
resolved_host = _resolve_server_host(config=loaded_config, host=host)
|
|
244
|
+
resolved_port = _resolve_server_port(config=loaded_config, port=port)
|
|
245
|
+
chaos = _resolve_chaos_settings(
|
|
246
|
+
config=loaded_config,
|
|
247
|
+
latency_ms=latency_ms,
|
|
248
|
+
error_rates=error_rates,
|
|
249
|
+
error_rate_429=error_rate_429,
|
|
250
|
+
error_rate_500=error_rate_500,
|
|
251
|
+
error_rate_503=error_rate_503,
|
|
252
|
+
)
|
|
253
|
+
responses = _resolve_mock_response_settings(config=loaded_config, response_style=response_style)
|
|
254
|
+
_set_server_env(chaos=chaos, responses=responses)
|
|
255
|
+
|
|
256
|
+
configured_rates = _format_error_rates(chaos)
|
|
257
|
+
if chaos.latency_ms or configured_rates:
|
|
258
|
+
typer.echo(f"Chaos: latency={chaos.latency_ms}ms errors=[{configured_rates or 'none'}]")
|
|
259
|
+
|
|
260
|
+
if config_path:
|
|
261
|
+
typer.echo(f"Config: {config_path}")
|
|
262
|
+
typer.echo(f"Responses: style={responses.response_style}")
|
|
263
|
+
typer.echo(f"Starting LLMock on http://{resolved_host}:{resolved_port}")
|
|
264
|
+
uvicorn.run(
|
|
265
|
+
"llmock.main:create_app",
|
|
266
|
+
factory=True,
|
|
267
|
+
host=resolved_host,
|
|
268
|
+
port=resolved_port,
|
|
269
|
+
reload=reload,
|
|
270
|
+
log_level=log_level,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
app()
|
llmock/errors.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Provider-specific exception handlers for LLMock.
|
|
2
|
+
|
|
3
|
+
Overrides FastAPI's default `{"detail": "..."}` shape with the real
|
|
4
|
+
error envelope each provider uses so that clients can parse them normally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from fastapi.exceptions import RequestValidationError
|
|
11
|
+
from starlette.exceptions import HTTPException
|
|
12
|
+
from starlette.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
from llmock.simulation import _build_error_content, provider_from_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _message_from_exc(exc: HTTPException) -> str:
|
|
18
|
+
detail = exc.detail
|
|
19
|
+
if isinstance(detail, str):
|
|
20
|
+
return detail
|
|
21
|
+
if isinstance(detail, dict):
|
|
22
|
+
return str(detail.get("msg") or detail.get("message") or exc.detail)
|
|
23
|
+
return str(detail)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _message_from_validation(exc: RequestValidationError) -> str:
|
|
27
|
+
errors = exc.errors()
|
|
28
|
+
if not errors:
|
|
29
|
+
return "Request validation error."
|
|
30
|
+
first = errors[0]
|
|
31
|
+
loc = " → ".join(str(p) for p in first.get("loc", []))
|
|
32
|
+
msg = first.get("msg", "validation error")
|
|
33
|
+
return f"{loc}: {msg}" if loc else msg
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
37
|
+
provider = provider_from_path(request.url.path)
|
|
38
|
+
message = _message_from_exc(exc)
|
|
39
|
+
content = _build_error_content(provider=provider, status_code=exc.status_code)
|
|
40
|
+
|
|
41
|
+
# Inject the actual message instead of the generic label.
|
|
42
|
+
_inject_message(content, message, provider)
|
|
43
|
+
|
|
44
|
+
headers = dict(exc.headers or {})
|
|
45
|
+
if exc.status_code in {429, 503, 504, 529} and "retry-after" not in {k.lower() for k in headers}:
|
|
46
|
+
headers["retry-after"] = "1"
|
|
47
|
+
|
|
48
|
+
return JSONResponse(status_code=exc.status_code, content=content, headers=headers or None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _validation_exception_handler(
|
|
52
|
+
request: Request, exc: RequestValidationError
|
|
53
|
+
) -> JSONResponse:
|
|
54
|
+
provider = provider_from_path(request.url.path)
|
|
55
|
+
status_code = 422
|
|
56
|
+
message = _message_from_validation(exc)
|
|
57
|
+
content = _build_error_content(provider=provider, status_code=status_code)
|
|
58
|
+
_inject_message(content, message, provider)
|
|
59
|
+
return JSONResponse(status_code=status_code, content=content)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _inject_message(content: dict, message: str, provider: str) -> None:
|
|
63
|
+
"""Mutate *content* in-place to use *message* instead of the generic label."""
|
|
64
|
+
if provider == "anthropic":
|
|
65
|
+
# {"type": "error", "error": {"type": "...", "message": "..."}}
|
|
66
|
+
if isinstance(content.get("error"), dict):
|
|
67
|
+
content["error"]["message"] = message
|
|
68
|
+
elif provider == "gemini":
|
|
69
|
+
# {"error": {"code": ..., "message": "...", "status": "..."}}
|
|
70
|
+
if isinstance(content.get("error"), dict):
|
|
71
|
+
content["error"]["message"] = message
|
|
72
|
+
elif provider == "cohere":
|
|
73
|
+
# {"message": "..."}
|
|
74
|
+
content["message"] = message
|
|
75
|
+
elif provider == "mistral":
|
|
76
|
+
# {"object": "error", "message": "...", "type": "...", "param": null, "code": "..."}
|
|
77
|
+
content["message"] = message
|
|
78
|
+
elif provider == "xai":
|
|
79
|
+
# {"error": {"message": "...", "type": "...", "code": "..."}}
|
|
80
|
+
if isinstance(content.get("error"), dict):
|
|
81
|
+
content["error"]["message"] = message
|
|
82
|
+
else:
|
|
83
|
+
# OpenAI-compatible: {"error": {"message": "...", "type": "...", ...}}
|
|
84
|
+
if isinstance(content.get("error"), dict):
|
|
85
|
+
content["error"]["message"] = message
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def register_error_handlers(app: FastAPI) -> None:
|
|
89
|
+
"""Register provider-aware error handlers on *app*."""
|
|
90
|
+
app.add_exception_handler(HTTPException, _http_exception_handler) # type: ignore[arg-type]
|
|
91
|
+
app.add_exception_handler(RequestValidationError, _validation_exception_handler) # type: ignore[arg-type]
|
llmock/main.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FastAPI application factory for LLMock."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from llmock import __version__
|
|
6
|
+
from llmock.chaos import ChaosMiddleware, ChaosSettings, chaos_settings
|
|
7
|
+
from llmock.errors import register_error_handlers
|
|
8
|
+
|
|
9
|
+
# Import every router module so their registry.register() calls fire at import time.
|
|
10
|
+
import llmock.routers.ai21 # noqa: F401
|
|
11
|
+
import llmock.routers.anthropic # noqa: F401
|
|
12
|
+
import llmock.routers.cohere # noqa: F401
|
|
13
|
+
import llmock.routers.gemini # noqa: F401
|
|
14
|
+
import llmock.routers.groq # noqa: F401
|
|
15
|
+
import llmock.routers.mistral # noqa: F401
|
|
16
|
+
import llmock.routers.openai # noqa: F401
|
|
17
|
+
import llmock.routers.perplexity # noqa: F401
|
|
18
|
+
import llmock.routers.together # noqa: F401
|
|
19
|
+
import llmock.routers.xai # noqa: F401
|
|
20
|
+
|
|
21
|
+
from llmock.routers.registry import get_all_routers
|
|
22
|
+
from llmock.simulation import MockResponseSettings
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_app(
|
|
26
|
+
chaos: ChaosSettings | None = None,
|
|
27
|
+
responses: MockResponseSettings | None = None,
|
|
28
|
+
) -> FastAPI:
|
|
29
|
+
settings = (chaos or ChaosSettings.from_env()).validated()
|
|
30
|
+
response_settings = (responses or MockResponseSettings.from_env()).validated()
|
|
31
|
+
|
|
32
|
+
app = FastAPI(
|
|
33
|
+
title="LLMock",
|
|
34
|
+
description="OpenAI-compatible mock server for LLM API resilience testing",
|
|
35
|
+
version=__version__,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app.state.chaos_settings = settings
|
|
39
|
+
app.state.mock_response_settings = response_settings
|
|
40
|
+
register_error_handlers(app)
|
|
41
|
+
app.add_middleware(ChaosMiddleware, settings=settings)
|
|
42
|
+
|
|
43
|
+
for router in get_all_routers():
|
|
44
|
+
app.include_router(router)
|
|
45
|
+
|
|
46
|
+
@app.get("/health")
|
|
47
|
+
def health() -> dict:
|
|
48
|
+
return {"status": "ok", "version": __version__}
|
|
49
|
+
|
|
50
|
+
return app
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = create_app(chaos=chaos_settings)
|
|
File without changes
|