oc_chat
Loading...
Searching...
No Matches
conformance.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# providers/openai_compat/conformance.py
4
5"""
6Lane conformance runner — the evidence behind a ``verification`` promotion.
7
8Purpose
9-------
10``python -m providers.openai_compat.conformance <lane> --live`` exercises
11one streamed chat round and one single-tool ReAct round against a lane's
12real endpoint and prints a verdict. A lane's catalogue ``verification``
13field is promoted from ``"chat"`` to ``"tools"`` only by a HUMAN who ran
14this and recorded the date in that row's ``notes`` (plan 215 P4) — the
15runner never edits the catalogue.
16
17Live network access requires the explicit ``--live`` flag. Without it the
18runner refuses to touch the network and exits non-zero, which is what
19makes it safe to exercise from the test suite: the suite drives it with an
20injected mock client and never opens a socket.
21
22Responsibilities
23----------------
24- :func:`run_conformance` — the two rounds against an injected client.
25- :func:`build_live_client` — construct a real client for a lane, or fail
26 with the env-var NAME the operator must set.
27- :func:`format_report` — render a :class:`ConformanceResult` as ASCII.
28- :func:`main` — argv parsing, the ``--live`` gate, and the exit code.
29
30Diagnostics
31-----------
32Domain: OC_CHAT.CLIENT
33Levels:
34 L2 — lifecycle events
35 L3 — semantic details
36 L4 — deep tracing
37
38Contracts
39---------
40- Without ``--live`` the runner NEVER constructs a network client and
41 exits 2. This is a refusal, not a silent skip.
42- ``--live`` with an unset key env var exits 2 naming the variable; it
43 never falls back to another lane's key.
44- Exit codes: 0 both rounds passed; 1 a round failed; 2 refused to run
45 (no ``--live``, unknown lane, or missing key).
46- The runner reports ONLY what it observed. A tools round that produced
47 no tool call is reported as a FAIL with the reason, never softened into
48 a pass, and the runner never writes the catalogue.
49- No key value is printed, logged, or included in any report line.
50
51Dependencies
52------------
53- oc_diagnostics (structured debug logging via _dbg)
54- providers.openai_compat.catalogue (LANES, Lane and lane_by_name to find the lane under test)
55- providers.openai_compat.client (lazy; OpenAICompatClient built for a live run)
56"""
57from __future__ import annotations
58
59import argparse
60import json
61import os
62import sys
63from dataclasses import dataclass, field
64from typing import Any, Sequence
65
66from oc_diagnostics import _dbg
67
68from .catalogue import LANES, Lane, lane_by_name
69
70#: The one tool the ReAct round offers. Deliberately trivial and pure: the
71#: round tests whether the LANE emits a well-formed tool call, not whether a
72#: model can solve anything.
73PROBE_TOOL: dict = {
74 "type": "function",
75 "function": {
76 "name": "get_current_temperature",
77 "description": "Return the current temperature for a named city.",
78 "parameters": {
79 "type": "object",
80 "properties": {
81 "city": {"type": "string", "description": "City name, e.g. 'Rotterdam'"},
82 },
83 "required": ["city"],
84 },
85 },
86}
87
88_CHAT_PROMPT = "Reply with exactly the word: pong"
89_TOOLS_PROMPT = "What is the current temperature in Rotterdam? Use the tool."
90
91
92@dataclass
94 """Outcome of one conformance round."""
95
96 name: str
97 passed: bool
98 detail: str
99 chunks: list[str] = field(default_factory=list)
100
101
102@dataclass
104 """Outcome of a full lane conformance run."""
105
106 lane_name: str
107 rounds: list[RoundResult] = field(default_factory=list)
108
109 @property
110 def passed(self) -> bool:
111 """True only when every round passed."""
112 return bool(self.rounds) and all(r.passed for r in self.rounds)
113
114 @property
115 def verdict(self) -> str:
116 """``"tools"`` when both rounds passed, else ``"chat"`` or ``"none"``."""
117 by_name = {r.name: r.passed for r in self.rounds}
118 if by_name.get("chat") and by_name.get("tools"):
119 return "tools"
120 if by_name.get("chat"):
121 return "chat"
122 return "none"
123
124
125def _chat_round(client: Any, model: str) -> RoundResult:
126 """One streamed chat round: assert content arrived."""
127 func = "_chat_round"
128 messages = [{"role": "user", "content": _CHAT_PROMPT}]
129 text_parts: list[str] = []
130 kinds: list[str] = []
131 try:
132 for kind, data in client.stream_chat(messages, model):
133 kinds.append(kind)
134 if kind == "content":
135 text_parts.append(str(data))
136 except Exception as exc:
137 # EXTERNAL_ERROR — the lane's endpoint failed the round.
138 _dbg("OC_CHAT.CLIENT", func,
139 f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2)
140 return RoundResult("chat", False, f"{type(exc).__name__}: {exc}", kinds)
141
142 text = "".join(text_parts).strip()
143 if not text:
144 return RoundResult("chat", False, "stream yielded no content chunk", kinds)
145 preview = text if len(text) <= 60 else text[:57] + "..."
146 return RoundResult("chat", True, f"content received: {preview!r}", kinds)
147
148
149def _tools_round(client: Any, model: str) -> RoundResult:
150 """One single-tool ReAct round: assert a well-formed tool call arrived."""
151 func = "_tools_round"
152 messages = [{"role": "user", "content": _TOOLS_PROMPT}]
153 calls: list[dict] = []
154 kinds: list[str] = []
155 try:
156 for kind, data in client.stream_chat(messages, model, tools=[PROBE_TOOL]):
157 kinds.append(kind)
158 if kind == "tool_call" and isinstance(data, dict):
159 calls.append(data)
160 except Exception as exc:
161 # EXTERNAL_ERROR — the lane's endpoint failed the round.
162 _dbg("OC_CHAT.CLIENT", func,
163 f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2)
164 return RoundResult("tools", False, f"{type(exc).__name__}: {exc}", kinds)
165
166 if not calls:
167 return RoundResult(
168 "tools", False,
169 "no tool_call chunk (the lane answered in prose or ignored the tool)",
170 kinds,
171 )
172 call = calls[0]
173 if call.get("name") != PROBE_TOOL["function"]["name"]:
174 return RoundResult(
175 "tools", False,
176 f"tool call named {call.get('name')!r}, expected "
177 f"{PROBE_TOOL['function']['name']!r}",
178 kinds,
179 )
180 try:
181 args = json.loads(call.get("arguments") or "{}")
182 except ValueError as exc:
183 # VALIDATION_ERROR — arguments were not parseable JSON.
184 _dbg("OC_CHAT.CLIENT", func,
185 f"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
186 return RoundResult(
187 "tools", False,
188 f"tool call arguments are not valid JSON: {exc}",
189 kinds,
190 )
191 if not isinstance(args, dict) or "city" not in args:
192 return RoundResult(
193 "tools", False,
194 f"tool call arguments missing the required 'city' key: {args!r}",
195 kinds,
196 )
197 return RoundResult("tools", True, f"tool call well-formed: {args!r}", kinds)
198
199
200def run_conformance(client: Any, lane: Lane, model: str | None = None) -> ConformanceResult:
201 """Run both rounds for *lane* against an already-built *client*.
202
203 The client is INJECTED so the test suite can drive this with a mock and
204 stay off the network entirely (plan 215 W5).
205 """
206 func = "run_conformance"
207 target = model or lane.default_model
208 _dbg("OC_CHAT.CLIENT", func, f"lane={lane.name} model={target}", 2)
209 result = ConformanceResult(lane_name=lane.name)
210 result.rounds.append(_chat_round(client, target))
211 result.rounds.append(_tools_round(client, target))
212 return result
213
214
215def build_live_client(lane: Lane) -> Any:
216 """Construct a real client for *lane*, or raise :exc:`SystemExit(2)`.
217
218 Reads the key from the env-var NAME the catalogue row advertises. The
219 key value is never logged, echoed, or stored anywhere in this module.
220 """
221 func = "build_live_client"
222 from .client import OpenAICompatClient
223
224 api_key = os.environ.get(lane.api_key_env_var, "").strip()
225 if not api_key:
226 print(
227 f"ERROR: {lane.api_key_env_var} is not set; cannot run lane "
228 f"{lane.name!r} live.",
229 file=sys.stderr,
230 )
231 raise SystemExit(2)
232 base_url = os.environ.get(lane.base_url_env_var, "").strip() or lane.default_base_url
233 _dbg("OC_CHAT.CLIENT", func, f"lane={lane.name} base_url={base_url}", 2)
234 return OpenAICompatClient(
235 api_key=api_key,
236 base_url=base_url,
237 extra_headers=lane.extra_headers,
238 lane_name=lane.name,
239 )
240
241
242def format_report(result: ConformanceResult, lane: Lane, model: str) -> str:
243 """Render *result* as an ASCII report block."""
244 lines = [
245 "=" * 66,
246 f" Lane conformance: {lane.name} ({lane.display_label})",
247 "=" * 66,
248 f" model : {model}",
249 f" base URL : {lane.default_base_url}",
250 f" catalogue : free={str(lane.free).lower()} verification={lane.verification}",
251 "",
252 ]
253 for round_result in result.rounds:
254 status = "PASS" if round_result.passed else "FAIL"
255 lines.append(f" [{status}] {round_result.name:<6} {round_result.detail}")
256 lines.append("")
257 lines.append(f" observed verification tier: {result.verdict}")
258 if result.verdict == "tools" and lane.verification != "tools":
259 lines.append(
260 " NOTE: this run supports promoting the catalogue row to "
261 "verification=\"tools\"."
262 )
263 lines.append(
264 " A human makes that edit and dates it in the row's notes; "
265 "this runner never writes the catalogue."
266 )
267 lines.append("=" * 66)
268 return "\n".join(lines)
269
270
271def build_arg_parser() -> argparse.ArgumentParser:
272 """Build the runner's argument parser."""
273 parser = argparse.ArgumentParser(
274 prog="python -m providers.openai_compat.conformance",
275 description=(
276 "Exercise one streamed chat round and one single-tool ReAct round "
277 "against a lane's live endpoint. Requires --live; without it the "
278 "runner refuses to touch the network."
279 ),
280 )
281 parser.add_argument("lane", help=f"lane name ({', '.join(l.name for l in LANES)})")
282 parser.add_argument(
283 "--live",
284 action="store_true",
285 help="REQUIRED to make real API calls. Costs money on a paid lane.",
286 )
287 parser.add_argument(
288 "--model",
289 default=None,
290 help="model id to probe (default: the lane's 'default' tier)",
291 )
292 return parser
293
294
295def main(argv: Sequence[str] | None = None) -> int:
296 """Entry point. Returns the process exit code (0 pass, 1 fail, 2 refused)."""
297 func = "main"
298 args = build_arg_parser().parse_args(argv)
299
300 try:
301 lane = lane_by_name(args.lane)
302 except KeyError as exc:
303 # VALIDATION_ERROR — unknown lane name supplied on the command line.
304 _dbg("OC_CHAT.CLIENT", func, f"VALIDATION_ERROR: {exc}", 3)
305 print(f"ERROR: {exc}", file=sys.stderr)
306 return 2
307
308 if not args.live:
309 print(
310 # OCT-LINT: disable=no_hardcoded_secrets reason="Operator-facing refusal message; high-entropy English, not a credential"
311 "ERROR: refusing to run without --live.\n"
312 " This runner makes REAL API calls against the lane's endpoint and "
313 "costs money on a paid lane.\n"
314 f" Re-run with: python -m providers.openai_compat.conformance "
315 f"{lane.name} --live",
316 file=sys.stderr,
317 )
318 return 2
319
320 client = build_live_client(lane)
321 model = args.model or lane.default_model
322 result = run_conformance(client, lane, model)
323 print(format_report(result, lane, model))
324 return 0 if result.passed else 1
325
326
327if __name__ == "__main__":
328 raise SystemExit(main())
329
330
331__all__ = [
332 "ConformanceResult",
333 "PROBE_TOOL",
334 "RoundResult",
335 "build_arg_parser",
336 "build_live_client",
337 "format_report",
338 "main",
339 "run_conformance",
340]
argparse.ArgumentParser build_arg_parser()
RoundResult _tools_round(Any client, str model)
str format_report(ConformanceResult result, Lane lane, str model)
RoundResult _chat_round(Any client, str model)
ConformanceResult run_conformance(Any client, Lane lane, str|None model=None)
int main(Sequence[str]|None argv=None)