oc_chat
Loading...
Searching...
No Matches
provider_wizard.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/provider_wizard.py
4
5"""
6The "Add provider" wizard — onboard a lane from the opening menu, safely.
7
8Purpose
9-------
10Plan 216 T3/T4 and the flow pinned in P5: the operator picks "Add
11provider" in the opening menu, optionally bases the new lane on a known
12one, enters a key ONCE with masked input, settles the model list (cached,
13fetched live, or typed), answers the two tier questions SEPARATELY (P3),
14records good-at / bad-at notes and an optional budget (P4), confirms, and
15the lane appears in the menu immediately.
16
17Two properties matter more than the flow itself:
18
19- **Cancel safety.** Every prompt can be cancelled, and a cancelled run
20 writes NOTHING. This is structural rather than careful: the run
21 accumulates answers in memory and commits both files only after the
22 final confirmation, so there is no interior state in which a partial
23 sidecar or a partial overlay can exist (W5).
24- **No key material anywhere but the sidecar.** The captured value is
25 passed to :func:`write_secret` and to the live fetch, and nowhere else.
26 Third-party error text is REDACTED against it before display, because
27 an upstream 401 body is entirely capable of quoting the key back.
28
29Responsibilities
30----------------
31- :func:`run_add_provider_wizard` — the whole flow; returns the written
32 :class:`OverlayRow`, or ``None`` when cancelled or refused.
33- :func:`fetch_models_for_lane` — T4: cache-first, live only on the
34 explicit action.
35
36Diagnostics
37-----------
38Domain: OC_CHAT.MAIN
39Levels:
40 L2 — lifecycle events (run started, cancelled, committed)
41 L3 — semantic details (step names, lane name, model counts)
42 L4 — deep tracing
43
44Contracts
45---------
46- **Validation happens ONCE, at the end**, against the same validator the
47 shipped catalogue uses. The wizard never re-prompts on a bad answer, so
48 a rejected run is indistinguishable from a cancelled one where it
49 matters: nothing is written either way. The rejection names the row and
50 the reason.
51- Typing ``q`` at any prompt cancels, as do Ctrl+C and EOF. A lane
52 literally named ``q`` is therefore unreachable through the wizard; it
53 remains reachable by hand-editing the overlay.
54- The ONLY network call this module can make is
55 :func:`fetch_models_for_lane` with ``force=True``, which happens only
56 on the operator's explicit "fetch" answer (P6). Nothing else here opens
57 a socket, and the test suite never sets ``force=True`` against a real
58 client.
59- ``verification`` is always written as ``"chat"``. The wizard has no way
60 to evidence tool support, and 215 P4 reserves promotion for a human who
61 ran the conformance runner against a live key.
62- Commit order is sidecar THEN overlay: the sidecar is the write that can
63 legitimately refuse (the W1 canary), so it goes first. A failure after
64 it leaves an unused environment variable, which is inert; the reverse
65 order would leave a visible lane with no key behind it.
66- Env-var names default to a derivation from the NEW lane name even when
67 a template lane is copied, so two lanes never silently share one key
68 variable.
69
70Dependencies
71------------
72- oc_diagnostics (structured debug logging via _dbg)
73- oc_chat.core.provider_secrets (masked capture + the gitignored sidecar)
74- oc_chat.core.lane_overlay (the untracked overlay row + merge)
75- providers.openai_compat.catalogue (lazy; the shipped Lane rows and the Lane type)
76- providers.openai_compat.client (lazy; OpenAICompatClient for the live model fetch)
77"""
78from __future__ import annotations
79
80import re
81from datetime import datetime, timezone
82from pathlib import Path
83from typing import Any, Callable, Sequence, TYPE_CHECKING
84
85from oc_diagnostics import _dbg
86
87from oc_chat.core import provider_secrets
88from oc_chat.core.lane_overlay import (
89 CAPABILITY_TIERS,
90 DEFAULT_OVERLAY_PATH,
91 OverlayError,
92 OverlayRow,
93 load_overlay,
94 merge_lanes,
95 row_from_json,
96 write_overlay_row,
97)
98
99if TYPE_CHECKING: # pragma: no cover — typing only
100 from providers.openai_compat.catalogue import Lane
101
102_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
103
104
105#: Typing this at any prompt cancels the run (see the module contract).
106CANCEL_ANSWER = "q"
107
108#: The four role tiers, in the order the wizard asks for them (P3 screen one).
109ROLE_TIERS: tuple[str, ...] = ("default", "cheap", "reasoning", "summary")
110
111_NON_NAME_RE = re.compile(r"[^A-Z0-9]+")
112
113
114def _now_iso() -> str:
115 """Current UTC time as an ISO-8601 string."""
116 return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
117
118
119def _redact(text: str, secret: str | None) -> str:
120 """Replace *secret* wherever it appears in *text*.
121
122 Upstream error bodies quote request material more often than anyone
123 would like. Since the key is known here, it can be scrubbed rather
124 than hoped about.
125 """
126 if not secret or not text:
127 return text
128 return text.replace(secret, "***")
129
130
131def _derive_env_var(lane_name: str, suffix: str) -> str:
132 """Derive an UPPER_SNAKE env-var name from *lane_name*."""
133 stem = _NON_NAME_RE.sub("_", lane_name.upper()).strip("_")
134 return f"{stem}_{suffix}" if stem else suffix
135
136
138 driver: Any,
139 presenter: Any,
140 prompt_text: str,
141 *,
142 default: str = "",
143) -> str | None:
144 """Ask one question. Return the answer, or ``None`` when cancelled."""
145 try:
146 if driver is not None:
147 raw = driver.read_user_input(None, prompt_text=prompt_text)
148 else:
149 raw = input(prompt_text)
150 except (EOFError, KeyboardInterrupt):
151 return None
152 if raw is None:
153 return None
154 answer = raw.strip()
155 if answer.lower() == CANCEL_ANSWER:
156 return None
157 return answer or default
158
159
161 lane: "Lane",
162 api_key: str,
163 *,
164 cached: Sequence[str] = (),
165 force: bool = False,
166 client_factory: Callable[[Any, str], Any] | None = None,
167) -> tuple[str, ...]:
168 """Return the model ids for *lane* — cache first, live only on demand.
169
170 Without ``force`` this returns *cached* verbatim and opens no
171 connection, including when the cache is EMPTY: an empty cache is not
172 an implicit licence to reach the network (P6).
173
174 The live path calls ``client.list_models()``, which is exactly what
175 ``OpenAICompatProvider.fetch_live_models`` delegates to. The provider
176 is not constructed here because it requires a validated tier map, and
177 during onboarding the tier map is the thing this call informs.
178 """
179 func = "fetch_models_for_lane"
180 if not force:
181 _dbg("OC_CHAT.MAIN", func,
182 f"lane={lane.name} served {len(cached)} model(s) from cache", 3)
183 return tuple(cached)
184
185 factory = client_factory if client_factory is not None else _default_client_factory
186 client = factory(lane, api_key)
187 models = tuple(str(model) for model in client.list_models())
188 _dbg("OC_CHAT.MAIN", func,
189 f"lane={lane.name} fetched {len(models)} model(s) live", 2)
190 return models
191
192
193def _default_client_factory(lane: "Lane", api_key: str) -> Any:
194 """Build the real OpenAI-compatible client for *lane*."""
195 from providers.openai_compat.client import OpenAICompatClient
196
197 return OpenAICompatClient(
198 api_key=api_key,
199 base_url=lane.default_base_url,
200 extra_headers=dict(lane.extra_headers),
201 lane_name=lane.name,
202 )
203
204
205def _known_lanes(shipped: Sequence["Lane"], overlay_path: Path) -> list["Lane"]:
206 """Shipped rows followed by overlay rows — the "base this on" list."""
207 try:
208 overlay = load_overlay(overlay_path)
209 except OverlayError:
210 overlay = ()
211 return list(shipped) + [row.lane for row in overlay]
212
213
215 catalogue: Any,
216 *,
217 name: str,
218 label: str,
219 base_url: str,
220 key_var: str,
221 url_var: str,
222 headers: dict[str, str],
223) -> "Lane":
224 """A Lane carrying only what the fetch step needs (no models yet).
225
226 :class:`Lane` is a plain frozen dataclass: construction does not
227 validate, which is what makes this legal. The row is never returned to
228 a caller and never registered — it exists to carry the base URL and
229 headers into the client factory.
230 """
231 return catalogue.Lane(
232 name=name,
233 display_label=label,
234 default_base_url=base_url,
235 base_url_env_var=url_var,
236 api_key_env_var=key_var,
237 models=(),
238 tiers={},
239 free=False,
240 verification="chat",
241 notes="(provisional - not registered)",
242 extra_headers=headers,
243 )
244
245
247 *,
248 capability_tier: str,
249 good_at: str,
250 bad_at: str,
251 model_source: str,
252 stamp: str,
253) -> str:
254 """Build the row's provenance ``notes``, which must never be empty.
255
256 The shipped catalogue requires provenance on every row, and the
257 wizard is a provenance event: it records WHEN the lane was added, HOW
258 the model list was obtained, and the operator's own judgment, then
259 states plainly that nothing here is verified beyond chat.
260 """
261 return (
262 f"Added by the OC Chat provider wizard on {stamp}. "
263 f"Capability tier (operator judgment): {capability_tier}. "
264 f"Good at: {good_at or 'not recorded'}. "
265 f"Bad at: {bad_at or 'not recorded'}. "
266 f"Model list: {model_source}. "
267 f"Context windows are the contract default (unmeasured). "
268 f"Verified: chat only - run the conformance runner against a live key "
269 f"before claiming tool support."
270 )
271
272
274 presenter: Any,
275 *,
276 prompt_driver: Any = None,
277 key_reader: Callable[[str], str] | None = None,
278 overlay_path: str | Path = DEFAULT_OVERLAY_PATH,
279 secrets_path: str | Path | None = None,
280 shipped_lanes: Sequence["Lane"] | None = None,
281 client_factory: Callable[[Any, str], Any] | None = None,
282 now: Callable[[], str] | None = None,
283) -> OverlayRow | None:
284 """Run the "Add provider" flow. Return the written row, or ``None``.
285
286 ``None`` means "nothing was written" in every case — cancelled at a
287 prompt, cancelled at the key, declined at the confirmation, a failed
288 live fetch, or a row the validator refused.
289 """
290 func = "run_add_provider_wizard"
291 try:
292 import providers.openai_compat.catalogue as catalogue
293 except ImportError:
294 presenter.show_error(
295 " Add provider needs the openai_compat provider bundle, which is "
296 "not installed."
297 )
298 return None
299
300 overlay_target = Path(overlay_path)
301 secrets_target = Path(
302 secrets_path if secrets_path is not None
303 else provider_secrets.DEFAULT_SECRETS_PATH
304 )
305 stamp = (now or _now_iso)()
306 shipped = tuple(shipped_lanes) if shipped_lanes is not None else catalogue.LANES
307
308 presenter.show_info("")
309 presenter.show_info(" Add a provider lane")
310 presenter.show_info(" " + "-" * 48)
311 presenter.show_info(f" Type '{CANCEL_ANSWER}' at any prompt to cancel. Nothing is")
312 presenter.show_info(" written until the final confirmation.")
313
314 # ── Step 1: base the new lane on a known one, or start blank ─────────
315 known = _known_lanes(shipped, overlay_target)
316 presenter.show_info("")
317 presenter.show_info(" Base the new lane on:")
318 for index, lane in enumerate(known, 1):
319 presenter.show_info(f" {index:>2}. {lane.name:<20} {lane.display_label}")
320 presenter.show_info(" Enter. (blank template)")
321 answer = _ask(prompt_driver, presenter, " Choose a template [blank]: ")
322 if answer is None:
323 return _cancelled(presenter, "template")
324 template: "Lane | None" = None
325 if answer:
326 if not answer.isdigit() or not (1 <= int(answer) <= len(known)):
327 presenter.show_error(f" Not a template choice: {answer!r}. Nothing written.")
328 return None
329 template = known[int(answer) - 1]
330 presenter.show_info(f" Basing on {template.name}.")
331
332 # ── Step 2-4: identity ───────────────────────────────────────────────
333 name = _ask(prompt_driver, presenter, " Lane name (lowercase identifier): ")
334 if name is None:
335 return _cancelled(presenter, "lane name")
336
337 label_default = template.display_label if template else name
338 label = _ask(
339 prompt_driver, presenter,
340 f" Display label [{label_default}]: ", default=label_default,
341 )
342 if label is None:
343 return _cancelled(presenter, "display label")
344
345 url_default = template.default_base_url if template else ""
346 base_url = _ask(
347 prompt_driver, presenter,
348 f" API base URL (https://...){f' [{url_default}]' if url_default else ''}: ",
349 default=url_default,
350 )
351 if base_url is None:
352 return _cancelled(presenter, "base URL")
353
354 # ── Step 5-6: env-var NAMES (never values) ───────────────────────────
355 key_var_default = _derive_env_var(name, "API_KEY")
356 key_var = _ask(
357 prompt_driver, presenter,
358 f" API key variable name [{key_var_default}]: ", default=key_var_default,
359 )
360 if key_var is None:
361 return _cancelled(presenter, "key variable")
362
363 url_var_default = _derive_env_var(name, "BASE_URL")
364 url_var = _ask(
365 prompt_driver, presenter,
366 f" Base URL variable name [{url_var_default}]: ", default=url_var_default,
367 )
368 if url_var is None:
369 return _cancelled(presenter, "base URL variable")
370
371 # ── The key: captured once, masked, held in memory only ──────────────
372 api_key = provider_secrets.capture_key(key_var, reader=key_reader)
373 if api_key is None:
374 return _cancelled(presenter, "API key")
375
376 # ── Step 7: settle the model list ────────────────────────────────────
377 headers = dict(template.extra_headers) if template else {}
378 cached = tuple(model.model_id for model in template.models) if template else ()
379 presenter.show_info("")
380 presenter.show_info(" Model list:")
381 presenter.show_info(
382 f" [c] use the cached/template list ({len(cached)} model(s))"
383 if cached else " [c] use the cached list (empty)"
384 )
385 presenter.show_info(" [f] fetch live from the API now (the only network call)")
386 presenter.show_info(" [m] type the ids by hand")
387 action = _ask(prompt_driver, presenter, " Choice (c/f/m) [c]: ", default="c")
388 if action is None:
389 return _cancelled(presenter, "model list")
390
391 model_source = "copied from the template" if cached else "entered by hand"
392 fetched_at: str | None = None
393 candidates = cached
394 if action.lower() == "f":
395 provisional = _provisional_lane(
396 catalogue, name=name, label=label, base_url=base_url,
397 key_var=key_var, url_var=url_var, headers=headers,
398 )
399 try:
400 candidates = fetch_models_for_lane(
401 provisional, api_key, cached=cached, force=True,
402 client_factory=client_factory,
403 )
404 except Exception as exc: # noqa: BLE001 — any client failure aborts the run
405 # EXTERNAL_ERROR (S6 L2). The text is redacted before display:
406 # an upstream auth error is quite capable of quoting the key.
407 detail = _redact(f"{type(exc).__name__}: {exc}", api_key)
408 _dbg("OC_CHAT.MAIN", func,
409 f"EXTERNAL_ERROR: live fetch for {name} failed: "
410 f"{type(exc).__name__}", 2, override=True)
411 presenter.show_error(f" Could not fetch models: {detail}")
412 presenter.show_error(" Nothing written.")
413 return None
414 fetched_at = stamp
415 model_source = f"fetched live at {stamp}"
416 presenter.show_info(f" Fetched {len(candidates)} model(s).")
417 elif action.lower() == "m":
418 candidates = ()
419 model_source = "entered by hand"
420
421 shown = ", ".join(candidates)
422 models_answer = _ask(
423 prompt_driver, presenter,
424 f" Model ids, comma separated{f' [{shown}]' if shown else ''}: ",
425 default=shown,
426 )
427 if models_answer is None:
428 return _cancelled(presenter, "model ids")
429 models = tuple(
430 part.strip() for part in models_answer.split(",") if part.strip()
431 )
432 if not models:
433 presenter.show_error(" No model ids given. Nothing written.")
434 return None
435
436 # ── Step 8-11: P3 screen one, the per-provider ROLE tiers ────────────
437 presenter.show_info("")
438 presenter.show_info(" Role tiers - which model answers each slash-command tier:")
439 for index, model_id in enumerate(models, 1):
440 presenter.show_info(f" {index:>2}. {model_id}")
441 tiers: dict[str, str] = {}
442 for role in ROLE_TIERS:
443 answer = _ask(
444 prompt_driver, presenter,
445 f" Role tier '{role}' (number or id) [1={models[0]}]: ",
446 )
447 if answer is None:
448 return _cancelled(presenter, f"role tier '{role}'")
449 if not answer:
450 tiers[role] = models[0]
451 elif answer.isdigit() and 1 <= int(answer) <= len(models):
452 tiers[role] = models[int(answer) - 1]
453 else:
454 tiers[role] = answer # validated with the rest, at the end
455
456 # ── Step 12-14: P3 screen two, the cross-pool CAPABILITY tier ────────
457 presenter.show_info("")
458 presenter.show_info(
459 " Capability tier - your judgment of this lane against the whole pool:"
460 )
461 for index, tier_name in enumerate(CAPABILITY_TIERS, 1):
462 presenter.show_info(f" {index:>2}. {tier_name}")
463 answer = _ask(
464 prompt_driver, presenter,
465 f" Capability tier {list(CAPABILITY_TIERS)}: ",
466 )
467 if answer is None:
468 return _cancelled(presenter, "capability tier")
469 capability_tier = answer
470 if answer.isdigit() and 1 <= int(answer) <= len(CAPABILITY_TIERS):
471 capability_tier = CAPABILITY_TIERS[int(answer) - 1]
472
473 good_at = _ask(prompt_driver, presenter, " Good at (free text, Enter to skip): ")
474 if good_at is None:
475 return _cancelled(presenter, "good-at notes")
476 bad_at = _ask(prompt_driver, presenter, " Bad at (free text, Enter to skip): ")
477 if bad_at is None:
478 return _cancelled(presenter, "bad-at notes")
479
480 # ── Step 15-16: access tier and the recorded budget ──────────────────
481 free_answer = _ask(
482 prompt_driver, presenter,
483 " Is this lane FREE to use? (y/N): ", default="n",
484 )
485 if free_answer is None:
486 return _cancelled(presenter, "free/paid")
487 free = free_answer.lower().startswith("y")
488
489 budget_answer = _ask(
490 prompt_driver, presenter,
491 " Budget for this lane (recorded, not enforced; Enter to skip): ",
492 )
493 if budget_answer is None:
494 return _cancelled(presenter, "budget")
495 budget = budget_answer or None
496
497 # ── Build the row and show it back before the point of no return ─────
498 row_json = {
499 "name": name,
500 "display_label": label,
501 "default_base_url": base_url,
502 "base_url_env_var": url_var,
503 "api_key_env_var": key_var,
504 "models": list(models),
505 "tiers": tiers,
506 "free": free,
507 # 215 P4: never promoted by a machine.
508 "verification": "chat",
509 "notes": _compose_notes(
510 capability_tier=capability_tier, good_at=good_at, bad_at=bad_at,
511 model_source=model_source, stamp=stamp,
512 ),
513 "capability_tier": capability_tier,
514 "good_at": good_at,
515 "bad_at": bad_at,
516 "budget": budget,
517 "cached_models": list(candidates if fetched_at else models),
518 "models_fetched_at": fetched_at,
519 "override": False,
520 }
521 if headers:
522 row_json["extra_headers"] = headers
523
524 presenter.show_info("")
525 presenter.show_info(f" Lane '{name}' ({label})")
526 presenter.show_info(f" base URL {base_url}")
527 presenter.show_info(f" key variable {key_var} (value goes to the sidecar only)")
528 presenter.show_info(f" models {', '.join(models)}")
529 presenter.show_info(f" role tiers {tiers}")
530 presenter.show_info(f" capability {capability_tier}")
531 presenter.show_info(f" access {'FREE' if free else 'PAID'}")
532 presenter.show_info(f" budget {budget or '(none recorded)'}")
533
534 confirm = _ask(
535 prompt_driver, presenter, " Write this lane? (y/N): ", default="n",
536 )
537 if confirm is None or not confirm.lower().startswith("y"):
538 return _cancelled(presenter, "confirmation")
539
540 # ── Validate ONCE, against the shipped validator, before any write ───
541 try:
542 row = row_from_json(row_json)
543 merge_lanes(shipped, list(load_overlay(overlay_target)) + [row])
544 except (OverlayError, catalogue.CatalogueError) as exc:
545 # VALIDATION_ERROR (S6 L3) — the answers do not make a legal lane.
546 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 2, override=True)
547 presenter.show_error(f" Refused: {exc}")
548 presenter.show_error(" Nothing written.")
549 return None
550
551 # ── Commit: sidecar first (it is the write that can refuse) ──────────
552 try:
553 provider_secrets.write_secret(key_var, api_key, path=secrets_target)
554 except (provider_secrets.SecretsPathError, ValueError) as exc:
555 # SECURITY_ERROR (S6 L1) on the canary path — never routed around.
556 _dbg("OC_CHAT.MAIN", func,
557 f"SECURITY_ERROR: sidecar write refused for {key_var}", 1, override=True)
558 presenter.show_error(f" Refused: {_redact(str(exc), api_key)}")
559 presenter.show_error(" Nothing written.")
560 return None
561
562 write_overlay_row(row, path=overlay_target)
563 provider_secrets.apply_to_environ(path=secrets_target)
564
565 _dbg("OC_CHAT.MAIN", func,
566 f"added lane {name!r} with {len(models)} model(s); key stored under "
567 f"{key_var}", 2)
568 presenter.show_info("")
569 presenter.show_info(f" Added lane '{name}'. Select it with OC_CHAT_PROVIDER={name}.")
570 presenter.show_info(f" Key stored in {secrets_target} (gitignored).")
571 return row
572
573
574def _cancelled(presenter: Any, step: str) -> None:
575 """Report a cancel at *step* and return ``None``. Nothing was written."""
576 func = "_cancelled"
577 _dbg("OC_CHAT.MAIN", func, f"run cancelled at the {step} step", 2)
578 presenter.show_info(f" Cancelled at the {step} step. Nothing written.")
579 return None
580
581
582__all__ = [
583 "CANCEL_ANSWER",
584 "ROLE_TIERS",
585 "fetch_models_for_lane",
586 "run_add_provider_wizard",
587]
list["Lane"] _known_lanes(Sequence["Lane"] shipped, Path overlay_path)
str _derive_env_var(str lane_name, str suffix)
str _compose_notes(*, str capability_tier, str good_at, str bad_at, str model_source, str stamp)
tuple[str,...] fetch_models_for_lane("Lane" lane, str api_key, *, Sequence[str] cached=(), bool force=False, Callable[[Any, str], Any]|None client_factory=None)
"Lane" _provisional_lane(Any catalogue, *, str name, str label, str base_url, str key_var, str url_var, dict[str, str] headers)
str|None _ask(Any driver, Any presenter, str prompt_text, *, str default="")
str _redact(str text, str|None secret)
OverlayRow|None run_add_provider_wizard(Any presenter, *, Any prompt_driver=None, Callable[[str], str]|None key_reader=None, str|Path overlay_path=DEFAULT_OVERLAY_PATH, str|Path|None secrets_path=None, Sequence["Lane"]|None shipped_lanes=None, Callable[[Any, str], Any]|None client_factory=None, Callable[[], str]|None now=None)
None _cancelled(Any presenter, str step)
Any _default_client_factory("Lane" lane, str api_key)