ai-code-engineer 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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Settings, the provider table, and the one endpoint policy every caller shares.
|
|
2
|
+
|
|
3
|
+
The provider list lives here rather than in ``providers`` because both ``validate`` and the
|
|
4
|
+
windows need it, and ``providers`` imports this module — the other direction would be a cycle.
|
|
5
|
+
|
|
6
|
+
This module is also the only place in the codebase that names a model server's address. A URL resolves
|
|
7
|
+
in one order everywhere: what the person typed, a signed override row (``overrides.py``), what their
|
|
8
|
+
environment says for that provider (``OLLAMA_HOST``, ``GROQ_BASE_URL`` and the rest of
|
|
9
|
+
``Kind.url_env``), then the provider's own row.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
import re
|
|
16
|
+
import tomllib
|
|
17
|
+
|
|
18
|
+
from .errors import AgentError, PolicyError
|
|
19
|
+
from . import overrides
|
|
20
|
+
|
|
21
|
+
# The folder this tool keeps its own records in — the same one `cli.app_dir()` and `webapp/launch.py`
|
|
22
|
+
# compute. It is spelled here as well because a profile read from the terminal has no window to ask.
|
|
23
|
+
APP_DIR = Path(__file__).resolve().parents[2]
|
|
24
|
+
from . import overrides
|
|
25
|
+
|
|
26
|
+
# The request timeout is the one number a user can set in either window, and the saved registry,
|
|
27
|
+
# the browser's number input and Tk's spinbox all disagree about it by construction. The range
|
|
28
|
+
# lives here once; the widgets advertise these and every setter clamps through this function.
|
|
29
|
+
REQUEST_TIMEOUT_DEFAULT = 300
|
|
30
|
+
REQUEST_TIMEOUT_LOW = 30
|
|
31
|
+
REQUEST_TIMEOUT_HIGH = 900
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clamp_request_timeout(value, fallback: int = REQUEST_TIMEOUT_DEFAULT) -> int:
|
|
35
|
+
"""One request timeout inside the advertised range, unreadable input included.
|
|
36
|
+
|
|
37
|
+
A caller that must keep the previous value on garbage passes it as ``fallback``.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
number = int(value)
|
|
41
|
+
except (TypeError, ValueError, RuntimeError): # RuntimeError catches tkinter's TclError
|
|
42
|
+
return fallback
|
|
43
|
+
return min(REQUEST_TIMEOUT_HIGH, max(REQUEST_TIMEOUT_LOW, number))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
LOOPBACK = frozenset(("127.0.0.1", "localhost", "::1"))
|
|
47
|
+
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Kind:
|
|
52
|
+
"""One provider the tool can talk to, and the rules that come with it.
|
|
53
|
+
|
|
54
|
+
``cloud`` is not a description of the vendor: it is the consent switch. A cloud row refuses to
|
|
55
|
+
run until the user has approved sending code off the device, because nothing here can prove
|
|
56
|
+
where a remote server actually is.
|
|
57
|
+
"""
|
|
58
|
+
key: str # the Settings.provider value
|
|
59
|
+
label: str # what both windows show
|
|
60
|
+
base: str # default endpoint; "" means the user must type one
|
|
61
|
+
shape: str # "ollama" | "openai" — which wire protocol to speak
|
|
62
|
+
cloud: bool # consent required before any request
|
|
63
|
+
needs_key: bool # an API key is required
|
|
64
|
+
key_env: str # environment variable that may supply it
|
|
65
|
+
routing: bool = False # OpenRouter only: upstream routing block and model echo
|
|
66
|
+
free_only: bool = False # OpenRouter only: a paid model needs an explicit choice
|
|
67
|
+
verified: tuple[str, ...] = () # names to offer when a live list cannot be fetched
|
|
68
|
+
# The variable that may supply this provider's base URL, named the way that server already
|
|
69
|
+
# documents it rather than the way this tool would invent. A row with "" has no such convention.
|
|
70
|
+
url_env: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
KINDS = (
|
|
74
|
+
Kind("ollama", "Ollama", "http://127.0.0.1:11434", "ollama", False, False, "",
|
|
75
|
+
url_env="OLLAMA_HOST"),
|
|
76
|
+
Kind("lmstudio", "LM Studio", "http://localhost:1234/v1", "openai", False, False, "",
|
|
77
|
+
url_env="LMSTUDIO_HOST"),
|
|
78
|
+
Kind("vllm", "vLLM", "http://localhost:8000/v1", "openai", False, False, "", url_env="VLLM_HOST"),
|
|
79
|
+
Kind("openai", "OpenAI", "https://api.openai.com/v1", "openai", True, True, "OPENAI_API_KEY",
|
|
80
|
+
verified=("gpt-4o-mini", "gpt-4o"), url_env="OPENAI_BASE_URL"),
|
|
81
|
+
Kind("groq", "Groq", "https://api.groq.com/openai/v1", "openai", True, True, "GROQ_API_KEY",
|
|
82
|
+
verified=("llama-3.3-70b-versatile", "llama-3.1-8b-instant"), url_env="GROQ_BASE_URL"),
|
|
83
|
+
Kind("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "openai", True, True,
|
|
84
|
+
"DEEPSEEK_API_KEY", verified=("deepseek-chat", "deepseek-reasoner"),
|
|
85
|
+
url_env="DEEPSEEK_BASE_URL"),
|
|
86
|
+
Kind("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai", True, True,
|
|
87
|
+
"OPENROUTER_API_KEY", routing=True, free_only=True, url_env="OPENROUTER_BASE_URL"),
|
|
88
|
+
# A user-typed base URL. The shape is OpenAI-compatible; the trust is decided by the host, so
|
|
89
|
+
# loopback is local and anything else is treated exactly like a cloud row.
|
|
90
|
+
Kind("generic", "Custom endpoint", "", "openai", False, False, "", url_env="AGENT_ENDPOINT"),
|
|
91
|
+
)
|
|
92
|
+
BY_KEY = {kind.key: kind for kind in KINDS}
|
|
93
|
+
OLLAMA = BY_KEY["ollama"]
|
|
94
|
+
OPENROUTER = BY_KEY["openrouter"]
|
|
95
|
+
GENERIC = BY_KEY["generic"]
|
|
96
|
+
DEFAULT_KIND = OLLAMA
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def kind_for(value) -> Kind:
|
|
100
|
+
"""The row for a key or a label, or None. Windows carry labels, settings carry keys."""
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
return None
|
|
103
|
+
text = value.strip()
|
|
104
|
+
return BY_KEY.get(text.casefold()) or next(
|
|
105
|
+
(kind for kind in KINDS if kind.label.casefold() == text.casefold()), None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def needs_consent(kind: Kind, endpoint: str) -> bool:
|
|
109
|
+
"""Whether this pair needs the cloud approval. A custom URL is judged by its host, not its name."""
|
|
110
|
+
return kind.cloud or (kind.key == GENERIC.key and not is_loopback(endpoint))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def free_mode(kind: Kind) -> str:
|
|
114
|
+
return f"{kind.label} \u00b7 Free"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def paid_mode(kind: Kind) -> str:
|
|
118
|
+
return f"{kind.label} \u00b7 Paid"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def mode_rows() -> tuple[tuple[str, Kind], ...]:
|
|
122
|
+
"""The provider list as both windows show it: ``(label, kind)`` pairs.
|
|
123
|
+
|
|
124
|
+
OpenRouter is two rows because its free list and its paid list differ in what they cost, which
|
|
125
|
+
is a decision per task rather than a setting. Every other row is one provider. This lives here
|
|
126
|
+
so the Tk window and the web controller cannot drift apart again the way ``MODES`` and the
|
|
127
|
+
catalog URLs did.
|
|
128
|
+
"""
|
|
129
|
+
rows = []
|
|
130
|
+
for kind in KINDS:
|
|
131
|
+
if kind.free_only:
|
|
132
|
+
rows.append((free_mode(kind), kind))
|
|
133
|
+
rows.append((paid_mode(kind), kind))
|
|
134
|
+
else:
|
|
135
|
+
rows.append((kind.label, kind))
|
|
136
|
+
return tuple(rows)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
MODES = tuple(label for label, _ in mode_rows())
|
|
140
|
+
MODE_KIND = dict(mode_rows())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def mode_for(provider: str, model: str = "") -> str:
|
|
144
|
+
"""The row a profile's provider (and the price class of its model) lands the window on."""
|
|
145
|
+
kind = kind_for(provider)
|
|
146
|
+
if kind is None:
|
|
147
|
+
return ""
|
|
148
|
+
if kind.free_only:
|
|
149
|
+
name = str(model or "")
|
|
150
|
+
if not name or name == "openrouter/free" or name.endswith(":free"):
|
|
151
|
+
return free_mode(kind)
|
|
152
|
+
return paid_mode(kind)
|
|
153
|
+
return kind.label
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_loopback(endpoint: str) -> bool:
|
|
157
|
+
try:
|
|
158
|
+
return urlparse(endpoint or "").hostname in LOOPBACK
|
|
159
|
+
except ValueError:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def env_url(kind: Kind) -> str:
|
|
164
|
+
"""The base URL this provider's own environment variable supplies, or "".
|
|
165
|
+
|
|
166
|
+
Normalising is the whole job here, because `OLLAMA_HOST` is documented as `host:port` with no
|
|
167
|
+
scheme: a value copied out of a shell profile has to become a URL the same policy can judge. A
|
|
168
|
+
variable set to nothing is not a value — the table's own base answers then.
|
|
169
|
+
"""
|
|
170
|
+
if not kind.url_env:
|
|
171
|
+
return ""
|
|
172
|
+
raw = str(os.environ.get(kind.url_env) or "").strip()
|
|
173
|
+
if not raw:
|
|
174
|
+
return ""
|
|
175
|
+
return raw if "://" in raw else "http://" + raw
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def endpoint_override(app_dir, kind: Kind) -> str:
|
|
179
|
+
"""The address this provider's signed row supplies, or "" when the store has nothing to say.
|
|
180
|
+
|
|
181
|
+
``app_dir`` is a caller's, never a guess here: a resolution that read whichever folder the process
|
|
182
|
+
happened to start in is how two surfaces end up disagreeing about where their traffic goes.
|
|
183
|
+
"""
|
|
184
|
+
if app_dir is None:
|
|
185
|
+
return ""
|
|
186
|
+
value = overrides.values(app_dir, kind.key).get("endpoint")
|
|
187
|
+
return str(value or "").strip()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def default_endpoint(kind: Kind, app_dir=None) -> str:
|
|
191
|
+
"""What the endpoint would be if nobody typed one, in the same order the gates resolve it.
|
|
192
|
+
|
|
193
|
+
A window that showed ``kind.base`` here could be advertising a URL its own policy then refuses,
|
|
194
|
+
which is the difference between a default and a hint that lies.
|
|
195
|
+
"""
|
|
196
|
+
return endpoint_override(app_dir, kind) or env_url(kind) or kind.base
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_endpoint(kind: Kind, endpoint) -> str:
|
|
200
|
+
"""The base URL for a provider, validated and normalised, or a PolicyError saying why not.
|
|
201
|
+
|
|
202
|
+
The order is one rule with three sources: what the person typed in this window, what their
|
|
203
|
+
environment says for that provider, and what the provider's own row in ``KINDS`` carries. Nothing
|
|
204
|
+
else in the codebase names a model URL, which is what keeps a fourth provider from being a code
|
|
205
|
+
change rather than a table entry and a profile.
|
|
206
|
+
|
|
207
|
+
Paths are allowed — they are the whole shape of an OpenAI-compatible base
|
|
208
|
+
(``http://localhost:1234/v1``) — which is what the old loopback rule got wrong. What stays
|
|
209
|
+
refused: any scheme but http/https, credentials in the URL, a query or a fragment, and a
|
|
210
|
+
provider that is not supposed to leave the machine doing exactly that.
|
|
211
|
+
"""
|
|
212
|
+
typed = str(endpoint or "").strip()
|
|
213
|
+
from_env = not typed and bool(env_url(kind))
|
|
214
|
+
where = f" (set by {kind.url_env})" if from_env else ""
|
|
215
|
+
raw = typed or env_url(kind) or kind.base
|
|
216
|
+
if not raw:
|
|
217
|
+
raise PolicyError("This provider needs an endpoint before it can be used.")
|
|
218
|
+
try:
|
|
219
|
+
url = urlparse(raw)
|
|
220
|
+
except ValueError:
|
|
221
|
+
raise PolicyError("The endpoint is not a valid URL.") from None
|
|
222
|
+
if url.scheme not in {"http", "https"} or not url.hostname:
|
|
223
|
+
raise PolicyError("The endpoint must be an http or https URL." + where)
|
|
224
|
+
if url.username or url.password:
|
|
225
|
+
raise PolicyError("The endpoint must not carry credentials." + where)
|
|
226
|
+
if url.query or url.fragment:
|
|
227
|
+
raise PolicyError("The endpoint must not carry a query or a fragment." + where)
|
|
228
|
+
local = url.hostname in LOOPBACK
|
|
229
|
+
if not kind.cloud and kind.key != GENERIC.key and not local:
|
|
230
|
+
raise PolicyError(f"{kind.label} is a local provider: the endpoint must be this device "
|
|
231
|
+
"(127.0.0.1, localhost or ::1)." + where)
|
|
232
|
+
# Cleartext to another machine is the one shape that leaks a key, so it is refused for everyone.
|
|
233
|
+
# A cloud row aimed at loopback is allowed: that is a local proxy on the user's own device, and
|
|
234
|
+
# ``make_provider`` still gates it behind the cloud consent switch before it can be reached.
|
|
235
|
+
if needs_consent(kind, raw) and not local and url.scheme != "https":
|
|
236
|
+
raise PolicyError(f"{kind.label} at a remote address sends your code and your key over the "
|
|
237
|
+
"internet, so the endpoint must be https." + where)
|
|
238
|
+
return raw.rstrip("/")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# The smallest budget a task can start inside. The instruction block, the task line and a repository
|
|
242
|
+
# map are spent before any of the project's own files are read, so a number under this one cannot be
|
|
243
|
+
# fixed by choosing a smaller repository -- every task fails on the first turn. It is refused where it
|
|
244
|
+
# is set, with a range in the message, rather than failing later with a sentence that blames the
|
|
245
|
+
# project. `test_the_smallest_budget_still_starts` keeps the two numbers from drifting apart.
|
|
246
|
+
MIN_CONTEXT_CHARS = 6000
|
|
247
|
+
|
|
248
|
+
# The number ranges, written once. `overrides.py` prints these into the file it creates and judges a
|
|
249
|
+
# row against them through ``validate``, so a limit cannot be restated in the module that stores them —
|
|
250
|
+
# a second table is what eventually disagrees with the first.
|
|
251
|
+
LIMITS = {"max_turns": (1, 30), "timeout_seconds": (1, 900),
|
|
252
|
+
"context_chars": (MIN_CONTEXT_CHARS, 100000), "output_tokens": (256, 8192)}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class Settings:
|
|
257
|
+
provider: str = "ollama"
|
|
258
|
+
model: str = "qwen2.5-coder:1.5b"
|
|
259
|
+
# No address of its own. "" means "ask the order `check_endpoint` implements": the profile, then
|
|
260
|
+
# the provider's environment variable, then its row in the table. A default written here would be
|
|
261
|
+
# one more place a URL is hardcoded, and the one that got read for a provider it never named.
|
|
262
|
+
endpoint: str = ""
|
|
263
|
+
max_turns: int = 12
|
|
264
|
+
timeout_seconds: int = 120
|
|
265
|
+
context_chars: int = 24000
|
|
266
|
+
output_tokens: int = 4096
|
|
267
|
+
# The *name* of the variable that holds a key, never a key. A profile that carried a value would
|
|
268
|
+
# put a credential in a file that is meant to be committed.
|
|
269
|
+
api_key_env: str = ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def apply_overrides(settings: Settings, app_dir=None, *, keep=()) -> Settings:
|
|
273
|
+
"""Put this provider's signed rows under the values the caller is actually looking at.
|
|
274
|
+
|
|
275
|
+
A row never carries an address or a provider, and that is the whole safety of the layer: where the
|
|
276
|
+
code and the key go stays decided by the profile or the field on screen, and swapping which vendor
|
|
277
|
+
answers a task from a side file is the failure ``providers.py`` already refuses upstream fallbacks
|
|
278
|
+
for. Everything else a row states outranks a profile line and a table default — that is the point of
|
|
279
|
+
a file the program writes rather than the operator. ``keep`` names the exceptions a caller states:
|
|
280
|
+
a window passes `model`, because the model its list has selected is the one being reviewed.
|
|
281
|
+
|
|
282
|
+
``app_dir=None`` keeps the store out of the resolution entirely, which is what a caller with no
|
|
283
|
+
records directory means, and what every test that has never heard of overrides relies on.
|
|
284
|
+
"""
|
|
285
|
+
if app_dir is None:
|
|
286
|
+
return settings
|
|
287
|
+
kind = kind_for(settings.provider)
|
|
288
|
+
if kind is None:
|
|
289
|
+
return settings
|
|
290
|
+
changes = {key: value for key, value in overrides.values(app_dir, kind.key).items()
|
|
291
|
+
if key != "endpoint" and key not in keep}
|
|
292
|
+
if not changes:
|
|
293
|
+
return settings
|
|
294
|
+
merged = replace(settings, **changes)
|
|
295
|
+
try:
|
|
296
|
+
validate(merged)
|
|
297
|
+
except AgentError:
|
|
298
|
+
# Individually valid rows can still stop fitting the rules this version has. Refusing the set
|
|
299
|
+
# silently would be the exact thing the signature exists to prevent, and raising would fail a
|
|
300
|
+
# task that has nothing to do with the row, so: named, dropped, and the profile is used.
|
|
301
|
+
for key in changes:
|
|
302
|
+
overrides.note(app_dir, key, "no-longer-valid")
|
|
303
|
+
return settings
|
|
304
|
+
return merged
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def load_settings(path: Path | None, app_dir=None) -> Settings:
|
|
308
|
+
if path is None:
|
|
309
|
+
# No profile read, so nothing was stated for it: the rows fill in the defaults, and an address
|
|
310
|
+
# they supply resolves through the same order a profile's own line would.
|
|
311
|
+
settings = Settings()
|
|
312
|
+
where = endpoint_override(app_dir, kind_for(settings.provider) or DEFAULT_KIND)
|
|
313
|
+
return apply_overrides(replace(settings, endpoint=where) if where else settings, app_dir)
|
|
314
|
+
try:
|
|
315
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
316
|
+
if set(data) - {"model", "limits"}:
|
|
317
|
+
raise AgentError("Unknown configuration section.")
|
|
318
|
+
model = data.get("model", {})
|
|
319
|
+
limits = data.get("limits", {})
|
|
320
|
+
if not isinstance(model, dict) or not isinstance(limits, dict):
|
|
321
|
+
raise AgentError("Configuration model and limits must be TOML tables.")
|
|
322
|
+
if set(model) - {"provider", "name", "endpoint", "api_key_env"} or set(limits) - {
|
|
323
|
+
"max_turns", "timeout_seconds", "context_chars", "output_tokens"
|
|
324
|
+
}:
|
|
325
|
+
raise AgentError("Unknown configuration field.")
|
|
326
|
+
settings = Settings(
|
|
327
|
+
provider=model.get("provider", "ollama"),
|
|
328
|
+
model=model.get("name", "qwen2.5-coder:1.5b"),
|
|
329
|
+
api_key_env=model.get("api_key_env", ""),
|
|
330
|
+
**limits,
|
|
331
|
+
)
|
|
332
|
+
# The endpoint is resolved against the provider this same file names, through the one function
|
|
333
|
+
# that owns the order. It used to fall back to a literal Ollama address here, which meant a
|
|
334
|
+
# cloud profile that forgot its endpoint validated — loopback passes the https rule — and then
|
|
335
|
+
# sent the task to a local port instead of to the vendor its own name.
|
|
336
|
+
kind = kind_for(settings.provider)
|
|
337
|
+
if kind is None:
|
|
338
|
+
raise AgentError("Provider must be one of: " + ", ".join(item.key for item in KINDS) + ".")
|
|
339
|
+
# An address the profile spells out is the one thing a row never replaces; when it names none,
|
|
340
|
+
# the order is the row, then the provider's own environment variable, then its table entry.
|
|
341
|
+
typed = str(model.get("endpoint", "") or "").strip()
|
|
342
|
+
settings = replace(settings, endpoint=check_endpoint(
|
|
343
|
+
kind, typed or endpoint_override(app_dir, kind)))
|
|
344
|
+
settings = apply_overrides(settings, app_dir)
|
|
345
|
+
validate(settings)
|
|
346
|
+
return settings
|
|
347
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
348
|
+
raise AgentError("Configuration is unreadable or invalid TOML/types.") from exc
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def validate(settings: Settings) -> None:
|
|
352
|
+
kind = kind_for(settings.provider)
|
|
353
|
+
if kind is None:
|
|
354
|
+
raise AgentError("Provider must be one of: " + ", ".join(item.key for item in KINDS) + ".")
|
|
355
|
+
if not isinstance(settings.model, str) or not settings.model.strip():
|
|
356
|
+
raise AgentError("A model name is required.")
|
|
357
|
+
if not isinstance(settings.endpoint, str):
|
|
358
|
+
raise AgentError("Endpoint must be a string.")
|
|
359
|
+
if not isinstance(settings.api_key_env, str) or (
|
|
360
|
+
settings.api_key_env and not ENV_NAME.fullmatch(settings.api_key_env)):
|
|
361
|
+
raise AgentError("api_key_env must name an environment variable, not hold a key.")
|
|
362
|
+
check_endpoint(kind, settings.endpoint)
|
|
363
|
+
for name, (low, high) in LIMITS.items():
|
|
364
|
+
value = getattr(settings, name)
|
|
365
|
+
if type(value) is not int or not low <= value <= high:
|
|
366
|
+
raise AgentError(f"{name} must be between {low} and {high}.")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def settings_for(kind: Kind, endpoint: str = "", *, app_dir=None, keep=("model",),
|
|
370
|
+
**changes) -> Settings:
|
|
371
|
+
"""A Settings for one provider row, with its own default base when nothing was typed.
|
|
372
|
+
|
|
373
|
+
Both windows used to hand every choice to ``replace(Settings(), …)`` with a literal provider
|
|
374
|
+
name; this is the one place that pairs a kind with the endpoint that kind actually uses.
|
|
375
|
+
|
|
376
|
+
``keep`` defaults to `model` because that is the one value a window is showing as a decision: the
|
|
377
|
+
model its list has selected is the one a person reviewed and clicked. The other numbers on the
|
|
378
|
+
screen are remembered preferences rather than choices made for this run, so a row outranks them —
|
|
379
|
+
and the Overrides section of both windows lists the rows currently in force, which is what keeps a
|
|
380
|
+
number the field disagrees with from being a surprise.
|
|
381
|
+
"""
|
|
382
|
+
settings = replace(Settings(), provider=kind.key,
|
|
383
|
+
endpoint=check_endpoint(kind, endpoint or endpoint_override(app_dir, kind)),
|
|
384
|
+
**changes)
|
|
385
|
+
return apply_overrides(settings, app_dir, keep=keep)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# A profile label arrives from a browser dropdown, so it is a name from a closed shape rather than
|
|
389
|
+
# a path: "local" reads profiles/local.toml and "../../windows/win.ini" reads nothing at all.
|
|
390
|
+
PROFILE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,39}")
|
|
391
|
+
PROFILES_DIR = Path(__file__).resolve().parents[2] / "profiles"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def profile_names(directory: Path | None = None) -> list[str]:
|
|
395
|
+
try:
|
|
396
|
+
return sorted(path.stem for path in (directory or PROFILES_DIR).glob("*.toml")
|
|
397
|
+
if PROFILE_NAME.fullmatch(path.stem))
|
|
398
|
+
except OSError:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def load_profile(label: str, directory: Path | None = None, app_dir=None) -> Settings:
|
|
403
|
+
if not PROFILE_NAME.fullmatch(str(label or "")):
|
|
404
|
+
raise AgentError("Unknown configuration profile.")
|
|
405
|
+
return load_settings((directory or PROFILES_DIR) / f"{label}.toml", app_dir)
|