97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
478 lines
15 KiB
Python
478 lines
15 KiB
Python
"""Focused coverage for the v2 Cohere clients and mode handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import builtins
|
|
import importlib
|
|
import runpy
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import cohere
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from pydantic import BaseModel
|
|
|
|
from instructor.v2.core.client import AsyncInstructor, Instructor
|
|
from instructor.v2.core.errors import (
|
|
ClientError,
|
|
ConfigurationError,
|
|
ModeError,
|
|
ResponseParsingError,
|
|
)
|
|
from instructor.v2.core.mode import Mode, reset_deprecated_mode_warnings
|
|
from instructor.v2.core.providers import Provider
|
|
from instructor.v2.dsl.iterable import IterableModel
|
|
from instructor.v2.providers.cohere.client import from_cohere
|
|
from instructor.v2.providers.cohere.handlers import (
|
|
CohereJSONSchemaHandler,
|
|
CohereMDJSONHandler,
|
|
CohereToolsHandler,
|
|
_convert_messages_to_cohere_v1,
|
|
_extract_text_from_response,
|
|
_extract_text_from_stream_chunk,
|
|
)
|
|
from instructor.v2.providers.cohere.templating import process_message
|
|
from tests.coverage._streams import async_items
|
|
|
|
|
|
class Answer(BaseModel):
|
|
answer: int
|
|
|
|
|
|
@pytest.fixture
|
|
def http_client() -> Iterator[httpx.Client]:
|
|
with httpx.Client(trust_env=False) as client:
|
|
yield client
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def async_http_client() -> AsyncIterator[httpx.AsyncClient]:
|
|
async with httpx.AsyncClient(trust_env=False) as client:
|
|
yield client
|
|
|
|
|
|
def _v1_response(text: str) -> cohere.NonStreamedChatResponse:
|
|
return cohere.NonStreamedChatResponse(text=text, finish_reason="COMPLETE")
|
|
|
|
|
|
def _v2_response(text: str) -> cohere.ChatResponse:
|
|
return cohere.ChatResponse(
|
|
id="response-1",
|
|
finish_reason="COMPLETE",
|
|
message=cohere.AssistantMessageResponse(
|
|
content=[cohere.TextAssistantMessageResponseContentItem(text=text)]
|
|
),
|
|
)
|
|
|
|
|
|
def _v2_delta(text: str | None) -> cohere.ContentDeltaV2ChatStreamResponse:
|
|
return cohere.ContentDeltaV2ChatStreamResponse(
|
|
delta=cohere.ChatContentDeltaEventDelta(
|
|
message=cohere.ChatContentDeltaEventDeltaMessage(
|
|
content=cohere.ChatContentDeltaEventDeltaMessageContent(text=text)
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def _stream_events(answer: int) -> list[Any]:
|
|
return [
|
|
_v2_delta('{"tasks":['),
|
|
cohere.ContentEndV2ChatStreamResponse(),
|
|
_v2_delta(f'{{"answer":{answer}}}]}}'),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("client_type", "response_factory", "version"),
|
|
[
|
|
(cohere.Client, _v1_response, "v1"),
|
|
(cohere.ClientV2, _v2_response, "v2"),
|
|
],
|
|
)
|
|
def test_sync_cohere_clients_retry_and_stream(
|
|
client_type: type[Any],
|
|
response_factory: Any,
|
|
version: str,
|
|
http_client: httpx.Client,
|
|
) -> None:
|
|
raw = client_type(
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:9",
|
|
httpx_client=http_client,
|
|
)
|
|
raw.chat = Mock(
|
|
side_effect=[
|
|
response_factory('{"answer":"wrong"}'),
|
|
response_factory('{"answer":7}'),
|
|
]
|
|
)
|
|
raw.chat_stream = Mock(return_value=iter(_stream_events(8)))
|
|
|
|
wrapped = from_cohere(raw, mode=Mode.TOOLS)
|
|
|
|
assert isinstance(wrapped, Instructor)
|
|
assert wrapped.provider is Provider.COHERE
|
|
assert wrapped.mode is Mode.TOOLS
|
|
assert wrapped.kwargs["_cohere_client_version"] == version
|
|
|
|
result = wrapped.create(
|
|
response_model=Answer,
|
|
messages=[{"role": "user", "content": "Give the answer"}],
|
|
model_name="command-r",
|
|
max_retries=1,
|
|
)
|
|
streamed = list(
|
|
wrapped.create_iterable(
|
|
response_model=Answer,
|
|
messages=[{"role": "user", "content": "Stream the answer"}],
|
|
model_name="command-r",
|
|
max_retries=0,
|
|
)
|
|
)
|
|
|
|
assert result.model_dump() == {"answer": 7}
|
|
assert streamed == [Answer(answer=8)]
|
|
assert raw.chat.call_count == 2
|
|
assert raw.chat_stream.call_count == 1
|
|
retry_kwargs = raw.chat.call_args_list[1].kwargs
|
|
stream_kwargs = raw.chat_stream.call_args.kwargs
|
|
assert retry_kwargs["model"] == stream_kwargs["model"] == "command-r"
|
|
assert "model_name" not in retry_kwargs
|
|
assert "stream" not in stream_kwargs
|
|
if version == "v1":
|
|
assert "messages" not in retry_kwargs
|
|
assert "Correct the following JSON response" in retry_kwargs["message"]
|
|
assert retry_kwargs["chat_history"][-1]["message"] == "Give the answer"
|
|
assert stream_kwargs["message"] == "Stream the answer"
|
|
else:
|
|
assert (
|
|
"Correct the following JSON response"
|
|
in retry_kwargs["messages"][-1]["content"]
|
|
)
|
|
assert stream_kwargs["messages"][-1]["content"] == "Stream the answer"
|
|
|
|
|
|
def test_cohere_legacy_tools_mode_warns_and_normalizes(
|
|
http_client: httpx.Client,
|
|
) -> None:
|
|
raw = cohere.ClientV2(
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:9",
|
|
httpx_client=http_client,
|
|
)
|
|
reset_deprecated_mode_warnings()
|
|
|
|
with pytest.warns(DeprecationWarning, match=r"Mode\.COHERE_TOOLS.*Mode\.TOOLS"):
|
|
wrapped = from_cohere(raw, mode=Mode.COHERE_TOOLS)
|
|
|
|
assert wrapped.mode is Mode.TOOLS
|
|
reset_deprecated_mode_warnings()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
{"role": "user", "content": "{{ name }}"},
|
|
{"role": "user", "message": [{"type": "text", "text": "{{ name }}"}]},
|
|
{"role": "user", "message": None},
|
|
],
|
|
)
|
|
def test_cohere_templating_leaves_non_string_messages_unchanged(
|
|
message: dict[str, Any],
|
|
) -> None:
|
|
calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def apply_template(value: str, context: dict[str, Any]) -> str:
|
|
calls.append((value, context))
|
|
return value
|
|
|
|
result = process_message(message, {"name": "Ada"}, apply_template)
|
|
|
|
assert result is message
|
|
assert calls == []
|
|
|
|
|
|
def test_cohere_templating_applies_context_to_a_string_message() -> None:
|
|
message = {"role": "user", "message": "Hello, {{ name }}"}
|
|
|
|
result = process_message(
|
|
message,
|
|
{"name": "Ada"},
|
|
lambda value, context: value.replace("{{ name }}", context["name"]),
|
|
)
|
|
|
|
assert result == {"role": "user", "message": "Hello, Ada"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("client_type", "response_factory", "version"),
|
|
[
|
|
(cohere.AsyncClient, _v1_response, "v1"),
|
|
(cohere.AsyncClientV2, _v2_response, "v2"),
|
|
],
|
|
)
|
|
async def test_async_cohere_clients_retry_and_stream(
|
|
client_type: type[Any],
|
|
response_factory: Any,
|
|
version: str,
|
|
async_http_client: httpx.AsyncClient,
|
|
) -> None:
|
|
raw = client_type(
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:9",
|
|
httpx_client=async_http_client,
|
|
)
|
|
responses = [
|
|
response_factory('{"answer":"wrong"}'),
|
|
response_factory('{"answer":9}'),
|
|
]
|
|
raw.chat = AsyncMock(side_effect=responses)
|
|
raw.chat_stream = Mock(return_value=async_items(_stream_events(10)))
|
|
|
|
wrapped = from_cohere(raw, mode=Mode.TOOLS)
|
|
|
|
assert isinstance(wrapped, AsyncInstructor)
|
|
assert wrapped.provider is Provider.COHERE
|
|
assert wrapped.mode is Mode.TOOLS
|
|
assert wrapped.kwargs["_cohere_client_version"] == version
|
|
|
|
result = await wrapped.create(
|
|
response_model=Answer,
|
|
messages=[{"role": "user", "content": "Give the answer"}],
|
|
model_name="command-r",
|
|
max_retries=1,
|
|
)
|
|
streamed = [
|
|
item
|
|
async for item in wrapped.create_iterable(
|
|
response_model=Answer,
|
|
messages=[{"role": "user", "content": "Stream the answer"}],
|
|
model_name="command-r",
|
|
max_retries=0,
|
|
)
|
|
]
|
|
|
|
assert result.model_dump() == {"answer": 9}
|
|
assert streamed == [Answer(answer=10)]
|
|
assert raw.chat.await_count == 2
|
|
assert raw.chat_stream.call_count == 1
|
|
retry_kwargs = raw.chat.call_args_list[1].kwargs
|
|
stream_kwargs = raw.chat_stream.call_args.kwargs
|
|
assert retry_kwargs["model"] == stream_kwargs["model"] == "command-r"
|
|
assert "stream" not in stream_kwargs
|
|
if version == "v1":
|
|
assert "Correct the following JSON response" in retry_kwargs["message"]
|
|
assert stream_kwargs["message"] == "Stream the answer"
|
|
else:
|
|
assert (
|
|
"Correct the following JSON response"
|
|
in retry_kwargs["messages"][-1]["content"]
|
|
)
|
|
assert stream_kwargs["messages"][-1]["content"] == "Stream the answer"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_cohere_client_accepts_a_non_awaitable_chat_fallback(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
async_http_client: httpx.AsyncClient,
|
|
) -> None:
|
|
raw = cohere.AsyncClientV2(
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:9",
|
|
httpx_client=async_http_client,
|
|
)
|
|
chat = Mock(return_value=_v2_response('{"answer":13}'))
|
|
monkeypatch.setattr(raw, "chat", chat)
|
|
|
|
wrapped = from_cohere(raw, mode=Mode.TOOLS)
|
|
result = await wrapped.create(
|
|
response_model=Answer,
|
|
messages=[{"role": "user", "content": "Give the answer"}],
|
|
model_name="command-r",
|
|
max_retries=0,
|
|
)
|
|
|
|
assert result.model_dump() == {"answer": 13}
|
|
chat.assert_called_once()
|
|
|
|
|
|
def test_cohere_factory_rejects_bad_mode_and_client(
|
|
http_client: httpx.Client,
|
|
) -> None:
|
|
raw = cohere.ClientV2(
|
|
api_key="test-key",
|
|
base_url="http://127.0.0.1:9",
|
|
httpx_client=http_client,
|
|
)
|
|
|
|
with pytest.raises(ModeError, match="Invalid mode 'json_mode'") as mode_error:
|
|
from_cohere(raw, mode=Mode.JSON)
|
|
|
|
assert mode_error.value.provider == "cohere"
|
|
assert "tool_call" in mode_error.value.valid_modes
|
|
with pytest.raises(ClientError, match="Got: object"):
|
|
from_cohere(cast(cohere.ClientV2, object()), mode=Mode.TOOLS)
|
|
|
|
|
|
def test_cohere_imports_fail_cleanly_without_optional_sdk(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
real_import = builtins.__import__
|
|
client_path = Path(
|
|
importlib.import_module("instructor.v2.providers.cohere.client").__file__
|
|
)
|
|
package_path = client_path.with_name("__init__.py")
|
|
|
|
def block_cohere(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
if name == "cohere":
|
|
raise ImportError("cohere is unavailable")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(builtins, "__import__", block_cohere)
|
|
client_globals = runpy.run_path(str(client_path))
|
|
|
|
assert client_globals["cohere"] is None
|
|
with pytest.raises(ClientError, match="cohere is not installed"):
|
|
client_globals["from_cohere"](object())
|
|
|
|
def block_client(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
if name == "instructor.v2.providers.cohere.client":
|
|
raise ImportError("cohere client is unavailable")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(builtins, "__import__", block_client)
|
|
package_globals = runpy.run_path(str(package_path))
|
|
|
|
assert package_globals["from_cohere"] is None
|
|
|
|
|
|
def test_cohere_v1_conversion_and_invalid_response_shapes() -> None:
|
|
converted = _convert_messages_to_cohere_v1(
|
|
{
|
|
"_cohere_client_version": "v1",
|
|
"messages": [
|
|
{"role": "user", "content": "First"},
|
|
{"role": "assistant", "content": "Second"},
|
|
{"role": "user", "content": "Third"},
|
|
],
|
|
"model_name": "command-r",
|
|
"strict": True,
|
|
}
|
|
)
|
|
|
|
assert converted == {
|
|
"message": "Third",
|
|
"chat_history": [
|
|
{"role": "user", "message": "First"},
|
|
{"role": "assistant", "message": "Second"},
|
|
],
|
|
"model": "command-r",
|
|
}
|
|
with pytest.raises(ResponseParsingError, match="Could not extract text"):
|
|
_extract_text_from_response(object())
|
|
with pytest.raises(ResponseParsingError, match="Could not extract text"):
|
|
_extract_text_from_response(
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content=[
|
|
SimpleNamespace(type="thinking", text="ignored"),
|
|
SimpleNamespace(type="image"),
|
|
]
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def test_cohere_stream_chunk_shapes_and_sync_extractor() -> None:
|
|
v1_text = cohere.TextGenerationStreamedChatResponse(text="v1 text")
|
|
v2_text = _v2_delta("v2 text")
|
|
empty_delta = _v2_delta(None)
|
|
content_end = cohere.ContentEndV2ChatStreamResponse()
|
|
|
|
assert _extract_text_from_stream_chunk(v1_text) == "v1 text"
|
|
assert _extract_text_from_stream_chunk(v2_text) == "v2 text"
|
|
assert _extract_text_from_stream_chunk(empty_delta) is None
|
|
assert _extract_text_from_stream_chunk(content_end) is None
|
|
assert list(
|
|
CohereToolsHandler().extract_streaming_json(
|
|
[content_end, v1_text, empty_delta, v2_text]
|
|
)
|
|
) == ["v1 text", "v2 text"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cohere_tools_stream_parser_forwards_context_and_strictness() -> None:
|
|
handler = CohereToolsHandler()
|
|
iterable_answer = IterableModel(Answer)
|
|
sync_values = list(
|
|
handler.parse_response(
|
|
_stream_events(11),
|
|
iterable_answer,
|
|
validation_context=None,
|
|
strict=None,
|
|
stream=True,
|
|
is_async=False,
|
|
)
|
|
)
|
|
async_values = [
|
|
item
|
|
async for item in handler.parse_response(
|
|
async_items(_stream_events(12)),
|
|
iterable_answer,
|
|
validation_context={"request_id": "req-1"},
|
|
strict=True,
|
|
stream=True,
|
|
is_async=True,
|
|
)
|
|
]
|
|
|
|
assert sync_values == [Answer(answer=11)]
|
|
assert async_values == [Answer(answer=12)]
|
|
with pytest.raises(ConfigurationError, match="Iterable or Partial"):
|
|
handler.parse_response(iter(_stream_events(13)), Answer, stream=True)
|
|
|
|
|
|
def test_cohere_tools_falls_back_when_tool_call_has_no_parameters() -> None:
|
|
response = SimpleNamespace(
|
|
text='{"answer":14}', tool_calls=[SimpleNamespace(name="answer")]
|
|
)
|
|
|
|
assert CohereToolsHandler().parse_response(response, Answer) == Answer(answer=14)
|
|
|
|
|
|
@pytest.mark.parametrize("handler", [CohereJSONSchemaHandler(), CohereMDJSONHandler()])
|
|
def test_cohere_json_handlers_reask_v1_and_reject_streaming(handler: Any) -> None:
|
|
without_history = handler.handle_reask(
|
|
{"message": "Original question"}, object(), ValueError("invalid field")
|
|
)
|
|
with_history = handler.handle_reask(
|
|
{
|
|
"message": "Original question",
|
|
"chat_history": [{"role": "assistant", "message": "Earlier"}],
|
|
},
|
|
_v1_response('{"answer":"wrong"}'),
|
|
ValueError("invalid field"),
|
|
)
|
|
|
|
assert without_history["chat_history"] == [
|
|
{"role": "user", "message": "Original question"}
|
|
]
|
|
assert "invalid field" in without_history["message"]
|
|
assert "object object" in without_history["message"]
|
|
assert with_history["chat_history"][-1] == {
|
|
"role": "user",
|
|
"message": "Original question",
|
|
}
|
|
assert 'JSON:\n{"answer":"wrong"}' in with_history["message"]
|
|
with pytest.raises(ConfigurationError, match="Streaming is not supported"):
|
|
handler.parse_response(iter(_stream_events(15)), Answer, stream=True)
|