char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Daniel Beveniusserver : use pytest-xdist for server tests (#28298)42f0225fe

char/aliceai-t5-moe
35.8 KiB1087 linesraw
1#!/usr/bin/env python3
2import pytest
3import base64
4import requests
5
6from utils import *
7
8server: ServerProcess
9
10
11def get_test_image_base64() -> str:
12    """Get a test image in base64 format"""
13    # Use the same test image as test_vision_api.py
14    IMG_URL = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/11_truck.png"
15    response = requests.get(IMG_URL)
16    response.raise_for_status()
17    return base64.b64encode(response.content).decode("utf-8")
18
19@pytest.fixture(autouse=True)
20def create_server():
21    global server
22    server = ServerPreset.tinyllama2()
23    server.model_alias = "tinyllama-2-anthropic"
24    server.n_slots = 1
25    server.n_ctx = 8192
26    server.n_batch = 2048
27
28
29@pytest.fixture
30def vision_server():
31    """Separate fixture for vision tests that require multimodal support"""
32    global server
33    server = ServerPreset.tinygemma3()
34    server.offline = False  # Allow downloading the model
35    server.model_alias = "tinygemma3-anthropic"
36    server.n_slots = 1
37    return server
38
39
40# Basic message tests
41
42def test_anthropic_messages_basic():
43    """Test basic Anthropic messages endpoint"""
44    server.start()
45
46    res = server.make_request("POST", "/v1/messages", data={
47        "model": "test",
48        "max_tokens": 50,
49        "messages": [
50            {"role": "user", "content": "Say hello"}
51        ]
52    })
53
54    assert res.status_code == 200, f"Expected 200, got {res.status_code}"
55    assert res.body["type"] == "message", f"Expected type 'message', got {res.body.get('type')}"
56    assert res.body["role"] == "assistant", f"Expected role 'assistant', got {res.body.get('role')}"
57    assert "content" in res.body, "Missing 'content' field"
58    assert isinstance(res.body["content"], list), "Content should be an array"
59    assert len(res.body["content"]) > 0, "Content array should not be empty"
60    assert res.body["content"][0]["type"] == "text", "First content block should be text"
61    assert "text" in res.body["content"][0], "Text content block missing 'text' field"
62    assert res.body["stop_reason"] in ["end_turn", "max_tokens"], f"Invalid stop_reason: {res.body.get('stop_reason')}"
63    assert "usage" in res.body, "Missing 'usage' field"
64    assert "cache_read_input_tokens" in res.body["usage"], "Missing usage.cache_read_input_tokens"
65    assert "input_tokens" in res.body["usage"], "Missing usage.input_tokens"
66    assert "output_tokens" in res.body["usage"], "Missing usage.output_tokens"
67    assert isinstance(res.body["usage"]["cache_read_input_tokens"], int), "cache_read_input_tokens should be integer"
68    assert isinstance(res.body["usage"]["input_tokens"], int), "input_tokens should be integer"
69    assert isinstance(res.body["usage"]["output_tokens"], int), "output_tokens should be integer"
70    assert res.body["usage"]["output_tokens"] > 0, "Should have generated some tokens"
71    # Anthropic API should NOT include timings
72    assert "timings" not in res.body, "Anthropic API should not include timings field"
73
74
75def test_anthropic_messages_with_system():
76    """Test messages with system prompt"""
77    server.start()
78
79    res = server.make_request("POST", "/v1/messages", data={
80        "model": "test",
81        "max_tokens": 50,
82        "system": "You are a helpful assistant.",
83        "messages": [
84            {"role": "user", "content": "Hello"}
85        ]
86    })
87
88    assert res.status_code == 200
89    assert res.body["type"] == "message"
90    assert len(res.body["content"]) > 0
91
92
93def test_anthropic_messages_multipart_content():
94    """Test messages with multipart content blocks"""
95    server.start()
96
97    res = server.make_request("POST", "/v1/messages", data={
98        "model": "test",
99        "max_tokens": 50,
100        "messages": [
101            {
102                "role": "user",
103                "content": [
104                    {"type": "text", "text": "What is"},
105                    {"type": "text", "text": " the answer?"}
106                ]
107            }
108        ]
109    })
110
111    assert res.status_code == 200
112    assert res.body["type"] == "message"
113
114
115def test_anthropic_messages_conversation():
116    """Test multi-turn conversation"""
117    server.start()
118
119    res = server.make_request("POST", "/v1/messages", data={
120        "model": "test",
121        "max_tokens": 50,
122        "messages": [
123            {"role": "user", "content": "Hello"},
124            {"role": "assistant", "content": "Hi there!"},
125            {"role": "user", "content": "How are you?"}
126        ]
127    })
128
129    assert res.status_code == 200
130    assert res.body["type"] == "message"
131
132
133# Streaming tests
134
135def test_anthropic_messages_streaming():
136    """Test streaming messages"""
137    server.start()
138
139    res = server.make_stream_request("POST", "/v1/messages", data={
140        "model": "test",
141        "max_tokens": 30,
142        "messages": [
143            {"role": "user", "content": "Say hello"}
144        ],
145        "stream": True
146    })
147
148    events = []
149    for data in res:
150        # Each event should have type and other fields
151        assert "type" in data, f"Missing 'type' in event: {data}"
152        events.append(data)
153
154    # Verify event sequence
155    event_types = [e["type"] for e in events]
156    assert "message_start" in event_types, "Missing message_start event"
157    assert "content_block_start" in event_types, "Missing content_block_start event"
158    assert "content_block_delta" in event_types, "Missing content_block_delta event"
159    assert "content_block_stop" in event_types, "Missing content_block_stop event"
160    assert "message_delta" in event_types, "Missing message_delta event"
161    assert "message_stop" in event_types, "Missing message_stop event"
162
163    # Check message_start structure
164    message_start = next(e for e in events if e["type"] == "message_start")
165    assert "message" in message_start, "message_start missing 'message' field"
166    assert message_start["message"]["type"] == "message"
167    assert message_start["message"]["role"] == "assistant"
168    assert message_start["message"]["content"] == []
169    assert "usage" in message_start["message"]
170    assert message_start["message"]["usage"]["input_tokens"] > 0
171
172    # Check content_block_start
173    block_start = next(e for e in events if e["type"] == "content_block_start")
174    assert "index" in block_start, "content_block_start missing 'index'"
175    assert block_start["index"] == 0, "First content block should be at index 0"
176    assert "content_block" in block_start
177    assert block_start["content_block"]["type"] == "text"
178
179    # Check content_block_delta
180    deltas = [e for e in events if e["type"] == "content_block_delta"]
181    assert len(deltas) > 0, "Should have at least one content_block_delta"
182    for delta in deltas:
183        assert "index" in delta
184        assert "delta" in delta
185        assert delta["delta"]["type"] == "text_delta"
186        assert "text" in delta["delta"]
187
188    # Check content_block_stop
189    block_stop = next(e for e in events if e["type"] == "content_block_stop")
190    assert "index" in block_stop
191    assert block_stop["index"] == 0
192
193    # Check message_delta
194    message_delta = next(e for e in events if e["type"] == "message_delta")
195    assert "delta" in message_delta
196    assert "stop_reason" in message_delta["delta"]
197    assert message_delta["delta"]["stop_reason"] in ["end_turn", "max_tokens"]
198    assert "usage" in message_delta
199    assert message_delta["usage"]["output_tokens"] > 0
200
201    # Check message_stop
202    message_stop = next(e for e in events if e["type"] == "message_stop")
203    # message_stop should NOT have timings for Anthropic API
204    assert "timings" not in message_stop, "Anthropic streaming should not include timings"
205
206
207# Token counting tests
208
209def test_anthropic_count_tokens():
210    """Test token counting endpoint"""
211    server.start()
212
213    res = server.make_request("POST", "/v1/messages/count_tokens", data={
214        "model": "test",
215        "messages": [
216            {"role": "user", "content": "Hello world"}
217        ]
218    })
219
220    assert res.status_code == 200
221    assert "input_tokens" in res.body
222    assert isinstance(res.body["input_tokens"], int)
223    assert res.body["input_tokens"] > 0
224    # Should only have input_tokens, no other fields
225    assert "output_tokens" not in res.body
226
227
228def test_anthropic_count_tokens_with_system():
229    """Test token counting with system prompt"""
230    server.start()
231
232    res = server.make_request("POST", "/v1/messages/count_tokens", data={
233        "model": "test",
234        "system": "You are a helpful assistant.",
235        "messages": [
236            {"role": "user", "content": "Hello"}
237        ]
238    })
239
240    assert res.status_code == 200
241    assert res.body["input_tokens"] > 0
242
243
244def test_anthropic_count_tokens_no_max_tokens():
245    """Test that count_tokens doesn't require max_tokens"""
246    server.start()
247
248    # max_tokens is NOT required for count_tokens
249    res = server.make_request("POST", "/v1/messages/count_tokens", data={
250        "model": "test",
251        "messages": [
252            {"role": "user", "content": "Hello"}
253        ]
254    })
255
256    assert res.status_code == 200
257    assert "input_tokens" in res.body
258
259
260# Tool use tests
261
262def test_anthropic_tool_use_basic():
263    """Test basic tool use"""
264    server.jinja = True
265    server.start()
266
267    res = server.make_request("POST", "/v1/messages", data={
268        "model": "test",
269        "max_tokens": 200,
270        "tools": [{
271            "name": "get_weather",
272            "description": "Get the current weather in a location",
273            "input_schema": {
274                "type": "object",
275                "properties": {
276                    "location": {
277                        "type": "string",
278                        "description": "City name"
279                    }
280                },
281                "required": ["location"]
282            }
283        }],
284        "messages": [
285            {"role": "user", "content": "What's the weather in Paris?"}
286        ]
287    })
288
289    assert res.status_code == 200
290    assert res.body["type"] == "message"
291    assert len(res.body["content"]) > 0
292
293    # Check if model used the tool (it might not always, depending on the model)
294    content_types = [block.get("type") for block in res.body["content"]]
295
296    if "tool_use" in content_types:
297        # Model used the tool
298        assert res.body["stop_reason"] == "tool_use"
299
300        # Find the tool_use block
301        tool_block = next(b for b in res.body["content"] if b.get("type") == "tool_use")
302        assert "id" in tool_block
303        assert "name" in tool_block
304        assert tool_block["name"] == "get_weather"
305        assert "input" in tool_block
306        assert isinstance(tool_block["input"], dict)
307
308
309def test_anthropic_tool_result():
310    """Test sending tool results back
311
312    This test verifies that tool_result blocks are properly converted to
313    role="tool" messages internally. Without proper conversion, this would
314    fail with a 500 error: "unsupported content[].type" because tool_result
315    blocks would remain in the user message content array.
316    """
317    server.jinja = True
318    server.start()
319
320    res = server.make_request("POST", "/v1/messages", data={
321        "model": "test",
322        "max_tokens": 100,
323        "messages": [
324            {"role": "user", "content": "What's the weather?"},
325            {
326                "role": "assistant",
327                "content": [
328                    {
329                        "type": "tool_use",
330                        "id": "test123",
331                        "name": "get_weather",
332                        "input": {"location": "Paris"}
333                    }
334                ]
335            },
336            {
337                "role": "user",
338                "content": [
339                    {
340                        "type": "tool_result",
341                        "tool_use_id": "test123",
342                        "content": "The weather is sunny, 25°C"
343                    }
344                ]
345            }
346        ]
347    })
348
349    # This would be 500 with the old bug where tool_result blocks weren't converted
350    assert res.status_code == 200
351    assert res.body["type"] == "message"
352    # Model should respond to the tool result
353    assert len(res.body["content"]) > 0
354    assert res.body["content"][0]["type"] == "text"
355
356
357def test_anthropic_tool_result_with_text():
358    """Test tool result mixed with text content
359
360    This tests the edge case where a user message contains both text and
361    tool_result blocks. The server must properly split these into separate
362    messages: a user message with text, followed by tool messages.
363    Without proper handling, this would fail with 500: "unsupported content[].type"
364    """
365    server.jinja = True
366    server.start()
367
368    res = server.make_request("POST", "/v1/messages", data={
369        "model": "test",
370        "max_tokens": 100,
371        "messages": [
372            {"role": "user", "content": "What's the weather?"},
373            {
374                "role": "assistant",
375                "content": [
376                    {
377                        "type": "tool_use",
378                        "id": "tool_1",
379                        "name": "get_weather",
380                        "input": {"location": "Paris"}
381                    }
382                ]
383            },
384            {
385                "role": "user",
386                "content": [
387                    {"type": "text", "text": "Here are the results:"},
388                    {
389                        "type": "tool_result",
390                        "tool_use_id": "tool_1",
391                        "content": "Sunny, 25°C"
392                    }
393                ]
394            }
395        ]
396    })
397
398    assert res.status_code == 200
399    assert res.body["type"] == "message"
400    assert len(res.body["content"]) > 0
401
402
403def test_anthropic_tool_result_with_image():
404    """Test tool result containing mixed text and image blocks
405
406    Verifies that image blocks inside Anthropic tool_result content are
407    properly converted to OpenAI image_url format rather than being
408    silently dropped. With a non-multimodal model, the converted image
409    triggers a clear error message instead of being ignored.
410    """
411    server.jinja = True
412    server.start()
413
414    # Small 1x1 red PNG image in base64 (same as vision tests)
415    red_pixel_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
416
417    res = server.make_request("POST", "/v1/messages", data={
418        "model": "test",
419        "max_tokens": 100,
420        "messages": [
421            {"role": "user", "content": "What is in this image?"},
422            {
423                "role": "assistant",
424                "content": [
425                    {
426                        "type": "tool_use",
427                        "id": "tool_1",
428                        "name": "read",
429                        "input": {"file": "test.png"}
430                    }
431                ]
432            },
433            {
434                "role": "user",
435                "content": [
436                    {
437                        "type": "tool_result",
438                        "tool_use_id": "tool_1",
439                        "content": [
440                            {"type": "text", "text": "File: test.png"},
441                            {
442                                "type": "image",
443                                "source": {
444                                    "type": "base64",
445                                    "media_type": "image/png",
446                                    "data": red_pixel_png
447                                }
448                            }
449                        ]
450                    }
451                ]
452            }
453        ]
454    })
455
456    # Without the fix, image block would cause "unsupported content[].type"
457    # With the fix, image is converted to image_url but tinyllama doesn't support images
458    assert res.status_code == 500
459    assert "image input is not supported" in res.body.get("error", {}).get("message", "").lower()
460
461
462def test_anthropic_tool_result_error():
463    """Test tool result with error flag"""
464    server.jinja = True
465    server.start()
466
467    res = server.make_request("POST", "/v1/messages", data={
468        "model": "test",
469        "max_tokens": 100,
470        "messages": [
471            {"role": "user", "content": "Get the weather"},
472            {
473                "role": "assistant",
474                "content": [
475                    {
476                        "type": "tool_use",
477                        "id": "test123",
478                        "name": "get_weather",
479                        "input": {"location": "InvalidCity"}
480                    }
481                ]
482            },
483            {
484                "role": "user",
485                "content": [
486                    {
487                        "type": "tool_result",
488                        "tool_use_id": "test123",
489                        "is_error": True,
490                        "content": "City not found"
491                    }
492                ]
493            }
494        ]
495    })
496
497    assert res.status_code == 200
498    assert res.body["type"] == "message"
499
500
501def test_anthropic_tool_streaming():
502    """Test streaming with tool use"""
503    server.jinja = True
504    server.start()
505
506    res = server.make_stream_request("POST", "/v1/messages", data={
507        "model": "test",
508        "max_tokens": 200,
509        "stream": True,
510        "tools": [{
511            "name": "calculator",
512            "description": "Calculate math",
513            "input_schema": {
514                "type": "object",
515                "properties": {
516                    "expression": {"type": "string"}
517                },
518                "required": ["expression"]
519            }
520        }],
521        "messages": [
522            {"role": "user", "content": "Calculate 2+2"}
523        ]
524    })
525
526    events = []
527    for data in res:
528        events.append(data)
529
530    event_types = [e["type"] for e in events]
531
532    # Should have basic events
533    assert "message_start" in event_types
534    assert "message_stop" in event_types
535
536    # If tool was used, check for proper tool streaming
537    if any(e.get("type") == "content_block_start" and
538           e.get("content_block", {}).get("type") == "tool_use"
539           for e in events):
540        # Find tool use block start
541        tool_starts = [e for e in events if
542                      e.get("type") == "content_block_start" and
543                      e.get("content_block", {}).get("type") == "tool_use"]
544
545        assert len(tool_starts) > 0, "Should have tool_use content_block_start"
546
547        # Check index is correct (should be 0 if no text, 1 if there's text)
548        tool_start = tool_starts[0]
549        assert "index" in tool_start
550        assert tool_start["content_block"]["type"] == "tool_use"
551        assert "name" in tool_start["content_block"]
552
553
554# Vision/multimodal tests
555
556def test_anthropic_vision_format_accepted():
557    """Test that Anthropic vision format is accepted (format validation only)"""
558    server.start()
559
560    # Small 1x1 red PNG image in base64
561    red_pixel_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
562
563    res = server.make_request("POST", "/v1/messages", data={
564        "model": "test",
565        "max_tokens": 10,
566        "messages": [
567            {
568                "role": "user",
569                "content": [
570                    {
571                        "type": "image",
572                        "source": {
573                            "type": "base64",
574                            "media_type": "image/png",
575                            "data": red_pixel_png
576                        }
577                    },
578                    {
579                        "type": "text",
580                        "text": "What is this?"
581                    }
582                ]
583            }
584        ]
585    })
586
587    # Server accepts the format but tinyllama doesn't support images
588    # So it should return 500 with clear error message about missing mmproj
589    assert res.status_code == 500
590    assert "image input is not supported" in res.body.get("error", {}).get("message", "").lower()
591
592
593def test_anthropic_vision_base64_with_multimodal_model(vision_server):
594    """Test vision with base64 image using Anthropic format with multimodal model"""
595    global server
596    server = vision_server
597    server.start()
598
599    # Get test image in base64 format
600    image_base64 = get_test_image_base64()
601
602    res = server.make_request("POST", "/v1/messages", data={
603        "model": "test",
604        "max_tokens": 10,
605        "messages": [
606            {
607                "role": "user",
608                "content": [
609                    {
610                        "type": "image",
611                        "source": {
612                            "type": "base64",
613                            "media_type": "image/png",
614                            "data": image_base64
615                        }
616                    },
617                    {
618                        "type": "text",
619                        "text": "What is this:\n"
620                    }
621                ]
622            }
623        ]
624    })
625
626    assert res.status_code == 200, f"Expected 200, got {res.status_code}: {res.body}"
627    assert res.body["type"] == "message"
628    assert len(res.body["content"]) > 0
629    assert res.body["content"][0]["type"] == "text"
630    # The model should generate some response about the image
631    assert len(res.body["content"][0]["text"]) > 0
632
633
634# Parameter tests
635
636def test_anthropic_stop_sequences():
637    """Test stop_sequences parameter"""
638    server.start()
639
640    res = server.make_request("POST", "/v1/messages", data={
641        "model": "test",
642        "max_tokens": 100,
643        "stop_sequences": ["\n", "END"],
644        "messages": [
645            {"role": "user", "content": "Count to 10"}
646        ]
647    })
648
649    assert res.status_code == 200
650    assert res.body["type"] == "message"
651
652
653def test_anthropic_temperature():
654    """Test temperature parameter"""
655    server.start()
656
657    res = server.make_request("POST", "/v1/messages", data={
658        "model": "test",
659        "max_tokens": 50,
660        "temperature": 0.5,
661        "messages": [
662            {"role": "user", "content": "Hello"}
663        ]
664    })
665
666    assert res.status_code == 200
667    assert res.body["type"] == "message"
668
669
670def test_anthropic_top_p():
671    """Test top_p parameter"""
672    server.start()
673
674    res = server.make_request("POST", "/v1/messages", data={
675        "model": "test",
676        "max_tokens": 50,
677        "top_p": 0.9,
678        "messages": [
679            {"role": "user", "content": "Hello"}
680        ]
681    })
682
683    assert res.status_code == 200
684    assert res.body["type"] == "message"
685
686
687def test_anthropic_top_k():
688    """Test top_k parameter (llama.cpp specific)"""
689    server.start()
690
691    res = server.make_request("POST", "/v1/messages", data={
692        "model": "test",
693        "max_tokens": 50,
694        "top_k": 40,
695        "messages": [
696            {"role": "user", "content": "Hello"}
697        ]
698    })
699
700    assert res.status_code == 200
701    assert res.body["type"] == "message"
702
703
704# Error handling tests
705
706def test_anthropic_missing_messages():
707    """Test error when messages are missing"""
708    server.start()
709
710    res = server.make_request("POST", "/v1/messages", data={
711        "model": "test",
712        "max_tokens": 50
713        # missing "messages" field
714    })
715
716    # Should return an error (400 or 500)
717    assert res.status_code >= 400
718
719
720def test_anthropic_empty_messages():
721    """Test permissive handling of empty messages array"""
722    server.start()
723
724    res = server.make_request("POST", "/v1/messages", data={
725        "model": "test",
726        "max_tokens": 50,
727        "messages": []
728    })
729
730    # Server is permissive and accepts empty messages (provides defaults)
731    # This matches the permissive validation design choice
732    assert res.status_code == 200
733    assert res.body["type"] == "message"
734
735
736# Content block index tests
737
738def test_anthropic_streaming_content_block_indices():
739    """Test that content block indices are correct in streaming"""
740    server.jinja = True
741    server.start()
742
743    # Request that might produce both text and tool use
744    res = server.make_stream_request("POST", "/v1/messages", data={
745        "model": "test",
746        "max_tokens": 400,
747        "stream": True,
748        "tools": [{
749            "name": "test_tool",
750            "description": "A test tool",
751            "input_schema": {
752                "type": "object",
753                "properties": {
754                    "param": {"type": "string"}
755                },
756                "required": ["param"]
757            }
758        }],
759        "messages": [
760            {"role": "user", "content": "Use the test tool"}
761        ]
762    })
763
764    events = []
765    for data in res:
766        events.append(data)
767
768    # Check content_block_start events have sequential indices
769    block_starts = [e for e in events if e.get("type") == "content_block_start"]
770    if len(block_starts) > 1:
771        # If there are multiple blocks, indices should be sequential
772        indices = [e["index"] for e in block_starts]
773        expected_indices = list(range(len(block_starts)))
774        assert indices == expected_indices, f"Expected indices {expected_indices}, got {indices}"
775
776    # Check content_block_stop events match the starts
777    block_stops = [e for e in events if e.get("type") == "content_block_stop"]
778    start_indices = set(e["index"] for e in block_starts)
779    stop_indices = set(e["index"] for e in block_stops)
780    assert start_indices == stop_indices, "content_block_stop indices should match content_block_start indices"
781
782
783# Extended features tests
784
785def test_anthropic_thinking():
786    """Test extended thinking parameter"""
787    server.jinja = True
788    server.start()
789
790    res = server.make_request("POST", "/v1/messages", data={
791        "model": "test",
792        "max_tokens": 100,
793        "thinking": {
794            "type": "enabled",
795            "budget_tokens": 50
796        },
797        "messages": [
798            {"role": "user", "content": "What is 2+2?"}
799        ]
800    })
801
802    assert res.status_code == 200
803    assert res.body["type"] == "message"
804
805
806def test_anthropic_metadata():
807    """Test metadata parameter"""
808    server.start()
809
810    res = server.make_request("POST", "/v1/messages", data={
811        "model": "test",
812        "max_tokens": 50,
813        "metadata": {
814            "user_id": "test_user_123"
815        },
816        "messages": [
817            {"role": "user", "content": "Hello"}
818        ]
819    })
820
821    assert res.status_code == 200
822    assert res.body["type"] == "message"
823
824
825# Compatibility tests
826
827def test_anthropic_vs_openai_different_response_format():
828    """Verify Anthropic format is different from OpenAI format"""
829    server.start()
830
831    # Make OpenAI request
832    openai_res = server.make_request("POST", "/v1/chat/completions", data={
833        "model": "test",
834        "max_tokens": 50,
835        "messages": [
836            {"role": "user", "content": "Hello"}
837        ]
838    })
839
840    # Make Anthropic request
841    anthropic_res = server.make_request("POST", "/v1/messages", data={
842        "model": "test",
843        "max_tokens": 50,
844        "messages": [
845            {"role": "user", "content": "Hello"}
846        ]
847    })
848
849    assert openai_res.status_code == 200
850    assert anthropic_res.status_code == 200
851
852    # OpenAI has "object", Anthropic has "type"
853    assert "object" in openai_res.body
854    assert "type" in anthropic_res.body
855    assert openai_res.body["object"] == "chat.completion"
856    assert anthropic_res.body["type"] == "message"
857
858    # OpenAI has "choices", Anthropic has "content"
859    assert "choices" in openai_res.body
860    assert "content" in anthropic_res.body
861
862    # Different usage field names
863    assert "prompt_tokens" in openai_res.body["usage"]
864    assert "input_tokens" in anthropic_res.body["usage"]
865    assert "completion_tokens" in openai_res.body["usage"]
866    assert "output_tokens" in anthropic_res.body["usage"]
867
868
869# Extended thinking tests with reasoning models
870
871# The next two tests cover the input path (conversation history):
872# Client sends thinking blocks -> convert_anthropic_to_oai -> reasoning_content -> template
873
874def test_anthropic_thinking_history_in_count_tokens():
875    """Test that interleaved thinking blocks in conversation history are not dropped during conversion."""
876    global server
877    server.jinja = True
878    server.chat_template_file = '../../../models/templates/Qwen-Qwen3-0.6B.jinja'
879    server.start()
880
881    tool = {
882        "name": "list_files",
883        "description": "List files",
884        "input_schema": {
885            "type": "object",
886            "properties": {"path": {"type": "string"}},
887            "required": ["path"]
888        }
889    }
890
891    messages_without_thinking = [
892        {"role": "user", "content": "Fix the bug"},
893        {
894            "role": "assistant",
895            "content": [
896                {"type": "tool_use", "id": "call_1", "name": "list_files", "input": {"path": "."}}
897            ]
898        },
899        {
900            "role": "user",
901            "content": [
902                {"type": "tool_result", "tool_use_id": "call_1", "content": "main.py"}
903            ]
904        },
905    ]
906
907    messages_with_thinking = [
908        {"role": "user", "content": "Fix the bug"},
909        {
910            "role": "assistant",
911            "content": [
912                {"type": "thinking", "thinking": "I should check the project structure first to understand the codebase layout."},
913                {"type": "tool_use", "id": "call_1", "name": "list_files", "input": {"path": "."}}
914            ]
915        },
916        {
917            "role": "user",
918            "content": [
919                {"type": "tool_result", "tool_use_id": "call_1", "content": "main.py"}
920            ]
921        },
922    ]
923
924    res_without = server.make_request("POST", "/v1/messages/count_tokens", data={
925        "model": "test",
926        "messages": messages_without_thinking,
927        "tools": [tool],
928    })
929    assert res_without.status_code == 200, f"Expected 200: {res_without.body}"
930
931    res_with = server.make_request("POST", "/v1/messages/count_tokens", data={
932        "model": "test",
933        "messages": messages_with_thinking,
934        "tools": [tool],
935    })
936    assert res_with.status_code == 200, f"Expected 200: {res_with.body}"
937
938    # Thinking blocks should increase the token count
939    assert res_with.body["input_tokens"] > res_without.body["input_tokens"], \
940        f"Expected more tokens with thinking ({res_with.body['input_tokens']}) than without ({res_without.body['input_tokens']})"
941
942
943def test_anthropic_thinking_history_in_template():
944    """Test that reasoning_content from converted interleaved thinking blocks renders in the prompt."""
945    global server
946    server.jinja = True
947    server.chat_template_file = '../../../models/templates/Qwen-Qwen3-0.6B.jinja'
948    server.start()
949
950    reasoning_1 = "I should check the project structure first."
951    reasoning_2 = "Now I need to read the main file."
952
953    res = server.make_request("POST", "/apply-template", data={
954        "messages": [
955            {"role": "user", "content": "Fix the bug in main.py"},
956            {
957                "role": "assistant",
958                "content": "",
959                "reasoning_content": reasoning_1,
960                "tool_calls": [{
961                    "id": "call_1",
962                    "type": "function",
963                    "function": {"name": "list_files", "arguments": "{\"path\": \".\"}"}
964                }]
965            },
966            {"role": "tool", "tool_call_id": "call_1", "content": "main.py\nutils.py"},
967            {
968                "role": "assistant",
969                "content": "",
970                "reasoning_content": reasoning_2,
971                "tool_calls": [{
972                    "id": "call_2",
973                    "type": "function",
974                    "function": {"name": "read_file", "arguments": "{\"path\": \"main.py\"}"}
975                }]
976            },
977            {"role": "tool", "tool_call_id": "call_2", "content": "print('hello')"},
978        ],
979        "tools": [{
980            "type": "function",
981            "function": {
982                "name": "list_files",
983                "description": "List files",
984                "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
985            }
986        }, {
987            "type": "function",
988            "function": {
989                "name": "read_file",
990                "description": "Read a file",
991                "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
992            }
993        }],
994    })
995    assert res.status_code == 200, f"Expected 200, got {res.status_code}: {res.body}"
996    prompt = res.body["prompt"]
997
998    # Both reasoning_content values should be rendered in <think> tags
999    assert reasoning_1 in prompt, f"Expected first reasoning text in prompt: {prompt}"
1000    assert reasoning_2 in prompt, f"Expected second reasoning text in prompt: {prompt}"
1001    assert prompt.count("<think>") >= 2, f"Expected at least 2 <think> blocks in prompt: {prompt}"
1002
1003
1004@pytest.mark.slow
1005@pytest.mark.parametrize("stream", [False, True])
1006def test_anthropic_thinking_with_reasoning_model(stream):
1007    """Test that thinking content blocks are properly returned for reasoning models"""
1008    global server
1009    server = ServerProcess()
1010    server.model_hf_repo = "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF"
1011    server.model_hf_file = "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf"
1012    server.reasoning_format = "deepseek"
1013    server.jinja = True
1014    server.n_ctx = 8192
1015    server.n_predict = 1024
1016    server.start(timeout_seconds=600)  # large model needs time to download
1017
1018    if stream:
1019        res = server.make_stream_request("POST", "/v1/messages", data={
1020            "model": "test",
1021            "max_tokens": 1024,
1022            "thinking": {
1023                "type": "enabled",
1024                "budget_tokens": 500
1025            },
1026            "messages": [
1027                {"role": "user", "content": "What is 2+2?"}
1028            ],
1029            "stream": True
1030        })
1031
1032        events = list(res)
1033
1034        # should have thinking content block events
1035        thinking_starts = [e for e in events if
1036            e.get("type") == "content_block_start" and
1037            e.get("content_block", {}).get("type") == "thinking"]
1038        assert len(thinking_starts) > 0, "Should have thinking content_block_start event"
1039        assert thinking_starts[0]["index"] == 0, "Thinking block should be at index 0"
1040
1041        # should have thinking_delta events
1042        thinking_deltas = [e for e in events if
1043            e.get("type") == "content_block_delta" and
1044            e.get("delta", {}).get("type") == "thinking_delta"]
1045        assert len(thinking_deltas) > 0, "Should have thinking_delta events"
1046
1047        # should have signature_delta event before thinking block closes (Anthropic API requirement)
1048        signature_deltas = [e for e in events if
1049            e.get("type") == "content_block_delta" and
1050            e.get("delta", {}).get("type") == "signature_delta"]
1051        assert len(signature_deltas) > 0, "Should have signature_delta event for thinking block"
1052
1053        # should have text block after thinking
1054        text_starts = [e for e in events if
1055            e.get("type") == "content_block_start" and
1056            e.get("content_block", {}).get("type") == "text"]
1057        assert len(text_starts) > 0, "Should have text content_block_start event"
1058        assert text_starts[0]["index"] == 1, "Text block should be at index 1 (after thinking)"
1059    else:
1060        res = server.make_request("POST", "/v1/messages", data={
1061            "model": "test",
1062            "max_tokens": 1024,
1063            "thinking": {
1064                "type": "enabled",
1065                "budget_tokens": 500
1066            },
1067            "messages": [
1068                {"role": "user", "content": "What is 2+2?"}
1069            ]
1070        })
1071
1072        assert res.status_code == 200
1073        assert res.body["type"] == "message"
1074
1075        content = res.body["content"]
1076        assert len(content) >= 2, "Should have at least thinking and text blocks"
1077
1078        # first block should be thinking
1079        thinking_blocks = [b for b in content if b.get("type") == "thinking"]
1080        assert len(thinking_blocks) > 0, "Should have thinking content block"
1081        assert "thinking" in thinking_blocks[0], "Thinking block should have 'thinking' field"
1082        assert len(thinking_blocks[0]["thinking"]) > 0, "Thinking content should not be empty"
1083        assert "signature" in thinking_blocks[0], "Thinking block should have 'signature' field (Anthropic API requirement)"
1084
1085        # should also have text block
1086        text_blocks = [b for b in content if b.get("type") == "text"]
1087        assert len(text_blocks) > 0, "Should have text content block"