oc_chat
Loading...
Searching...
No Matches
lane_switch.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/lane_switch.py
4
5"""
6In-session lane switching — rebuild the client and model manager, keep the talk.
7
8Purpose
9-------
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.
16
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
23assignments inside
24:func:`~oc_chat.core.session_transition.uninterruptible_commit`.
25
26Responsibilities
27----------------
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
33 do in between.
34
35Diagnostics
36-----------
37Domain: OC_CHAT.MAIN
38Levels:
39 L2 — lifecycle events (switch prepared, switch committed, refusal).
40 L3 — semantic details (lane and model names).
41 L4 — deep tracing.
42
43Contracts
44---------
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.
68
69Dependencies
70------------
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)
78"""
79from __future__ import annotations
80
81import os
82from dataclasses import dataclass, field
83from pathlib import Path
84from typing import Any, Mapping
85
86from oc_diagnostics import _dbg
87
88_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
89
90
91class LaneKeyMissingError(PermissionError):
92 """The chosen lane has no API key set — refused at selection (P3).
93
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.
98 """
99
100 def __init__(self, lane_name: str, env_var: str) -> None:
101 self.lane_name = lane_name
102 self.env_var = env_var
103 super().__init__(
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"
106 )
107
108
109@dataclass(frozen=True)
111 """A switch fully built and not yet installed.
112
113 Every field here was produced by a call that could have failed. Once
114 this value exists, committing it is assignment only.
115 """
116
117 lane_name: str
118 model_id: str
119 previous_lane: str
120 provider: Any
121 client: Any
122 model_manager: Any
123 endpoint_registry: Any = field(default=None)
124
125
126def _environ(env: Mapping[str, str] | None) -> Mapping[str, str]:
127 return os.environ if env is None else env
128
129
130def _build_endpoint_registry(provider: Any) -> Any:
131 """Re-derive the provider-scoped endpoint registry, best effort.
132
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
136 operator asked for.
137 """
138 func = "_build_endpoint_registry"
139 try:
140 from oc_chat.core.endpoint_registry import EndpointRegistry
141
142 registry = EndpointRegistry(provider)
143 registry.load_env(os.environ)
144 registry.load_toml(Path.home() / ".oc_chat" / "endpoints.toml")
145 return registry
146 except Exception as exc: # noqa: BLE001 — best effort, exactly as main() is
147 # SYSTEM_ERROR (S6 L1) — surfaced through _dbg; the switch still lands.
148 _dbg("OC_CHAT.MAIN", func,
149 f"SYSTEM_ERROR: endpoint registry rebuild: {type(exc).__name__}: {exc}",
150 1, override=True)
151 return None
152
153
155 target: Any,
156 lane_name: str,
157 model_id: str = "",
158 *,
159 env: Mapping[str, str] | None = None,
160) -> PreparedLaneSwitch:
161 """Build everything the switch needs. Raises rather than half-applying.
162
163 :raises KeyError: no lane called *lane_name* is registered.
164 :raises LaneKeyMissingError: the lane declares a key variable that is
165 unset or blank.
166 """
167 func = "prepare_lane_switch"
168 from oc_chat.core.client_interface import ClientInterface
169 from oc_chat.core.models import ModelManager
170 from oc_chat.core.provider_plugin import registry
171
172 environ = _environ(env)
173 provider = registry.get(lane_name) # KeyError names the available lanes
174
175 key_var = getattr(provider, "api_key_env_var", "") or ""
176 from oc_chat.core.provider_oauth import resolve_api_key
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)
181 raise LaneKeyMissingError(lane_name, key_var)
182
183 url_var = getattr(provider, "base_url_env_var", "") or ""
184 base_url = (
185 (environ.get(url_var) or "") if url_var else ""
186 ) or getattr(provider, "default_base_url", "")
187
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)
191 if chosen:
192 manager.ensure(chosen)
193
194 previous = getattr(getattr(target, "client", None), "provider", None)
195 prepared = PreparedLaneSwitch(
196 lane_name=lane_name,
197 model_id=chosen,
198 previous_lane=getattr(previous, "name", "") or "",
199 provider=provider,
200 client=client,
201 model_manager=manager,
202 endpoint_registry=_build_endpoint_registry(provider),
203 )
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)
207 return prepared
208
209
210def commit_lane_switch(target: Any, prepared: PreparedLaneSwitch) -> None:
211 """Install *prepared* on *target*. Assignment only — this cannot fail.
212
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.
216 """
217 func = "commit_lane_switch"
218 from oc_chat.core.session_transition import uninterruptible_commit
219
220 with uninterruptible_commit():
221 target.client = prepared.client
222 target.model_manager = prepared.model_manager
223
224 agent = getattr(target, "agent", None)
225 if agent is not None:
226 agent.client = prepared.client
227 agent.model_manager = prepared.model_manager
228
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
233
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"):
239 # Named an endpoint of the OUTGOING provider; meaningless now.
240 session.endpoint_name = ""
241 session.dirty = True
242
243 if hasattr(target, "endpoint_registry"):
244 target.endpoint_registry = prepared.endpoint_registry
245
246 _dbg("OC_CHAT.MAIN", func,
247 f"committed lane {prepared.lane_name} model={prepared.model_id!r} "
248 f"(history preserved)", 2)
249
250
252 target: Any,
253 lane_name: str,
254 model_id: str = "",
255 *,
256 env: Mapping[str, str] | None = None,
257) -> PreparedLaneSwitch:
258 """Prepare and commit in one call; returns what was installed."""
259 prepared = prepare_lane_switch(target, lane_name, model_id, env=env)
260 commit_lane_switch(target, prepared)
261 return prepared
262
263
264__all__ = [
265 "LaneKeyMissingError",
266 "PreparedLaneSwitch",
267 "commit_lane_switch",
268 "prepare_lane_switch",
269 "switch_lane",
270]
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)