7Runtime configuration validation.
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.
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
22- :class:`ConfigValidationResult` - group issues and expose convenience
23 accessors for callers that need to stop on errors.
29 L2 - startup validation summary.
30 L3 - validation details.
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``.
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)
47from __future__
import annotations
49from collections.abc
import Mapping, Sequence
50from dataclasses
import dataclass
53from pathlib
import Path
55from typing
import TYPE_CHECKING, Literal
56from urllib.parse
import urlparse
58from oc_diagnostics
import _dbg
64_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
67Severity = Literal[
"error",
"warning"]
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"})
73@dataclass(frozen=True)
75 """One startup configuration diagnostic."""
83@dataclass(frozen=True)
85 """Collection of startup configuration diagnostics."""
87 issues: tuple[ConfigIssue, ...] = ()
90 def errors(self) -> tuple[ConfigIssue, ...]:
91 """Return fatal validation issues."""
92 return tuple(issue
for issue
in self.
issues if issue.severity ==
"error")
96 """Return non-fatal validation issues."""
97 return tuple(issue
for issue
in self.
issues if issue.severity ==
"warning")
100 def ok(self) -> bool:
101 """True when no fatal validation errors were found."""
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}"
110 text += f
" Hint: {issue.hint}"
115 """Format multiple configuration diagnostics."""
120 issues: list[ConfigIssue],
126 issues.append(
ConfigIssue(severity=severity, key=key, message=message, hint=hint))
130 issues: list[ConfigIssue],
134 parsed = urlparse(value)
135 if parsed.scheme
not in (
"http",
"https")
or not parsed.netloc
or not parsed.hostname:
140 f
"{value!r} is not a valid HTTP(S) URL.",
141 "Use a URL like http://localhost:11434 or https://api.example.com.",
148 return env.get(
"OC_CHAT_ALLOW_REMOTE_ENDPOINTS",
"") ==
"1"
152 parsed = urlparse(base_url)
153 host = (parsed.hostname
or "").strip().lower()
156 if host
in _LOCAL_HOSTS:
159 ip = ipaddress.ip_address(host)
170 issues: list[ConfigIssue],
172 provider_names: Sequence[str] |
None,
174 if not provider_name:
179 "provider name is blank.",
180 "Unset OC_CHAT_PROVIDER or set it to a registered provider.",
183 if provider_names
is not None and provider_name
not in set(provider_names):
184 available =
", ".join(sorted(provider_names))
or "(none)"
189 f
"unknown provider {provider_name!r}.",
190 f
"Available providers: {available}.",
195 issues: list[ConfigIssue],
196 env: Mapping[str, str],
197 provider:
"ProviderPlugin",
200 if (provider.api_key_env_var
and not env.get(provider.api_key_env_var,
"").strip()
201 and not has_usable_credentials(provider)):
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.",
210 if not provider.base_url_env_var:
213 if provider.base_url_env_var
in env
and not env[provider.base_url_env_var].strip():
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.",
223 base_url = env.get(provider.base_url_env_var, provider.default_base_url).strip()
229 allow_remote = env.get(
"OC_CHAT_ALLOW_REMOTE_ENDPOINTS")
230 if allow_remote
not in (
None,
"",
"1"):
234 "OC_CHAT_ALLOW_REMOTE_ENDPOINTS",
235 f
"value {allow_remote!r} does not enable remote endpoints.",
237 "Set OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1 to opt in, or unset it.",
240 raw = env.get(
"OLLAMA_BASE_URLS",
"").strip()
244 seen: set[str] = set()
245 for entry
in raw.split(
","):
246 entry = entry.strip()
249 match = _ENDPOINT_ENTRY_RE.match(entry)
255 f
"malformed endpoint entry {entry!r}.",
256 "Use name=url entries separated by commas.",
259 name, url = match.group(1), match.group(2)
265 f
"endpoint {name!r} is defined more than once; later value wins.",
275 f
"endpoint {name!r} points to a public/remote host and will be blocked.",
277 "Set OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1 only if you intend to allow it.",
282 issues: list[ConfigIssue],
283 env: Mapping[str, str],
284 sessions_dir: str |
None,
286 if "OC_CHAT_SESSIONS_DIR" in env
and not env[
"OC_CHAT_SESSIONS_DIR"].strip():
290 "OC_CHAT_SESSIONS_DIR",
291 "sessions directory is set to a blank value.",
292 "Unset it or set a writable directory path.",
296 path_text = sessions_dir
or env.get(
"OC_CHAT_SESSIONS_DIR",
"")
299 path = Path(path_text)
300 if path.exists()
and not path.is_dir():
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.",
311 issues: list[ConfigIssue],
312 env: Mapping[str, str],
315 if "OC_CHAT_CONTEXT_FILE" not in env:
317 raw = env.get(
"OC_CHAT_CONTEXT_FILE",
"")
321 candidates = [path]
if path.is_absolute()
else [cwd / path]
322 if not any(candidate.exists()
and candidate.is_file()
for candidate
in candidates):
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.",
333 if "NO_COLOR" in env
and "FORCE_COLOR" in env:
337 "NO_COLOR/FORCE_COLOR",
338 "both are set; FORCE_COLOR takes precedence.",
339 "Unset one of them to make terminal color behavior explicit.",
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 = (
357 if provider_name
is not None
358 else env_map.get(
"OC_CHAT_PROVIDER",
"deepseek")
360 root = Path.cwd()
if cwd
is None else cwd
362 issues: list[ConfigIssue] = []
364 if provider
is not None:
375 f
"errors={len(result.errors)} warnings={len(result.warnings)}",
383 "ConfigValidationResult",
384 "format_config_issue",
385 "format_config_issues",
386 "validate_runtime_config",
tuple[ConfigIssue,...] errors(self)
tuple[ConfigIssue,...] warnings(self)
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)