Different LLM providers return different JSON shapes. Write normalize_llm_response(raw) that maps an OpenAI-style raw response dict to one normalised dict with three keys: 'text' (the assistant message content as a string, or '' if it is null), 'tool_calls' (a list of {'name', 'args'} where args is parsed from the tool call's JSON arguments string), and 'stop_reason' (the choice's finish_reason). The raw shape is raw['choices'][0]: use ['message'] for content and tool_calls, and ['finish_reason'] for the stop reason. Standard library only.
from unittest import TestCase
from exercise import normalize_llm_response
class Evaluate(TestCase):
def test_extracts_text(self):
raw = {'choices': [{'message': {'content': 'hello'}, 'finish_reason': 'stop'}]}
self.assertEqual(normalize_llm_response(raw)['text'], 'hello')
def test_null_content_becomes_empty_string(self):
raw = {'choices': [{'message': {'content': None}, 'finish_reason': 'stop'}]}
self.assertEqual(normalize_llm_response(raw)['text'], '')
def test_maps_stop_reason(self):
raw = {'choices': [{'message': {'content': 'x'}, 'finish_reason': 'length'}]}
self.assertEqual(normalize_llm_response(raw)['stop_reason'], 'length')
def test_parses_tool_call_arguments_to_dict(self):
raw = {'choices': [{'message': {'content': None, 'tool_calls': [
{'function': {'name': 'add', 'arguments': '{"a": 1, "b": 2}'}}]},
'finish_reason': 'tool_calls'}]}
out = normalize_llm_response(raw)
self.assertEqual(out['tool_calls'], [{'name': 'add', 'args': {'a': 1, 'b': 2}}])
import json
def normalize_llm_response(raw):
choice = raw['choices'][0]
msg = choice.get('message', {})
tool_calls = []
for tc in msg.get('tool_calls') or []:
fn = tc.get('function', {})
args = fn.get('arguments') or '{}'
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {}
tool_calls.append({'name': fn.get('name', ''), 'args': args})
return {
'text': msg.get('content') or '',
'tool_calls': tool_calls,
'stop_reason': choice.get('finish_reason'),
}