6In-session lane switching — rebuild the client and model manager, keep the talk.
10Plan 217 T3 / P3. Choosing a lane is a real switch, not a hint for the
11next launch: the active :class:`~oc_chat.core.client_interface.ClientInterface`
12and :class:`~oc_chat.core.models.ModelManager` are rebuilt for the new
13provider, the turn after the switch runs on the new lane, and the
14conversation history and session identity survive untouched.
15``OC_CHAT_PROVIDER`` keeps its one remaining job: the startup default.
17The shape is plan 152's PREPARE / COMMIT split, for the reason that plan
18found: the old ``/endpoint use`` committed five things in sequence under
19an unguarded dispatch, and a Ctrl+C between any two of them split the
20tuple. Here every fallible step — registry lookup, key presence, client
21construction, model-registry build, endpoint re-derivation — happens
22BEFORE anything is installed, and the installs themselves are pure
24:func:`~oc_chat.core.session_transition.uninterruptible_commit`.
28- :exc:`LaneKeyMissingError` — the typed keyless refusal (W5).
29- :class:`PreparedLaneSwitch` — everything built, nothing installed.
30- :func:`prepare_lane_switch` — the fallible half.
31- :func:`commit_lane_switch` — the no-fail half.
32- :func:`switch_lane` — prepare-then-commit for callers with nothing to
39 L2 — lifecycle events (switch prepared, switch committed, refusal).
40 L3 — semantic details (lane and model names).
45- **The target is duck-typed on purpose.** It needs ``client`` and
46 ``model_manager``; it MAY carry ``session``, ``agent``, ``config`` and
47 ``endpoint_registry``. That covers both callers without either of them
48 knowing about the other: the slash-command ``Context`` in a live
49 session, and the bare :class:`~oc_chat.core.agent.Agent` at the opening
50 menu, where no session exists yet.
51- **The agent's own references move with the context's.** ``Agent.client``
52 is the object ``run_turn`` passes to the provider, so updating only
53 ``ctx.client`` would leave the NEXT TURN on the old lane while every
54 command reported the new one. When the target exposes an ``agent``, both
55 are written in the same commit block.
56- **A keyless lane is refused, never fallen back from.** The refusal names
57 the env var and points at the wizard. It is raised in PREPARE, so a
58 refused switch has touched nothing at all.
59- **The endpoint registry does not survive a provider swap.** It is
60 provider-scoped, and ``ClientInterface.rebind_to`` enforces only the
61 forbidden-host rule — so a stale registry would let ``/endpoint use``
62 rebind the NEW lane's client to an OLD provider's base URL. It is
63 rebuilt in PREPARE and replaced at COMMIT.
64- Key handling is presence-only in this module's own logic (plan 215 P6):
65 the value is read from the environment mapping solely to hand to
66 ``ClientInterface``, and never logged, returned, or stored on the
67 prepared value's own fields.
71- oc_diagnostics (structured debug logging via _dbg)
72- oc_chat.core.client_interface (lazy; the rebuilt provider facade)
73- oc_chat.core.models (lazy; the rebuilt model registry)
74- oc_chat.core.session_transition (lazy; the shared no-fail commit block)
75- oc_chat.core.endpoint_registry (lazy; the rebuilt provider-scoped endpoint registry)
76- oc_chat.core.provider_oauth (lazy; API key resolution for the chosen lane)
77- oc_chat.core.provider_plugin (lazy; provider registry lookup of the chosen lane)
79from __future__
import annotations
82from dataclasses
import dataclass, field
83from pathlib
import Path
84from typing
import Any, Mapping
86from oc_diagnostics
import _dbg
88_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
92 """The chosen lane has no API key set — refused at selection (P3).
94 SECURITY_ERROR-adjacent (S6 L1 class of "refuse, never route around"),
95 but the operator-facing condition is mundane and fixable, so the
96 message names the variable and says where to set it. Raised in
97 PREPARE: nothing has been rebuilt when it surfaces.
100 def __init__(self, lane_name: str, env_var: str) ->
None:
104 f
"lane {lane_name!r} has no API key: set {env_var}, or onboard the "
105 f
"lane with [5] Add provider in the opening menu"
109@dataclass(frozen=True)
111 """A switch fully built and not yet installed.
113 Every field here was produced by a call that could have failed. Once
114 this value exists, committing it is assignment only.
123 endpoint_registry: Any = field(default=
None)
126def _environ(env: Mapping[str, str] |
None) -> Mapping[str, str]:
127 return os.environ
if env
is None else env
131 """Re-derive the provider-scoped endpoint registry, best effort.
133 Mirrors ``main.py``'s own startup init, including its posture: a
134 registry that cannot be built degrades to ``None`` (``/endpoint``
135 then reports that there is none) rather than aborting a switch the
138 func =
"_build_endpoint_registry"
142 registry = EndpointRegistry(provider)
143 registry.load_env(os.environ)
144 registry.load_toml(Path.home() /
".oc_chat" /
"endpoints.toml")
146 except Exception
as exc:
148 _dbg(
"OC_CHAT.MAIN", func,
149 f
"SYSTEM_ERROR: endpoint registry rebuild: {type(exc).__name__}: {exc}",
159 env: Mapping[str, str] |
None =
None,
160) -> PreparedLaneSwitch:
161 """Build everything the switch needs. Raises rather than half-applying.
163 :raises KeyError: no lane called *lane_name* is registered.
164 :raises LaneKeyMissingError: the lane declares a key variable that is
167 func =
"prepare_lane_switch"
173 provider = registry.get(lane_name)
175 key_var = getattr(provider,
"api_key_env_var",
"")
or ""
177 api_key = resolve_api_key(provider, environ=environ)
178 if key_var
and not api_key:
179 _dbg(
"OC_CHAT.MAIN", func,
180 f
"refused {lane_name!r}: {key_var} is unset", 2, override=
True)
183 url_var = getattr(provider,
"base_url_env_var",
"")
or ""
185 (environ.get(url_var)
or "")
if url_var
else ""
186 )
or getattr(provider,
"default_base_url",
"")
188 chosen = model_id
or getattr(provider,
"default_model",
"")
or ""
189 client = ClientInterface(provider, api_key=api_key, base_url=base_url)
190 manager = ModelManager(provider)
192 manager.ensure(chosen)
194 previous = getattr(getattr(target,
"client",
None),
"provider",
None)
198 previous_lane=getattr(previous,
"name",
"")
or "",
201 model_manager=manager,
204 _dbg(
"OC_CHAT.MAIN", func,
205 f
"prepared {prepared.previous_lane or '(none)'} -> {lane_name} "
206 f
"model={chosen!r} (nothing installed yet)", 2)
211 """Install *prepared* on *target*. Assignment only — this cannot fail.
213 Order matters only for readability, because the whole block runs with
214 Ctrl+C deferred: engine objects first, then the agent's mirrors, then
215 the session's model and the now-stale endpoint state.
217 func =
"commit_lane_switch"
220 with uninterruptible_commit():
221 target.client = prepared.client
222 target.model_manager = prepared.model_manager
224 agent = getattr(target,
"agent",
None)
225 if agent
is not None:
226 agent.client = prepared.client
227 agent.model_manager = prepared.model_manager
229 for holder
in (agent, target):
230 config = getattr(holder,
"config",
None)
231 if config
is not None and hasattr(config,
"provider_name"):
232 config.provider_name = prepared.lane_name
234 session = getattr(target,
"session",
None)
235 if session
is not None:
236 if prepared.model_id:
237 session.set_model(prepared.model_id)
238 if hasattr(session,
"endpoint_name"):
240 session.endpoint_name =
""
243 if hasattr(target,
"endpoint_registry"):
244 target.endpoint_registry = prepared.endpoint_registry
246 _dbg(
"OC_CHAT.MAIN", func,
247 f
"committed lane {prepared.lane_name} model={prepared.model_id!r} "
248 f
"(history preserved)", 2)
256 env: Mapping[str, str] |
None =
None,
257) -> PreparedLaneSwitch:
258 """Prepare and commit in one call; returns what was installed."""
265 "LaneKeyMissingError",
266 "PreparedLaneSwitch",
267 "commit_lane_switch",
268 "prepare_lane_switch",
None __init__(self, str lane_name, str env_var)
Mapping[str, str] _environ(Mapping[str, str]|None env)
None commit_lane_switch(Any target, PreparedLaneSwitch prepared)
Any _build_endpoint_registry(Any provider)
PreparedLaneSwitch switch_lane(Any target, str lane_name, str model_id="", *, Mapping[str, str]|None env=None)
PreparedLaneSwitch prepare_lane_switch(Any target, str lane_name, str model_id="", *, Mapping[str, str]|None env=None)