File size: 26,355 Bytes
aef804e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
"""
Comprehensive streaming tests for BYOKHandler stream_completion method.

This test file targets stream_completion method coverage (lines ~1372-1518 in byok_handler.py)
Tests cover:
- Async streaming with AsyncMock for OpenAI, Anthropic, DeepSeek providers
- Provider fallback on streaming failures
- Empty stream handling
- System instruction and temperature parameters
- Governance tracking with agent execution records
- Error handling (rate limits, connection errors)

Target: 75%+ coverage for stream_completion method
"""

import os
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.orm import Session

from core.llm.byok_handler import BYOKHandler, QueryComplexity


# =============================================================================
# FIXTURES
# =============================================================================

@pytest.fixture
def mock_async_openai_client():
    """Mock AsyncOpenAI client for streaming tests."""
    client = AsyncMock()
    return client


@pytest.fixture
def mock_stream_response():
    """Mock async streaming response generator."""
    async def generate_chunks():
        """Generate mock streaming chunks."""
        # Chunk 1
        chunk1 = MagicMock()
        chunk1.choices = [MagicMock(delta=MagicMock(content="Hello"))]
        yield chunk1

        # Chunk 2
        chunk2 = MagicMock()
        chunk2.choices = [MagicMock(delta=MagicMock(content=" world"))]
        yield chunk2

        # Chunk 3
        chunk3 = MagicMock()
        chunk3.choices = [MagicMock(delta=MagicMock(content="!"))]
        yield chunk3

        # Final chunk with finish_reason
        chunk4 = MagicMock()
        chunk4.choices = [MagicMock(finish_reason="stop")]
        yield chunk4

    # Return the async generator function
    return generate_chunks


@pytest.fixture
def handler_with_mocked_client(mock_async_openai_client):
    """Create BYOKHandler with mocked async clients."""
    handler = BYOKHandler()

    # Replace async_clients with mock
    handler.async_clients = {
        "openai": mock_async_openai_client,
        "anthropic": AsyncMock(),
        "deepseek": AsyncMock(),
    }

    # Set up clients dict too
    handler.clients = handler.async_clients.copy()

    return handler


@pytest.fixture
def mock_db_session():
    """Mock database session for governance tracking."""
    db = MagicMock(spec=Session)

    # Mock agent execution creation
    mock_execution = MagicMock()
    mock_execution.id = "test-execution-123"
    db.add = MagicMock()
    db.commit = MagicMock()
    db.refresh = MagicMock()

    return db


# =============================================================================
# TEST STREAM COMPLETION
# =============================================================================

class TestStreamCompletion:
    """Test suite for stream_completion async streaming method."""

    @pytest.mark.asyncio
    async def test_stream_completion_yields_tokens(self, handler_with_mocked_client, mock_stream_response):
        """Test that stream_completion yields string tokens."""
        handler = handler_with_mocked_client

        # Mock the create method to return streaming generator
        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=mock_stream_response()
        )

        # Collect streamed tokens
        messages = [{"role": "user", "content": "Hello"}]
        tokens = []

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            tokens.append(token)

        # Verify tokens were collected
        assert len(tokens) >= 3
        assert "Hello" in "".join(tokens)
        assert "world" in "".join(tokens)

    @pytest.mark.asyncio
    async def test_stream_completion_with_openai_provider(self, handler_with_mocked_client):
        """Test streaming with OpenAI provider."""
        handler = handler_with_mocked_client

        # Create OpenAI-style streaming response
        async def openai_stream():
            """Stream OpenAI response."""
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="OpenAI response"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=openai_stream()
        )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        assert "OpenAI response" in result

    @pytest.mark.asyncio
    async def test_stream_completion_with_deepseek_provider(self, handler_with_mocked_client):
        """Test streaming with DeepSeek provider."""
        handler = handler_with_mocked_client

        # Create DeepSeek streaming response
        async def deepseek_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="DeepSeek streaming"))]
            yield chunk

        handler.async_clients["deepseek"].chat.completions.create = AsyncMock(
            return_value=deepseek_stream()
        )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        async for token in handler.stream_completion(
            messages=messages,
            model="deepseek-chat",
            provider_id="deepseek"
        ):
            result += token

        assert "DeepSeek streaming" in result

    @pytest.mark.asyncio
    async def test_stream_completion_empty_stream(self, handler_with_mocked_client):
        """Test handling of empty stream gracefully."""
        handler = handler_with_mocked_client

        # Create empty stream
        async def empty_stream():
            # Yield nothing
            return
            yield  # Never reached

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=empty_stream()
        )

        messages = [{"role": "user", "content": "Test"}]
        tokens = []

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            tokens.append(token)

        # Should handle empty stream gracefully
        assert isinstance(tokens, list)

    @pytest.mark.asyncio
    async def test_stream_completion_with_system_instruction(self, handler_with_mocked_client):
        """Test streaming with system instruction included in messages."""
        handler = handler_with_mocked_client

        async def stream_with_system():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Response"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=stream_with_system()
        )

        messages = [
            {"role": "system", "content": "You are a helpful assistant."},
            {"role": "user", "content": "Hello"}
        ]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Verify the create method was called with system message
        handler.async_clients["openai"].chat.completions.create.assert_called_once()
        call_args = handler.async_clients["openai"].chat.completions.create.call_args
        messages_arg = call_args[1]["messages"]
        assert len(messages_arg) == 2
        assert messages_arg[0]["role"] == "system"

    @pytest.mark.asyncio
    async def test_stream_completion_with_temperature(self, handler_with_mocked_client):
        """Test streaming with custom temperature parameter."""
        handler = handler_with_mocked_client

        async def stream_with_temp():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Response"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=stream_with_temp()
        )

        messages = [{"role": "user", "content": "Test"}]

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai",
            temperature=0.5
        ):
            pass  # Consume stream

        # Verify temperature was passed
        handler.async_clients["openai"].chat.completions.create.assert_called_once()
        call_args = handler.async_clients["openai"].chat.completions.create.call_args
        assert call_args[1]["temperature"] == 0.5

    @pytest.mark.asyncio
    async def test_stream_completion_with_max_tokens(self, handler_with_mocked_client):
        """Test streaming with max_tokens parameter."""
        handler = handler_with_mocked_client

        async def stream_with_max():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Token"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=stream_with_max()
        )

        messages = [{"role": "user", "content": "Test"}]

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai",
            max_tokens=100
        ):
            pass  # Consume stream

        # Verify max_tokens was passed
        call_args = handler.async_clients["openai"].chat.completions.create.call_args
        assert call_args[1]["max_tokens"] == 100


class TestStreamCompletionErrors:
    """Test suite for streaming error handling."""

    @pytest.mark.asyncio
    async def test_stream_completion_provider_fallback(self, handler_with_mocked_client):
        """Test provider fallback on streaming failure."""
        handler = handler_with_mocked_client

        # Make OpenAI fail
        async def failing_stream():
            raise Exception("OpenAI connection error")
            yield  # Never reached

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            side_effect=failing_stream()
        )

        # Make Anthropic succeed
        async def success_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Fallback response"))]
            yield chunk

        handler.async_clients["anthropic"].chat.completions.create = AsyncMock(
            return_value=success_stream()
        )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        # Should fall back to Anthropic
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"  # Request OpenAI, but it will fail
        ):
            result += token

        # Should get response from fallback provider
        assert "Fallback response" in result or "Error" in result

    @pytest.mark.asyncio
    async def test_stream_completion_all_providers_fail(self, handler_with_mocked_client):
        """Test error message when all providers fail."""
        handler = handler_with_mocked_client

        # Make all providers fail
        async def failing_stream():
            raise Exception("Provider unavailable")
            yield

        for provider_id in handler.async_clients:
            handler.async_clients[provider_id].chat.completions.create = AsyncMock(
                side_effect=failing_stream()
            )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should include error message
        assert "Error" in result or "failed" in result.lower()

    @pytest.mark.asyncio
    async def test_stream_completion_no_clients_available(self):
        """Test error when no clients are initialized."""
        handler = BYOKHandler()
        handler.async_clients = {}
        handler.clients = {}

        messages = [{"role": "user", "content": "Test"}]

        with pytest.raises(ValueError) as exc_info:
            async for token in handler.stream_completion(
                messages=messages,
                model="gpt-4o",
                provider_id="openai"
            ):
                pass

        assert "No clients initialized" in str(exc_info.value) or "No available providers" in str(exc_info.value)


class TestStreamCompletionGovernance:
    """Test suite for governance tracking during streaming."""

    @pytest.mark.asyncio
    async def test_stream_completion_with_agent_tracking(self, handler_with_mocked_client, mock_db_session):
        """Test agent execution tracking during streaming."""
        handler = handler_with_mocked_client

        # Mock successful stream
        async def tracked_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Tracked response"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=tracked_stream()
        )

        messages = [{"role": "user", "content": "Test"}]
        agent_id = "test-agent-123"

        # Enable governance tracking
        with patch.dict(os.environ, {"STREAMING_GOVERNANCE_ENABLED": "true"}):
            result = ""
            async for token in handler.stream_completion(
                messages=messages,
                model="gpt-4o",
                provider_id="openai",
                agent_id=agent_id,
                db=mock_db_session
            ):
                result += token

        # Verify agent execution was created
        mock_db_session.add.assert_called()
        mock_db_session.commit.assert_called()

        assert "Tracked response" in result

    @pytest.mark.asyncio
    async def test_stream_completion_governance_disabled(self, handler_with_mocked_client, mock_db_session):
        """Test streaming without governance tracking when disabled."""
        handler = handler_with_mocked_client

        async def untracked_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Untracked response"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=untracked_stream()
        )

        messages = [{"role": "user", "content": "Test"}]

        # Disable governance tracking
        with patch.dict(os.environ, {"STREAMING_GOVERNANCE_ENABLED": "false"}):
            result = ""
            async for token in handler.stream_completion(
                messages=messages,
                model="gpt-4o",
                provider_id="openai",
                agent_id="test-agent",
                db=mock_db_session
            ):
                result += token

        # Verify no agent execution was created
        mock_db_session.add.assert_not_called()

        assert "Untracked response" in result


class TestStreamCompletionTokenHandling:
    """Test suite for token handling in streams."""

    @pytest.mark.asyncio
    async def test_stream_completion_handles_none_content(self, handler_with_mocked_client):
        """Test handling of chunks with None content."""
        handler = handler_with_mocked_client

        async def stream_with_none():
            # Chunk with content
            chunk1 = MagicMock()
            chunk1.choices = [MagicMock(delta=MagicMock(content="Hello"))]
            yield chunk1

            # Chunk with None content (should be skipped)
            chunk2 = MagicMock()
            chunk2.choices = [MagicMock(delta=MagicMock(content=None))]
            yield chunk2

            # Chunk with content again
            chunk3 = MagicMock()
            chunk3.choices = [MagicMock(delta=MagicMock(content=" world"))]
            yield chunk3

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=stream_with_none()
        )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should only include non-None content
        assert "Hello world" in result

    @pytest.mark.asyncio
    async def test_stream_completion_handles_missing_delta_attribute(self, handler_with_mocked_client):
        """Test handling of chunks without delta attribute."""
        handler = handler_with_mocked_client

        async def stream_without_delta():
            chunk1 = MagicMock()
            chunk1.choices = [MagicMock(delta=MagicMock(content="Start"))]
            yield chunk1

            # Chunk without delta attribute
            chunk2 = MagicMock()
            chunk2.choices = [MagicMock()]  # No delta attribute
            yield chunk2

            chunk3 = MagicMock()
            chunk3.choices = [MagicMock(delta=MagicMock(content="End"))]
            yield chunk3

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=stream_without_delta()
        )

        messages = [{"role": "user", "content": "Test"}]
        result = ""

        # Should handle gracefully
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should include content from chunks with delta
        assert "Start" in result or "End" in result


class TestVisionHandling:
    """Test suite for _get_coordinated_vision_description method (lines 1309-1370)
    
    Tests multimodal input handling:
    - Base64 image payload processing
    - URL image payload processing
    - Vision-capable model selection (excludes REASONING_MODELS_WITHOUT_VISION)
    - Fallback when primary model lacks vision
    - Mixed text and image in requests
    - VISION_ONLY_MODELS usage (Janus, etc.)
    """

    @pytest.mark.asyncio
    async def test_coordinated_vision_with_base64_image(self, handler_with_mocked_client):
        """Test base64 image payload is processed correctly"""
        handler = handler_with_mocked_client

        # Create mock vision response
        async def vision_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Image shows a button"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=vision_stream()
        )

        # Base64 image payload
        base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="

        messages = [{"role": "user", "content": "What's in this image?"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should process base64 image
        assert "Image shows" in result or "button" in result

    @pytest.mark.asyncio
    async def test_coordinated_vision_with_image_url(self, handler_with_mocked_client):
        """Test URL image payload is processed correctly"""
        handler = handler_with_mocked_client

        async def vision_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="URL image analyzed"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=vision_stream()
        )

        # URL image payload
        image_url = "https://example.com/screenshot.png"

        messages = [{"role": "user", "content": "Analyze this image"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should process URL image
        assert "URL image" in result or "analyzed" in result

    @pytest.mark.asyncio
    async def test_coordinated_vision_selects_vision_capable_model(self, handler_with_mocked_client):
        """Test model selection excludes REASONING_MODELS_WITHOUT_VISION"""
        from core.llm.byok_handler import REASONING_MODELS_WITHOUT_VISION

        handler = handler_with_mocked_client

        # Verify reasoning models are in exclusion list
        assert "deepseek-v3.2" in REASONING_MODELS_WITHOUT_VISION
        assert "o3" in REASONING_MODELS_WITHOUT_VISION

        # Vision request should not use reasoning-only models
        async def vision_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Vision result"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=vision_stream()
        )

        messages = [{"role": "user", "content": "Describe this image"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",  # Vision-capable model
            provider_id="openai"
        ):
            result += token

        # Should successfully use vision model
        assert "Vision result" in result

    @pytest.mark.asyncio
    async def test_coordinated_vision_fallback_to_separate_models(self, handler_with_mocked_client):
        """Test fallback when primary model lacks vision support"""
        handler = handler_with_mocked_client

        # Mock vision model (GPT-4o has vision)
        async def vision_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Fallback vision description"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=vision_stream()
        )

        messages = [{"role": "user", "content": "What do you see?"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",  # Vision-capable
            provider_id="openai"
        ):
            result += token

        # Should provide fallback vision description
        assert "Fallback" in result or "vision" in result.lower()

    @pytest.mark.asyncio
    async def test_coordinated_vision_mixed_text_image(self, handler_with_mocked_client):
        """Test both text and image are included in request"""
        handler = handler_with_mocked_client

        async def multimodal_stream():
            chunk1 = MagicMock()
            chunk1.choices = [MagicMock(delta=MagicMock(content="I can see "))]
            yield chunk1

            chunk2 = MagicMock()
            chunk2.choices = [MagicMock(delta=MagicMock(content="the image"))]
            yield chunk2

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=multimodal_stream()
        )

        messages = [
            {"role": "system", "content": "You are a vision assistant."},
            {"role": "user", "content": "Describe this screenshot"}
        ]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Verify both text and image context
        assert "I can see" in result or "the image" in result

    @pytest.mark.asyncio
    async def test_coordinated_vision_with_vision_only_model(self, handler_with_mocked_client):
        """Test VISION_ONLY_MODELS are used appropriately"""
        from core.llm.byok_handler import REASONING_MODELS_WITHOUT_VISION

        handler = handler_with_mocked_client

        # Janus is a vision-only model
        # Test that it's not in the reasoning exclusion list
        # (meaning it has vision capabilities)
        assert "janus-pro-7b" not in REASONING_MODELS_WITHOUT_VISION

        async def janus_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Janus vision analysis"))]
            yield chunk

        # Mock deepseek client for Janus model
        handler.async_clients["deepseek"] = AsyncMock()
        handler.async_clients["deepseek"].chat.completions.create = AsyncMock(
            return_value=janus_stream()
        )

        messages = [{"role": "user", "content": "Analyze screenshot"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="janus-pro-7b",
            provider_id="deepseek"
        ):
            result += token

        # Should use vision-only model
        assert "Janus" in result or "vision" in result.lower()

    @pytest.mark.asyncio
    async def test_coordinated_vision_with_image_payload_attribute(self, handler_with_mocked_client):
        """Test image_payload parameter is handled correctly"""
        handler = handler_with_mocked_client

        async def vision_stream():
            chunk = MagicMock()
            chunk.choices = [MagicMock(delta=MagicMock(content="Image received"))]
            yield chunk

        handler.async_clients["openai"].chat.completions.create = AsyncMock(
            return_value=vision_stream()
        )

        # Test with image payload (stream_completion doesn't directly support it,
        # but generate_response does which is called internally)
        messages = [{"role": "user", "content": "Analyze image"}]

        result = ""
        async for token in handler.stream_completion(
            messages=messages,
            model="gpt-4o",
            provider_id="openai"
        ):
            result += token

        # Should handle vision request
        assert "Image" in result or "received" in result