oc_chat
Loading...
Searching...
No Matches
config_validation.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/config_validation.py
4
5
6"""
7Runtime configuration validation.
8
9Purpose
10-------
11Validate user-controlled environment variables before OC Chat builds
12provider clients, session state, or endpoint registries. The checks here
13are intentionally local and deterministic: no network probes, no writes,
14and no dependency on an interactive terminal.
15
16Responsibilities
17----------------
18- :func:`validate_runtime_config` - inspect env-derived startup config
19 and return structured errors / warnings.
20- :func:`format_config_issue` - convert one issue into a user-facing
21 one-line diagnostic.
22- :class:`ConfigValidationResult` - group issues and expose convenience
23 accessors for callers that need to stop on errors.
24
25Diagnostics
26-----------
27Domain: OC_CHAT.MAIN
28Levels:
29 L2 - startup validation summary.
30 L3 - validation details.
31 L4 - module load.
32
33Contracts
34---------
35- Validation never mutates the filesystem or environment.
36- Warnings are advisory; errors should stop startup before provider
37 clients or sessions are constructed.
38- Provider-specific API keys are required only when the provider
39 declares a non-empty ``api_key_env_var``.
40
41Dependencies
42------------
43- oc_diagnostics (structured debug logging via _dbg)
44- oc_chat.core.provider_oauth (lazy; stored-credential check when the API key variable is unset)
45- oc_chat.core.provider_plugin (lazy; ProviderPlugin type of the provider being validated)
46"""
47from __future__ import annotations
48
49from collections.abc import Mapping, Sequence
50from dataclasses import dataclass
51import ipaddress
52import os
53from pathlib import Path
54import re
55from typing import TYPE_CHECKING, Literal
56from urllib.parse import urlparse
57
58from oc_diagnostics import _dbg
59
60if TYPE_CHECKING:
61 from oc_chat.core.provider_plugin import ProviderPlugin
62
63
64_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
65
66
67Severity = Literal["error", "warning"]
68
69_ENDPOINT_ENTRY_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*=\s*(\S.*?)\s*$")
70_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"})
71
72
73@dataclass(frozen=True)
75 """One startup configuration diagnostic."""
76
77 severity: Severity
78 key: str
79 message: str
80 hint: str = ""
81
82
83@dataclass(frozen=True)
85 """Collection of startup configuration diagnostics."""
86
87 issues: tuple[ConfigIssue, ...] = ()
88
89 @property
90 def errors(self) -> tuple[ConfigIssue, ...]:
91 """Return fatal validation issues."""
92 return tuple(issue for issue in self.issues if issue.severity == "error")
93
94 @property
95 def warnings(self) -> tuple[ConfigIssue, ...]:
96 """Return non-fatal validation issues."""
97 return tuple(issue for issue in self.issues if issue.severity == "warning")
98
99 @property
100 def ok(self) -> bool:
101 """True when no fatal validation errors were found."""
102 return not self.errors
103
104
105def format_config_issue(issue: ConfigIssue) -> str:
106 """Return a user-facing one-line diagnostic for *issue*."""
107 prefix = "ERROR" if issue.severity == "error" else "WARNING"
108 text = f"{prefix}: {issue.key}: {issue.message}"
109 if issue.hint:
110 text += f" Hint: {issue.hint}"
111 return text
112
113
114def format_config_issues(issues: Sequence[ConfigIssue]) -> list[str]:
115 """Format multiple configuration diagnostics."""
116 return [format_config_issue(issue) for issue in issues]
117
118
120 issues: list[ConfigIssue],
121 severity: Severity,
122 key: str,
123 message: str,
124 hint: str = "",
125) -> None:
126 issues.append(ConfigIssue(severity=severity, key=key, message=message, hint=hint))
127
128
130 issues: list[ConfigIssue],
131 key: str,
132 value: str,
133) -> bool:
134 parsed = urlparse(value)
135 if parsed.scheme not in ("http", "https") or not parsed.netloc or not parsed.hostname:
136 _add(
137 issues,
138 "error",
139 key,
140 f"{value!r} is not a valid HTTP(S) URL.",
141 "Use a URL like http://localhost:11434 or https://api.example.com.",
142 )
143 return False
144 return True
145
146
147def _remote_allowed(env: Mapping[str, str]) -> bool:
148 return env.get("OC_CHAT_ALLOW_REMOTE_ENDPOINTS", "") == "1"
149
150
151def _classify_endpoint_url(base_url: str, env: Mapping[str, str]) -> str:
152 parsed = urlparse(base_url)
153 host = (parsed.hostname or "").strip().lower()
154 if not host:
155 return "blocked"
156 if host in _LOCAL_HOSTS:
157 return "local"
158 try:
159 ip = ipaddress.ip_address(host)
160 if ip.is_loopback:
161 return "local"
162 if ip.is_private:
163 return "lan"
164 except ValueError:
165 pass
166 return "remote" if _remote_allowed(env) else "blocked"
167
168
170 issues: list[ConfigIssue],
171 provider_name: str,
172 provider_names: Sequence[str] | None,
173) -> None:
174 if not provider_name:
175 _add(
176 issues,
177 "error",
178 "OC_CHAT_PROVIDER",
179 "provider name is blank.",
180 "Unset OC_CHAT_PROVIDER or set it to a registered provider.",
181 )
182 return
183 if provider_names is not None and provider_name not in set(provider_names):
184 available = ", ".join(sorted(provider_names)) or "(none)"
185 _add(
186 issues,
187 "error",
188 "OC_CHAT_PROVIDER",
189 f"unknown provider {provider_name!r}.",
190 f"Available providers: {available}.",
191 )
192
193
195 issues: list[ConfigIssue],
196 env: Mapping[str, str],
197 provider: "ProviderPlugin",
198) -> None:
199 from oc_chat.core.provider_oauth import has_usable_credentials
200 if (provider.api_key_env_var and not env.get(provider.api_key_env_var, "").strip()
201 and not has_usable_credentials(provider)):
202 _add(
203 issues,
204 "error",
205 provider.api_key_env_var,
206 "required API key is not set.",
207 f"Set {provider.api_key_env_var} in the environment or .env file.",
208 )
209
210 if not provider.base_url_env_var:
211 return
212
213 if provider.base_url_env_var in env and not env[provider.base_url_env_var].strip():
214 _add(
215 issues,
216 "error",
217 provider.base_url_env_var,
218 "base URL is set to a blank value.",
219 "Unset it to use the provider default, or set a valid HTTP(S) URL.",
220 )
221 return
222
223 base_url = env.get(provider.base_url_env_var, provider.default_base_url).strip()
224 if base_url:
225 _validate_http_url(issues, provider.base_url_env_var, base_url)
226
227
228def _validate_endpoint_env(issues: list[ConfigIssue], env: Mapping[str, str]) -> None:
229 allow_remote = env.get("OC_CHAT_ALLOW_REMOTE_ENDPOINTS")
230 if allow_remote not in (None, "", "1"):
231 _add(
232 issues,
233 "warning",
234 "OC_CHAT_ALLOW_REMOTE_ENDPOINTS",
235 f"value {allow_remote!r} does not enable remote endpoints.",
236 # OCT-LINT: disable=no_hardcoded_secrets reason="Environment variable names and user-facing hints, not secrets"
237 "Set OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1 to opt in, or unset it.",
238 )
239
240 raw = env.get("OLLAMA_BASE_URLS", "").strip()
241 if not raw:
242 return
243
244 seen: set[str] = set()
245 for entry in raw.split(","):
246 entry = entry.strip()
247 if not entry:
248 continue
249 match = _ENDPOINT_ENTRY_RE.match(entry)
250 if not match:
251 _add(
252 issues,
253 "error",
254 "OLLAMA_BASE_URLS",
255 f"malformed endpoint entry {entry!r}.",
256 "Use name=url entries separated by commas.",
257 )
258 continue
259 name, url = match.group(1), match.group(2)
260 if name in seen:
261 _add(
262 issues,
263 "warning",
264 "OLLAMA_BASE_URLS",
265 f"endpoint {name!r} is defined more than once; later value wins.",
266 )
267 seen.add(name)
268 if not _validate_http_url(issues, "OLLAMA_BASE_URLS", url):
269 continue
270 if _classify_endpoint_url(url, env) == "blocked":
271 _add(
272 issues,
273 "warning",
274 "OLLAMA_BASE_URLS",
275 f"endpoint {name!r} points to a public/remote host and will be blocked.",
276 # OCT-LINT: disable=no_hardcoded_secrets reason="Environment variable names and user-facing hints, not secrets"
277 "Set OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1 only if you intend to allow it.",
278 )
279
280
282 issues: list[ConfigIssue],
283 env: Mapping[str, str],
284 sessions_dir: str | None,
285) -> None:
286 if "OC_CHAT_SESSIONS_DIR" in env and not env["OC_CHAT_SESSIONS_DIR"].strip():
287 _add(
288 issues,
289 "error",
290 "OC_CHAT_SESSIONS_DIR",
291 "sessions directory is set to a blank value.",
292 "Unset it or set a writable directory path.",
293 )
294 return
295
296 path_text = sessions_dir or env.get("OC_CHAT_SESSIONS_DIR", "")
297 if not path_text:
298 return
299 path = Path(path_text)
300 if path.exists() and not path.is_dir():
301 _add(
302 issues,
303 "error",
304 "OC_CHAT_SESSIONS_DIR",
305 f"{str(path)!r} exists but is not a directory.",
306 "Set OC_CHAT_SESSIONS_DIR to a directory path.",
307 )
308
309
311 issues: list[ConfigIssue],
312 env: Mapping[str, str],
313 cwd: Path,
314) -> None:
315 if "OC_CHAT_CONTEXT_FILE" not in env:
316 return
317 raw = env.get("OC_CHAT_CONTEXT_FILE", "")
318 if not raw.strip():
319 return
320 path = Path(raw)
321 candidates = [path] if path.is_absolute() else [cwd / path]
322 if not any(candidate.exists() and candidate.is_file() for candidate in candidates):
323 _add(
324 issues,
325 "warning",
326 "OC_CHAT_CONTEXT_FILE",
327 f"context file {raw!r} was not found.",
328 "Unset OC_CHAT_CONTEXT_FILE or point it at a readable text file.",
329 )
330
331
332def _validate_color_env(issues: list[ConfigIssue], env: Mapping[str, str]) -> None:
333 if "NO_COLOR" in env and "FORCE_COLOR" in env:
334 _add(
335 issues,
336 "warning",
337 "NO_COLOR/FORCE_COLOR",
338 "both are set; FORCE_COLOR takes precedence.",
339 "Unset one of them to make terminal color behavior explicit.",
340 )
341
342
344 *,
345 env: Mapping[str, str] | None = None,
346 provider_name: str | None = None,
347 provider_names: Sequence[str] | None = None,
348 provider: "ProviderPlugin | None" = None,
349 sessions_dir: str | None = None,
350 cwd: Path | None = None,
351) -> ConfigValidationResult:
352 """Validate runtime config and return structured diagnostics."""
353 func = "validate_runtime_config"
354 env_map = os.environ if env is None else env
355 selected_provider = (
356 provider_name
357 if provider_name is not None
358 else env_map.get("OC_CHAT_PROVIDER", "deepseek")
359 ).strip()
360 root = Path.cwd() if cwd is None else cwd
361
362 issues: list[ConfigIssue] = []
363 _validate_provider_selection(issues, selected_provider, provider_names)
364 if provider is not None:
365 _validate_provider_env(issues, env_map, provider)
366 _validate_endpoint_env(issues, env_map)
367 _validate_sessions_dir(issues, env_map, sessions_dir)
368 _validate_context_file(issues, env_map, root)
369 _validate_color_env(issues, env_map)
370
371 result = ConfigValidationResult(tuple(issues))
372 _dbg(
373 "OC_CHAT.MAIN",
374 func,
375 f"errors={len(result.errors)} warnings={len(result.warnings)}",
376 3,
377 )
378 return result
379
380
381__all__ = [
382 "ConfigIssue",
383 "ConfigValidationResult",
384 "format_config_issue",
385 "format_config_issues",
386 "validate_runtime_config",
387]
None _validate_endpoint_env(list[ConfigIssue] issues, Mapping[str, str] env)
None _validate_context_file(list[ConfigIssue] issues, Mapping[str, str] env, Path cwd)
None _validate_sessions_dir(list[ConfigIssue] issues, Mapping[str, str] env, str|None sessions_dir)
str format_config_issue(ConfigIssue issue)
None _validate_provider_selection(list[ConfigIssue] issues, str provider_name, Sequence[str]|None provider_names)
bool _validate_http_url(list[ConfigIssue] issues, str key, str value)
ConfigValidationResult validate_runtime_config(*, Mapping[str, str]|None env=None, str|None provider_name=None, Sequence[str]|None provider_names=None, "ProviderPlugin | None" provider=None, str|None sessions_dir=None, Path|None cwd=None)
str _classify_endpoint_url(str base_url, Mapping[str, str] env)
bool _remote_allowed(Mapping[str, str] env)
None _add(list[ConfigIssue] issues, Severity severity, str key, str message, str hint="")
None _validate_color_env(list[ConfigIssue] issues, Mapping[str, str] env)
list[str] format_config_issues(Sequence[ConfigIssue] issues)
None _validate_provider_env(list[ConfigIssue] issues, Mapping[str, str] env, "ProviderPlugin" provider)