-
Notifications
You must be signed in to change notification settings - Fork 119
Expand file tree
/
Copy pathconfig.py
More file actions
276 lines (234 loc) · 9.14 KB
/
Copy pathconfig.py
File metadata and controls
276 lines (234 loc) · 9.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""LLM connection + sampling settings for the terminal agent.
``ModelConfig`` is the resolved LLM config a session runs with. It is built
by the YAML profile loader (:mod:`apodex.profiles`), which pulls
model/base_url/limits from a profile and secrets (``${OPENAI_API_KEY}`` …)
from ``.env`` — either via ``config/providers.yaml`` (``llm.provider:``) or
explicit ``${VAR}`` refs. Secrets live only in ``.env``; everything else
lives in the profile YAML.
"""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
if TYPE_CHECKING:
from apodex.profiles import AgentProfile
@dataclass
class ModelConfig:
"""LLM connection + sampling settings for the coding agent."""
model: str
api_key: str
base_url: str | None
temperature: float = 0.0
# Nucleus / top-k sampling. ``None`` leaves the server's own default, which
# is what every profile got before these existed. Neither is a
# Chat-Completions parameter on ``OpenAIClient``, so ``build_llm`` sends both
# through ``extra_body`` (vLLM / SGLang read them there).
#
# They only mean anything once ``temperature`` is off 0: at 0 SGLang takes
# the argmax path and does no probabilistic sampling, so both are inert.
top_p: float | None = None
top_k: int | None = None
max_tokens: int = 8192
# Model context window (tokens) — drives the context-fill indicator and the
# compaction limit. Override with $OPENAI_CONTEXT_WINDOW for big/small models.
context_window: int = 128_000
@property
def redacted_key(self) -> str:
k = self.api_key or ""
if len(k) <= 8:
return "***" if k else "(none)"
return f"{k[:4]}…{k[-4:]}"
@dataclass(frozen=True)
class RuntimeConfigIssue:
"""One secret-free runtime configuration finding."""
code: str
message: str
env_var: str | None = None
blocking: bool = True
@dataclass(frozen=True)
class RuntimeConfigStatus:
"""A safe, immutable view of resolved runtime configuration.
It intentionally has no API-key or raw-URL field. This makes the object
safe to pass to line-mode renderers, the TUI, logs, and screenshots.
"""
mode: str
profile_name: str
profile_path: str
provider: str
model: str
endpoint_host: str | None
api_key_env: str | None
api_key_configured: bool
issues: tuple[RuntimeConfigIssue, ...] = ()
@property
def errors(self) -> tuple[RuntimeConfigIssue, ...]:
return tuple(issue for issue in self.issues if issue.blocking)
@property
def warnings(self) -> tuple[RuntimeConfigIssue, ...]:
return tuple(issue for issue in self.issues if not issue.blocking)
@property
def ok(self) -> bool:
return not self.errors
_UNRESOLVED_ENV_RE = re.compile(r"\$(?:\{|[A-Z_])")
def _configured(value: str | None) -> bool:
stripped = (value or "").strip()
return bool(stripped) and not _UNRESOLVED_ENV_RE.search(stripped)
def inspect_runtime_config(
cfg: ModelConfig,
*,
profile: AgentProfile,
mode: str | None = None,
environ: Mapping[str, str] | None = None,
) -> RuntimeConfigStatus:
"""Perform a local, structural runtime preflight with no network calls."""
env = os.environ if environ is None else environ
active_mode = mode or profile.name
provider = profile.provider or "custom"
api_key_env = profile.api_key_env
key_configured = _configured(cfg.api_key)
if provider != "local" and (cfg.api_key or "").strip() == "EMPTY":
key_configured = False
issues: list[RuntimeConfigIssue] = []
if not key_configured:
source = f" ({api_key_env})" if api_key_env else ""
issues.append(RuntimeConfigIssue(
code="missing_api_key",
message=f"API key{source} is missing for provider {provider}.",
env_var=api_key_env,
))
model = (cfg.model or "").strip()
if not model:
issues.append(RuntimeConfigIssue(
code="missing_model",
message="The active model is empty.",
env_var=profile.model_env,
))
endpoint_host: str | None = None
if cfg.base_url:
try:
parsed = urlsplit(cfg.base_url)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
raise ValueError
endpoint_host = parsed.hostname
except (TypeError, ValueError):
issues.append(RuntimeConfigIssue(
code="invalid_base_url",
message="The provider base URL must be a valid HTTP(S) URL.",
env_var=profile.base_url_env,
))
if active_mode == "research":
if not _configured(env.get("SERPER_API_KEY")):
issues.append(RuntimeConfigIssue(
code="missing_serper_api_key",
message=(
"SERPER_API_KEY is required in research mode because "
"web_search cannot return results without it."
),
env_var="SERPER_API_KEY",
))
if not _configured(env.get("JINA_API_KEY")):
issues.append(RuntimeConfigIssue(
code="missing_jina_api_key",
message=(
"JINA_API_KEY is missing; web_fetch will use its direct-fetch fallback."
),
env_var="JINA_API_KEY",
blocking=False,
))
return RuntimeConfigStatus(
mode=active_mode,
profile_name=profile.name,
profile_path=str(profile.path),
provider=provider,
model=model,
endpoint_host=endpoint_host,
api_key_env=api_key_env,
api_key_configured=key_configured,
issues=tuple(issues),
)
def format_runtime_config_status(status: RuntimeConfigStatus) -> str:
"""Format safe runtime metadata for line mode or TUI display."""
source = f" ({status.api_key_env})" if status.api_key_env else ""
endpoint = status.endpoint_host or "provider default"
profile_path = status.profile_path or "unknown"
lines = [
f"profile: {status.profile_name} ({profile_path})",
f"mode: {status.mode}",
f"provider: {status.provider}",
f"model: {status.model or 'missing'}",
f"endpoint: {endpoint}",
f"API key{source}: {'configured' if status.api_key_configured else 'missing'}",
]
lines.extend(f"error: {issue.message}" for issue in status.errors)
lines.extend(f"warning: {issue.message}" for issue in status.warnings)
return "\n".join(lines)
def format_preflight_errors(status: RuntimeConfigStatus) -> str:
"""Format actionable, copyable guidance without rendering secret data."""
lines = ["error: runtime configuration preflight failed"]
for issue in status.errors:
lines.append(f"- {issue.message}")
env_vars = list(dict.fromkeys(
issue.env_var for issue in status.errors if issue.env_var
))
if env_vars:
lines.append("Set the missing or invalid values and retry, for example:")
lines.extend(f" export {name}=..." for name in env_vars)
else:
lines.append("Update the active profile's llm configuration and retry.")
lines.append(" frontier-agent --cwd .")
return "\n".join(lines)
_USER_SETTINGS_PATH = os.path.expanduser("~/.config/apodex/settings.json")
@dataclass
class UserSettings:
"""Persistent user preferences across CLI/TUI sessions (~/.config/apodex/settings.json)."""
theme: str = "tokyo-night"
workflow: str = "react"
auto_approve: bool = False
auto_for_me: bool = False
verbose: bool = True
plan_mode: bool = False
path: str = _USER_SETTINGS_PATH
@classmethod
def load(cls, path: str = _USER_SETTINGS_PATH) -> UserSettings:
import json
try:
with open(path, encoding="utf-8") as f:
d = json.load(f)
return cls(
theme=str(d.get("theme") or "tokyo-night"),
workflow=str(d.get("workflow") or "react"),
auto_approve=bool(d.get("auto_approve", False)),
auto_for_me=bool(d.get("auto_for_me", False)),
verbose=bool(d.get("verbose", True)),
plan_mode=bool(d.get("plan_mode", False)),
path=path,
)
except Exception:
return cls(path=path)
def save(self) -> None:
import json
try:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
json.dump({
"theme": self.theme,
"workflow": self.workflow,
"auto_approve": self.auto_approve,
"auto_for_me": self.auto_for_me,
"verbose": self.verbose,
"plan_mode": self.plan_mode,
}, f, indent=2)
except Exception:
pass
__all__ = [
"ModelConfig",
"RuntimeConfigIssue",
"RuntimeConfigStatus",
"UserSettings",
"format_preflight_errors",
"format_runtime_config_status",
"inspect_runtime_config",
]