oc_chat
Loading...
Searching...
No Matches
provider.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/openai_compat/provider.py
4
5"""
6OpenAICompatProvider — one class, one configured instance per lane.
7
8Purpose
9-------
10Concrete :class:`ProviderPlugin` for any OpenAI-compatible lane. Unlike
11the single-provider bundles, this class is NOT configured by class
12attributes: each instance is built from a :class:`Lane` catalogue row, so
13seven rows produce seven independently-named providers backed by one
14implementation (plan 215 P1). The registry keys on ``name`` and only
15rejects a DIFFERENT class re-using an existing name, so registering one
16class under seven names is exactly what its contract allows.
17
18Responsibilities
19----------------
20- :class:`OpenAICompatProvider` — implement the abstract surface from a
21 lane row.
22- :meth:`OpenAICompatProvider.register_lane` — build and register one
23 instance for a row.
24- :func:`register_all` — validate the catalogue, then register every row.
25- :func:`registered_lanes` — the lane rows this bundle registered, in
26 catalogue declaration order.
27
28Diagnostics
29-----------
30Domain: OC_CHAT.CLIENT
31Levels:
32 L2 — lifecycle events
33 L3 — semantic details
34 L4 — deep tracing
35
36Contracts
37---------
38- Identity (``name`` / ``api_key_env_var`` / ``base_url_env_var`` /
39 ``default_base_url`` / ``default_model``) is set per INSTANCE from the
40 row, shadowing the ABC's class-level defaults. Two instances never
41 share mutable state.
42- :meth:`build_extra_body` returns ``None`` always: no catalogue lane is
43 configured for reasoning (the OCON as-shipped condition), so there is
44 no provider-specific payload to add. A lane that later needs one adds
45 it here keyed on its own name — never a blanket default.
46- :meth:`transform_outgoing_messages` is a true pass-through: no lane has
47 a prefix-cache trick to serve.
48- :meth:`parse_usage` delegates to :func:`normalise_usage`, which is
49 total over malformed input.
50- :func:`register_all` is idempotent and validates FIRST: an invalid row
51 raises :exc:`CatalogueError` before ANY lane is registered, so a
52 half-registered bundle is unreachable (plan 215 P5).
53- Nothing here reads an API key. The harness resolves the key from the
54 env-var NAME this provider advertises.
55
56Dependencies
57------------
58- oc_diagnostics (structured debug logging via _dbg)
59- option_c_agent_contracts (ProviderPlugin ABC, ModelCaps, UsageStats, registry)
60- option_c_agent_contracts.provider_plugin (OAuthCapability return type of the oauth property)
61- providers.openai_compat.catalogue (LANES, Lane rows and validate_catalogue for register_all)
62- providers.openai_compat.client (OpenAICompatClient returned by build_client)
63- providers.openai_compat.usage (normalise_usage behind parse_usage)
64- providers.openai_compat.oauth (lazy; OpenRouterOAuth for the openrouter lane's oauth property)
65"""
66from __future__ import annotations
67
68from typing import Sequence
69
70from oc_diagnostics import _dbg
71
72from option_c_agent_contracts import (
73 ProviderPlugin,
74 ModelCaps,
75 UsageStats,
76 BaseClient,
77)
78
79from option_c_agent_contracts.provider_plugin import OAuthCapability
80
81from .catalogue import LANES, Lane, validate_catalogue
82from .client import OpenAICompatClient
83from .usage import normalise_usage
84
85
86#: Rows registered by :func:`register_all`, in catalogue declaration order.
87#: The opening menu discovers lanes through the registry rather than through
88#: this list; it exists so a caller can ask what the bundle contributed.
89_REGISTERED: list[Lane] = []
90
91
92class OpenAICompatProvider(ProviderPlugin):
93 """A :class:`ProviderPlugin` configured from one :class:`Lane` row."""
94
95 def __init__(self, lane: Lane) -> None:
96 func = "__init__"
97 self.lane = lane
98 # Per-instance identity shadows the ABC's class attributes — this is
99 # what lets one class serve seven distinctly-named lanes.
100 self.name = lane.name
101 self.api_key_env_var = lane.api_key_env_var
102 self.base_url_env_var = lane.base_url_env_var
103 self.default_base_url = lane.default_base_url
104 self.default_model = lane.default_model
105 self._models = lane.model_caps()
106 _dbg("OC_CHAT.CLIENT", func,
107 f"lane={lane.name} models={len(self._models)} free={lane.free} "
108 f"verification={lane.verification}", 3)
109
110 @property
111 def oauth(self) -> OAuthCapability | None:
112 """Expose OAuth only for the OpenRouter lane; all other rows opt out."""
113 if self.name != 'openrouter':
114 return None
115 from .oauth import OpenRouterOAuth
116 return OpenRouterOAuth()
117
118 # ── Client construction ──────────────────────────────────────────────────
119
120 def build_client(self, api_key: str, base_url: str) -> BaseClient:
121 func = "build_client"
122 _dbg("OC_CHAT.CLIENT", func, f"lane={self.name} base_url={base_url}", 2)
123 return OpenAICompatClient(
124 api_key=api_key,
125 base_url=base_url,
126 extra_headers=self.lane.extra_headers,
127 lane_name=self.name,
128 )
129
130 # ── Model registry ───────────────────────────────────────────────────────
131
132 def models(self) -> dict[str, ModelCaps]:
133 return self._models
134
135 def fetch_live_models(self, client: BaseClient) -> list[str]:
136 return client.list_models()
137
138 def default_caps(self) -> ModelCaps:
139 """Caps for a live-discovered model absent from the curated set.
140
141 Deliberately the same honest shape the catalogue uses: tools
142 attemptable, nothing claimed about thinking/reasoning, and the
143 contract's conservative context default rather than a guess.
144 """
145 return ModelCaps(
146 supports_thinking=False,
147 supports_reasoning=False,
148 supports_tools=True,
149 max_context_tokens=65_536,
150 tool_schema_dialect="openai_chat",
151 )
152
153 # ── Tier mapping ─────────────────────────────────────────────────────────
154
155 def tier(self, name: str) -> str | None:
156 return self.lane.tiers.get(name)
157
158 # ── Per-call shaping ─────────────────────────────────────────────────────
159
161 self,
162 model: str,
163 *,
164 enable_thinking: bool,
165 effort: str | None,
166 ) -> dict | None:
167 """Return ``None`` — no catalogue lane carries a thinking payload.
168
169 DeepSeek returns ``{"thinking": {"type": "enabled"}}`` here. Every
170 lane in this bundle runs as-shipped: no ``ModelCaps`` in the
171 catalogue advertises ``supports_thinking``, so honouring
172 *enable_thinking* would mean inventing a payload shape that was
173 never proven against the endpoint.
174 """
175 return None
176
178 self,
179 messages: list[dict],
180 tool_schemas: list[dict] | None,
181 ) -> list[dict]:
182 """Return *messages* unchanged — no lane has a prefix-cache trick."""
183 return messages
184
185 # ── Response parsing ─────────────────────────────────────────────────────
186
187 def parse_usage(self, usage: dict) -> UsageStats:
188 return normalise_usage(usage)
189
190 # ── Registration ─────────────────────────────────────────────────────────
191
192 @classmethod
193 def register_lane(cls, lane: Lane) -> "OpenAICompatProvider":
194 """Build and register one provider instance for *lane*."""
195 from option_c_agent_contracts import registry
196
197 instance = cls(lane)
198 registry.register(instance)
199 return instance
200
201
202def register_all(lanes: Sequence[Lane] = LANES) -> list["OpenAICompatProvider"]:
203 """Validate *lanes*, then register one provider per row.
204
205 Validation runs to completion BEFORE the first registration, so an
206 invalid row aborts loudly with its name and leaves the registry
207 untouched (plan 215 P5/W1).
208 """
209 func = "register_all"
210 validated = validate_catalogue(lanes)
211 instances = [OpenAICompatProvider.register_lane(lane) for lane in validated]
212 _REGISTERED.clear()
213 _REGISTERED.extend(validated)
214 _dbg("OC_CHAT.CLIENT", func,
215 f"registered {len(instances)} lane(s): "
216 f"{', '.join(i.name for i in instances)}", 2)
217 return instances
218
219
220def registered_lanes() -> tuple[Lane, ...]:
221 """The lane rows this bundle registered, in catalogue declaration order."""
222 return tuple(_REGISTERED)
223
224
225__all__ = [
226 "OpenAICompatProvider",
227 "register_all",
228 "registered_lanes",
229]
dict|None build_extra_body(self, str model, *, bool enable_thinking, str|None effort)
Definition provider.py:166
list[str] fetch_live_models(self, BaseClient client)
Definition provider.py:135
"OpenAICompatProvider" register_lane(cls, Lane lane)
Definition provider.py:193
list[dict] transform_outgoing_messages(self, list[dict] messages, list[dict]|None tool_schemas)
Definition provider.py:181
BaseClient build_client(self, str api_key, str base_url)
Definition provider.py:120
tuple[Lane,...] registered_lanes()
Definition provider.py:220
list["OpenAICompatProvider"] register_all(Sequence[Lane] lanes=LANES)
Definition provider.py:202