6Provider-agnostic client wrapper.
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.
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.
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
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).
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.
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)
62oc_chat/docs/conventions/CONTRACTS.md §5 Streaming chunk tuples — owner of the
63engine-side stream_chat tuple contract.
65from __future__
import annotations
67from typing
import Any, Iterator
69from oc_diagnostics
import _dbg, dbg_timed
76 """Wrap a :class:`ProviderPlugin` + its SDK client behind one façade.
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).
85 provider: ProviderPlugin,
87 api_key: str |
None =
None,
88 base_url: str |
None =
None,
93 key = resolve_api_key(provider, api_key)
96 if base_url
is not None
97 else os.getenv(provider.base_url_env_var,
"")
or provider.default_base_url
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)
108 def rebind_to(self, endpoint:
"ProviderEndpoint") ->
None:
109 """Rebuild the underlying SDK client against *endpoint* (Phase O2).
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`
118 func =
"ClientInterface.rebind_to"
119 _dbg(
"OC_CHAT.CLIENT", func, f
"endpoint={endpoint.name} url={endpoint.base_url}", 2)
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}"
130 """Build an UNATTACHED SDK client for *endpoint* — the PREPARE half.
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.
138 The returned object is inert until :meth:`install_prepared` publishes it.
139 Callers must NOT reach for the private ``_client`` themselves.
141 func =
"ClientInterface.build_candidate"
142 _dbg(
"OC_CHAT.CLIENT", func, f
"endpoint={endpoint.name} url={endpoint.base_url}", 2)
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}"
151 """Publish a client built by :meth:`build_candidate` — the COMMIT half.
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.
157 func =
"ClientInterface.install_prepared"
159 _dbg(
"OC_CHAT.CLIENT", func,
"installed prepared client", 2)
165 messages: list[dict],
167 effort: str |
None =
None,
168 extra_body: dict |
None =
None,
169 tools: list |
None =
None,
170 max_tokens: int |
None =
None,
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.
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".
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
191 If *cancellation* is provided, each yielded chunk is preceded by a
192 check; a cancelled token raises :class:`CancellationError`.
195 _dbg(
"OC_CHAT.CLIENT", func,
196 f
"model={model} effort={effort} tools={'yes' if tools else 'no'} max_tokens={max_tokens}", 2)
204 plan = build_request_plan(
208 reasoning_mode=(effort
or "off"),
209 output_max_tokens=max_tokens,
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):
220 extra_body=out_extra_body,
222 max_tokens=out_max_tokens,
223 cancellation=cancellation,
225 if cancellation
is not None:
226 cancellation.raise_if_cancelled()
233 messages: list[dict],
236 effort: str |
None =
None,
237 extra_body: dict |
None =
None,
238 max_tokens: int |
None =
None,
240 """Non-streaming single call. Used for context compression summaries."""
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):
249 extra_body=extra_body,
250 max_tokens=max_tokens,
256 """Return the live model id list from the underlying API."""
260 """Normalise an API usage block into :class:`UsageStats`."""
264 """Extract tok/s performance data from the final usage chunk."""
None __init__(self, ProviderPlugin provider, *, str|None api_key=None, str|None base_url=None)
None install_prepared(self, BaseClient built)
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)
list[str] list_models(self)
str one_shot(self, list[dict] messages, str model, *, str|None effort=None, dict|None extra_body=None, int|None max_tokens=None)
UsageStats parse_usage(self, dict usage)
PerformanceStats parse_performance(self, dict chunk)
None rebind_to(self, "ProviderEndpoint" endpoint)