Skip to content

Commit 23bcef6

Browse files
committed
Add structured response format validation for LLM adapters
- Introduce LLMResponseFormatError for provider response parsing failures with descriptive error messages - Extract dedicated _extract_openai_content, _extract_claude_content, and _extract_gemini_content helpers with explicit field validation instead of silent empty-string fallback - Validate streaming delta format via _first_choice_delta
1 parent da61504 commit 23bcef6

3 files changed

Lines changed: 102 additions & 10 deletions

File tree

teaagent/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
LLMMessage,
4242
LLMRequest,
4343
LLMResponse,
44+
LLMResponseFormatError,
4445
ProviderConfig,
4546
available_providers,
4647
check_llm_configuration,
@@ -145,6 +146,7 @@
145146
'LLMMessage',
146147
'LLMRequest',
147148
'LLMResponse',
149+
'LLMResponseFormatError',
148150
'MemoryCatalog',
149151
'MemoryEntry',
150152
'ModelConformanceReport',

teaagent/llm.py

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class LLMHTTPError(LLMAdapterError):
2121
pass
2222

2323

24+
class LLMResponseFormatError(LLMAdapterError):
25+
pass
26+
27+
2428
@dataclass(frozen=True)
2529
class LLMMessage:
2630
role: str
@@ -163,7 +167,7 @@ def complete(self, request: LLMRequest) -> LLMResponse:
163167
payload,
164168
timeout=self.timeout,
165169
)
166-
content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
170+
content = _extract_openai_content(self.provider, response)
167171
usage = response.get('usage', {})
168172
return LLMResponse(
169173
provider=self.provider,
@@ -201,7 +205,7 @@ def _complete_streaming(
201205
if usage:
202206
input_tokens = usage.get('prompt_tokens', 0)
203207
output_tokens = usage.get('completion_tokens', 0)
204-
delta = parsed.get('choices', [{}])[0].get('delta', {})
208+
delta = _first_choice_delta(self.provider, parsed)
205209
chunk = delta.get('content', '')
206210
if chunk and request.on_chunk:
207211
request.on_chunk(chunk)
@@ -266,12 +270,7 @@ def complete(self, request: LLMRequest) -> LLMResponse:
266270
payload,
267271
timeout=self.timeout,
268272
)
269-
content_blocks = response.get('content', [])
270-
content = ''.join(
271-
block.get('text', '')
272-
for block in content_blocks
273-
if block.get('type') == 'text'
274-
)
273+
content = _extract_claude_content(response)
275274
usage = response.get('usage', {})
276275
return LLMResponse(
277276
provider=self.provider,
@@ -318,8 +317,7 @@ def complete(self, request: LLMRequest) -> LLMResponse:
318317
payload,
319318
timeout=self.timeout,
320319
)
321-
parts = response.get('candidates', [{}])[0].get('content', {}).get('parts', [])
322-
content = ''.join(part.get('text', '') for part in parts)
320+
content = _extract_gemini_content(response)
323321
metadata = response.get('usageMetadata', {})
324322
return LLMResponse(
325323
provider=self.provider,
@@ -331,6 +329,73 @@ def complete(self, request: LLMRequest) -> LLMResponse:
331329
)
332330

333331

332+
def _extract_openai_content(provider: str, response: dict[str, Any]) -> str:
333+
choices = response.get('choices')
334+
if not isinstance(choices, list) or not choices:
335+
raise LLMResponseFormatError(f'{provider} response missing choices')
336+
first_choice = choices[0]
337+
if not isinstance(first_choice, dict):
338+
raise LLMResponseFormatError(f'{provider} response choice is not an object')
339+
message = first_choice.get('message')
340+
if not isinstance(message, dict):
341+
raise LLMResponseFormatError(f'{provider} response missing message')
342+
content = message.get('content')
343+
if not isinstance(content, str) or not content:
344+
raise LLMResponseFormatError(f'{provider} response missing text content')
345+
return content
346+
347+
348+
def _first_choice_delta(provider: str, response: dict[str, Any]) -> dict[str, Any]:
349+
choices = response.get('choices')
350+
if not isinstance(choices, list) or not choices:
351+
raise LLMResponseFormatError(f'{provider} stream chunk missing choices')
352+
first_choice = choices[0]
353+
if not isinstance(first_choice, dict):
354+
raise LLMResponseFormatError(f'{provider} stream choice is not an object')
355+
delta = first_choice.get('delta', {})
356+
if not isinstance(delta, dict):
357+
raise LLMResponseFormatError(f'{provider} stream delta is not an object')
358+
return delta
359+
360+
361+
def _extract_claude_content(response: dict[str, Any]) -> str:
362+
content_blocks = response.get('content')
363+
if not isinstance(content_blocks, list):
364+
raise LLMResponseFormatError('claude response missing content blocks')
365+
text_parts = [
366+
block.get('text', '')
367+
for block in content_blocks
368+
if isinstance(block, dict) and block.get('type') == 'text'
369+
]
370+
content = ''.join(part for part in text_parts if isinstance(part, str))
371+
if not content:
372+
raise LLMResponseFormatError('claude response missing text content')
373+
return content
374+
375+
376+
def _extract_gemini_content(response: dict[str, Any]) -> str:
377+
candidates = response.get('candidates')
378+
if not isinstance(candidates, list) or not candidates:
379+
raise LLMResponseFormatError('gemini response missing candidates')
380+
first_candidate = candidates[0]
381+
if not isinstance(first_candidate, dict):
382+
raise LLMResponseFormatError('gemini candidate is not an object')
383+
content = first_candidate.get('content')
384+
if not isinstance(content, dict):
385+
raise LLMResponseFormatError('gemini response missing content')
386+
parts = content.get('parts')
387+
if not isinstance(parts, list):
388+
raise LLMResponseFormatError('gemini response missing parts')
389+
text = ''.join(
390+
part.get('text', '')
391+
for part in parts
392+
if isinstance(part, dict) and isinstance(part.get('text'), str)
393+
)
394+
if not text:
395+
raise LLMResponseFormatError('gemini response missing text content')
396+
return text
397+
398+
334399
PROVIDER_CONFIGS = {
335400
'claude': ProviderConfig(
336401
name='claude',

tests/test_llm.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from teaagent import (
1111
LLMMessage,
1212
LLMRequest,
13+
LLMResponseFormatError,
1314
available_providers,
1415
check_llm_configuration,
1516
create_llm_adapter,
@@ -222,6 +223,30 @@ def test_openai_streaming_reads_sse_lines_incrementally(self) -> None:
222223
self.assertEqual(result.input_tokens, 2)
223224
self.assertEqual(result.output_tokens, 3)
224225

226+
def test_openai_adapter_rejects_malformed_response(self) -> None:
227+
transport = FakeTransport({'error': {'message': 'blocked'}})
228+
with patch.dict(os.environ, {'OPENAI_API_KEY': 'key'}, clear=True):
229+
adapter = create_llm_adapter('gpt', transport=transport, model='gpt-test')
230+
231+
with self.assertRaises(LLMResponseFormatError):
232+
adapter.complete(LLMRequest(messages=[LLMMessage('user', 'hi')]))
233+
234+
def test_claude_adapter_rejects_malformed_response(self) -> None:
235+
transport = FakeTransport({'content': [{'type': 'tool_use'}]})
236+
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'key'}, clear=True):
237+
adapter = create_llm_adapter('claude', transport=transport)
238+
239+
with self.assertRaises(LLMResponseFormatError):
240+
adapter.complete(LLMRequest(messages=[LLMMessage('user', 'hi')]))
241+
242+
def test_gemini_adapter_rejects_malformed_response(self) -> None:
243+
transport = FakeTransport({'promptFeedback': {'blockReason': 'SAFETY'}})
244+
with patch.dict(os.environ, {'GEMINI_API_KEY': 'key'}, clear=True):
245+
adapter = create_llm_adapter('gemini', transport=transport)
246+
247+
with self.assertRaises(LLMResponseFormatError):
248+
adapter.complete(LLMRequest(messages=[LLMMessage('user', 'hi')]))
249+
225250

226251
if __name__ == '__main__':
227252
unittest.main()

0 commit comments

Comments
 (0)