LastNoob commited on
Commit
ecc6b8a
·
1 Parent(s): 697cf2e

added more tests

Browse files
tests/test_config.py CHANGED
@@ -57,6 +57,30 @@ class TestSettings:
57
 
58
  assert NVIDIA_NIM_BASE_URL == "https://integrate.api.nvidia.com/v1"
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  # --- NimSettings Validation Tests ---
62
 
 
57
 
58
  assert NVIDIA_NIM_BASE_URL == "https://integrate.api.nvidia.com/v1"
59
 
60
+ def test_lm_studio_base_url_from_env(self, monkeypatch):
61
+ """LM_STUDIO_BASE_URL env var is loaded into settings."""
62
+ from config.settings import Settings
63
+
64
+ monkeypatch.setenv("LM_STUDIO_BASE_URL", "http://custom:5678/v1")
65
+ settings = Settings()
66
+ assert settings.lm_studio_base_url == "http://custom:5678/v1"
67
+
68
+ def test_provider_rate_limit_from_env(self, monkeypatch):
69
+ """PROVIDER_RATE_LIMIT env var is loaded into settings."""
70
+ from config.settings import Settings
71
+
72
+ monkeypatch.setenv("PROVIDER_RATE_LIMIT", "20")
73
+ settings = Settings()
74
+ assert settings.provider_rate_limit == 20
75
+
76
+ def test_provider_rate_window_from_env(self, monkeypatch):
77
+ """PROVIDER_RATE_WINDOW env var is loaded into settings."""
78
+ from config.settings import Settings
79
+
80
+ monkeypatch.setenv("PROVIDER_RATE_WINDOW", "30")
81
+ settings = Settings()
82
+ assert settings.provider_rate_window == 30
83
+
84
 
85
  # --- NimSettings Validation Tests ---
86
 
tests/test_dependencies.py CHANGED
@@ -104,6 +104,21 @@ async def test_get_provider_lmstudio():
104
  assert provider._api_key == "lm-studio"
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  @pytest.mark.asyncio
108
  async def test_get_provider_unknown_type():
109
  """Test that unknown provider_type raises ValueError."""
 
104
  assert provider._api_key == "lm-studio"
105
 
106
 
107
+ @pytest.mark.asyncio
108
+ async def test_get_provider_lmstudio_uses_lm_studio_base_url():
109
+ """LM Studio provider uses lm_studio_base_url from settings."""
110
+ with patch("api.dependencies.get_settings") as mock_settings:
111
+ mock_settings.return_value = _make_mock_settings(
112
+ provider_type="lmstudio",
113
+ lm_studio_base_url="http://custom:9999/v1",
114
+ )
115
+
116
+ provider = get_provider()
117
+
118
+ assert isinstance(provider, LMStudioProvider)
119
+ assert provider._base_url == "http://custom:9999/v1"
120
+
121
+
122
  @pytest.mark.asyncio
123
  async def test_get_provider_unknown_type():
124
  """Test that unknown provider_type raises ValueError."""
tests/test_lmstudio.py CHANGED
@@ -6,9 +6,72 @@ from unittest.mock import AsyncMock, MagicMock, patch
6
 
7
  from providers.base import ProviderConfig
8
  from providers.lmstudio import LMStudioProvider
 
9
  from config.nim import NimSettings
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  class MockMessage:
13
  def __init__(self, role, content):
14
  self.role = role
@@ -190,3 +253,395 @@ async def test_stream_response_reasoning_content(lmstudio_provider):
190
  if "Thinking..." in e:
191
  found_thinking = True
192
  assert found_thinking
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from providers.base import ProviderConfig
8
  from providers.lmstudio import LMStudioProvider
9
+ from providers.lmstudio.request import LMSTUDIO_DEFAULT_MAX_TOKENS
10
  from config.nim import NimSettings
11
 
12
 
13
+ class AsyncStreamMock:
14
+ """Async iterable mock that yields chunks then optionally raises."""
15
+
16
+ def __init__(self, chunks, error=None):
17
+ self._chunks = chunks
18
+ self._error = error
19
+
20
+ def __aiter__(self):
21
+ return self._aiter()
22
+
23
+ async def _aiter(self):
24
+ for chunk in self._chunks:
25
+ yield chunk
26
+ if self._error:
27
+ raise self._error
28
+
29
+
30
+ def _make_chunk(
31
+ content=None, finish_reason=None, tool_calls=None, reasoning_content=None
32
+ ):
33
+ """Create a mock streaming chunk."""
34
+ delta = MagicMock()
35
+ delta.content = content
36
+ delta.tool_calls = tool_calls
37
+ delta.reasoning_content = reasoning_content if reasoning_content else None
38
+
39
+ choice = MagicMock()
40
+ choice.delta = delta
41
+ choice.finish_reason = finish_reason
42
+
43
+ chunk = MagicMock()
44
+ chunk.choices = [choice]
45
+ chunk.usage = None
46
+ return chunk
47
+
48
+
49
+ def _make_request(model="test-model", **kwargs):
50
+ """Create a mock request with all fields build_request_body needs."""
51
+ req = MagicMock()
52
+ req.model = model
53
+ req.messages = [MagicMock(role="user", content="Hello")]
54
+ req.system = None
55
+ req.max_tokens = 100
56
+ req.temperature = None
57
+ req.top_p = None
58
+ req.stop_sequences = None
59
+ req.tools = None
60
+ req.tool_choice = None
61
+ req.thinking = MagicMock(enabled=True)
62
+ for k, v in kwargs.items():
63
+ setattr(req, k, v)
64
+ return req
65
+
66
+
67
+ async def _collect_stream(provider, request):
68
+ """Collect all SSE events from a stream."""
69
+ events = []
70
+ async for event in provider.stream_response(request):
71
+ events.append(event)
72
+ return events
73
+
74
+
75
  class MockMessage:
76
  def __init__(self, role, content):
77
  self.role = role
 
253
  if "Thinking..." in e:
254
  found_thinking = True
255
  assert found_thinking
256
+
257
+
258
+ # --- Stream Error Handling ---
259
+
260
+
261
+ class TestLMStudioStreamingExceptionHandling:
262
+ """Tests for error paths during stream_response."""
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_api_error_emits_sse_error_event(self, lmstudio_provider):
266
+ """When API raises during streaming, SSE error event is emitted."""
267
+ request = _make_request()
268
+
269
+ with patch.object(
270
+ lmstudio_provider._client.chat.completions,
271
+ "create",
272
+ new_callable=AsyncMock,
273
+ side_effect=RuntimeError("API failed"),
274
+ ):
275
+ events = await _collect_stream(lmstudio_provider, request)
276
+
277
+ event_text = "".join(events)
278
+ assert "message_start" in event_text
279
+ assert "API failed" in event_text
280
+ assert "message_stop" in event_text
281
+ assert "[DONE]" in event_text
282
+
283
+ @pytest.mark.asyncio
284
+ async def test_error_after_partial_content(self, lmstudio_provider):
285
+ """Error after partial content: blocks closed, error emitted."""
286
+ request = _make_request()
287
+ chunk1 = _make_chunk(content="Hello ")
288
+ stream_mock = AsyncStreamMock(
289
+ [chunk1], error=ConnectionResetError("Connection lost")
290
+ )
291
+
292
+ with patch.object(
293
+ lmstudio_provider._client.chat.completions,
294
+ "create",
295
+ new_callable=AsyncMock,
296
+ return_value=stream_mock,
297
+ ):
298
+ events = await _collect_stream(lmstudio_provider, request)
299
+
300
+ event_text = "".join(events)
301
+ assert "Hello" in event_text
302
+ assert "Connection lost" in event_text
303
+ assert "message_stop" in event_text
304
+
305
+ @pytest.mark.asyncio
306
+ async def test_empty_response_gets_space(self, lmstudio_provider):
307
+ """Empty response with no text/tools gets a single space text block."""
308
+ request = _make_request()
309
+ empty_chunk = _make_chunk(finish_reason="stop")
310
+ stream_mock = AsyncStreamMock([empty_chunk])
311
+
312
+ with patch.object(
313
+ lmstudio_provider._client.chat.completions,
314
+ "create",
315
+ new_callable=AsyncMock,
316
+ return_value=stream_mock,
317
+ ):
318
+ events = await _collect_stream(lmstudio_provider, request)
319
+
320
+ event_text = "".join(events)
321
+ assert '"text_delta"' in event_text
322
+ assert "message_stop" in event_text
323
+
324
+
325
+ # --- Stream Chunk Edge Cases ---
326
+
327
+
328
+ class TestLMStudioStreamChunkEdgeCases:
329
+ """Tests for edge cases in stream chunk handling."""
330
+
331
+ @pytest.mark.asyncio
332
+ async def test_stream_chunk_with_empty_choices_skipped(self, lmstudio_provider):
333
+ """Chunk with choices=[] is skipped without crashing."""
334
+ request = _make_request()
335
+ empty_choices_chunk = MagicMock()
336
+ empty_choices_chunk.choices = []
337
+ empty_choices_chunk.usage = None
338
+ finish_chunk = _make_chunk(finish_reason="stop")
339
+ stream_mock = AsyncStreamMock([empty_choices_chunk, finish_chunk])
340
+
341
+ with patch.object(
342
+ lmstudio_provider._client.chat.completions,
343
+ "create",
344
+ new_callable=AsyncMock,
345
+ return_value=stream_mock,
346
+ ):
347
+ events = await _collect_stream(lmstudio_provider, request)
348
+
349
+ event_text = "".join(events)
350
+ assert "message_start" in event_text
351
+ assert "message_stop" in event_text
352
+ assert "[DONE]" in event_text
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_stream_chunk_with_none_delta_handled(self, lmstudio_provider):
356
+ """Chunk with choice.delta=None is handled defensively."""
357
+ request = _make_request()
358
+ none_delta_chunk = MagicMock()
359
+ none_delta_chunk.usage = None
360
+ choice = MagicMock()
361
+ choice.delta = None
362
+ choice.finish_reason = None
363
+ none_delta_chunk.choices = [choice]
364
+ finish_chunk = _make_chunk(finish_reason="stop")
365
+ stream_mock = AsyncStreamMock([none_delta_chunk, finish_chunk])
366
+
367
+ with patch.object(
368
+ lmstudio_provider._client.chat.completions,
369
+ "create",
370
+ new_callable=AsyncMock,
371
+ return_value=stream_mock,
372
+ ):
373
+ events = await _collect_stream(lmstudio_provider, request)
374
+
375
+ event_text = "".join(events)
376
+ assert "message_start" in event_text
377
+ assert "message_stop" in event_text
378
+ assert "[DONE]" in event_text
379
+
380
+
381
+ # --- Native Tool Calls ---
382
+
383
+
384
+ @pytest.mark.asyncio
385
+ async def test_stream_response_tool_call(lmstudio_provider):
386
+ """Test streaming tool calls."""
387
+ request = _make_request()
388
+ mock_tc = MagicMock()
389
+ mock_tc.index = 0
390
+ mock_tc.id = "call_1"
391
+ mock_tc.function.name = "search"
392
+ mock_tc.function.arguments = '{"q": "test"}'
393
+
394
+ mock_chunk = MagicMock()
395
+ mock_chunk.choices = [
396
+ MagicMock(
397
+ delta=MagicMock(content=None, reasoning_content=None, tool_calls=[mock_tc]),
398
+ finish_reason=None,
399
+ )
400
+ ]
401
+ mock_chunk.usage = None
402
+
403
+ async def mock_stream():
404
+ yield mock_chunk
405
+
406
+ with patch.object(
407
+ lmstudio_provider._client.chat.completions,
408
+ "create",
409
+ new_callable=AsyncMock,
410
+ return_value=mock_stream(),
411
+ ):
412
+ events = []
413
+ async for event in lmstudio_provider.stream_response(request):
414
+ events.append(event)
415
+
416
+ starts = [
417
+ e for e in events if "event: content_block_start" in e and '"tool_use"' in e
418
+ ]
419
+ assert len(starts) == 1
420
+ assert "search" in starts[0]
421
+
422
+
423
+ # --- Think Tag Parsing ---
424
+
425
+
426
+ @pytest.mark.asyncio
427
+ async def test_stream_response_think_tag_parsing(lmstudio_provider):
428
+ """Thinking content via think tags is emitted as thinking blocks."""
429
+ request = _make_request()
430
+ chunk1 = _make_chunk(content="<think>reasoning</think>answer")
431
+ chunk2 = _make_chunk(finish_reason="stop")
432
+ stream_mock = AsyncStreamMock([chunk1, chunk2])
433
+
434
+ with patch.object(
435
+ lmstudio_provider._client.chat.completions,
436
+ "create",
437
+ new_callable=AsyncMock,
438
+ return_value=stream_mock,
439
+ ):
440
+ events = await _collect_stream(lmstudio_provider, request)
441
+
442
+ event_text = "".join(events)
443
+ assert "thinking" in event_text
444
+ assert "reasoning" in event_text
445
+ assert "answer" in event_text
446
+
447
+
448
+ # --- _process_tool_call and _flush_task_arg_buffers ---
449
+
450
+
451
+ class TestLMStudioProcessToolCall:
452
+ """Tests for _process_tool_call method."""
453
+
454
+ def test_tool_call_with_id(self, lmstudio_provider):
455
+ """Tool call with id starts a tool block."""
456
+ from providers.nvidia_nim.utils import SSEBuilder
457
+
458
+ sse = SSEBuilder("msg_test", "test-model")
459
+ tc = {
460
+ "index": 0,
461
+ "id": "call_123",
462
+ "function": {"name": "search", "arguments": '{"q": "test"}'},
463
+ }
464
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
465
+ event_text = "".join(events)
466
+ assert "tool_use" in event_text
467
+ assert "search" in event_text
468
+ assert "call_123" in event_text
469
+
470
+ def test_tool_call_without_id_generates_uuid(self, lmstudio_provider):
471
+ """Tool call without id generates a uuid-based id."""
472
+ from providers.nvidia_nim.utils import SSEBuilder
473
+
474
+ sse = SSEBuilder("msg_test", "test-model")
475
+ tc = {
476
+ "index": 0,
477
+ "id": None,
478
+ "function": {"name": "test", "arguments": "{}"},
479
+ }
480
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
481
+ event_text = "".join(events)
482
+ assert "tool_" in event_text
483
+
484
+ def test_task_tool_forces_background_false(self, lmstudio_provider):
485
+ """Task tool with run_in_background=true is forced to false."""
486
+ from providers.nvidia_nim.utils import SSEBuilder
487
+
488
+ sse = SSEBuilder("msg_test", "test-model")
489
+ args = json.dumps({"run_in_background": True, "prompt": "test"})
490
+ tc = {
491
+ "index": 0,
492
+ "id": "call_task",
493
+ "function": {"name": "Task", "arguments": args},
494
+ }
495
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
496
+ event_text = "".join(events)
497
+ assert "false" in event_text.lower()
498
+
499
+ def test_task_tool_chunked_args_forces_background_false(self, lmstudio_provider):
500
+ """Chunked Task args are buffered until valid JSON, then forced to false."""
501
+ from providers.nvidia_nim.utils import SSEBuilder
502
+
503
+ sse = SSEBuilder("msg_test", "test-model")
504
+ tc1 = {
505
+ "index": 0,
506
+ "id": "call_task_chunked",
507
+ "function": {"name": "Task", "arguments": '{"run_in_background": true,'},
508
+ }
509
+ tc2 = {
510
+ "index": 0,
511
+ "id": "call_task_chunked",
512
+ "function": {"name": None, "arguments": ' "prompt": "test"}'},
513
+ }
514
+
515
+ events1 = list(lmstudio_provider._process_tool_call(tc1, sse))
516
+ assert len(events1) > 0
517
+ assert "false" not in "".join(events1).lower()
518
+
519
+ events2 = list(lmstudio_provider._process_tool_call(tc2, sse))
520
+ event_text = "".join(events1 + events2)
521
+ assert "false" in event_text.lower()
522
+
523
+ def test_task_tool_invalid_json_logs_warning_on_flush(
524
+ self, lmstudio_provider, caplog
525
+ ):
526
+ """Invalid JSON args for Task tool emits {} on flush and logs a warning."""
527
+ from providers.nvidia_nim.utils import SSEBuilder
528
+
529
+ sse = SSEBuilder("msg_test", "test-model")
530
+ tc = {
531
+ "index": 0,
532
+ "id": "call_task2",
533
+ "function": {"name": "Task", "arguments": "not json"},
534
+ }
535
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
536
+ assert len(events) > 0
537
+
538
+ with caplog.at_level("WARNING"):
539
+ flushed = list(lmstudio_provider._flush_task_arg_buffers(sse))
540
+ assert len(flushed) > 0
541
+ assert "{}" in "".join(flushed)
542
+ assert any(
543
+ "LMSTUDIO_INTERCEPT: Task args invalid JSON" in r.message
544
+ for r in caplog.records
545
+ )
546
+
547
+ def test_negative_tool_index_fallback(self, lmstudio_provider):
548
+ """tc_index < 0 uses len(tool_indices) as fallback."""
549
+ from providers.nvidia_nim.utils import SSEBuilder
550
+
551
+ sse = SSEBuilder("msg_test", "test-model")
552
+ tc = {
553
+ "index": -1,
554
+ "id": "call_neg",
555
+ "function": {"name": "test", "arguments": "{}"},
556
+ }
557
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
558
+ assert len(events) > 0
559
+
560
+ def test_tool_args_emitted_as_delta(self, lmstudio_provider):
561
+ """Arguments are emitted as input_json_delta events."""
562
+ from providers.nvidia_nim.utils import SSEBuilder
563
+
564
+ sse = SSEBuilder("msg_test", "test-model")
565
+ tc = {
566
+ "index": 0,
567
+ "id": "call_args",
568
+ "function": {"name": "grep", "arguments": '{"pattern": "test"}'},
569
+ }
570
+ events = list(lmstudio_provider._process_tool_call(tc, sse))
571
+ event_text = "".join(events)
572
+ assert "input_json_delta" in event_text
573
+
574
+ def test_stream_malformed_tool_args_chunked(self, lmstudio_provider):
575
+ """Chunked tool args that never form valid JSON are flushed with {}."""
576
+ from providers.nvidia_nim.utils import SSEBuilder
577
+
578
+ sse = SSEBuilder("msg_test", "test-model")
579
+ tc1 = {
580
+ "index": 0,
581
+ "id": "call_malformed",
582
+ "function": {"name": "Task", "arguments": '{"broken":'},
583
+ }
584
+ tc2 = {
585
+ "index": 0,
586
+ "id": "call_malformed",
587
+ "function": {"name": None, "arguments": " never valid }"},
588
+ }
589
+
590
+ events1 = list(lmstudio_provider._process_tool_call(tc1, sse))
591
+ events2 = list(lmstudio_provider._process_tool_call(tc2, sse))
592
+ flushed = list(lmstudio_provider._flush_task_arg_buffers(sse))
593
+
594
+ event_text = "".join(events1 + events2 + flushed)
595
+ assert "tool_use" in event_text
596
+ assert "{}" in event_text
597
+
598
+
599
+ # --- Request Body Edge Cases ---
600
+
601
+
602
+ def test_build_request_body_max_tokens_default(lmstudio_provider):
603
+ """max_tokens=None or 0 uses LMSTUDIO_DEFAULT_MAX_TOKENS."""
604
+ req = MockRequest(max_tokens=None)
605
+ body = lmstudio_provider._build_request_body(req)
606
+ assert body["max_tokens"] == LMSTUDIO_DEFAULT_MAX_TOKENS
607
+ assert body["max_tokens"] == 81920
608
+
609
+ req2 = MockRequest(max_tokens=0)
610
+ body2 = lmstudio_provider._build_request_body(req2)
611
+ assert body2["max_tokens"] == LMSTUDIO_DEFAULT_MAX_TOKENS
612
+
613
+
614
+ def test_build_request_body_stop_sequences(lmstudio_provider):
615
+ """stop_sequences non-empty adds stop key to body."""
616
+ req = MockRequest(stop_sequences=["STOP", "END"])
617
+ body = lmstudio_provider._build_request_body(req)
618
+ assert body["stop"] == ["STOP", "END"]
619
+
620
+
621
+ def test_build_request_body_tools_and_tool_choice(lmstudio_provider):
622
+ """tools and tool_choice non-empty add to body."""
623
+ tool = MagicMock()
624
+ tool.name = "test_tool"
625
+ tool.description = "A test"
626
+ tool.input_schema = {"type": "object"}
627
+ req = MockRequest(tools=[tool], tool_choice="auto")
628
+ body = lmstudio_provider._build_request_body(req)
629
+ assert "tools" in body
630
+ assert body["tool_choice"] == "auto"
631
+
632
+
633
+ # --- Base URL Trailing Slash ---
634
+
635
+
636
+ def test_init_base_url_strips_trailing_slash():
637
+ """Config with base_url trailing slash is stored without it."""
638
+ config = ProviderConfig(
639
+ api_key="lm-studio",
640
+ base_url="http://localhost:1234/v1/",
641
+ rate_limit=10,
642
+ rate_window=60,
643
+ nim_settings=NimSettings(),
644
+ )
645
+ with patch("providers.lmstudio.client.AsyncOpenAI") as mock_openai:
646
+ provider = LMStudioProvider(config)
647
+ assert provider._base_url == "http://localhost:1234/v1"
tests/test_open_router.py CHANGED
@@ -4,6 +4,7 @@ import pytest
4
  import json
5
  from unittest.mock import MagicMock, AsyncMock, patch
6
  from providers.open_router import OpenRouterProvider
 
7
  from providers.base import ProviderConfig
8
  from config.nim import NimSettings
9
 
@@ -94,6 +95,14 @@ def test_build_request_body_base_url_and_model(open_router_provider):
94
  assert body["model"] == "stepfun/step-3.5-flash:free"
95
 
96
 
 
 
 
 
 
 
 
 
97
  @pytest.mark.asyncio
98
  async def test_stream_response_text(open_router_provider):
99
  """Test streaming text response."""
 
4
  import json
5
  from unittest.mock import MagicMock, AsyncMock, patch
6
  from providers.open_router import OpenRouterProvider
7
+ from providers.open_router.request import OPENROUTER_DEFAULT_MAX_TOKENS
8
  from providers.base import ProviderConfig
9
  from config.nim import NimSettings
10
 
 
95
  assert body["model"] == "stepfun/step-3.5-flash:free"
96
 
97
 
98
+ def test_build_request_body_default_max_tokens(open_router_provider):
99
+ """max_tokens=None uses OPENROUTER_DEFAULT_MAX_TOKENS (81920)."""
100
+ req = MockRequest(max_tokens=None)
101
+ body = open_router_provider._build_request_body(req)
102
+ assert body["max_tokens"] == OPENROUTER_DEFAULT_MAX_TOKENS
103
+ assert body["max_tokens"] == 81920
104
+
105
+
106
  @pytest.mark.asyncio
107
  async def test_stream_response_text(open_router_provider):
108
  """Test streaming text response."""
uv.lock CHANGED
@@ -1,6 +1,6 @@
1
  version = 1
2
  revision = 3
3
- requires-python = "==3.14.2"
4
 
5
  [[package]]
6
  name = "annotated-doc"
 
1
  version = 1
2
  revision = 3
3
+ requires-python = ">=3.14.2"
4
 
5
  [[package]]
6
  name = "annotated-doc"