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 ADDED
@@ -0,0 +1,5 @@
1
+ """LLMock - OpenAI-compatible mock server for LLM API resilience testing."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
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