v2.17.0 (#822)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
564a14365e
commit
6bedf271ce
223 changed files with 10533 additions and 6947 deletions
132
tests/backend/data/anthropic.py
Normal file
132
tests/backend/data/anthropic.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse
|
||||
|
||||
|
||||
def _sse_event(data: dict[str, Any]) -> Chunk:
|
||||
return f"data: {json.dumps(data, separators=(',', ':'))}".encode()
|
||||
|
||||
|
||||
def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# Flatten every structured content block across a request's messages
|
||||
# (text/thinking/tool_use/tool_result blocks).
|
||||
return [
|
||||
block
|
||||
for message in payload["messages"]
|
||||
if isinstance(message["content"], list)
|
||||
for block in message["content"]
|
||||
]
|
||||
|
||||
|
||||
def anthropic_message(
|
||||
text: str,
|
||||
*,
|
||||
input_tokens: int = 12,
|
||||
output_tokens: int = 3,
|
||||
stop_reason: str = "end_turn",
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
||||
"stop_reason": stop_reason,
|
||||
}
|
||||
|
||||
|
||||
def anthropic_tool_use(
|
||||
name: str,
|
||||
tool_input: dict[str, Any],
|
||||
*,
|
||||
tool_id: str = "toolu_1",
|
||||
input_tokens: int = 20,
|
||||
output_tokens: int = 5,
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "tool_use", "id": tool_id, "name": name, "input": tool_input}
|
||||
],
|
||||
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
||||
"stop_reason": "tool_use",
|
||||
}
|
||||
|
||||
|
||||
def anthropic_reasoning_tool_use_stream(
|
||||
name: str,
|
||||
arguments: str,
|
||||
*,
|
||||
reasoning: str = "thinking...",
|
||||
signature: str = "sig",
|
||||
tool_id: str = "toolu_1",
|
||||
input_tokens: int = 20,
|
||||
output_tokens: int = 5,
|
||||
) -> list[Chunk]:
|
||||
return [
|
||||
_sse_event(e)
|
||||
for e in (
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {"usage": {"input_tokens": input_tokens}},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": reasoning},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "signature_delta", "signature": signature},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "tool_use", "id": tool_id, "name": name},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "input_json_delta", "partial_json": arguments},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 1},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use"},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def anthropic_text_stream(
|
||||
text: str, *, input_tokens: int = 12, output_tokens: int = 3
|
||||
) -> list[Chunk]:
|
||||
return [
|
||||
_sse_event(e)
|
||||
for e in (
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {"usage": {"input_tokens": input_tokens}},
|
||||
},
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
]
|
||||
|
|
@ -1,7 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
|
||||
|
||||
def mistral_completion(
|
||||
content: str,
|
||||
*,
|
||||
prompt_tokens: int = 100,
|
||||
completion_tokens: int = 50,
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"id": "cmpl_test",
|
||||
"created": 1234567890,
|
||||
"model": "devstral-latest",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
},
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls" if tool_calls else "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||
(
|
||||
"https://api.mistral.ai",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import json
|
|||
from typing import Any
|
||||
|
||||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
|
||||
OPENAI_RESPONSES_TEST_BASE_URL = "https://api.openai.com"
|
||||
from tests.constants import OPENAI_BASE_URL
|
||||
|
||||
|
||||
def _sse_event(data: dict[str, Any] | str) -> Chunk:
|
||||
|
|
@ -90,9 +89,134 @@ def _function_call_item(
|
|||
}
|
||||
|
||||
|
||||
def openai_message_item(
|
||||
text: str, *, phase: str | None = None, message_id: str = "msg_1"
|
||||
) -> dict[str, Any]:
|
||||
return _message_output_item(message_id, text, phase=phase)
|
||||
|
||||
|
||||
def openai_function_call_item(
|
||||
name: str, arguments: str, *, item_id: str = "fc_1", call_id: str = "call_1"
|
||||
) -> dict[str, Any]:
|
||||
return _function_call_item(item_id, call_id, name, arguments)
|
||||
|
||||
|
||||
def openai_response(
|
||||
output: list[dict[str, Any]],
|
||||
*,
|
||||
input_tokens: int = 10,
|
||||
output_tokens: int = 2,
|
||||
response_id: str = "resp_1",
|
||||
model: str = "gpt-test",
|
||||
) -> JsonResponse:
|
||||
return {
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"created_at": 1234567890,
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": input_tokens + output_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def openai_sse(*events: dict[str, Any] | str) -> list[Chunk]:
|
||||
return [_sse_event(event) for event in events]
|
||||
|
||||
|
||||
def openai_reasoning_tool_call_stream(
|
||||
name: str,
|
||||
arguments: str,
|
||||
*,
|
||||
reasoning: str = "thinking...",
|
||||
call_id: str = "call_1",
|
||||
item_id: str = "fc_1",
|
||||
input_tokens: int = 20,
|
||||
output_tokens: int = 5,
|
||||
) -> list[Chunk]:
|
||||
return openai_sse(
|
||||
{"type": "response.created", "response": {"id": "resp_1", "output": []}},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": _stream_message_item("msg_r", phase="commentary"),
|
||||
},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"output_index": 0,
|
||||
"delta": reasoning,
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 1,
|
||||
"item": _function_call_item(
|
||||
item_id, call_id, name, "", status="in_progress"
|
||||
),
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": 1,
|
||||
"call_id": call_id,
|
||||
"delta": arguments,
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.done",
|
||||
"output_index": 1,
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 1,
|
||||
"item": _function_call_item(item_id, call_id, name, arguments),
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": openai_response(
|
||||
[_function_call_item(item_id, call_id, name, arguments)],
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
),
|
||||
},
|
||||
"[DONE]",
|
||||
)
|
||||
|
||||
|
||||
def openai_text_stream(
|
||||
text: str, *, input_tokens: int = 10, output_tokens: int = 2
|
||||
) -> list[Chunk]:
|
||||
return openai_sse(
|
||||
{"type": "response.created", "response": {"id": "resp_1", "output": []}},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": _stream_message_item("msg_1"),
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": openai_response(
|
||||
[openai_message_item(text)],
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
),
|
||||
},
|
||||
"[DONE]",
|
||||
)
|
||||
|
||||
|
||||
SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
{
|
||||
"id": "resp_fake_id_1234",
|
||||
"object": "response",
|
||||
|
|
@ -113,7 +237,7 @@ SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
|||
|
||||
TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
{
|
||||
"id": "resp_fake_id_9012",
|
||||
"object": "response",
|
||||
|
|
@ -144,7 +268,7 @@ TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
|||
|
||||
STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"type": "response.created",
|
||||
|
|
@ -235,7 +359,7 @@ STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultDat
|
|||
|
||||
COMMENTARY_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
{
|
||||
"id": "resp_thinking_1234",
|
||||
"object": "response",
|
||||
|
|
@ -268,7 +392,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
|||
tuple[Url, list[Chunk], list[ResultData]]
|
||||
] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"type": "response.created",
|
||||
|
|
@ -419,7 +543,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[
|
|||
|
||||
STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [
|
||||
(
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
OPENAI_BASE_URL,
|
||||
[
|
||||
_sse_event({
|
||||
"type": "response.created",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import json
|
|||
|
||||
import pytest
|
||||
|
||||
from tests.constants import ANTHROPIC_BASE_URL, ANTHROPIC_MESSAGES_PATH
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper
|
||||
from vibe.core.types import (
|
||||
|
|
@ -30,7 +31,7 @@ def adapter():
|
|||
def provider():
|
||||
return ProviderConfig(
|
||||
name="anthropic",
|
||||
api_base="https://api.anthropic.com",
|
||||
api_base=ANTHROPIC_BASE_URL,
|
||||
api_key_env_var="ANTHROPIC_API_KEY",
|
||||
api_style="anthropic",
|
||||
)
|
||||
|
|
@ -229,75 +230,6 @@ class TestMapperParseResponse:
|
|||
assert chunk.usage.completion_tokens == 7
|
||||
|
||||
|
||||
class TestMapperStreamingEvents:
|
||||
def test_text_delta(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "text_delta", "text": "hi"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.content == "hi"
|
||||
|
||||
def test_thinking_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.reasoning_content == "hmm"
|
||||
|
||||
def test_tool_use_start(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"content_block": {"type": "tool_use", "id": "t1", "name": "search"},
|
||||
"index": 2,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].id == "t1"
|
||||
assert idx == 2
|
||||
|
||||
def test_input_json_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"q":'},
|
||||
"index": 1,
|
||||
},
|
||||
0,
|
||||
)
|
||||
assert chunk.message.tool_calls[0].function.arguments == '{"q":'
|
||||
|
||||
def test_message_start_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_start",
|
||||
{"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}},
|
||||
0,
|
||||
)
|
||||
assert chunk.usage.prompt_tokens == 60
|
||||
|
||||
def test_message_delta_usage(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"message_delta", {"usage": {"output_tokens": 42}}, 0
|
||||
)
|
||||
assert chunk.usage.completion_tokens == 42
|
||||
|
||||
def test_unknown_event(self, mapper):
|
||||
chunk, idx = mapper.parse_streaming_event("ping", {}, 5)
|
||||
assert chunk is None
|
||||
assert idx == 5
|
||||
|
||||
def test_signature_delta(self, mapper):
|
||||
chunk, _ = mapper.parse_streaming_event(
|
||||
"content_block_delta",
|
||||
{"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0},
|
||||
0,
|
||||
)
|
||||
assert chunk is not None
|
||||
assert chunk.message.reasoning_signature == "sig"
|
||||
|
||||
|
||||
class TestAdapterPrepareRequest:
|
||||
def test_basic(self, adapter, provider):
|
||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||
|
|
@ -316,7 +248,7 @@ class TestAdapterPrepareRequest:
|
|||
assert payload["model"] == "claude-sonnet-4-20250514"
|
||||
assert payload["max_tokens"] == 1024
|
||||
assert "temperature" not in payload
|
||||
assert req.endpoint == "/v1/messages"
|
||||
assert req.endpoint == ANTHROPIC_MESSAGES_PATH
|
||||
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||
|
||||
def test_beta_features(self, adapter, provider):
|
||||
|
|
|
|||
|
|
@ -36,8 +36,9 @@ from tests.backend.data.mistral import (
|
|||
STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||
TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS,
|
||||
)
|
||||
from tests.constants import CHAT_COMPLETIONS_PATH
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY, create_backend
|
||||
from vibe.core.llm.backend.generic import GenericBackend
|
||||
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper
|
||||
from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder
|
||||
|
|
@ -71,7 +72,7 @@ class TestBackend:
|
|||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/chat/completions").mock(
|
||||
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||
return_value=httpx.Response(status_code=200, json=json_response)
|
||||
)
|
||||
provider = ProviderConfig(
|
||||
|
|
@ -139,7 +140,7 @@ class TestBackend:
|
|||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/chat/completions").mock(
|
||||
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
|
|
@ -291,7 +292,7 @@ class TestBackend:
|
|||
response: httpx.Response,
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/chat/completions").mock(return_value=response)
|
||||
mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=response)
|
||||
provider = ProviderConfig(
|
||||
name="provider_name",
|
||||
api_base=f"{base_url}/v1",
|
||||
|
|
@ -335,7 +336,7 @@ class TestBackend:
|
|||
self, base_url: Url, provider_name: str, expected_stream_options: dict
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
route = mock_api.post("/v1/chat/completions").mock(
|
||||
route = mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
stream=httpx.ByteStream(
|
||||
|
|
@ -399,7 +400,7 @@ class TestBackend:
|
|||
],
|
||||
}
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/chat/completions").mock(
|
||||
mock_api.post(CHAT_COMPLETIONS_PATH).mock(
|
||||
return_value=httpx.Response(status_code=200, json=json_response)
|
||||
)
|
||||
|
||||
|
|
@ -441,7 +442,7 @@ class TestBackend:
|
|||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
mock_api.post("/v1/chat/completions").mock(return_value=mock_response)
|
||||
mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=mock_response)
|
||||
|
||||
provider = ProviderConfig(
|
||||
name="provider_name",
|
||||
|
|
@ -470,13 +471,29 @@ class TestBackend:
|
|||
|
||||
class TestMistralRetry:
|
||||
@staticmethod
|
||||
def _create_test_backend() -> MistralBackend:
|
||||
def _create_test_backend(
|
||||
timeout: float = 720.0, retry_max_elapsed_time: float = 300.0
|
||||
) -> MistralBackend:
|
||||
provider = ProviderConfig(
|
||||
name="test_provider",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="API_KEY",
|
||||
)
|
||||
return MistralBackend(provider=provider)
|
||||
return MistralBackend(
|
||||
provider=provider,
|
||||
timeout=timeout,
|
||||
retry_max_elapsed_time=retry_max_elapsed_time,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_fast_http_retry_config() -> RetryConfig:
|
||||
return RetryConfig(
|
||||
strategy="backoff",
|
||||
backoff=BackoffStrategy(
|
||||
initial_interval=1, max_interval=1, exponent=1, max_elapsed_time=10000
|
||||
),
|
||||
retry_connection_errors=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_creation_includes_timeout_and_retry_config(self):
|
||||
|
|
@ -492,6 +509,61 @@ class TestMistralRetry:
|
|||
assert call_kwargs["retry_config"] is backend._retry_config
|
||||
assert "async_client" in call_kwargs
|
||||
|
||||
def test_retry_budget_uses_explicit_config(self):
|
||||
backend = self._create_test_backend(
|
||||
timeout=7200.0, retry_max_elapsed_time=1234.0
|
||||
)
|
||||
|
||||
assert backend._timeout == 7200.0
|
||||
assert backend._retry_config.backoff.max_elapsed_time == 1234000
|
||||
|
||||
def test_create_backend_passes_retry_budget(self):
|
||||
provider = ProviderConfig(
|
||||
name="test_provider",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
|
||||
backend = create_backend(
|
||||
provider=provider, timeout=7200.0, retry_max_elapsed_time=1234.0
|
||||
)
|
||||
|
||||
assert isinstance(backend, MistralBackend)
|
||||
assert backend._timeout == 7200.0
|
||||
assert backend._retry_config.backoff.max_elapsed_time == 1234000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_retries_retryable_http_error(self):
|
||||
with respx.mock(base_url="https://api.mistral.ai") as mock_api:
|
||||
route = mock_api.post("/v1/chat/completions").mock(
|
||||
side_effect=[
|
||||
httpx.Response(status_code=502, text="Bad Gateway"),
|
||||
httpx.Response(
|
||||
status_code=200, json=MISTRAL_SIMPLE_CONVERSATION_PARAMS[0][1]
|
||||
),
|
||||
]
|
||||
)
|
||||
backend = self._create_test_backend()
|
||||
backend._retry_config = self._build_fast_http_retry_config()
|
||||
model = ModelConfig(
|
||||
name="model_name", provider="test_provider", alias="model_alias"
|
||||
)
|
||||
messages = [LLMMessage(role=Role.user, content="Just say hi")]
|
||||
|
||||
result = await backend.complete(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=0.2,
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
tool_choice=None,
|
||||
extra_headers=None,
|
||||
)
|
||||
|
||||
assert result.message.content == "Some content"
|
||||
assert route.call_count == 2
|
||||
|
||||
|
||||
class TestMistralMapperPrepareMessage:
|
||||
"""Tests for MistralMapper.prepare_message thinking-block handling.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from vibe.core.llm.backend._image import (
|
|||
to_base64,
|
||||
to_data_uri,
|
||||
)
|
||||
from vibe.core.types import ImageAttachment
|
||||
from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
|
||||
|
|
@ -24,7 +24,9 @@ def _clear_cache() -> None:
|
|||
def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment:
|
||||
p = tmp_path / name
|
||||
p.write_bytes(PNG_BYTES)
|
||||
return ImageAttachment(path=p, alias=name, mime_type="image/png")
|
||||
return ImageAttachment(
|
||||
source=FileImageSource(path=p), alias=name, mime_type="image/png"
|
||||
)
|
||||
|
||||
|
||||
def test_repeated_calls_hit_cache_and_skip_disk(tmp_path: Path) -> None:
|
||||
|
|
@ -46,13 +48,14 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None:
|
|||
to_base64(att)
|
||||
assert _encode_cached.cache_info().misses == 1
|
||||
|
||||
assert isinstance(att.source, FileImageSource)
|
||||
new_bytes = PNG_BYTES + b"\x01"
|
||||
att.path.write_bytes(new_bytes)
|
||||
att.source.path.write_bytes(new_bytes)
|
||||
# Force a distinct mtime even on coarse-resolution filesystems.
|
||||
import os
|
||||
|
||||
stat = att.path.stat()
|
||||
os.utime(att.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||
stat = att.source.path.stat()
|
||||
os.utime(att.source.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
|
||||
|
||||
refreshed = to_base64(att)
|
||||
assert refreshed == base64.b64encode(new_bytes).decode("ascii")
|
||||
|
|
@ -61,8 +64,23 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None:
|
|||
|
||||
def test_missing_file_raises_image_read_error(tmp_path: Path) -> None:
|
||||
att = ImageAttachment(
|
||||
path=tmp_path / "nope.png", alias="nope.png", mime_type="image/png"
|
||||
source=FileImageSource(path=tmp_path / "nope.png"),
|
||||
alias="nope.png",
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
with pytest.raises(ImageReadError):
|
||||
to_data_uri(att)
|
||||
|
||||
|
||||
def test_inline_data_is_returned_without_touching_disk() -> None:
|
||||
encoded = base64.b64encode(PNG_BYTES).decode("ascii")
|
||||
att = ImageAttachment(
|
||||
source=InlineImageSource(data=encoded),
|
||||
alias="pasted.png",
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
assert to_base64(att) == encoded
|
||||
assert to_data_uri(att) == f"data:image/png;base64,{encoded}"
|
||||
assert _encode_cached.cache_info().misses == 0
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from vibe.core.llm.backend.generic import OpenAIAdapter
|
|||
from vibe.core.llm.backend.mistral import MistralMapper
|
||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
EXPECTED_B64 = base64.b64encode(PNG_BYTES).decode("ascii")
|
||||
|
|
@ -25,7 +25,9 @@ EXPECTED_DATA_URI = f"data:image/png;base64,{EXPECTED_B64}"
|
|||
def image_attachment(tmp_path: Path) -> ImageAttachment:
|
||||
path = tmp_path / "shot.png"
|
||||
path.write_bytes(PNG_BYTES)
|
||||
return ImageAttachment(path=path, alias="shot.png", mime_type="image/png")
|
||||
return ImageAttachment(
|
||||
source=FileImageSource(path=path), alias="shot.png", mime_type="image/png"
|
||||
)
|
||||
|
||||
|
||||
def _user_message(image: ImageAttachment) -> LLMMessage:
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ import respx
|
|||
from tests.backend.data import Chunk, JsonResponse, ResultData, Url
|
||||
from tests.backend.data.openai_responses import (
|
||||
COMMENTARY_CONVERSATION_PARAMS,
|
||||
OPENAI_RESPONSES_TEST_BASE_URL,
|
||||
SIMPLE_CONVERSATION_PARAMS,
|
||||
STREAMED_COMMENTARY_CONVERSATION_PARAMS,
|
||||
STREAMED_SIMPLE_CONVERSATION_PARAMS,
|
||||
STREAMED_TOOL_CONVERSATION_PARAMS,
|
||||
TOOL_CONVERSATION_PARAMS,
|
||||
)
|
||||
from tests.constants import OPENAI_BASE_URL, OPENAI_RESPONSES_PATH
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.llm.backend.generic import GenericBackend
|
||||
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
|
||||
|
|
@ -55,7 +55,7 @@ def model():
|
|||
return _make_model()
|
||||
|
||||
|
||||
def _make_provider(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> ProviderConfig:
|
||||
def _make_provider(base_url: Url = OPENAI_BASE_URL) -> ProviderConfig:
|
||||
return ProviderConfig(
|
||||
name="openai",
|
||||
api_base=f"{base_url}/v1",
|
||||
|
|
@ -68,7 +68,7 @@ def _make_model() -> ModelConfig:
|
|||
return ModelConfig(name="gpt-4o", provider="openai", alias="gpt-4o")
|
||||
|
||||
|
||||
def _make_backend(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> GenericBackend:
|
||||
def _make_backend(base_url: Url = OPENAI_BASE_URL) -> GenericBackend:
|
||||
return GenericBackend(provider=_make_provider(base_url))
|
||||
|
||||
|
||||
|
|
@ -1229,7 +1229,7 @@ class TestGenericBackendIntegration:
|
|||
self, base_url: Url, json_response: JsonResponse, result_data: ResultData
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/responses").mock(
|
||||
mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||
return_value=httpx.Response(status_code=200, json=json_response)
|
||||
)
|
||||
backend = _make_backend(base_url)
|
||||
|
|
@ -1261,7 +1261,7 @@ class TestGenericBackendIntegration:
|
|||
self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData]
|
||||
):
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
mock_api.post("/v1/responses").mock(
|
||||
mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
|
||||
|
|
@ -1289,9 +1289,9 @@ class TestGenericBackendIntegration:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_payload_includes_stream_flag(self):
|
||||
base_url = OPENAI_RESPONSES_TEST_BASE_URL
|
||||
base_url = OPENAI_BASE_URL
|
||||
with respx.mock(base_url=base_url) as mock_api:
|
||||
route = mock_api.post("/v1/responses").mock(
|
||||
route = mock_api.post(OPENAI_RESPONSES_PATH).mock(
|
||||
return_value=httpx.Response(
|
||||
status_code=200,
|
||||
stream=httpx.ByteStream(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue