infer-stack 0.6.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.
Files changed (44) hide show
  1. infer_stack/__init__.py +2 -0
  2. infer_stack/backends/__init__.py +7 -0
  3. infer_stack/backends/compose_renderer.py +243 -0
  4. infer_stack/backends/kubeai_renderer.py +202 -0
  5. infer_stack/benchmark.py +38 -0
  6. infer_stack/catalog.py +438 -0
  7. infer_stack/cli/__init__.py +169 -0
  8. infer_stack/cli/__main__.py +4 -0
  9. infer_stack/cli/commands_profile.py +467 -0
  10. infer_stack/cli/commands_runtime.py +719 -0
  11. infer_stack/cli/commands_smoke.py +691 -0
  12. infer_stack/cli/compose.py +755 -0
  13. infer_stack/cli/context.py +471 -0
  14. infer_stack/cli/options.py +134 -0
  15. infer_stack/cli/probes.py +178 -0
  16. infer_stack/config.py +450 -0
  17. infer_stack/contracts.py +223 -0
  18. infer_stack/diff_prompt.py +117 -0
  19. infer_stack/docker_utils.py +230 -0
  20. infer_stack/env_utils.py +97 -0
  21. infer_stack/experimental/model_catalog_discover.py +1155 -0
  22. infer_stack/experimental/model_memory_estimator.py +1264 -0
  23. infer_stack/experimental/stress_test_long_context.py +397 -0
  24. infer_stack/hardware.py +70 -0
  25. infer_stack/kubeai_ops.py +76 -0
  26. infer_stack/paths.py +87 -0
  27. infer_stack/profile_runtime.py +46 -0
  28. infer_stack/renderer.py +19 -0
  29. infer_stack/resolver.py +1092 -0
  30. infer_stack/templates/default-models.yaml +674 -0
  31. infer_stack/templates/default-ollama-models.yaml +31 -0
  32. infer_stack/templates/default-profiles.yaml +1731 -0
  33. infer_stack/templates/default-vllm-models.yaml +714 -0
  34. infer_stack/templates/docker-compose.yml.j2 +430 -0
  35. infer_stack/templates/litellm_config.yaml.j2 +44 -0
  36. infer_stack/templates/nginx.conf.j2 +84 -0
  37. infer_stack/tuning.py +3 -0
  38. infer_stack/validator.py +314 -0
  39. infer_stack/verification.py +46 -0
  40. infer_stack-0.6.0.dist-info/METADATA +1034 -0
  41. infer_stack-0.6.0.dist-info/RECORD +44 -0
  42. infer_stack-0.6.0.dist-info/WHEEL +5 -0
  43. infer_stack-0.6.0.dist-info/entry_points.txt +2 -0
  44. infer_stack-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from .config import CONFIG_FILE, initial_config, load_yaml
7
+ from .hardware import simulate_inventory
8
+ from .paths import config_root
9
+ from .resolver import resolve
10
+ from .profile_runtime import default_base_url
11
+
12
+
13
+ def _default_access(
14
+ service: dict[str, Any], deployment: dict[str, Any]
15
+ ) -> dict[str, Any]:
16
+ backend = str(deployment.get('backend', 'compose')).lower()
17
+ if backend == 'kubeai':
18
+ return {
19
+ 'name': 'kubeai-frontdoor',
20
+ 'kind': 'openai-compatible',
21
+ 'base_url': default_base_url(deployment),
22
+ 'request_model_name': service['profile_public_name'],
23
+ 'auth_env_name': 'KUBEAI_OPENAI_API_KEY',
24
+ 'auth_placeholder': 'EMPTY',
25
+ 'auth_required': False,
26
+ 'notes': [
27
+ 'Use the KubeAI OpenAI-compatible front door for routed requests.'
28
+ ],
29
+ }
30
+ return {
31
+ 'name': 'compose-router',
32
+ 'kind': 'openai-compatible',
33
+ 'base_url': default_base_url(deployment),
34
+ 'request_model_name': service['served_model_name'],
35
+ 'auth_env_name': 'LITELLM_MASTER_KEY',
36
+ 'auth_placeholder': 'SET_LITELLM_MASTER_KEY_IN_ENV',
37
+ 'auth_required': True,
38
+ 'notes': ['Use the LiteLLM router front door for routed requests.'],
39
+ }
40
+
41
+
42
+ def _additional_accesses(
43
+ service: dict[str, Any], deployment: dict[str, Any]
44
+ ) -> list[dict[str, Any]]:
45
+ benchmark_transport = deepcopy(service.get('benchmark_transport', {}))
46
+ if not benchmark_transport:
47
+ return []
48
+ default = _default_access(service, deployment)
49
+ default_auth_env = (
50
+ 'VLLM_API_KEY'
51
+ if benchmark_transport.get('kind') == 'vllm-direct'
52
+ else default['auth_env_name']
53
+ )
54
+ default_auth_placeholder = (
55
+ 'EMPTY'
56
+ if benchmark_transport.get('kind') == 'vllm-direct'
57
+ else default['auth_placeholder']
58
+ )
59
+ access = {
60
+ 'name': benchmark_transport.get('name')
61
+ or benchmark_transport.get('kind')
62
+ or 'compatibility-access',
63
+ 'kind': benchmark_transport.get('kind') or default['kind'],
64
+ 'base_url': benchmark_transport.get('base_url') or default['base_url'],
65
+ 'request_model_name': benchmark_transport.get('request_model_name')
66
+ or service.get('hf_model_id')
67
+ or service['served_model_name'],
68
+ 'auth_env_name': benchmark_transport.get('api_key_env')
69
+ or default_auth_env,
70
+ 'auth_placeholder': benchmark_transport.get('api_key_placeholder')
71
+ or default_auth_placeholder,
72
+ 'auth_required': bool(
73
+ benchmark_transport.get(
74
+ 'api_key_required', default_auth_placeholder != 'EMPTY'
75
+ )
76
+ ),
77
+ 'notes': [
78
+ 'Optional compatibility access hint retained for external integrations.'
79
+ ],
80
+ }
81
+ dedupe_key = (
82
+ access['kind'],
83
+ access['base_url'],
84
+ access['request_model_name'],
85
+ )
86
+ default_key = (
87
+ default['kind'],
88
+ default['base_url'],
89
+ default['request_model_name'],
90
+ )
91
+ if dedupe_key == default_key:
92
+ return []
93
+ return [access]
94
+
95
+
96
+ def build_profile_contract(deployment: dict[str, Any]) -> dict[str, Any]:
97
+ services = []
98
+ for service in deployment.get('services', []):
99
+ services.append(
100
+ {
101
+ 'profile_name': service['profile_name'],
102
+ 'public_name': service['profile_public_name'],
103
+ 'service_name': service['service_name'],
104
+ 'kubernetes_name': service['kubernetes_name'],
105
+ 'model': {
106
+ 'model_ref': service['model_ref'],
107
+ 'hf_model_id': service['hf_model_id'],
108
+ 'logical_model_name': service['logical_model_name'],
109
+ 'served_model_name': service['served_model_name'],
110
+ 'served_aliases': deepcopy(
111
+ service.get('served_aliases', [])
112
+ ),
113
+ 'tokenizer_name': service['tokenizer_name'],
114
+ 'modalities': deepcopy(service.get('modalities', [])),
115
+ },
116
+ 'protocol': {
117
+ 'mode': service['protocol_mode'],
118
+ 'engine': service['engine'],
119
+ 'features': deepcopy(service.get('features', [])),
120
+ },
121
+ 'access': {
122
+ 'default': _default_access(service, deployment),
123
+ 'additional': _additional_accesses(service, deployment),
124
+ },
125
+ 'runtime': {
126
+ 'resource_profile': service['resource_profile'],
127
+ 'priority_class_name': service.get('priority_class_name'),
128
+ 'min_replicas': service['min_replicas'],
129
+ 'max_replicas': service['max_replicas'],
130
+ 'tensor_parallel_size': service['tensor_parallel_size'],
131
+ 'data_parallel_size': service['data_parallel_size'],
132
+ 'max_model_len': service['max_model_len'],
133
+ 'gpu_memory_utilization': service['gpu_memory_utilization'],
134
+ 'max_num_batched_tokens': service['max_num_batched_tokens'],
135
+ 'max_num_seqs': service['max_num_seqs'],
136
+ 'enable_prefix_caching': service['enable_prefix_caching'],
137
+ 'extra_args': deepcopy(service.get('extra_args', [])),
138
+ },
139
+ 'placement': {
140
+ 'gpu_indices': deepcopy(service.get('gpu_indices', [])),
141
+ 'placement': deepcopy(service.get('placement', {})),
142
+ 'topology': deepcopy(service.get('topology', {})),
143
+ },
144
+ 'notes': deepcopy(service.get('notes', [])),
145
+ 'caveats': deepcopy(service.get('audit_notes', [])),
146
+ }
147
+ )
148
+ return {
149
+ 'schema_version': 1,
150
+ 'kind': 'serving-profile-contract',
151
+ 'backend': deployment['backend'],
152
+ 'profile': {
153
+ 'name': deployment['serving_profile']['name'],
154
+ 'public_name': deployment['serving_profile']['public_name'],
155
+ 'description': deployment['serving_profile'].get('description', ''),
156
+ 'kubernetes_name': deployment['serving_profile'].get(
157
+ 'kubernetes_name', ''
158
+ ),
159
+ 'service_name': deployment['serving_profile'].get(
160
+ 'service_name', ''
161
+ ),
162
+ 'protocol_mode': deployment['serving_profile'].get(
163
+ 'protocol_mode', ''
164
+ ),
165
+ 'engine': deployment['serving_profile'].get('engine', ''),
166
+ 'resource_profile': deployment['serving_profile'].get(
167
+ 'resource_profile', ''
168
+ ),
169
+ 'notes': deepcopy(deployment['serving_profile'].get('notes', [])),
170
+ 'caveats': deepcopy(
171
+ deployment['serving_profile'].get('audit_notes', [])
172
+ ),
173
+ },
174
+ 'router': {
175
+ 'type': deployment.get('router', {}).get('type', ''),
176
+ 'aliases': deepcopy(
177
+ deployment.get('router', {}).get('aliases', {})
178
+ ),
179
+ },
180
+ 'services': services,
181
+ }
182
+
183
+
184
+ def describe_profile_contract(
185
+ config: dict[str, Any],
186
+ *,
187
+ resolve_fn,
188
+ profile_name: str | None = None,
189
+ inventory: dict[str, Any] | None = None,
190
+ ) -> dict[str, Any]:
191
+ deployment = resolve_fn(
192
+ config, inventory=inventory, profile_name=profile_name
193
+ )
194
+ return build_profile_contract(deployment)
195
+
196
+
197
+ def load_profile_contract(
198
+ profile_name: str,
199
+ *,
200
+ backend: str | None = None,
201
+ simulate_hardware_spec: str | None = None,
202
+ ) -> dict[str, Any]:
203
+ config_path = config_root() / CONFIG_FILE
204
+ if config_path.exists():
205
+ config = load_yaml(config_path)
206
+ else:
207
+ config = initial_config()
208
+ config.setdefault('catalog', {})
209
+ config['catalog']['builtin_models'] = True
210
+ config['catalog']['builtin_profiles'] = True
211
+ if backend is not None:
212
+ config['backend'] = backend
213
+ inventory = (
214
+ simulate_inventory(simulate_hardware_spec)
215
+ if simulate_hardware_spec
216
+ else None
217
+ )
218
+ return describe_profile_contract(
219
+ config,
220
+ resolve_fn=resolve,
221
+ profile_name=profile_name,
222
+ inventory=inventory,
223
+ )
@@ -0,0 +1,117 @@
1
+ """Render-time diff confirmation.
2
+
3
+ Builds a unified diff of every file the renderer is about to overwrite, prints
4
+ each one as its own panel using Rich syntax-highlighting, and asks the user to
5
+ accept the changes interactively unless ``assume_yes=True`` is passed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from difflib import unified_diff
11
+ from pathlib import Path
12
+ from typing import Mapping
13
+
14
+ from rich.console import Console, Group
15
+ from rich.panel import Panel
16
+ from rich.prompt import Confirm
17
+ from rich.syntax import Syntax
18
+ from rich.text import Text
19
+
20
+
21
+ def _read(path: Path) -> str:
22
+ if not path.exists():
23
+ return ''
24
+ return path.read_text(encoding='utf-8')
25
+
26
+
27
+ def _diff(old: str, new: str, label: Path) -> str:
28
+ return ''.join(
29
+ unified_diff(
30
+ old.splitlines(keepends=True),
31
+ new.splitlines(keepends=True),
32
+ fromfile=f'a/{label}',
33
+ tofile=f'b/{label}',
34
+ n=3,
35
+ )
36
+ )
37
+
38
+
39
+ def confirm_writes(
40
+ planned: Mapping[Path, str],
41
+ *,
42
+ assume_yes: bool,
43
+ console: Console | None = None,
44
+ title: str = 'Pending render changes',
45
+ ) -> bool:
46
+ """Display a per-file diff and prompt for acceptance.
47
+
48
+ Returns True if the writes should proceed, False if the user declined.
49
+ A planned write whose new content matches the existing file is shown as
50
+ "no changes" and never blocks acceptance on its own.
51
+ """
52
+ console = console or Console()
53
+
54
+ panels: list[Panel] = []
55
+ any_changes = False
56
+ new_files: list[Path] = []
57
+ modified_files: list[Path] = []
58
+ for path, new_content in planned.items():
59
+ old = _read(path)
60
+ if old == new_content:
61
+ panels.append(
62
+ Panel(
63
+ Text('(no changes)', style='dim'),
64
+ title=str(path),
65
+ border_style='green',
66
+ title_align='left',
67
+ )
68
+ )
69
+ continue
70
+ any_changes = True
71
+ diff_text = _diff(old, new_content, path)
72
+ if not diff_text:
73
+ # New file — synthesize a diff against an empty original.
74
+ diff_text = _diff('', new_content, path)
75
+ if not old:
76
+ new_files.append(path)
77
+ border = 'yellow'
78
+ tag = '[bold yellow]NEW[/bold yellow]'
79
+ else:
80
+ modified_files.append(path)
81
+ border = 'cyan'
82
+ tag = '[bold cyan]MOD[/bold cyan]'
83
+ body = Syntax(
84
+ diff_text,
85
+ 'diff',
86
+ theme='ansi_dark',
87
+ background_color='default',
88
+ word_wrap=False,
89
+ line_numbers=False,
90
+ )
91
+ panels.append(
92
+ Panel(
93
+ body,
94
+ title=f'{tag} {path}',
95
+ border_style=border,
96
+ title_align='left',
97
+ )
98
+ )
99
+
100
+ console.rule(f'[bold]{title}[/bold]')
101
+ console.print(Group(*panels))
102
+
103
+ if not any_changes:
104
+ console.print('[green]No changes to apply.[/green]')
105
+ return True
106
+
107
+ summary = (
108
+ f'[cyan]{len(modified_files)} modified[/cyan], '
109
+ f'[yellow]{len(new_files)} new[/yellow]'
110
+ )
111
+ console.print(f'Summary: {summary}')
112
+
113
+ if assume_yes:
114
+ console.print('[green]--yes provided; applying changes.[/green]')
115
+ return True
116
+
117
+ return Confirm.ask('Apply these changes?', default=False, console=console)
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import json
5
+ import socket
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class DockerCommandError(RuntimeError):
11
+ pass
12
+
13
+
14
+ class PortInUseError(RuntimeError):
15
+ """One or more host ports the stack needs are already bound."""
16
+
17
+ def __init__(self, busy: list[tuple[str, int, str]]):
18
+ self.busy = busy
19
+ lines = ['Cannot start stack: required host ports are already bound.']
20
+ for service, port, host in busy:
21
+ lines.append('')
22
+ lines.append(
23
+ f' {service}: {host}:{port} is already in use. Find the owner with:'
24
+ )
25
+ lines.append(f" ss -tlnp 'sport = :{port}'")
26
+ lines.append(f' sudo lsof -nP -iTCP:{port} -sTCP:LISTEN')
27
+ lines.append(f' docker ps --filter publish={port}')
28
+ lines.append('')
29
+ lines.append(
30
+ 'If the conflict is a leftover container from this stack, run '
31
+ '`infer-stack down` (or `docker stop <name> && docker rm <name>`).'
32
+ )
33
+ lines.append(
34
+ 'If a non-stack process owns the port, either stop that process or '
35
+ 'pick different ports: `infer-stack setup --litellm-port N '
36
+ '--open-webui-port M`, then `infer-stack render --yes`.'
37
+ )
38
+ super().__init__('\n'.join(lines))
39
+
40
+
41
+ def our_published_ports(
42
+ compose_cmd: str, compose_file: Path, env_file: Path
43
+ ) -> set[int]:
44
+ """Return host ports currently published by our own compose project.
45
+
46
+ Used by the pre-flight check to skip ports that are "in use" only because
47
+ one of our containers is already publishing them. Requires
48
+ ``docker compose ps --format json`` (compose v2.6+); on older versions or
49
+ when the command otherwise fails this returns an empty set, falling back
50
+ to the strict check.
51
+ """
52
+ if not compose_file.exists():
53
+ return set()
54
+ cmd = compose_cmd.split() + [
55
+ '-f',
56
+ str(compose_file),
57
+ '--env-file',
58
+ str(env_file),
59
+ 'ps',
60
+ '--format',
61
+ 'json',
62
+ ]
63
+ try:
64
+ proc = subprocess.run(
65
+ cmd, capture_output=True, text=True, check=False, timeout=10
66
+ )
67
+ except (subprocess.SubprocessError, OSError):
68
+ return set()
69
+ if proc.returncode != 0 or not proc.stdout.strip():
70
+ return set()
71
+ ports: set[int] = set()
72
+ # `docker compose ps --format json` emits either one JSON object per line
73
+ # (newer versions) or a single JSON array (older versions). Accept both.
74
+ text = proc.stdout.strip()
75
+ parsed_any = False
76
+ if text.startswith('['):
77
+ try:
78
+ for entry in json.loads(text):
79
+ _collect_published_ports(entry, ports)
80
+ parsed_any = True
81
+ except json.JSONDecodeError:
82
+ return set()
83
+ if not parsed_any:
84
+ for line in text.splitlines():
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ try:
89
+ entry = json.loads(line)
90
+ except json.JSONDecodeError:
91
+ continue
92
+ _collect_published_ports(entry, ports)
93
+ return ports
94
+
95
+
96
+ def _collect_published_ports(entry: dict, out: set[int]) -> None:
97
+ for pub in entry.get('Publishers') or []:
98
+ port = pub.get('PublishedPort')
99
+ if isinstance(port, int) and port > 0:
100
+ out.add(port)
101
+
102
+
103
+ def check_ports_available(ports: list[tuple[str, int, str]]) -> None:
104
+ """Pre-flight check: try to bind each (service, port, host) tuple.
105
+
106
+ ``host`` is the interface the rendered compose file binds the publication
107
+ on (e.g. ``"0.0.0.0"`` for litellm / open-webui, ``"127.0.0.1"`` for vllm
108
+ services). Binding on ``0.0.0.0`` collides with any listener on any
109
+ interface for that port (including IPv6 ``[::]`` due to
110
+ IPv4-mapped-IPv6), which is the most common cause of the
111
+ ``failed to bind port`` errors users hit at ``compose up`` time.
112
+
113
+ Raises ``PortInUseError`` listing every conflicting service so the user
114
+ sees the full picture in one shot, not one failure at a time.
115
+ """
116
+ busy: list[tuple[str, int, str]] = []
117
+ for service, port, host in ports:
118
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
119
+ try:
120
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
121
+ try:
122
+ sock.bind((host, port))
123
+ except OSError as ex:
124
+ if ex.errno in (errno.EADDRINUSE, errno.EACCES):
125
+ busy.append((service, port, host))
126
+ else:
127
+ raise
128
+ finally:
129
+ sock.close()
130
+ if busy:
131
+ raise PortInUseError(busy)
132
+
133
+
134
+ def _cmd(
135
+ compose_cmd: str, compose_file: Path, env_file: Path, *args: str
136
+ ) -> list[str]:
137
+ return compose_cmd.split() + [
138
+ '-f',
139
+ str(compose_file),
140
+ '--env-file',
141
+ str(env_file),
142
+ *args,
143
+ ]
144
+
145
+
146
+ def run(cmd: list[str]) -> None:
147
+ proc = subprocess.run(cmd)
148
+ if proc.returncode != 0:
149
+ raise DockerCommandError(
150
+ f'Command failed with exit code {proc.returncode}: {" ".join(cmd)}'
151
+ )
152
+
153
+
154
+ def compose_up(
155
+ compose_cmd: str,
156
+ compose_file: Path,
157
+ env_file: Path,
158
+ *,
159
+ detach: bool = False,
160
+ remove_orphans: bool = True,
161
+ force_recreate: bool = False,
162
+ services: list[str] | None = None,
163
+ ) -> None:
164
+ args = ['up']
165
+ if detach:
166
+ args.append('-d')
167
+ if remove_orphans:
168
+ args.append('--remove-orphans')
169
+ if force_recreate:
170
+ args.append('--force-recreate')
171
+ if services:
172
+ args.extend(services)
173
+ run(_cmd(compose_cmd, compose_file, env_file, *args))
174
+
175
+
176
+ def compose_down(compose_cmd: str, compose_file: Path, env_file: Path) -> None:
177
+ """Stop and remove services. Never removes named volumes."""
178
+ run(_cmd(compose_cmd, compose_file, env_file, 'down', '--remove-orphans'))
179
+
180
+
181
+ def docker_rm_dirs(dirs: list[Path], docker_cmd: str = 'docker') -> None:
182
+ """Delete host directories that may be root-owned (written by Docker containers).
183
+
184
+ Groups paths by parent directory and removes them from inside a temporary
185
+ Alpine container so that permission errors from user-space rm are avoided.
186
+ """
187
+ from collections import defaultdict
188
+
189
+ by_parent: dict[Path, list[str]] = defaultdict(list)
190
+ for d in dirs:
191
+ if d.exists():
192
+ by_parent[d.parent].append(d.name)
193
+
194
+ for parent, names in by_parent.items():
195
+ targets = ' '.join(f'/mnt/{name}' for name in names)
196
+ cmd = [
197
+ docker_cmd,
198
+ 'run',
199
+ '--rm',
200
+ '-v',
201
+ f'{parent}:/mnt',
202
+ 'alpine',
203
+ 'sh',
204
+ '-c',
205
+ f'rm -rf {targets}',
206
+ ]
207
+ run(cmd)
208
+
209
+
210
+ def compose_recreate_router(
211
+ compose_cmd: str,
212
+ compose_file: Path,
213
+ env_file: Path,
214
+ *,
215
+ detach: bool = True,
216
+ ) -> None:
217
+ """Recreate the LiteLLM router container in place.
218
+
219
+ Forces LiteLLM to reload its config from the rendered YAML. Open WebUI is
220
+ deliberately *not* recreated: it re-fetches ``/v1/models`` from LiteLLM
221
+ on user actions, so a brief stale-cache window is fine, while
222
+ force-recreating would log every user out of the chat UI.
223
+ """
224
+ args = ['up']
225
+ if detach:
226
+ args.append('-d')
227
+ args.extend(
228
+ ['--remove-orphans', '--force-recreate', '--no-deps', 'litellm']
229
+ )
230
+ run(_cmd(compose_cmd, compose_file, env_file, *args))
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from pathlib import Path
5
+
6
+
7
+ def parse_env_file(path: Path) -> dict[str, str]:
8
+ """Return a flat dict of KEY=value pairs from a .env file.
9
+
10
+ Comments, blank lines, and malformed lines (no '=') are skipped. Both
11
+ managed and unknown keys are returned together.
12
+ """
13
+ if not path.exists():
14
+ return {}
15
+ data: dict[str, str] = {}
16
+ for line in path.read_text(encoding='utf-8').splitlines():
17
+ stripped = line.strip()
18
+ if not stripped or stripped.startswith('#') or '=' not in stripped:
19
+ continue
20
+ k, v = stripped.split('=', 1)
21
+ data[k.strip()] = v
22
+ return data
23
+
24
+
25
+ def ensure_secret(
26
+ env: dict[str, str], key: str, length: int = 32, prefix: str = ''
27
+ ) -> str:
28
+ """Return ``env[key]`` if present (and matches ``prefix``), else a fresh secret.
29
+
30
+ The ``prefix`` requirement is checked against any existing value: if the
31
+ stored value does not start with ``prefix``, a new secret is generated.
32
+ This matters for keys whose downstream consumer enforces a format
33
+ (e.g. LiteLLM rejects auth tokens that don't start with ``sk-``).
34
+ """
35
+ value = env.get(key, '').strip()
36
+ if value and (not prefix or value.startswith(prefix)):
37
+ return value
38
+ return prefix + secrets.token_urlsafe(length)
39
+
40
+
41
+ def _parse_env_lines(text: str) -> list[tuple[str, str | None, str]]:
42
+ """Tokenize a .env file into ordered records.
43
+
44
+ Each record is a tuple ``(kind, key, raw_line)`` where ``kind`` is one of
45
+ ``"kv"`` (a syntactically valid ``KEY=VALUE`` line) or ``"other"`` (a blank
46
+ line, comment, or malformed line). For ``"kv"`` records, ``key`` is the
47
+ variable name; otherwise ``key`` is None. ``raw_line`` is the verbatim
48
+ line (without trailing newline) to preserve formatting on rewrite.
49
+ """
50
+ records: list[tuple[str, str | None, str]] = []
51
+ for line in text.splitlines():
52
+ stripped = line.strip()
53
+ if not stripped or stripped.startswith('#') or '=' not in stripped:
54
+ records.append(('other', None, line))
55
+ continue
56
+ k, _ = stripped.split('=', 1)
57
+ key = k.strip()
58
+ if not key or any(c.isspace() for c in key):
59
+ records.append(('other', None, line))
60
+ continue
61
+ records.append(('kv', key, line))
62
+ return records
63
+
64
+
65
+ def write_env_file(path: Path, values: dict[str, str]) -> None:
66
+ """Write the .env file non-destructively.
67
+
68
+ - Existing unknown ``KEY=value`` pairs are preserved verbatim.
69
+ - Comments and blank lines are preserved in their original positions.
70
+ - Keys present in ``values`` are updated in place (preserving line order).
71
+ - Keys in ``values`` not yet present in the file are appended at the end.
72
+ """
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ existing_text = path.read_text(encoding='utf-8') if path.exists() else ''
76
+ records = _parse_env_lines(existing_text)
77
+
78
+ seen: set[str] = set()
79
+ out_lines: list[str] = []
80
+ for kind, key, raw in records:
81
+ if kind == 'kv' and key in values:
82
+ out_lines.append(f'{key}={values[key]}')
83
+ seen.add(key)
84
+ else:
85
+ out_lines.append(raw)
86
+
87
+ for key, value in values.items():
88
+ if key in seen:
89
+ continue
90
+ out_lines.append(f'{key}={value}')
91
+
92
+ text = '\n'.join(out_lines)
93
+ if text and not text.endswith('\n'):
94
+ text += '\n'
95
+
96
+ print(f'Write .env to {path}')
97
+ path.write_text(text, encoding='utf-8')