LastNoob commited on
Commit
8f8bcdc
·
1 Parent(s): 3cd182b

Updated token counting and removed non streaming support

Browse files
README.md CHANGED
@@ -221,17 +221,9 @@ Extend `BaseProvider` in `providers/` to add support for other APIs:
221
  from providers.base import BaseProvider, ProviderConfig
222
 
223
  class MyProvider(BaseProvider):
224
- async def complete(self, request):
225
- # Make API call, return raw JSON
226
- pass
227
-
228
  async def stream_response(self, request, input_tokens=0):
229
  # Yield Anthropic SSE format events
230
  pass
231
-
232
- def convert_response(self, response_json, original_request):
233
- # Convert to Anthropic response format
234
- pass
235
  ```
236
 
237
  ### Adding Your Own Messaging App
 
221
  from providers.base import BaseProvider, ProviderConfig
222
 
223
  class MyProvider(BaseProvider):
 
 
 
 
224
  async def stream_response(self, request, input_tokens=0):
225
  # Yield Anthropic SSE format events
226
  pass
 
 
 
 
227
  ```
228
 
229
  ### Adding Your Own Messaging App
api/models/anthropic.py CHANGED
@@ -99,7 +99,7 @@ class MessagesRequest(BaseModel):
99
  messages: List[Message]
100
  system: Optional[Union[str, List[SystemContent]]] = None
101
  stop_sequences: Optional[List[str]] = None
102
- stream: Optional[bool] = False
103
  temperature: Optional[float] = None
104
  top_p: Optional[float] = None
105
  top_k: Optional[int] = None
 
99
  messages: List[Message]
100
  system: Optional[Union[str, List[SystemContent]]] = None
101
  stop_sequences: Optional[List[str]] = None
102
+ stream: Optional[bool] = True
103
  temperature: Optional[float] = None
104
  top_p: Optional[float] = None
105
  top_k: Optional[int] = None
api/request_utils.py CHANGED
@@ -32,33 +32,78 @@ def get_token_count(
32
  total_tokens += len(ENCODER.encode(system))
33
  elif isinstance(system, list):
34
  for block in system:
35
- if hasattr(block, "text"):
36
- total_tokens += len(ENCODER.encode(block.text))
 
 
 
 
 
 
37
 
38
  for msg in messages:
39
  if isinstance(msg.content, str):
40
  total_tokens += len(ENCODER.encode(msg.content))
41
  elif isinstance(msg.content, list):
42
  for block in msg.content:
43
- b_type = getattr(block, "type", None)
 
 
44
 
45
  if b_type == "text":
46
- total_tokens += len(ENCODER.encode(getattr(block, "text", "")))
 
 
 
47
  elif b_type == "thinking":
48
- total_tokens += len(ENCODER.encode(getattr(block, "thinking", "")))
 
 
 
49
  elif b_type == "tool_use":
50
- name = getattr(block, "name", "")
51
- inp = getattr(block, "input", {})
 
 
 
 
 
 
 
52
  total_tokens += len(ENCODER.encode(name))
53
  total_tokens += len(ENCODER.encode(json.dumps(inp)))
54
- total_tokens += 10
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  elif b_type == "tool_result":
56
- content = getattr(block, "content", "")
 
 
 
 
 
57
  if isinstance(content, str):
58
  total_tokens += len(ENCODER.encode(content))
59
  else:
60
  total_tokens += len(ENCODER.encode(json.dumps(content)))
61
- total_tokens += 5
 
 
 
 
 
 
62
 
63
  if tools:
64
  for tool in tools:
@@ -67,7 +112,7 @@ def get_token_count(
67
  )
68
  total_tokens += len(ENCODER.encode(tool_str))
69
 
70
- total_tokens += len(messages) * 3
71
  if tools:
72
  total_tokens += len(tools) * 5
73
 
 
32
  total_tokens += len(ENCODER.encode(system))
33
  elif isinstance(system, list):
34
  for block in system:
35
+ text = (
36
+ getattr(block, "text", None)
37
+ if hasattr(block, "text")
38
+ else (block.get("text", "") if isinstance(block, dict) else "")
39
+ )
40
+ if text:
41
+ total_tokens += len(ENCODER.encode(text))
42
+ total_tokens += 4 # System block formatting overhead
43
 
44
  for msg in messages:
45
  if isinstance(msg.content, str):
46
  total_tokens += len(ENCODER.encode(msg.content))
47
  elif isinstance(msg.content, list):
48
  for block in msg.content:
49
+ b_type = getattr(block, "type", None) or (
50
+ block.get("type") if isinstance(block, dict) else None
51
+ )
52
 
53
  if b_type == "text":
54
+ text = getattr(block, "text", "") or (
55
+ block.get("text", "") if isinstance(block, dict) else ""
56
+ )
57
+ total_tokens += len(ENCODER.encode(text))
58
  elif b_type == "thinking":
59
+ thinking = getattr(block, "thinking", "") or (
60
+ block.get("thinking", "") if isinstance(block, dict) else ""
61
+ )
62
+ total_tokens += len(ENCODER.encode(thinking))
63
  elif b_type == "tool_use":
64
+ name = getattr(block, "name", "") or (
65
+ block.get("name", "") if isinstance(block, dict) else ""
66
+ )
67
+ inp = getattr(block, "input", {}) or (
68
+ block.get("input", {}) if isinstance(block, dict) else {}
69
+ )
70
+ block_id = getattr(block, "id", "") or (
71
+ block.get("id", "") if isinstance(block, dict) else ""
72
+ )
73
  total_tokens += len(ENCODER.encode(name))
74
  total_tokens += len(ENCODER.encode(json.dumps(inp)))
75
+ total_tokens += len(ENCODER.encode(str(block_id)))
76
+ total_tokens += 15
77
+ elif b_type == "image":
78
+ source = getattr(block, "source", None) or (
79
+ block.get("source", {}) if isinstance(block, dict) else {}
80
+ )
81
+ if isinstance(source, dict):
82
+ data = source.get("data") or source.get("base64") or ""
83
+ if data:
84
+ total_tokens += max(85, len(data) // 3000)
85
+ else:
86
+ total_tokens += 765
87
+ else:
88
+ total_tokens += 765
89
  elif b_type == "tool_result":
90
+ content = getattr(block, "content", "") or (
91
+ block.get("content", "") if isinstance(block, dict) else ""
92
+ )
93
+ tool_use_id = getattr(block, "tool_use_id", "") or (
94
+ block.get("tool_use_id", "") if isinstance(block, dict) else ""
95
+ )
96
  if isinstance(content, str):
97
  total_tokens += len(ENCODER.encode(content))
98
  else:
99
  total_tokens += len(ENCODER.encode(json.dumps(content)))
100
+ total_tokens += len(ENCODER.encode(str(tool_use_id)))
101
+ total_tokens += 8
102
+ else:
103
+ try:
104
+ total_tokens += len(ENCODER.encode(json.dumps(block)))
105
+ except (TypeError, ValueError):
106
+ total_tokens += len(ENCODER.encode(str(block)))
107
 
108
  if tools:
109
  for tool in tools:
 
112
  )
113
  total_tokens += len(ENCODER.encode(tool_str))
114
 
115
+ total_tokens += len(messages) * 4
116
  if tools:
117
  total_tokens += len(tools) * 5
118
 
api/routes.py CHANGED
@@ -33,7 +33,7 @@ async def create_message(
33
  provider: BaseProvider = Depends(get_provider),
34
  settings: Settings = Depends(get_settings),
35
  ):
36
- """Create a message (streaming or non-streaming)."""
37
 
38
  try:
39
  optimized = try_optimizations(request_data, settings)
@@ -43,22 +43,18 @@ async def create_message(
43
  request_id = f"req_{uuid.uuid4().hex[:12]}"
44
  log_request_compact(logger, request_id, request_data)
45
 
46
- if request_data.stream:
47
- input_tokens = get_token_count(
48
- request_data.messages, request_data.system, request_data.tools
49
- )
50
- return StreamingResponse(
51
- provider.stream_response(request_data, input_tokens=input_tokens),
52
- media_type="text/event-stream",
53
- headers={
54
- "X-Accel-Buffering": "no",
55
- "Cache-Control": "no-cache",
56
- "Connection": "keep-alive",
57
- },
58
- )
59
- else:
60
- response_json = await provider.complete(request_data)
61
- return provider.convert_response(response_json, request_data)
62
 
63
  except ProviderError:
64
  raise
 
33
  provider: BaseProvider = Depends(get_provider),
34
  settings: Settings = Depends(get_settings),
35
  ):
36
+ """Create a message (always streaming)."""
37
 
38
  try:
39
  optimized = try_optimizations(request_data, settings)
 
43
  request_id = f"req_{uuid.uuid4().hex[:12]}"
44
  log_request_compact(logger, request_id, request_data)
45
 
46
+ input_tokens = get_token_count(
47
+ request_data.messages, request_data.system, request_data.tools
48
+ )
49
+ return StreamingResponse(
50
+ provider.stream_response(request_data, input_tokens=input_tokens),
51
+ media_type="text/event-stream",
52
+ headers={
53
+ "X-Accel-Buffering": "no",
54
+ "Cache-Control": "no-cache",
55
+ "Connection": "keep-alive",
56
+ },
57
+ )
 
 
 
 
58
 
59
  except ProviderError:
60
  raise
providers/base.py CHANGED
@@ -28,11 +28,6 @@ class BaseProvider(ABC):
28
  def __init__(self, config: ProviderConfig):
29
  self.config = config
30
 
31
- @abstractmethod
32
- async def complete(self, request: Any) -> dict:
33
- """Make a non-streaming completion request. Returns raw JSON response."""
34
- pass
35
-
36
  @abstractmethod
37
  async def stream_response(
38
  self, request: Any, input_tokens: int = 0
@@ -40,8 +35,3 @@ class BaseProvider(ABC):
40
  """Stream response in Anthropic SSE format."""
41
  if False:
42
  yield ""
43
-
44
- @abstractmethod
45
- def convert_response(self, response_json: dict, original_request: Any) -> Any:
46
- """Convert provider response to Anthropic format."""
47
- pass
 
28
  def __init__(self, config: ProviderConfig):
29
  self.config = config
30
 
 
 
 
 
 
31
  @abstractmethod
32
  async def stream_response(
33
  self, request: Any, input_tokens: int = 0
 
35
  """Stream response in Anthropic SSE format."""
36
  if False:
37
  yield ""
 
 
 
 
 
providers/nvidia_nim/client.py CHANGED
@@ -10,7 +10,6 @@ from openai import AsyncOpenAI
10
  from providers.base import BaseProvider, ProviderConfig
11
  from providers.rate_limit import GlobalRateLimiter
12
  from .request import build_request_body
13
- from .response import convert_response
14
  from .errors import map_error
15
  from .utils import (
16
  SSEBuilder,
@@ -44,9 +43,9 @@ class NvidiaNimProvider(BaseProvider):
44
  timeout=300.0,
45
  )
46
 
47
- def _build_request_body(self, request: Any, stream: bool = False) -> dict:
48
  """Internal helper for tests and shared building."""
49
- return build_request_body(request, self._nim_settings, stream=stream)
50
 
51
  async def stream_response(
52
  self, request: Any, input_tokens: int = 0
@@ -55,7 +54,7 @@ class NvidiaNimProvider(BaseProvider):
55
  message_id = f"msg_{uuid.uuid4()}"
56
  sse = SSEBuilder(message_id, request.model, input_tokens)
57
 
58
- body = self._build_request_body(request, stream=True)
59
  logger.info(
60
  f"NIM_STREAM: model={body.get('model')} msgs={len(body.get('messages', []))} tools={len(body.get('tools', []))}"
61
  )
@@ -219,31 +218,17 @@ class NvidiaNimProvider(BaseProvider):
219
  if usage_info and hasattr(usage_info, "completion_tokens")
220
  else sse.estimate_output_tokens()
221
  )
 
 
 
 
 
 
 
222
  yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
223
  yield sse.message_stop()
224
  yield sse.done()
225
 
226
- async def complete(self, request: Any) -> dict:
227
- """Make a non-streaming completion request."""
228
- body = self._build_request_body(request, stream=False)
229
- logger.info(
230
- f"NIM_COMPLETE: model={body.get('model')} msgs={len(body.get('messages', []))} tools={len(body.get('tools', []))}"
231
- )
232
-
233
- try:
234
- response = await self._global_rate_limiter.execute_with_retry(
235
- self._client.chat.completions.create, **body
236
- )
237
- # Response converter expects a dict
238
- return response.model_dump()
239
- except Exception as e:
240
- logger.error(f"NIM_ERROR: {type(e).__name__}: {e}")
241
- raise map_error(e)
242
-
243
- def convert_response(self, response_json: dict, original_request: Any) -> Any:
244
- """Convert provider response to Anthropic format."""
245
- return convert_response(response_json, original_request)
246
-
247
  def _process_tool_call(self, tc: dict, sse: Any):
248
  """Process a single tool call delta and yield SSE events.
249
 
 
10
  from providers.base import BaseProvider, ProviderConfig
11
  from providers.rate_limit import GlobalRateLimiter
12
  from .request import build_request_body
 
13
  from .errors import map_error
14
  from .utils import (
15
  SSEBuilder,
 
43
  timeout=300.0,
44
  )
45
 
46
+ def _build_request_body(self, request: Any) -> dict:
47
  """Internal helper for tests and shared building."""
48
+ return build_request_body(request, self._nim_settings)
49
 
50
  async def stream_response(
51
  self, request: Any, input_tokens: int = 0
 
54
  message_id = f"msg_{uuid.uuid4()}"
55
  sse = SSEBuilder(message_id, request.model, input_tokens)
56
 
57
+ body = self._build_request_body(request)
58
  logger.info(
59
  f"NIM_STREAM: model={body.get('model')} msgs={len(body.get('messages', []))} tools={len(body.get('tools', []))}"
60
  )
 
218
  if usage_info and hasattr(usage_info, "completion_tokens")
219
  else sse.estimate_output_tokens()
220
  )
221
+ if usage_info and hasattr(usage_info, "prompt_tokens"):
222
+ provider_input = usage_info.prompt_tokens
223
+ if isinstance(provider_input, int):
224
+ diff = provider_input - input_tokens
225
+ logger.debug(
226
+ f"TOKEN_ESTIMATE: our={input_tokens} provider={provider_input} diff={diff:+d}"
227
+ )
228
  yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
229
  yield sse.message_stop()
230
  yield sse.done()
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  def _process_tool_call(self, tc: dict, sse: Any):
233
  """Process a single tool call delta and yield SSE events.
234
 
providers/nvidia_nim/request.py CHANGED
@@ -23,9 +23,7 @@ def _set_extra(
23
  extra_body[key] = value
24
 
25
 
26
- def build_request_body(
27
- request_data: Any, nim: NimSettings, stream: bool = False
28
- ) -> dict:
29
  """Build OpenAI-format request body from Anthropic request."""
30
  messages = AnthropicToOpenAIConverter.convert_messages(request_data.messages)
31
 
 
23
  extra_body[key] = value
24
 
25
 
26
+ def build_request_body(request_data: Any, nim: NimSettings) -> dict:
 
 
27
  """Build OpenAI-format request body from Anthropic request."""
28
  messages = AnthropicToOpenAIConverter.convert_messages(request_data.messages)
29
 
providers/nvidia_nim/utils/sse_builder.py CHANGED
@@ -281,9 +281,17 @@ class SSEBuilder:
281
  name = self.blocks.tool_names.get(idx, "")
282
  tool_tokens += len(ENCODER.encode(name))
283
  tool_tokens += len(ENCODER.encode(content))
284
- tool_tokens += 10 # Control tokens overhead
285
 
286
- return text_tokens + reasoning_tokens + tool_tokens
 
 
 
 
 
 
 
 
287
 
288
  text_tokens = len(self._accumulated_text) // 4
289
  reasoning_tokens = len(self._accumulated_reasoning) // 4
 
281
  name = self.blocks.tool_names.get(idx, "")
282
  tool_tokens += len(ENCODER.encode(name))
283
  tool_tokens += len(ENCODER.encode(content))
284
+ tool_tokens += 15 # Control tokens overhead per tool
285
 
286
+ # Per-block overhead (~4 tokens per content block)
287
+ block_count = (
288
+ (1 if self._accumulated_reasoning else 0)
289
+ + (1 if self._accumulated_text else 0)
290
+ + len(self.blocks.tool_indices)
291
+ )
292
+ block_overhead = block_count * 4
293
+
294
+ return text_tokens + reasoning_tokens + tool_tokens + block_overhead
295
 
296
  text_tokens = len(self._accumulated_text) // 4
297
  reasoning_tokens = len(self._accumulated_reasoning) // 4
tests/test_api.py CHANGED
@@ -7,9 +7,19 @@ from providers.exceptions import APIError
7
 
8
  # Mock provider
9
  mock_provider = MagicMock(spec=NvidiaNimProvider)
10
- mock_provider.complete = AsyncMock()
11
- mock_provider.stream_response = AsyncMock()
12
- mock_provider.convert_response = MagicMock()
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  def override_get_provider():
@@ -32,39 +42,33 @@ def test_health():
32
  assert response.json()["status"] == "healthy"
33
 
34
 
35
- def test_create_message_non_stream():
36
- mock_provider.complete.return_value = {"id": "123", "choices": []}
37
- mock_provider.convert_response.return_value = {
38
- "id": "msg_123",
39
- "type": "message",
40
- "role": "assistant",
41
- "model": "test-model",
42
- "content": [{"type": "text", "text": "Hello"}],
43
- "usage": {"input_tokens": 10, "output_tokens": 5},
44
- }
45
-
46
  payload = {
47
  "model": "claude-3-sonnet",
48
  "messages": [{"role": "user", "content": "Hi"}],
49
  "max_tokens": 100,
50
- "stream": False,
51
  }
52
-
53
  response = client.post("/v1/messages", json=payload)
54
  assert response.status_code == 200
55
- assert response.json()["content"][0]["text"] == "Hello"
56
- mock_provider.complete.assert_called_once()
 
57
 
58
 
59
  def test_model_mapping():
60
  # Test Haiku mapping
 
61
  payload_haiku = {
62
  "model": "claude-3-haiku-20240307",
63
  "messages": [{"role": "user", "content": "Hi"}],
64
  "max_tokens": 100,
 
65
  }
66
  client.post("/v1/messages", json=payload_haiku)
67
- args, _ = mock_provider.complete.call_args
 
68
  assert args[0].model != "claude-3-haiku-20240307"
69
  assert args[0].original_model == "claude-3-haiku-20240307"
70
 
@@ -76,65 +80,77 @@ def test_error_fallbacks():
76
  OverloadedError,
77
  )
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  # 1. Authentication Error (401)
80
- mock_provider.complete.side_effect = AuthenticationError("Invalid Key")
81
- response = client.post(
82
- "/v1/messages", json={"model": "test", "messages": [], "max_tokens": 10}
83
- )
84
  assert response.status_code == 401
85
  assert response.json()["error"]["type"] == "authentication_error"
86
 
87
  # 2. Rate Limit (429)
88
- mock_provider.complete.side_effect = RateLimitError("Too Many Requests")
89
- response = client.post(
90
- "/v1/messages", json={"model": "test", "messages": [], "max_tokens": 10}
91
- )
92
  assert response.status_code == 429
93
  assert response.json()["error"]["type"] == "rate_limit_error"
94
 
95
  # 3. Overloaded (529)
96
- mock_provider.complete.side_effect = OverloadedError("Server Overloaded")
97
- response = client.post(
98
- "/v1/messages", json={"model": "test", "messages": [], "max_tokens": 10}
99
- )
100
  assert response.status_code == 529
101
  assert response.json()["error"]["type"] == "overloaded_error"
102
 
103
- # Reset side_effect for subsequent tests
104
- mock_provider.complete.side_effect = None
105
 
106
 
107
  def test_generic_exception_returns_500():
108
  """Non-ProviderError exceptions are caught and returned as HTTPException(500)."""
109
- mock_provider.complete.side_effect = RuntimeError("unexpected crash")
 
 
 
 
110
  response = client.post(
111
  "/v1/messages",
112
  json={
113
  "model": "test",
114
  "messages": [{"role": "user", "content": "Hi"}],
115
  "max_tokens": 10,
116
- "stream": False,
117
  },
118
  )
119
  assert response.status_code == 500
120
- mock_provider.complete.side_effect = None
121
 
122
 
123
  def test_generic_exception_with_status_code():
124
  """Exception with status_code attribute uses that status."""
125
- exc = APIError("bad gateway", status_code=502)
126
- mock_provider.complete.side_effect = exc
 
 
 
127
  response = client.post(
128
  "/v1/messages",
129
  json={
130
  "model": "test",
131
  "messages": [{"role": "user", "content": "Hi"}],
132
  "max_tokens": 10,
133
- "stream": False,
134
  },
135
  )
136
  assert response.status_code == 502
137
- mock_provider.complete.side_effect = None
138
 
139
 
140
  def test_count_tokens_endpoint():
 
7
 
8
  # Mock provider
9
  mock_provider = MagicMock(spec=NvidiaNimProvider)
10
+
11
+ # Track stream_response calls for test_model_mapping
12
+ _stream_response_calls = []
13
+
14
+
15
+ async def _mock_stream_response(*args, **kwargs):
16
+ """Minimal async generator for streaming tests."""
17
+ _stream_response_calls.append((args, kwargs))
18
+ yield "event: message_start\ndata: {}\n\n"
19
+ yield "[DONE]\n\n"
20
+
21
+
22
+ mock_provider.stream_response = _mock_stream_response
23
 
24
 
25
  def override_get_provider():
 
42
  assert response.json()["status"] == "healthy"
43
 
44
 
45
+ def test_create_message_stream():
46
+ """Create message returns streaming response."""
 
 
 
 
 
 
 
 
 
47
  payload = {
48
  "model": "claude-3-sonnet",
49
  "messages": [{"role": "user", "content": "Hi"}],
50
  "max_tokens": 100,
51
+ "stream": True,
52
  }
 
53
  response = client.post("/v1/messages", json=payload)
54
  assert response.status_code == 200
55
+ assert "text/event-stream" in response.headers.get("content-type", "")
56
+ content = b"".join(response.iter_bytes())
57
+ assert b"message_start" in content or b"event:" in content
58
 
59
 
60
  def test_model_mapping():
61
  # Test Haiku mapping
62
+ _stream_response_calls.clear()
63
  payload_haiku = {
64
  "model": "claude-3-haiku-20240307",
65
  "messages": [{"role": "user", "content": "Hi"}],
66
  "max_tokens": 100,
67
+ "stream": True,
68
  }
69
  client.post("/v1/messages", json=payload_haiku)
70
+ assert len(_stream_response_calls) == 1
71
+ args = _stream_response_calls[0][0]
72
  assert args[0].model != "claude-3-haiku-20240307"
73
  assert args[0].original_model == "claude-3-haiku-20240307"
74
 
 
80
  OverloadedError,
81
  )
82
 
83
+ base_payload = {"model": "test", "messages": [], "max_tokens": 10, "stream": True}
84
+
85
+ def _raise_auth(*args, **kwargs):
86
+ raise AuthenticationError("Invalid Key")
87
+
88
+ def _raise_rate_limit(*args, **kwargs):
89
+ raise RateLimitError("Too Many Requests")
90
+
91
+ def _raise_overloaded(*args, **kwargs):
92
+ raise OverloadedError("Server Overloaded")
93
+
94
  # 1. Authentication Error (401)
95
+ mock_provider.stream_response = _raise_auth
96
+ response = client.post("/v1/messages", json=base_payload)
 
 
97
  assert response.status_code == 401
98
  assert response.json()["error"]["type"] == "authentication_error"
99
 
100
  # 2. Rate Limit (429)
101
+ mock_provider.stream_response = _raise_rate_limit
102
+ response = client.post("/v1/messages", json=base_payload)
 
 
103
  assert response.status_code == 429
104
  assert response.json()["error"]["type"] == "rate_limit_error"
105
 
106
  # 3. Overloaded (529)
107
+ mock_provider.stream_response = _raise_overloaded
108
+ response = client.post("/v1/messages", json=base_payload)
 
 
109
  assert response.status_code == 529
110
  assert response.json()["error"]["type"] == "overloaded_error"
111
 
112
+ # Reset for subsequent tests
113
+ mock_provider.stream_response = _mock_stream_response
114
 
115
 
116
  def test_generic_exception_returns_500():
117
  """Non-ProviderError exceptions are caught and returned as HTTPException(500)."""
118
+
119
+ def _raise_runtime(*args, **kwargs):
120
+ raise RuntimeError("unexpected crash")
121
+
122
+ mock_provider.stream_response = _raise_runtime
123
  response = client.post(
124
  "/v1/messages",
125
  json={
126
  "model": "test",
127
  "messages": [{"role": "user", "content": "Hi"}],
128
  "max_tokens": 10,
129
+ "stream": True,
130
  },
131
  )
132
  assert response.status_code == 500
133
+ mock_provider.stream_response = _mock_stream_response
134
 
135
 
136
  def test_generic_exception_with_status_code():
137
  """Exception with status_code attribute uses that status."""
138
+
139
+ def _raise_api_error(*args, **kwargs):
140
+ raise APIError("bad gateway", status_code=502)
141
+
142
+ mock_provider.stream_response = _raise_api_error
143
  response = client.post(
144
  "/v1/messages",
145
  json={
146
  "model": "test",
147
  "messages": [{"role": "user", "content": "Hi"}],
148
  "max_tokens": 10,
149
+ "stream": True,
150
  },
151
  )
152
  assert response.status_code == 502
153
+ mock_provider.stream_response = _mock_stream_response
154
 
155
 
156
  def test_count_tokens_endpoint():
tests/test_nvidia_nim.py CHANGED
@@ -65,7 +65,7 @@ async def test_init(provider_config):
65
  async def test_build_request_body(nim_provider):
66
  """Test request body construction."""
67
  req = MockRequest()
68
- body = nim_provider._build_request_body(req, stream=True)
69
 
70
  assert body["model"] == "test-model"
71
  assert body["temperature"] == 0.5
@@ -164,50 +164,6 @@ async def test_stream_response_thinking_reasoning_content(nim_provider):
164
  assert found_thinking
165
 
166
 
167
- @pytest.mark.asyncio
168
- async def test_complete_success(nim_provider):
169
- """Test successful completion."""
170
- req = MockRequest()
171
-
172
- mock_response = MagicMock()
173
- mock_response.model_dump.return_value = {
174
- "id": "test_id",
175
- "choices": [
176
- {
177
- "message": {"role": "assistant", "content": "Hello world"},
178
- "finish_reason": "stop",
179
- }
180
- ],
181
- "usage": {"prompt_tokens": 10, "completion_tokens": 5},
182
- }
183
-
184
- with patch.object(
185
- nim_provider._client.chat.completions, "create", new_callable=AsyncMock
186
- ) as mock_create:
187
- mock_create.return_value = mock_response
188
-
189
- result = await nim_provider.complete(req)
190
- assert result["id"] == "test_id"
191
- assert result["choices"][0]["message"]["content"] == "Hello world"
192
-
193
-
194
- @pytest.mark.asyncio
195
- async def test_complete_error_handling(nim_provider):
196
- """Test error handling on completion."""
197
- req = MockRequest()
198
-
199
- import openai
200
-
201
- with patch.object(
202
- nim_provider._client.chat.completions,
203
- "create",
204
- side_effect=openai.APIError("API Error", request=MagicMock(), body=None),
205
- ):
206
- with pytest.raises(APIError) as exc:
207
- await nim_provider.complete(req)
208
- assert "API Error" in str(exc.value)
209
-
210
-
211
  @pytest.mark.asyncio
212
  async def test_tool_call_stream(nim_provider):
213
  """Test streaming tool calls."""
 
65
  async def test_build_request_body(nim_provider):
66
  """Test request body construction."""
67
  req = MockRequest()
68
+ body = nim_provider._build_request_body(req)
69
 
70
  assert body["model"] == "test-model"
71
  assert body["temperature"] == 0.5
 
164
  assert found_thinking
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  @pytest.mark.asyncio
168
  async def test_tool_call_stream(nim_provider):
169
  """Test streaming tool calls."""
tests/test_request_utils.py CHANGED
@@ -453,6 +453,100 @@ class TestGetTokenCount:
453
  # Double message should have more tokens (including overhead)
454
  assert count_double > count_single
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
  # --- Parametrized Edge Case Tests ---
458
 
 
453
  # Double message should have more tokens (including overhead)
454
  assert count_double > count_single
455
 
456
+ def test_per_message_overhead_four_tokens(self):
457
+ """Per-message overhead is 4 tokens (was 3)."""
458
+ msg = MagicMock()
459
+ msg.content = "x" # Minimal content
460
+ count = get_token_count([msg])
461
+ # 1 msg * 4 overhead + content tokens
462
+ assert count >= 5
463
+
464
+ def test_system_overhead_added(self):
465
+ """System prompt adds ~4 tokens overhead."""
466
+ msg = MagicMock()
467
+ msg.content = "Hi"
468
+ count_no_sys = get_token_count([msg])
469
+ count_with_sys = get_token_count([msg], system="You are helpful")
470
+ assert count_with_sys >= count_no_sys + 4
471
+
472
+ def test_system_as_list_of_dicts(self):
473
+ """System blocks as dicts (not objects) are counted."""
474
+ msg = MagicMock()
475
+ msg.content = "Hi"
476
+ count_no_sys = get_token_count([msg])
477
+ system_dicts = [{"type": "text", "text": "System prompt from dict"}]
478
+ count_with_dict_sys = get_token_count([msg], system=system_dicts)
479
+ assert count_with_dict_sys > count_no_sys
480
+
481
+ def test_tool_use_includes_id(self):
482
+ """Tool use blocks count id field."""
483
+ tool_block = MagicMock()
484
+ tool_block.type = "tool_use"
485
+ tool_block.name = "search"
486
+ tool_block.input = {"q": "test"}
487
+ tool_block.id = "call_abc123"
488
+ msg = MagicMock()
489
+ msg.content = [tool_block]
490
+ count = get_token_count([msg])
491
+ assert count > 0
492
+
493
+ def test_tool_result_includes_tool_use_id(self):
494
+ """Tool result blocks count tool_use_id field."""
495
+ result_block = MagicMock()
496
+ result_block.type = "tool_result"
497
+ result_block.content = "ok"
498
+ result_block.tool_use_id = "call_xyz"
499
+ msg = MagicMock()
500
+ msg.content = [result_block]
501
+ count = get_token_count([msg])
502
+ assert count > 0
503
+
504
+ def test_unrecognized_block_type_fallback(self):
505
+ """Unrecognized block types are tokenized via json.dumps fallback."""
506
+ unknown_block = {"type": "custom", "spec": "data"}
507
+ msg = MagicMock()
508
+ msg.content = [unknown_block]
509
+ count = get_token_count([msg])
510
+ assert count > 0
511
+
512
+ def test_message_with_image_block(self):
513
+ """Test token count includes image blocks."""
514
+ image_block = MagicMock()
515
+ image_block.type = "image"
516
+ image_block.source = {
517
+ "type": "base64",
518
+ "media_type": "image/png",
519
+ "data": "x" * 3000,
520
+ }
521
+ msg = MagicMock()
522
+ msg.content = [image_block]
523
+ count = get_token_count([msg])
524
+ assert count >= 85
525
+
526
+ def test_image_block_with_dict_source(self):
527
+ """Image block with dict-style source is counted."""
528
+ image_block = {"type": "image", "source": {"data": "a" * 10000}}
529
+ msg = MagicMock()
530
+ msg.content = [image_block]
531
+ count = get_token_count([msg])
532
+ assert count >= 85
533
+
534
+ def test_known_payload_estimate_range(self):
535
+ """Known payload produces estimate within expected range (validation harness)."""
536
+ import tiktoken
537
+
538
+ enc = tiktoken.get_encoding("cl100k_base")
539
+ system_text = "You are a helpful assistant."
540
+ user_text = "Hello, how are you?"
541
+ sys_tokens = len(enc.encode(system_text))
542
+ user_tokens = len(enc.encode(user_text))
543
+ # Min: content tokens + system overhead (4) + per-msg overhead (4)
544
+ expected_min = sys_tokens + user_tokens + 4 + 4
545
+ msg = MagicMock()
546
+ msg.content = user_text
547
+ count = get_token_count([msg], system=system_text)
548
+ assert count >= expected_min, f"count={count} < expected_min={expected_min}"
549
+
550
 
551
  # --- Parametrized Edge Case Tests ---
552