migrated from httpx client to openai client

This commit is contained in:
Alishahryar1 2026-01-31 12:45:15 -08:00
parent 3acabcb8e0
commit 7c54008a95
5 changed files with 342 additions and 367 deletions

View file

@ -78,7 +78,6 @@ class RequestBuilderMixin:
"model": request_data.model,
"messages": messages,
"max_tokens": request_data.max_tokens,
"stream": stream,
}
if request_data.temperature is not None:
@ -90,21 +89,24 @@ class RequestBuilderMixin:
if request_data.tools:
body["tools"] = AnthropicToOpenAIConverter.convert_tools(request_data.tools)
# Handle non-standard parameters via extra_body
extra_params = request_data.extra_body.copy() if request_data.extra_body else {}
# Handle thinking/reasoning mode
extra_body = request_data.extra_body.copy() if request_data.extra_body else {}
if request_data.thinking and getattr(request_data.thinking, "enabled", True):
extra_body.setdefault("thinking", {"type": "enabled"})
extra_body.setdefault("reasoning_split", True)
extra_body.setdefault(
extra_params.setdefault("thinking", {"type": "enabled"})
extra_params.setdefault("reasoning_split", True)
extra_params.setdefault(
"chat_template_kwargs",
{"thinking": True, "reasoning_split": True, "clear_thinking": False},
)
body.update(extra_body)
if extra_params:
body["extra_body"] = extra_params
# Apply NIM defaults
for key, val in self._nim_params.items():
if key not in body:
if key not in body and key not in extra_params:
body[key] = val
return body
@ -117,38 +119,38 @@ class ErrorMapperMixin:
ProviderError subclasses for standardized error handling.
"""
def _map_error(self, response_status: int, error_text: str) -> Exception:
"""Map HTTP status and error body to specific ProviderError.
def _map_error(self, e: Exception) -> Exception:
"""Map OpenAI exception to specific ProviderError.
Args:
response_status: HTTP status code
error_text: Raw error response body
e: The OpenAI exception to map
Returns:
Appropriate ProviderError subclass instance
"""
try:
error_data = json.loads(error_text)
message = error_data.get("error", {}).get("message", error_text)
except Exception:
message = error_text
import openai
if response_status == 401:
return AuthenticationError(message, raw_error=error_text)
if response_status == 429:
if isinstance(e, openai.AuthenticationError):
return AuthenticationError(str(e), raw_error=str(e))
if isinstance(e, openai.RateLimitError):
# Trigger global rate limit block
from .rate_limit import GlobalRateLimiter
GlobalRateLimiter.get_instance().set_blocked(60) # Default 60s cooldown
return RateLimitError(message, raw_error=error_text)
if response_status in (400, 422):
return InvalidRequestError(message, raw_error=error_text)
if response_status >= 500:
return RateLimitError(str(e), raw_error=str(e))
if isinstance(e, openai.BadRequestError):
return InvalidRequestError(str(e), raw_error=str(e))
if isinstance(e, openai.InternalServerError):
message = str(e)
if "overloaded" in message.lower() or "capacity" in message.lower():
return OverloadedError(message, raw_error=error_text)
return APIError(message, status_code=response_status, raw_error=error_text)
return OverloadedError(message, raw_error=str(e))
return APIError(message, status_code=500, raw_error=str(e))
if isinstance(e, openai.APIError):
return APIError(
str(e), status_code=getattr(e, "status_code", 500), raw_error=str(e)
)
return APIError(message, status_code=response_status, raw_error=error_text)
return e
class ResponseConverterMixin:

View file

@ -1,13 +1,10 @@
"""NVIDIA NIM provider - converts Anthropic format to OpenAI format for NIM."""
import logging
import os
import json
import uuid
from typing import Any, AsyncIterator
import httpx
from httpx import TimeoutException, ConnectTimeout
from openai import AsyncOpenAI
from .base import BaseProvider, ProviderConfig
from .utils import (
@ -23,7 +20,6 @@ from .exceptions import (
)
from .nvidia_mixins import (
RequestBuilderMixin,
StreamProcessorMixin,
ErrorMapperMixin,
ResponseConverterMixin,
)
@ -34,12 +30,11 @@ logger = logging.getLogger(__name__)
class NvidiaNimProvider(
RequestBuilderMixin,
StreamProcessorMixin,
ErrorMapperMixin,
ResponseConverterMixin,
BaseProvider,
):
"""NVIDIA NIM provider using direct httpx requests."""
"""NVIDIA NIM provider using official OpenAI client."""
def __init__(self, config: ProviderConfig):
super().__init__(config)
@ -50,20 +45,21 @@ class NvidiaNimProvider(
).rstrip("/")
self._nim_params = self._load_nim_params()
self._global_rate_limiter = GlobalRateLimiter.get_instance()
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(300.0, connect=30.0, read=60.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
self._client = AsyncOpenAI(
api_key=self._api_key,
base_url=self._base_url,
max_retries=2,
timeout=300.0,
)
async def stream_response(
self, request: Any, input_tokens: int = 0
) -> AsyncIterator[str]:
"""Stream response in Anthropic SSE format."""
# Wait if globally rate limited (proactive throttle + reactive block)
# Wait if globally rate limited
waited_reactively = await self._global_rate_limiter.wait_if_blocked()
if waited_reactively:
# Yield error event for reactive rate limit blocking (user feedback)
message_id = f"msg_{uuid.uuid4()}"
sse = SSEBuilder(message_id, request.model, input_tokens)
error_msg = "⏱️ Global rate limit active. Resuming now..."
@ -71,20 +67,12 @@ class NvidiaNimProvider(
yield sse.message_start()
for event in sse.emit_error(error_msg):
yield event
# After notification, we continue to the actual request
body = self._build_request_body(request, stream=True)
# Log compact request summary
logger.info(
f"NIM_STREAM: model={body.get('model')} msgs={len(body.get('messages', []))} tools={len(body.get('tools', []))}"
)
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
message_id = f"msg_{uuid.uuid4()}"
sse = SSEBuilder(message_id, request.model, input_tokens)
think_parser = ThinkTagParser()
@ -98,48 +86,46 @@ class NvidiaNimProvider(
error_message = ""
try:
async for chunk_json in self._stream_chunks(headers, body):
# Process metadata
if "usage" in chunk_json:
usage_info = chunk_json["usage"]
stream = await self._client.chat.completions.create(**body, stream=True)
async for chunk in stream:
# OpenAI client returns objects, not JSON
if getattr(chunk, "usage", None):
usage_info = chunk.usage
if "choices" not in chunk_json or not chunk_json["choices"]:
if not chunk.choices:
continue
choice = chunk_json["choices"][0]
delta = choice.get("delta", {})
choice = chunk.choices[0]
delta = choice.delta
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
if choice.finish_reason:
finish_reason = choice.finish_reason
logger.debug(f"NIM finish_reason: {finish_reason}")
# Handle reasoning content from delta
reasoning = extract_reasoning_from_delta(delta)
reasoning = getattr(delta, "reasoning_content", None)
if reasoning:
for event in sse.ensure_thinking_block():
yield event
yield sse.emit_thinking_delta(reasoning)
# Handle text content with think tag and heuristic tool parsing
if delta.get("content"):
for chunk in think_parser.feed(delta["content"]):
if chunk.type == ContentType.THINKING:
# Handle text content
if delta.content:
for part in think_parser.feed(delta.content):
if part.type == ContentType.THINKING:
for event in sse.ensure_thinking_block():
yield event
yield sse.emit_thinking_delta(chunk.content)
yield sse.emit_thinking_delta(part.content)
else:
# Pass non-thinking text through heuristic tool parser
filtered_text, detected_tools = heuristic_parser.feed(
chunk.content
part.content
)
# Emit filtered text if any
if filtered_text:
for event in sse.ensure_text_block():
yield event
yield sse.emit_text_delta(filtered_text)
# Emit detected heuristic tool calls
for tool_use in detected_tools:
for event in sse.close_content_blocks():
yield event
@ -159,34 +145,34 @@ class NvidiaNimProvider(
yield sse.content_block_stop(block_idx)
# Handle native tool calls
if delta.get("tool_calls"):
if delta.tool_calls:
for event in sse.close_content_blocks():
yield event
for tc in delta["tool_calls"]:
for event in self._process_tool_call(tc, sse):
for tc in delta.tool_calls:
# Convert OpenAI tool call object to dict for existing logic
tc_info = {
"index": tc.index,
"id": tc.id,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for event in self._process_tool_call(tc_info, sse):
yield event
except TimeoutException as e:
timeout_type = "connect" if isinstance(e, ConnectTimeout) else "read"
logger.error(f"NIM_TIMEOUT: type={timeout_type} model={body.get('model')}")
error_occurred = True
error_message = (
f"⏱️ API Timeout ({timeout_type}): Request exceeded time limit"
)
logger.info("NIM_STREAM: Emitting SSE error event for timeout")
except Exception as e:
logger.error(f"NIM_ERROR: {type(e).__name__}: {e}")
mapped_e = self._map_error(e)
error_occurred = True
error_message = str(e)
logger.info("NIM_STREAM: Emitting SSE error event for exception")
error_message = str(mapped_e)
logger.info(f"NIM_STREAM: Emitting SSE error event for {type(e).__name__}")
# Handle errors
if error_occurred:
for event in sse.emit_error(error_message):
yield event
logger.info("NIM_STREAM: Error event yielded, total events emitted")
# Flush remaining content from parsers
# Flush remaining content
remaining = think_parser.flush()
if remaining:
if remaining.type == ContentType.THINKING:
@ -198,7 +184,6 @@ class NvidiaNimProvider(
yield event
yield sse.emit_text_delta(remaining.content)
# Flush heuristic tool calls
for tool_use in heuristic_parser.flush():
for event in sse.close_content_blocks():
yield event
@ -217,8 +202,6 @@ class NvidiaNimProvider(
)
yield sse.content_block_stop(block_idx)
# Ensure at least one text block exists to avoid "(no content)" in Claude Code
# This handles cases where model only returns thinking or empty responses
if (
not error_occurred
and sse.blocks.text_index == -1
@ -226,82 +209,76 @@ class NvidiaNimProvider(
):
for event in sse.ensure_text_block():
yield event
yield sse.emit_text_delta(" ") # Single space placeholder
yield sse.emit_text_delta(" ")
# Close all blocks
for event in sse.close_all_blocks():
yield event
# Final events
output_tokens = (
usage_info.get("completion_tokens", 0)
if usage_info
usage_info.completion_tokens
if usage_info and hasattr(usage_info, "completion_tokens")
else sse.estimate_output_tokens()
)
yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
yield sse.message_stop()
yield sse.done()
async def _stream_chunks(self, headers: dict, body: dict):
"""Generator that yields parsed SSE data chunks from the API."""
async with self._client.stream(
"POST", f"{self._base_url}/chat/completions", headers=headers, json=body
) as response:
if response.status_code != 200:
error_text = await response.aread()
raise self._map_error(
response_status=response.status_code,
error_text=error_text.decode("utf-8", errors="replace"),
)
buffer = ""
async for chunk in response.aiter_text():
buffer += chunk
while "\n\n" in buffer:
event_end = buffer.index("\n\n")
event_data = buffer[:event_end]
buffer = buffer[event_end + 2 :]
parsed = self._parse_sse_event(event_data)
if parsed is not None:
yield parsed
# Flush remaining non-empty buffer
if buffer.strip():
parsed = self._parse_sse_event(buffer)
if parsed is not None:
yield parsed
async def complete(self, request: Any) -> dict:
"""Make a non-streaming completion request."""
# Wait if globally rate limited (proactive throttle + reactive block)
await self._global_rate_limiter.wait_if_blocked()
body = self._build_request_body(request, stream=False)
# Log compact request summary
logger.info(
f"NIM_COMPLETE: model={body.get('model')} msgs={len(body.get('messages', []))} tools={len(body.get('tools', []))}"
)
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
try:
response = await self._client.post(
f"{self._base_url}/chat/completions", headers=headers, json=body
)
except TimeoutException as e:
timeout_type = "connect" if isinstance(e, ConnectTimeout) else "read"
logger.error(f"NIM_TIMEOUT: type={timeout_type} model={body.get('model')}")
raise APIError(
f"API Timeout ({timeout_type}): Request exceeded time limit",
status_code=504,
response = await self._client.chat.completions.create(**body)
# ResponseconverterMixin expects a dict
return response.model_dump()
except Exception as e:
logger.error(f"NIM_ERROR: {type(e).__name__}: {e}")
raise self._map_error(e)
def _process_tool_call(self, tc: dict, sse: Any):
"""Process a single tool call delta and yield SSE events.
Args:
tc: Tool call delta info dict
sse: SSEBuilder instance
"""
import uuid
tc_index = tc.get("index", 0)
if tc_index < 0:
tc_index = len(sse.blocks.tool_indices)
fn_delta = tc.get("function", {})
if fn_delta.get("name") is not None:
sse.blocks.tool_names[tc_index] = (
sse.blocks.tool_names.get(tc_index, "") + fn_delta["name"]
)
if response.status_code != 200:
raise self._map_error(
response_status=response.status_code, error_text=response.text
)
return response.json()
if tc_index not in sse.blocks.tool_indices:
name = sse.blocks.tool_names.get(tc_index, "")
if name or tc.get("id"):
tool_id = tc.get("id") or f"tool_{uuid.uuid4()}"
yield sse.start_tool_block(tc_index, tool_id, name)
sse.blocks.tool_started[tc_index] = True
elif not sse.blocks.tool_started.get(tc_index) and sse.blocks.tool_names.get(
tc_index
):
tool_id = tc.get("id") or f"tool_{uuid.uuid4()}"
name = sse.blocks.tool_names[tc_index]
yield sse.start_tool_block(tc_index, tool_id, name)
sse.blocks.tool_started[tc_index] = True
args = fn_delta.get("arguments", "")
if args:
if not sse.blocks.tool_started.get(tc_index):
tool_id = tc.get("id") or f"tool_{uuid.uuid4()}"
name = sse.blocks.tool_names.get(tc_index, "tool_call") or "tool_call"
yield sse.start_tool_block(tc_index, tool_id, name)
sse.blocks.tool_started[tc_index] = True
yield sse.emit_tool_delta(tc_index, args)

View file

@ -15,6 +15,7 @@ dependencies = [
"python-telegram-bot>=21.0",
"pydantic-settings>=2.12.0",
"aiolimiter>=1.2.1",
"openai>=2.16.0",
]
[dependency-groups]

View file

@ -1,6 +1,5 @@
import pytest
import json
import httpx
from unittest.mock import MagicMock, AsyncMock, patch
from providers.nvidia_nim import (
NvidiaNimProvider,
@ -39,19 +38,6 @@ class MockRequest:
setattr(self, k, v)
class MockThinking:
def __init__(self, enabled=True, budget_tokens=1000):
self.enabled = enabled
self.budget_tokens = budget_tokens
@pytest.fixture
def mock_httpx_ssl():
"""Mock ssl context for older httpx versions if needed, or just bypass."""
# This is often needed if the code under test creates an SSL context
pass
@pytest.fixture(autouse=True)
def mock_rate_limiter():
"""Mock the global rate limiter to prevent waiting."""
@ -64,9 +50,11 @@ def mock_rate_limiter():
@pytest.mark.asyncio
async def test_init(provider_config):
"""Test provider initialization."""
provider = NvidiaNimProvider(provider_config)
assert provider._api_key == "test_key"
assert provider._base_url == "https://test.api.nvidia.com/v1"
with patch("providers.nvidia_nim.AsyncOpenAI") as mock_openai:
provider = NvidiaNimProvider(provider_config)
assert provider._api_key == "test_key"
assert provider._base_url == "https://test.api.nvidia.com/v1"
mock_openai.assert_called_once()
@pytest.mark.asyncio
@ -76,91 +64,58 @@ async def test_build_request_body(nim_provider):
body = nim_provider._build_request_body(req, stream=True)
assert body["model"] == "test-model"
assert body["stream"] is True
assert body["temperature"] == 0.5
assert len(body["messages"]) == 2 # System + User
assert body["messages"][0]["role"] == "system"
assert body["messages"][0]["content"] == "System prompt"
assert "thinking" in body
assert body["thinking"]["type"] == "enabled"
@pytest.mark.asyncio
async def test_build_request_body_with_tools(nim_provider):
"""Test request body with tools."""
tool = MockTool(
name="test_tool",
description="A test tool",
input_schema={"type": "object", "properties": {"arg": {"type": "string"}}},
)
req = MockRequest(tools=[tool])
body = nim_provider._build_request_body(req)
assert "tools" in body
assert len(body["tools"]) == 1
assert body["tools"][0]["function"]["name"] == "test_tool"
@pytest.mark.asyncio
async def test_build_request_body_deepseek(nim_provider):
"""Test request body with DeepSeek model."""
req = MockRequest(model="deepseek-ai/deepseek-r1")
body = nim_provider._build_request_body(req)
assert "chat_template_kwargs" in body
assert body["chat_template_kwargs"] == {"thinking": True}
@pytest.mark.asyncio
async def test_build_request_body_non_deepseek(nim_provider):
"""Test request body with non-DeepSeek model."""
req = MockRequest(model="meta/llama-3.3-70b-instruct")
body = nim_provider._build_request_body(req)
assert "chat_template_kwargs" not in body
assert "extra_body" in body
assert "thinking" in body["extra_body"]
assert body["extra_body"]["thinking"]["type"] == "enabled"
@pytest.mark.asyncio
async def test_stream_response_text(nim_provider):
"""Test streaming text response."""
# Mock stream response
req = MockRequest()
mock_chunks = [
'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: {"id":"1","choices":[{"delta":{"content":" World"}}]}\n\n',
"data: [DONE]\n\n",
# Create mock chunks
mock_chunk1 = MagicMock()
mock_chunk1.choices = [
MagicMock(
delta=MagicMock(content="Hello", reasoning_content=""), finish_reason=None
)
]
mock_chunk1.usage = None
# Create a mock response that yields chunks
mock_response = AsyncMock()
mock_response.status_code = 200
mock_chunk2 = MagicMock()
mock_chunk2.choices = [
MagicMock(
delta=MagicMock(content=" World", reasoning_content=""),
finish_reason="stop",
)
]
mock_chunk2.usage = MagicMock(completion_tokens=10)
async def mock_aiter():
for chunk in mock_chunks:
yield chunk
async def mock_stream():
yield mock_chunk1
yield mock_chunk2
mock_response.aiter_text = mock_aiter
mock_response.aread = AsyncMock(return_value=b"")
with patch.object(
nim_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
# Mock the stream context manager
mock_stream_ctx = AsyncMock()
mock_stream_ctx.__aenter__.return_value = mock_response
with patch.object(nim_provider._client, "stream", return_value=mock_stream_ctx):
events = []
async for event in nim_provider.stream_response(req):
events.append(event)
# Verify events
assert len(events) > 0
assert "event: message_start" in events[0]
# Check text content
text_content = ""
for e in events:
if "event: content_block_delta" in e and '"text_delta"' in e:
# Extract json from data: ...
for line in e.splitlines():
if line.startswith("data: "):
data = json.loads(line[6:])
@ -171,107 +126,38 @@ async def test_stream_response_text(nim_provider):
@pytest.mark.asyncio
async def test_stream_response_thinking_interleaved(nim_provider):
"""Test streaming with interleaved thinking tags."""
async def test_stream_response_thinking_reasoning_content(nim_provider):
"""Test streaming with native reasoning_content."""
req = MockRequest()
mock_chunks = [
'data: {"choices":[{"delta":{"content":"<think>Thinking process..."}}]}\n\n',
'data: {"choices":[{"delta":{"content":"...</think>Answer"}}]}\n\n',
"data: [DONE]\n\n",
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(content=None, reasoning_content="Thinking..."),
finish_reason=None,
)
]
mock_chunk.usage = None
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aiter_text = lambda: (
c for c in mock_chunks
) # sync iterator wrapper check? No, must be async generator
async def mock_stream():
yield mock_chunk
async def mock_aiter():
for chunk in mock_chunks:
yield chunk
with patch.object(
nim_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
mock_response.aiter_text = mock_aiter
mock_stream_ctx = AsyncMock()
mock_stream_ctx.__aenter__.return_value = mock_response
with patch.object(nim_provider._client, "stream", return_value=mock_stream_ctx):
events = []
async for event in nim_provider.stream_response(req):
events.append(event)
# Should have thinking events
think_deltas = [
e
for e in events
if "event: content_block_delta" in e and '"thinking_delta"' in e
]
assert len(think_deltas) > 0
# Should have text events
text_deltas = [
e
for e in events
if "event: content_block_delta" in e and '"text_delta"' in e
]
assert len(text_deltas) > 0
@pytest.mark.asyncio
async def test_stream_response_error_429(nim_provider):
"""Test 429 Rate Limit error."""
req = MockRequest()
mock_response = AsyncMock()
mock_response.status_code = 429
mock_response.aread = AsyncMock(
return_value=b'{"error": {"message": "Too many requests"}}'
)
mock_stream_ctx = AsyncMock()
mock_stream_ctx.__aenter__.return_value = mock_response
with patch.object(nim_provider._client, "stream", return_value=mock_stream_ctx):
# The provider yields an error event rather than raising
events = []
async for event in nim_provider.stream_response(req):
events.append(event)
# Check for error message in text delta
found_error = False
# Check for thinking_delta
found_thinking = False
for e in events:
if "event: content_block_delta" in e and '"text_delta"' in e:
if "Too many requests" in e:
found_error = True
break
assert found_error
@pytest.mark.asyncio
async def test_stream_response_timeout(nim_provider):
"""Test timeout handling."""
req = MockRequest()
# Mock stream to raise TimeoutException
mock_stream_ctx = AsyncMock()
mock_stream_ctx.__aenter__.side_effect = httpx.ConnectTimeout(
"Connection timed out"
)
with patch.object(nim_provider._client, "stream", return_value=mock_stream_ctx):
events = []
async for event in nim_provider.stream_response(req):
events.append(event)
# Check for error message in text delta
found_error = False
for e in events:
if "event: content_block_delta" in e and '"text_delta"' in e:
if "Timeout" in e:
found_error = True
break
assert found_error
if "event: content_block_delta" in e and '"thinking_delta"' in e:
if "Thinking..." in e:
found_thinking = True
assert found_thinking
@pytest.mark.asyncio
@ -280,8 +166,7 @@ async def test_complete_success(nim_provider):
req = MockRequest()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
mock_response.model_dump.return_value = {
"id": "test_id",
"choices": [
{
@ -293,9 +178,9 @@ async def test_complete_success(nim_provider):
}
with patch.object(
nim_provider._client, "post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = mock_response
nim_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_response
result = await nim_provider.complete(req)
assert result["id"] == "test_id"
@ -303,52 +188,20 @@ async def test_complete_success(nim_provider):
@pytest.mark.asyncio
async def test_complete_error_500(nim_provider):
"""Test 500 error on completion."""
async def test_complete_error_handling(nim_provider):
"""Test error handling on completion."""
req = MockRequest()
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = '{"error": {"message": "Internal Server Error"}}'
import openai
with patch.object(
nim_provider._client, "post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = mock_response
nim_provider._client.chat.completions,
"create",
side_effect=openai.APIError("API Error", request=MagicMock(), body=None),
):
with pytest.raises(APIError) as exc:
await nim_provider.complete(req)
assert "Internal Server Error" in str(exc.value)
@pytest.mark.asyncio
async def test_convert_response(nim_provider):
"""Test response conversion."""
req = MockRequest()
openai_resp = {
"id": "resp_1",
"choices": [
{
"message": {
"role": "assistant",
"content": "Response text",
"reasoning_content": "Found logic",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 50, "completion_tokens": 20},
}
result = nim_provider.convert_response(openai_resp, req)
assert result["id"] == "resp_1"
assert result["type"] == "message"
assert len(result["content"]) == 2
assert result["content"][0]["type"] == "thinking"
assert result["content"][0]["thinking"] == "Found logic"
assert result["content"][1]["type"] == "text"
assert result["content"][1]["text"] == "Response text"
assert "API Error" in str(exc.value)
@pytest.mark.asyncio
@ -356,31 +209,34 @@ async def test_tool_call_stream(nim_provider):
"""Test streaming tool calls."""
req = MockRequest()
mock_chunks = [
'data: {"choices":[{"delta":{"content":null,"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"q\\": "}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"test\\"}"}}]}}]}\n\n',
"data: [DONE]\n\n",
# Mock tool call delta
mock_tc = MagicMock()
mock_tc.index = 0
mock_tc.id = "call_1"
mock_tc.function.name = "search"
mock_tc.function.arguments = '{"q": "test"}'
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(content=None, reasoning_content="", tool_calls=[mock_tc]),
finish_reason=None,
)
]
mock_chunk.usage = None
async def mock_aiter():
for chunk in mock_chunks:
yield chunk
async def mock_stream():
yield mock_chunk
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.aiter_text = mock_aiter
with patch.object(
nim_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
mock_stream_ctx = AsyncMock()
mock_stream_ctx.__aenter__.return_value = mock_response
with patch.object(nim_provider._client, "stream", return_value=mock_stream_ctx):
events = []
async for event in nim_provider.stream_response(req):
events.append(event)
# Should have content_block_start for tool_use
# Looking for event: content_block_start ... type: tool_use
starts = [
e for e in events if "event: content_block_start" in e and '"tool_use"' in e
]

139
uv.lock generated
View file

@ -58,6 +58,7 @@ dependencies = [
{ name = "aiolimiter" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
@ -79,6 +80,7 @@ requires-dist = [
{ name = "aiolimiter", specifier = ">=1.2.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.11" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "openai", specifier = ">=2.16.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
@ -318,6 +320,15 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "dnspython"
version = "2.7.0"
@ -534,6 +545,103 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jiter"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/91/13cb9505f7be74a933f37da3af22e029f6ba64f5669416cb8b2774bc9682/jiter-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7acbaba9703d5de82a2c98ae6a0f59ab9770ab5af5fa35e43a303aee962cf65", size = 316652, upload-time = "2025-11-09T20:46:41.021Z" },
{ url = "https://files.pythonhosted.org/packages/4e/76/4e9185e5d9bb4e482cf6dec6410d5f78dfeb374cfcecbbe9888d07c52daa/jiter-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:364f1a7294c91281260364222f535bc427f56d4de1d8ffd718162d21fbbd602e", size = 319829, upload-time = "2025-11-09T20:46:43.281Z" },
{ url = "https://files.pythonhosted.org/packages/86/af/727de50995d3a153138139f259baae2379d8cb0522c0c00419957bc478a6/jiter-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ee4d25805d4fb23f0a5167a962ef8e002dbfb29c0989378488e32cf2744b62", size = 350568, upload-time = "2025-11-09T20:46:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c1/d6e9f4b7a3d5ac63bcbdfddeb50b2dcfbdc512c86cffc008584fdc350233/jiter-0.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:796f466b7942107eb889c08433b6e31b9a7ed31daceaecf8af1be26fb26c0ca8", size = 369052, upload-time = "2025-11-09T20:46:46.818Z" },
{ url = "https://files.pythonhosted.org/packages/eb/be/00824cd530f30ed73fa8a4f9f3890a705519e31ccb9e929f1e22062e7c76/jiter-0.12.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35506cb71f47dba416694e67af996bbdefb8e3608f1f78799c2e1f9058b01ceb", size = 481585, upload-time = "2025-11-09T20:46:48.319Z" },
{ url = "https://files.pythonhosted.org/packages/74/b6/2ad7990dff9504d4b5052eef64aa9574bd03d722dc7edced97aad0d47be7/jiter-0.12.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:726c764a90c9218ec9e4f99a33d6bf5ec169163f2ca0fc21b654e88c2abc0abc", size = 380541, upload-time = "2025-11-09T20:46:49.643Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c7/f3c26ecbc1adbf1db0d6bba99192143d8fe8504729d9594542ecc4445784/jiter-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa47810c5565274810b726b0dc86d18dce5fd17b190ebdc3890851d7b2a0e74", size = 364423, upload-time = "2025-11-09T20:46:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/18/51/eac547bf3a2d7f7e556927278e14c56a0604b8cddae75815d5739f65f81d/jiter-0.12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ec0259d3f26c62aed4d73b198c53e316ae11f0f69c8fbe6682c6dcfa0fcce2", size = 389958, upload-time = "2025-11-09T20:46:53.432Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1f/9ca592e67175f2db156cff035e0d817d6004e293ee0c1d73692d38fcb596/jiter-0.12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:79307d74ea83465b0152fa23e5e297149506435535282f979f18b9033c0bb025", size = 522084, upload-time = "2025-11-09T20:46:54.848Z" },
{ url = "https://files.pythonhosted.org/packages/83/ff/597d9cdc3028f28224f53e1a9d063628e28b7a5601433e3196edda578cdd/jiter-0.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf6e6dd18927121fec86739f1a8906944703941d000f0639f3eb6281cc601dca", size = 513054, upload-time = "2025-11-09T20:46:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/24/6d/1970bce1351bd02e3afcc5f49e4f7ef3dabd7fb688f42be7e8091a5b809a/jiter-0.12.0-cp310-cp310-win32.whl", hash = "sha256:b6ae2aec8217327d872cbfb2c1694489057b9433afce447955763e6ab015b4c4", size = 206368, upload-time = "2025-11-09T20:46:58.638Z" },
{ url = "https://files.pythonhosted.org/packages/e3/6b/eb1eb505b2d86709b59ec06681a2b14a94d0941db091f044b9f0e16badc0/jiter-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c7f49ce90a71e44f7e1aa9e7ec415b9686bbc6a5961e57eab511015e6759bc11", size = 204847, upload-time = "2025-11-09T20:47:00.295Z" },
{ url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" },
{ url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" },
{ url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" },
{ url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" },
{ url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" },
{ url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" },
{ url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" },
{ url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" },
{ url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" },
{ url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" },
{ url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" },
{ url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" },
{ url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" },
{ url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" },
{ url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" },
{ url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" },
{ url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" },
{ url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" },
{ url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" },
{ url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" },
{ url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" },
{ url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" },
{ url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" },
{ url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" },
{ url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" },
{ url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" },
{ url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" },
{ url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" },
{ url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" },
{ url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" },
{ url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" },
{ url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" },
{ url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" },
{ url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" },
{ url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" },
{ url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" },
{ url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" },
{ url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" },
{ url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@ -613,6 +721,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "openai"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -1173,6 +1300,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "tqdm"
version = "4.67.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/89/4b0001b2dab8df0a5ee2787dcbe771de75ded01f18f1f8d53dedeea2882b/tqdm-4.67.2.tar.gz", hash = "sha256:649aac53964b2cb8dec76a14b405a4c0d13612cb8933aae547dd144eacc99653", size = 169514, upload-time = "2026-01-30T23:12:06.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" },
]
[[package]]
name = "typer"
version = "0.15.2"