Skip to content

Commit 1114ba9

Browse files
committed
Add model conformance check CLI command for live provider testing
- Introduce ModelConformanceResult and ModelConformanceReport data types for per-provider and aggregate status (passed/failed/skipped) - Add run_model_conformance() that attempts a simple prompt across selected or all configured providers and reports tokens and cost - Add `model conformance` CLI subcommand with --provider, --model, --prompt, and --max-tokens flags
1 parent 4a30739 commit 1114ba9

4 files changed

Lines changed: 264 additions & 0 deletions

File tree

teaagent/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
check_llm_configuration,
4747
create_llm_adapter,
4848
)
49+
from teaagent.llm_conformance import (
50+
ModelConformanceReport,
51+
ModelConformanceResult,
52+
run_model_conformance,
53+
)
4954
from teaagent.mcp_http import build_mcp_http_server, serve_mcp_http
5055
from teaagent.mcp_server import handle_mcp_request, serve_mcp_stdio
5156
from teaagent.memory import MemoryCatalog, MemoryEntry
@@ -142,6 +147,8 @@
142147
'LLMResponse',
143148
'MemoryCatalog',
144149
'MemoryEntry',
150+
'ModelConformanceReport',
151+
'ModelConformanceResult',
145152
'ModelRoute',
146153
'ModelDecisionEngine',
147154
'PermissionMode',
@@ -218,4 +225,5 @@
218225
'serve_mcp_stdio',
219226
'run_chat_agent',
220227
'run_eval',
228+
'run_model_conformance',
221229
]

teaagent/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
check_llm_configuration,
2121
create_llm_adapter,
2222
)
23+
from teaagent.llm_conformance import run_model_conformance
2324
from teaagent.mcp_http import DEFAULT_PORT as MCP_HTTP_DEFAULT_PORT
2425
from teaagent.mcp_http import serve_mcp_http
2526
from teaagent.mcp_server import serve_mcp_stdio
@@ -386,6 +387,29 @@ def build_parser() -> argparse.ArgumentParser:
386387
)
387388
smoke_model.set_defaults(func=model_smoke)
388389

390+
conformance_model = model_subparsers.add_parser(
391+
'conformance', help='Run live conformance checks across providers.'
392+
)
393+
conformance_model.add_argument(
394+
'--provider',
395+
action='append',
396+
choices=available_providers(),
397+
default=None,
398+
help='Provider to check. Can be repeated. Defaults to all providers.',
399+
)
400+
conformance_model.add_argument(
401+
'--model',
402+
default=None,
403+
help='Override model name for all selected providers.',
404+
)
405+
conformance_model.add_argument(
406+
'--prompt', default='Reply with exactly: ok', help='Prompt to send.'
407+
)
408+
conformance_model.add_argument(
409+
'--max-tokens', type=int, default=32, help='Maximum output tokens.'
410+
)
411+
conformance_model.set_defaults(func=model_conformance)
412+
389413
route = model_subparsers.add_parser(
390414
'route', help='Classify a task and choose a provider-specific model.'
391415
)
@@ -822,6 +846,17 @@ def model_smoke(args: argparse.Namespace) -> int:
822846
return 0
823847

824848

849+
def model_conformance(args: argparse.Namespace) -> int:
850+
report = run_model_conformance(
851+
args.provider,
852+
prompt=args.prompt,
853+
max_tokens=args.max_tokens,
854+
model=args.model,
855+
)
856+
print_json(report.as_dict())
857+
return 0 if report.ok else 1
858+
859+
825860
def model_route(args: argparse.Namespace) -> int:
826861
print_json(
827862
route_model(args.task, provider=args.provider, model=args.model).to_dict()

teaagent/llm_conformance.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Callable, Iterable
5+
6+
from teaagent.llm import (
7+
LLMMessage,
8+
LLMRequest,
9+
available_providers,
10+
check_llm_configuration,
11+
create_llm_adapter,
12+
)
13+
14+
15+
@dataclass(frozen=True)
16+
class ModelConformanceResult:
17+
provider: str
18+
status: str
19+
model: str | None = None
20+
content: str = ''
21+
error: str | None = None
22+
input_tokens: int = 0
23+
output_tokens: int = 0
24+
estimated_cost_cents: float = 0.0
25+
26+
def as_dict(self) -> dict[str, object]:
27+
payload: dict[str, object] = {
28+
'provider': self.provider,
29+
'status': self.status,
30+
}
31+
if self.model is not None:
32+
payload['model'] = self.model
33+
if self.content:
34+
payload['content'] = self.content
35+
if self.error:
36+
payload['error'] = self.error
37+
if self.status == 'passed':
38+
payload['input_tokens'] = self.input_tokens
39+
payload['output_tokens'] = self.output_tokens
40+
payload['estimated_cost_cents'] = self.estimated_cost_cents
41+
return payload
42+
43+
44+
@dataclass(frozen=True)
45+
class ModelConformanceReport:
46+
results: list[ModelConformanceResult]
47+
48+
@property
49+
def passed(self) -> int:
50+
return sum(1 for result in self.results if result.status == 'passed')
51+
52+
@property
53+
def failed(self) -> int:
54+
return sum(1 for result in self.results if result.status == 'failed')
55+
56+
@property
57+
def skipped(self) -> int:
58+
return sum(1 for result in self.results if result.status == 'skipped')
59+
60+
@property
61+
def ok(self) -> bool:
62+
return self.failed == 0
63+
64+
def as_dict(self) -> dict[str, object]:
65+
return {
66+
'ok': self.ok,
67+
'passed': self.passed,
68+
'failed': self.failed,
69+
'skipped': self.skipped,
70+
'results': [result.as_dict() for result in self.results],
71+
}
72+
73+
74+
AdapterFactory = Callable[..., object]
75+
ConfigurationChecker = Callable[[str], tuple[bool, str]]
76+
77+
78+
def run_model_conformance(
79+
providers: Iterable[str] | None = None,
80+
*,
81+
prompt: str = 'Reply with exactly: ok',
82+
max_tokens: int = 32,
83+
model: str | None = None,
84+
adapter_factory: AdapterFactory = create_llm_adapter,
85+
configuration_checker: ConfigurationChecker = check_llm_configuration,
86+
) -> ModelConformanceReport:
87+
selected_providers = list(providers or available_providers())
88+
results = [
89+
_run_provider_conformance(
90+
provider,
91+
prompt=prompt,
92+
max_tokens=max_tokens,
93+
model=model,
94+
adapter_factory=adapter_factory,
95+
configuration_checker=configuration_checker,
96+
)
97+
for provider in selected_providers
98+
]
99+
return ModelConformanceReport(results=results)
100+
101+
102+
def _run_provider_conformance(
103+
provider: str,
104+
*,
105+
prompt: str,
106+
max_tokens: int,
107+
model: str | None,
108+
adapter_factory: AdapterFactory,
109+
configuration_checker: ConfigurationChecker,
110+
) -> ModelConformanceResult:
111+
configured, message = configuration_checker(provider)
112+
if not configured:
113+
return ModelConformanceResult(
114+
provider=provider, status='skipped', error=message
115+
)
116+
117+
try:
118+
adapter = adapter_factory(provider, model=model)
119+
response = adapter.complete( # type: ignore[attr-defined]
120+
LLMRequest(
121+
messages=[LLMMessage(role='user', content=prompt)],
122+
max_tokens=max_tokens,
123+
)
124+
)
125+
except Exception as exc:
126+
return ModelConformanceResult(
127+
provider=provider,
128+
status='failed',
129+
error=f'{type(exc).__name__}: {exc}',
130+
)
131+
132+
content = response.content.strip()
133+
if not content:
134+
return ModelConformanceResult(
135+
provider=provider,
136+
status='failed',
137+
model=response.model,
138+
error='provider returned empty content',
139+
)
140+
return ModelConformanceResult(
141+
provider=provider,
142+
status='passed',
143+
model=response.model,
144+
content=content,
145+
input_tokens=response.input_tokens,
146+
output_tokens=response.output_tokens,
147+
estimated_cost_cents=response.estimated_cost_cents,
148+
)

tests/test_llm_conformance.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import json
5+
import unittest
6+
from contextlib import redirect_stdout
7+
from unittest.mock import patch
8+
9+
from conftest import FakeAdapter
10+
11+
from teaagent.cli import main
12+
from teaagent.llm_conformance import run_model_conformance
13+
14+
15+
class FailingAdapter:
16+
def complete(self, request: object) -> object:
17+
raise RuntimeError('boom')
18+
19+
20+
class LLMConformanceTests(unittest.TestCase):
21+
def test_run_model_conformance_reports_pass_skip_and_fail(self) -> None:
22+
adapters = {'gpt': FakeAdapter(['ok']), 'claude': FailingAdapter()}
23+
24+
def check(provider: str) -> tuple[bool, str]:
25+
if provider == 'gemini':
26+
return False, 'GEMINI_API_KEY is not set'
27+
return True, 'configured'
28+
29+
def factory(provider: str, *, model: str | None = None) -> object:
30+
return adapters[provider]
31+
32+
report = run_model_conformance(
33+
['gpt', 'gemini', 'claude'],
34+
adapter_factory=factory,
35+
configuration_checker=check,
36+
)
37+
38+
self.assertFalse(report.ok)
39+
self.assertEqual(report.passed, 1)
40+
self.assertEqual(report.skipped, 1)
41+
self.assertEqual(report.failed, 1)
42+
payload = report.as_dict()
43+
self.assertEqual(payload['results'][0]['status'], 'passed')
44+
self.assertEqual(payload['results'][1]['status'], 'skipped')
45+
self.assertEqual(payload['results'][2]['status'], 'failed')
46+
47+
def test_cli_model_conformance_outputs_report(self) -> None:
48+
output = io.StringIO()
49+
50+
with (
51+
patch('teaagent.cli.run_model_conformance') as run_model_conformance,
52+
redirect_stdout(output),
53+
):
54+
run_model_conformance.return_value.ok = True
55+
run_model_conformance.return_value.as_dict.return_value = {
56+
'ok': True,
57+
'passed': 1,
58+
'failed': 0,
59+
'skipped': 0,
60+
'results': [{'provider': 'gpt', 'status': 'passed'}],
61+
}
62+
exit_code = main(['model', 'conformance', '--provider', 'gpt'])
63+
64+
payload = json.loads(output.getvalue())
65+
self.assertEqual(exit_code, 0)
66+
self.assertTrue(payload['ok'])
67+
run_model_conformance.assert_called_once_with(
68+
['gpt'], prompt='Reply with exactly: ok', max_tokens=32, model=None
69+
)
70+
71+
72+
if __name__ == '__main__':
73+
unittest.main()

0 commit comments

Comments
 (0)