"""python -m unittest discover -s tests -p test_stream_tool_calls.py -v""" import json import unittest from pathlib import Path from unittest.mock import patch import torch from fastapi.testclient import TestClient from openai import OpenAI from transformers import AutoTokenizer from model.model_minimind import MiniMindConfig, MiniMindForCausalLM from scripts import serve_openai_api as server TOOL = '{"name":"weather","arguments":{"city":"杭州"}}' TOOLS = [{'type': 'function', 'function': {'name': 'weather', 'parameters': { 'type': 'object', 'properties': {'city': {'type': 'string'}}, 'required': ['city']}}}] class ScriptedModel: def __init__(self, chunks, tokenize=False): self.chunks, self.tokenize = chunks, tokenize def generate(self, input_ids, streamer, **kwargs): if self.tokenize: streamer.put(input_ids.cpu()) for token in streamer.tokenizer.encode(''.join(self.chunks), add_special_tokens=False): streamer.put(torch.tensor([token])) streamer.end() else: for chunk in self.chunks: streamer.on_finalized_text(chunk) streamer.on_finalized_text('', stream_end=True) class StreamToolTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.tokenizer = AutoTokenizer.from_pretrained( Path(__file__).resolve().parents[1] / 'model', local_files_only=True) def setUp(self): globals_patch = patch.multiple(server, tokenizer=self.tokenizer, device='cpu', model=ScriptedModel([]), create=True) globals_patch.start() self.addCleanup(globals_patch.stop) self.http = TestClient(server.app) self.addCleanup(self.http.close) self.request = {'model': 'minimind', 'messages': [{'role': 'user', 'content': 'Hi'}]} def frames(self, chunks, thinking=False): server.model = ScriptedModel(chunks) response = self.http.post('/v1/chat/completions', json={ **self.request, 'stream': True, 'open_thinking': thinking}) self.assertEqual(response.status_code, 200) self.assertIn('text/event-stream', response.headers['content-type']) return [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith('data: ')] def assert_output(self, frames, content, count, reasoning=''): self.assertFalse(any('error' in frame for frame in frames), frames) deltas = [frame['choices'][0]['delta'] for frame in frames] self.assertEqual(''.join(d.get('content', '') for d in deltas), content) self.assertEqual(''.join(d.get('reasoning_content', '') for d in deltas), reasoning) calls = [call for d in deltas for call in d.get('tool_calls', [])] self.assertEqual(len(calls), count) self.assertEqual([call['index'] for call in calls], list(range(count))) self.assertEqual(len({call['id'] for call in calls}), count) for call in calls: self.assertEqual(call['function']['name'], 'weather') self.assertEqual(json.loads(call['function']['arguments']), {'city': '杭州'}) self.assertEqual(frames[-1]['choices'][0]['finish_reason'], 'tool_calls' if count else 'stop') def test_split_markers_and_multiple_calls(self): text = 'Before ' + TOOL + ' between ' + TOOL + ' after' for chunks in ([text], list(text), ['Before \n\n' + TOOL + ' answer' self.assert_output(self.frames(list(text), thinking=True), ' answer', 1, reasoning) def test_invalid_and_unfinished_calls_preserve_text(self): for text in ('plain < text', 'end {"name":', 'bad json', '[]'): with self.subTest(text=text): self.assert_output(self.frames(list(text)), text, 0) invalid = 'bad json' self.assert_output(self.frames([invalid, TOOL]), invalid, 1) def test_empty_and_unfinished_thinking(self): self.assert_output(self.frames([]), '', 0) self.assert_output(self.frames(['reason