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``.
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.
290 func =
"run_add_provider_wizard"
294 presenter.show_error(
295 " Add provider needs the openai_compat provider bundle, which is "
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
305 stamp = (now
or _now_iso)()
306 shipped = tuple(shipped_lanes)
if shipped_lanes
is not None else catalogue.LANES
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.")
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]: ")
324 template:
"Lane | None" =
None
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.")
329 template = known[int(answer) - 1]
330 presenter.show_info(f
" Basing on {template.name}.")
333 name =
_ask(prompt_driver, presenter,
" Lane name (lowercase identifier): ")
337 label_default = template.display_label
if template
else name
339 prompt_driver, presenter,
340 f
" Display label [{label_default}]: ", default=label_default,
345 url_default = template.default_base_url
if template
else ""
347 prompt_driver, presenter,
348 f
" API base URL (https://...){f' [{url_default}]' if url_default else ''}: ",
357 prompt_driver, presenter,
358 f
" API key variable name [{key_var_default}]: ", default=key_var_default,
365 prompt_driver, presenter,
366 f
" Base URL variable name [{url_var_default}]: ", default=url_var_default,
369 return _cancelled(presenter,
"base URL variable")
372 api_key = provider_secrets.capture_key(key_var, reader=key_reader)
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:")
382 f
" [c] use the cached/template list ({len(cached)} model(s))"
383 if cached
else " [c] use the cached list (empty)"
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")
391 model_source =
"copied from the template" if cached
else "entered by hand"
392 fetched_at: str |
None =
None
394 if action.lower() ==
"f":
396 catalogue, name=name, label=label, base_url=base_url,
397 key_var=key_var, url_var=url_var, headers=headers,
401 provisional, api_key, cached=cached, force=
True,
402 client_factory=client_factory,
404 except Exception
as exc:
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.")
415 model_source = f
"fetched live at {stamp}"
416 presenter.show_info(f
" Fetched {len(candidates)} model(s).")
417 elif action.lower() ==
"m":
419 model_source =
"entered by hand"
421 shown =
", ".join(candidates)
422 models_answer =
_ask(
423 prompt_driver, presenter,
424 f
" Model ids, comma separated{f' [{shown}]' if shown else ''}: ",
427 if models_answer
is None:
430 part.strip()
for part
in models_answer.split(
",")
if part.strip()
433 presenter.show_error(
" No model ids given. Nothing written.")
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:
444 prompt_driver, presenter,
445 f
" Role tier '{role}' (number or id) [1={models[0]}]: ",
448 return _cancelled(presenter, f
"role tier '{role}'")
450 tiers[role] = models[0]
451 elif answer.isdigit()
and 1 <= int(answer) <= len(models):
452 tiers[role] = models[int(answer) - 1]
457 presenter.show_info(
"")
459 " Capability tier - your judgment of this lane against the whole pool:"
461 for index, tier_name
in enumerate(CAPABILITY_TIERS, 1):
462 presenter.show_info(f
" {index:>2}. {tier_name}")
464 prompt_driver, presenter,
465 f
" Capability tier {list(CAPABILITY_TIERS)}: ",
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]
473 good_at =
_ask(prompt_driver, presenter,
" Good at (free text, Enter to skip): ")
476 bad_at =
_ask(prompt_driver, presenter,
" Bad at (free text, Enter to skip): ")
482 prompt_driver, presenter,
483 " Is this lane FREE to use? (y/N): ", default=
"n",
485 if free_answer
is None:
487 free = free_answer.lower().startswith(
"y")
489 budget_answer =
_ask(
490 prompt_driver, presenter,
491 " Budget for this lane (recorded, not enforced; Enter to skip): ",
493 if budget_answer
is None:
495 budget = budget_answer
or None
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),
508 "verification":
"chat",
510 capability_tier=capability_tier, good_at=good_at, bad_at=bad_at,
511 model_source=model_source, stamp=stamp,
513 "capability_tier": capability_tier,
517 "cached_models": list(candidates
if fetched_at
else models),
518 "models_fetched_at": fetched_at,
522 row_json[
"extra_headers"] = headers
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)'}")
535 prompt_driver, presenter,
" Write this lane? (y/N): ", default=
"n",
537 if confirm
is None or not confirm.lower().startswith(
"y"):
542 row = row_from_json(row_json)
543 merge_lanes(shipped, list(load_overlay(overlay_target)) + [row])
544 except (OverlayError, catalogue.CatalogueError)
as exc:
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.")
553 provider_secrets.write_secret(key_var, api_key, path=secrets_target)
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.")
562 write_overlay_row(row, path=overlay_target)
563 provider_secrets.apply_to_environ(path=secrets_target)
565 _dbg(
"OC_CHAT.MAIN", func,
566 f
"added lane {name!r} with {len(models)} model(s); key stored under "
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).")