oc_chat
Loading...
Searching...
No Matches
client_interface.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/client_interface.py
4
5"""
6Provider-agnostic client wrapper.
7
8Purpose
9-------
10:class:`ClientInterface` is the single thing the rest of OC Chat talks
11to for LLM API calls. It holds the active :class:`ProviderPlugin` plus
12the underlying SDK client built by that provider, and routes outgoing
13messages through the provider's transform / response-parsing hooks.
14
15The kwargs accepted by :meth:`stream_chat` and :meth:`one_shot` are the
16*existing* DeepSeek-shaped names (``effort``, ``extra_body``,
17``max_tokens``) for now, so the swap from ``DeepSeekClient`` to
18``ClientInterface`` is a pure rename at every call site. Phase C.3e
19will retire ``extra_body`` in favour of ``enable_thinking`` + the
20provider's :meth:`build_extra_body` hook.
21
22Responsibilities
23----------------
24- Build the underlying SDK client at construction (env-var lookup for
25 API key + base URL, with explicit overrides for tests).
26- :meth:`stream_chat` — yield ``(chunk_type, data)`` tuples for streaming
27 ReAct iterations, after running the provider's outgoing-message
28 transform if tool schemas are supplied.
29- :meth:`one_shot` — single non-streaming call, used by context
30 compression for the summarisation step.
31- :meth:`list_models`, :meth:`parse_usage` — pass-through helpers to
32 the provider / SDK.
33
34Diagnostics
35-----------
36Domain: OC_CHAT.CLIENT
37Levels:
38 L2 — entry into stream_chat / one_shot (one line per call).
39 L3 — api_stream / api_one_shot timings via ``dbg_timed``.
40 L4 — provider transform decisions (currently unused).
41
42Contracts
43---------
44- Constructor never reads from disk and never makes a network call;
45 the SDK client is built lazily by the provider's ``build_client``.
46- ``stream_chat`` is a generator; iteration order matches the SDK's.
47- ``one_shot`` returns a plain ``str`` — never ``None``.
48- All errors are surfaced by the underlying SDK; this wrapper does not
49 catch or normalise them.
50
51Dependencies
52------------
53- oc_diagnostics (structured debug logging via _dbg)
54- oc_chat.core.provider_plugin (ProviderPlugin ABC and registry singleton)
55- oc_chat.core.cancellation (CancellationToken, CancellationError - the per-chunk cancellation check in stream_chat)
56- oc_chat.core.egress_guard (lazy; is_forbidden_provider_host - refuses a base URL at a forbidden internal address)
57- oc_chat.core.provider_oauth (lazy; resolve_api_key - the provider's API key at construction)
58- oc_chat.core.request_plan (lazy; build_request_plan - the provider-neutral request IR for stream_chat)
59
60See Also
61--------
62oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — owner of the
63engine-side stream_chat tuple contract.
64"""
65from __future__ import annotations
66
67from typing import Any, Iterator
68
69from oc_diagnostics import _dbg, dbg_timed
70
71from oc_chat.core.provider_plugin import ProviderPlugin, BaseClient, UsageStats, PerformanceStats, ProviderEndpoint, render_request
72from oc_chat.core.cancellation import CancellationToken, CancellationError
73
74
76 """Wrap a :class:`ProviderPlugin` + its SDK client behind one façade.
77
78 Constructor reads the API key and base URL from the env vars the
79 provider declares (``api_key_env_var`` / ``base_url_env_var``);
80 callers may pass them explicitly to override (useful in tests).
81 """
82
84 self,
85 provider: ProviderPlugin,
86 *,
87 api_key: str | None = None,
88 base_url: str | None = None,
89 ) -> None:
90 import os
91 self.provider = provider
92 from oc_chat.core.provider_oauth import resolve_api_key
93 key = resolve_api_key(provider, api_key)
94 url = (
95 base_url
96 if base_url is not None
97 else os.getenv(provider.base_url_env_var, "") or provider.default_base_url
98 )
99 # M8.0 WU-4c (F72): refuse binding the provider channel to a forbidden
100 # internal address (IMDS / link-local / multicast / reserved) -- e.g. a
101 # .env-injected base_url. Cloud (public) and Ollama loopback/LAN stay legal.
102 from oc_chat.core.egress_guard import is_forbidden_provider_host
103 if is_forbidden_provider_host(url):
104 raise PermissionError(f"provider base_url is a forbidden internal address: {url}")
105 self._client: BaseClient = provider.build_client(key, url)
106 self._api_key = key
107
108 def rebind_to(self, endpoint: "ProviderEndpoint") -> None:
109 """Rebuild the underlying SDK client against *endpoint* (Phase O2).
110
111 Used by the ``/endpoint use <name>`` slash command to swap the
112 active base URL at runtime without restarting OC Chat. The API
113 key (read at construction time) is reused; only the base URL
114 changes. ModelManager cache invalidation is the caller's
115 responsibility — :func:`oc_chat.core.commands.cmd_endpoint`
116 already handles it.
117 """
118 func = "ClientInterface.rebind_to"
119 _dbg("OC_CHAT.CLIENT", func, f"endpoint={endpoint.name} url={endpoint.base_url}", 2)
120 from oc_chat.core.egress_guard import is_forbidden_provider_host
121 if is_forbidden_provider_host(endpoint.base_url):
122 raise PermissionError(
123 f"endpoint {endpoint.name!r} base_url is a forbidden internal address: {endpoint.base_url}"
124 )
125 self._client = self.provider.build_client(self._api_key, endpoint.base_url)
126
127 # ── Prepared/apply transition primitives (plan 152 D4 / ADR-2) ──────────
128
129 def build_candidate(self, endpoint: "ProviderEndpoint") -> BaseClient:
130 """Build an UNATTACHED SDK client for *endpoint* — the PREPARE half.
131
132 Does everything :meth:`rebind_to` does EXCEPT assigning ``_client``:
133 enforces the M8.0 WU-4c forbidden-host policy and calls the provider's
134 ``build_client``. Both are fallible, which is exactly why they belong in
135 PREPARE — the live client is untouched, so a raise here leaves the whole
136 old tuple intact and the transition aborts fail-closed.
137
138 The returned object is inert until :meth:`install_prepared` publishes it.
139 Callers must NOT reach for the private ``_client`` themselves.
140 """
141 func = "ClientInterface.build_candidate"
142 _dbg("OC_CHAT.CLIENT", func, f"endpoint={endpoint.name} url={endpoint.base_url}", 2)
143 from oc_chat.core.egress_guard import is_forbidden_provider_host
144 if is_forbidden_provider_host(endpoint.base_url):
145 raise PermissionError(
146 f"endpoint {endpoint.name!r} base_url is a forbidden internal address: {endpoint.base_url}"
147 )
148 return self.provider.build_client(self._api_key, endpoint.base_url)
149
150 def install_prepared(self, built: BaseClient) -> None:
151 """Publish a client built by :meth:`build_candidate` — the COMMIT half.
152
153 A pure assignment: no rebuild, no policy re-run (policy already ran in
154 PREPARE, on this exact object's endpoint). Cannot raise, which is what
155 lets the transition's commit block be a no-fail critical section.
156 """
157 func = "ClientInterface.install_prepared"
158 self._client = built
159 _dbg("OC_CHAT.CLIENT", func, "installed prepared client", 2)
160
161 # ── Streaming chat ──────────────────────────────────────────────────────
162
164 self,
165 messages: list[dict],
166 model: str,
167 effort: str | None = None,
168 extra_body: dict | None = None,
169 tools: list | None = None,
170 max_tokens: int | None = None,
171 *,
172 tool_schemas: list[dict] | None = None,
173 cancellation: CancellationToken | None = None,
174 ) -> Iterator[tuple[str, Any]]:
175 """Stream a chat completion. Yields ``(chunk_type, data)`` tuples.
176
177 Chunk types: ``"reasoning"``, ``"content"``, ``"tool_call"``,
178 ``"usage"``, ``"finish_reason"`` — same shape as
179 ``DeepSeekClient.stream_chat``. ``"finish_reason"`` is optional:
180 a provider that cannot report one simply never yields it, and
181 consumers must treat its absence as "unknown", not as "stop".
182
183 The request is assembled through the provider-neutral request IR
184 (plan 135 §3.4): a :class:`ProviderRequestPlan` is built here and the
185 provider's :meth:`render_request` shapes the wire request. For chat
186 providers the default renderer reproduces ``messages[0]`` byte-for-byte.
187 *tool_schemas* is accepted for call-site compatibility; the tool catalog
188 sent to the API comes from *tools* (the schema-embedding transform is
189 retired).
190
191 If *cancellation* is provided, each yielded chunk is preceded by a
192 check; a cancelled token raises :class:`CancellationError`.
193 """
194 func = "stream_chat"
195 _dbg("OC_CHAT.CLIENT", func,
196 f"model={model} effort={effort} tools={'yes' if tools else 'no'} max_tokens={max_tokens}", 2)
197 # Plan 135 §3.4/§4.5: build the provider-neutral request IR at the assembly
198 # point and let the provider render it. The default (OpenAI-Chat) renderer
199 # reproduces messages[0] byte-for-byte; effort/extra_body keep their
200 # caller-side ownership. ``None``-vs-``[]`` for tools is preserved via the
201 # fallback below, so chat behaviour is byte/semantically unchanged.
202 # (transform_outgoing_messages is retired — render_request is the seam.)
203 from oc_chat.core.request_plan import build_request_plan
204 plan = build_request_plan(
205 messages,
206 model,
207 tool_catalog=tools,
208 reasoning_mode=(effort or "off"),
209 output_max_tokens=max_tokens,
210 )
211 rendered = render_request(self.provider, plan)
212 out_tools = rendered.tools if rendered.tools is not None else tools
213 out_extra_body = rendered.extra_body if rendered.extra_body is not None else extra_body
214 out_max_tokens = rendered.max_tokens if rendered.max_tokens is not None else max_tokens
215 with dbg_timed("OC_CHAT.CLIENT", func, label="api_stream", level=3):
216 for chunk in self._client.stream_chat(
217 rendered.messages,
218 model,
219 effort=effort,
220 extra_body=out_extra_body,
221 tools=out_tools,
222 max_tokens=out_max_tokens,
223 cancellation=cancellation,
224 ):
225 if cancellation is not None:
226 cancellation.raise_if_cancelled()
227 yield chunk
228
229 # ── One-shot (non-streaming) ────────────────────────────────────────────
230
232 self,
233 messages: list[dict],
234 model: str,
235 *,
236 effort: str | None = None,
237 extra_body: dict | None = None,
238 max_tokens: int | None = None,
239 ) -> str:
240 """Non-streaming single call. Used for context compression summaries."""
241 func = "one_shot"
242 _dbg("OC_CHAT.CLIENT", func,
243 f"model={model} effort={effort} max_tokens={max_tokens}", 2)
244 with dbg_timed("OC_CHAT.CLIENT", func, label="api_one_shot", level=3):
245 return self._client.one_shot(
246 messages,
247 model,
248 effort=effort,
249 extra_body=extra_body,
250 max_tokens=max_tokens,
251 )
252
253 # ── Auxiliary ───────────────────────────────────────────────────────────
254
255 def list_models(self) -> list[str]:
256 """Return the live model id list from the underlying API."""
257 return self._client.list_models()
258
259 def parse_usage(self, usage: dict) -> UsageStats:
260 """Normalise an API usage block into :class:`UsageStats`."""
261 return self.provider.parse_usage(usage)
262
263 def parse_performance(self, chunk: dict) -> PerformanceStats:
264 """Extract tok/s performance data from the final usage chunk."""
265 return self.provider.parse_performance(chunk)
None __init__(self, ProviderPlugin provider, *, str|None api_key=None, str|None base_url=None)
Iterator[tuple[str, Any]] stream_chat(self, list[dict] messages, str model, str|None effort=None, dict|None extra_body=None, list|None tools=None, int|None max_tokens=None, *, list[dict]|None tool_schemas=None, CancellationToken|None cancellation=None)
BaseClient build_candidate(self, "ProviderEndpoint" endpoint)
str one_shot(self, list[dict] messages, str model, *, str|None effort=None, dict|None extra_body=None, int|None max_tokens=None)
PerformanceStats parse_performance(self, dict chunk)
None rebind_to(self, "ProviderEndpoint" endpoint)