Files
pydantic-ai/tests/test_embeddings.py

2654 lines
121 KiB
Python

from __future__ import annotations
import importlib
import io
import json
import os
import re
import threading
from collections.abc import Iterator, Sequence
from copy import deepcopy
from dataclasses import dataclass
from decimal import Decimal
from importlib.util import find_spec
from pathlib import Path
from typing import Any, Literal, get_args
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlparse
import anyio
import httpx
import httpx2
import pytest
from opentelemetry.trace import StatusCode
from pytest_mock import MockerFixture
import pydantic_ai.models
from pydantic_ai._utils import BaseExceptionGroup
from pydantic_ai.embeddings import (
Embedder,
EmbeddingResult,
EmbeddingSettings,
EmbedInputType,
InstrumentedEmbeddingModel,
KnownEmbeddingModelName,
TestEmbeddingModel,
WrapperEmbeddingModel,
infer_embedding_model,
)
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UserError
from pydantic_ai.models.instrumented import InstrumentationSettings
from pydantic_ai.usage import RequestUsage
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsFloat, IsInt, IsList, IsStr, TestEnv, try_import
pytestmark = [
pytest.mark.usefixtures('allow_model_requests'),
]
with try_import() as logfire_imports_successful:
from logfire.testing import CaptureLogfire
with try_import() as openai_imports_successful:
from openai import AsyncOpenAI
from pydantic_ai.embeddings.openai import LatestOpenAIEmbeddingModelNames, OpenAIEmbeddingModel
from pydantic_ai.providers.openai import OpenAIProvider
with try_import() as cohere_imports_successful:
import cohere
from cohere import AsyncClientV2
from cohere.types.embed_by_type_response import EmbedByTypeResponse
from cohere.types.embed_by_type_response_embeddings import EmbedByTypeResponseEmbeddings
from pydantic_ai.embeddings import cohere as cohere_embeddings
from pydantic_ai.embeddings.cohere import (
CohereEmbeddingModel,
CohereEmbeddingSettings,
LatestCohereEmbeddingModelNames,
)
from pydantic_ai.providers.cohere import CohereProvider
with try_import() as bedrock_imports_successful:
from botocore.awsrequest import AWSPreparedRequest, AWSResponse, HTTPHeaders
from botocore.exceptions import ClientError, EndpointConnectionError, ReadTimeoutError
from urllib3 import HTTPResponse
from pydantic_ai.embeddings.bedrock import (
BedrockEmbeddingModel,
BedrockEmbeddingSettings,
LatestBedrockEmbeddingModelNames,
)
from pydantic_ai.providers.bedrock import BedrockProvider
with try_import() as google_imports_successful:
from pydantic_ai.embeddings.google import (
GoogleEmbeddingModel,
GoogleEmbeddingSettings,
LatestGoogleGLAEmbeddingModelNames,
LatestGoogleVertexEmbeddingModelNames,
)
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.google_cloud import GoogleCloudProvider
# `sentence_transformers` imports `torch`, and `voyageai` imports both when `sentence-transformers` is installed, which
# takes seconds: the tests that use them import them, so collecting this module stays cheap. See "Test cost" in
# `tests/AGENTS.md`.
voyageai_installed = find_spec('voyageai') is not None
sentence_transformers_installed = find_spec('sentence_transformers') is not None
@pytest.fixture(scope='module')
def voyageai_imported() -> None:
"""Pay for importing `voyageai` in shared setup, outside each test's time budget."""
importlib.import_module('pydantic_ai.embeddings.voyageai')
@pytest.fixture(scope='module')
def sentence_transformers_imported() -> None:
"""Pay for importing `sentence_transformers` and `torch` in shared setup, outside each test's time budget."""
importlib.import_module('pydantic_ai.embeddings.sentence_transformers')
@pytest.mark.skipif(not openai_imports_successful(), reason='openai not installed')
async def test_openai_embedding_model_blocks_requests_when_disabled():
model = OpenAIEmbeddingModel('text-embedding-3-small', provider=OpenAIProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.embed('hello', input_type='query')
@pytest.mark.skipif(not cohere_imports_successful(), reason='cohere not installed')
async def test_cohere_embedding_model_blocks_requests_when_disabled():
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.embed('hello', input_type='query')
@pytest.mark.skipif(not google_imports_successful(), reason='google not installed')
async def test_google_embedding_model_blocks_requests_when_disabled():
model = GoogleEmbeddingModel('gemini-embedding-001', provider=GoogleProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.embed('hello', input_type='query')
@pytest.mark.skipif(not bedrock_imports_successful(), reason='bedrock not installed')
async def test_bedrock_embedding_model_blocks_requests_when_disabled():
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=BedrockProvider(bedrock_client=MagicMock()))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.embed('hello', input_type='query')
@pytest.mark.skipif(not voyageai_installed, reason='voyageai not installed')
@pytest.mark.usefixtures('voyageai_imported')
async def test_voyageai_embedding_model_blocks_requests_when_disabled():
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-4', provider=VoyageAIProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.embed('hello', input_type='query')
@pytest.mark.skipif(not google_imports_successful(), reason='google not installed')
async def test_google_embedding_model_blocks_count_tokens_when_disabled():
model = GoogleEmbeddingModel('gemini-embedding-001', provider=GoogleProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.count_tokens('hello')
@pytest.mark.skipif(not cohere_imports_successful(), reason='cohere not installed')
async def test_cohere_embedding_model_blocks_count_tokens_when_disabled():
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key='test-key'))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await model.count_tokens('hello')
@pytest.mark.skipif(not openai_imports_successful(), reason='openai not installed')
async def test_embedder_blocks_requests_when_disabled():
"""Pins the guard on the public `Embedder` surface, which is what issue #6763 reports as leaking.
A guard that fires before the request is made can't be exercised through a VCR recording.
The per-model tests above prove each `embed()` guards; this proves the wrapper chain
(`Embedder` -> `InstrumentedEmbeddingModel` / `WrapperEmbeddingModel` -> concrete model)
surfaces the `RuntimeError` rather than swallowing or wrapping it.
"""
embedder = Embedder(OpenAIEmbeddingModel('text-embedding-3-small', provider=OpenAIProvider(api_key='test-key')))
with pydantic_ai.models.override_allow_model_requests(False):
with pytest.raises(RuntimeError, match='Model requests are not allowed'):
await embedder.embed_query('hello')
async def test_test_embedding_model_is_exempt_from_request_guard():
"""`ALLOW_MODEL_REQUESTS`'s docstring promises `TestEmbeddingModel` is unaffected; pin that promise.
Without this, adding the guard to `TestEmbeddingModel.embed` would break every user's test
suite while the whole file still passed, since the module-level `allow_model_requests`
fixture keeps the flag on for every other test here.
"""
embedder = Embedder(TestEmbeddingModel())
with pydantic_ai.models.override_allow_model_requests(False):
result = await embedder.embed_query('hello')
assert result.embeddings == snapshot([[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]])
async def test_test_embedding_model_counts_blank_input_as_zero_tokens():
"""Blank input reports no tokens on both methods.
The estimator behind them is shared with the other test models, and it counts blank text as one
token; `TestEmbeddingModel` guards that, so a blank input contributes nothing to the reported
usage instead of one phantom token per empty string in the batch.
"""
model = TestEmbeddingModel()
assert await model.count_tokens('') == snapshot(0)
result = await model.embed(['', 'hi there'], input_type='document')
assert result.usage.input_tokens == snapshot(2)
STSB_BERT_TINY_MODEL = 'sentence-transformers-testing/stsb-bert-tiny-safetensors'
# Pinned so a warm HF cache is served without revalidating files against the Hub.
# Keep in sync with the HF cache keys and warmup commands in .github/workflows/ci.yml;
# `test_stsb_model_pin_matches_ci` guards against drift.
STSB_BERT_TINY_REVISION = 'f3cb857cba53019a20df283396bcca179cf051a4'
def _hf_hub_unavailable(exc: BaseException) -> bool:
"""Whether an exception from a SentenceTransformer load means the HF Hub is
unavailable (worth skipping the real-model smoke tests) rather than a genuine
integration defect or a bad model pin (which must fail loudly). Shapes verified
against real outage and bad-pin probes.
"""
if isinstance(exc, httpx.TransportError):
# Transport-level httpx errors (e.g. connect timeouts) escape
# huggingface_hub 1.x unwrapped, and the only httpx traffic in a model
# load is Hub traffic. Status-level httpx errors are not outage proof.
return True
if isinstance(exc, RuntimeError):
# After a connection error, huggingface_hub 1.x retries on an httpx client
# it just closed, surfacing httpx's RuntimeError instead of its own error.
return 'client has been closed' in str(exc)
if type(exc).__module__.startswith('huggingface_hub'):
# huggingface_hub errors are OSError subclasses. Ones carrying an HTTP
# response are an outage only for transient statuses; 401/403/404 mean a
# bad pin or an auth problem. Ones without a response (e.g.
# LocalEntryNotFoundError) mean the Hub couldn't be reached at all.
status = getattr(getattr(exc, 'response', None), 'status_code', None)
return status is None or status == 429 or status >= 500
# transformers wraps connection failures in a plain OSError: "We couldn't
# connect to 'https://huggingface.co' to load the files [...]". Other OSError
# shapes (corrupt cache, invalid model identifier) must propagate.
return "couldn't connect" in str(exc).lower()
def test_hf_hub_unavailable_classifier():
"""Exercises the fixture's outage-classification guard on synthetic exceptions:
no HTTP is involved (hence no VCR), just shapes captured from real probes."""
class FakeResponse:
def __init__(self, status_code: int):
self.status_code = status_code
class FakeHubError(OSError):
def __init__(self, msg: str, status_code: int | None = None):
super().__init__(msg)
self.response = FakeResponse(status_code) if status_code is not None else None
FakeHubError.__module__ = 'huggingface_hub.errors'
# Hub unavailability: skip the real-model smoke tests.
assert _hf_hub_unavailable(httpx.ConnectTimeout('timed out'))
assert _hf_hub_unavailable(RuntimeError('Cannot send a request, as the client has been closed.'))
assert _hf_hub_unavailable(FakeHubError('504 Server Error', 504))
assert _hf_hub_unavailable(FakeHubError('429 Too Many Requests', 429))
assert _hf_hub_unavailable(FakeHubError('cannot find the requested files in the disk cache'))
assert _hf_hub_unavailable(OSError("We couldn't connect to 'https://huggingface.co' to load the files"))
# Anything else fails loudly: bad pins, auth problems, local defects.
assert not _hf_hub_unavailable(FakeHubError('401 Unauthorized', 401))
assert not _hf_hub_unavailable(FakeHubError('404 Repository Not Found', 404))
assert not _hf_hub_unavailable(
httpx.HTTPStatusError('404', request=httpx.Request('GET', 'https://x'), response=httpx.Response(404))
)
assert not _hf_hub_unavailable(RuntimeError('CUDA error: device-side assert triggered'))
assert not _hf_hub_unavailable(OSError('Unable to load weights from pytorch checkpoint file'))
assert not _hf_hub_unavailable(
OSError(
"xyz is not a local folder and is not a valid model identifier listed on 'https://huggingface.co/models'"
)
)
def test_stsb_model_pin_matches_ci():
"""Parses CI configuration only, no network or VCR involved: drift between this
pin and ci.yml silently reintroduces per-run Hub downloads, because the stale
cache key keeps exact-hitting, so CI never caches the new revision."""
ci_yml = Path(__file__).parent.parent / '.github' / 'workflows' / 'ci.yml'
if not ci_yml.is_file(): # pragma: lax no cover
pytest.skip('not running from a repo checkout')
stsb_lines = [line for line in ci_yml.read_text().splitlines() if 'stsb-bert-tiny' in line]
cache_keys = [line for line in stsb_lines if 'key:' in line]
warmups = [line for line in stsb_lines if 'snapshot_download' in line]
assert len(cache_keys) >= 2, 'expected a model cache key in both test jobs in ci.yml'
assert len(warmups) >= 2, 'expected a model warmup command in both test jobs in ci.yml'
assert all(STSB_BERT_TINY_MODEL in line for line in warmups)
for line in cache_keys + warmups:
assert STSB_BERT_TINY_REVISION in line, f'stale or missing revision pin in ci.yml: {line.strip()}'
stray = {sha for line in stsb_lines for sha in re.findall(r'[0-9a-f]{40}', line)} - {STSB_BERT_TINY_REVISION}
assert not stray, f'stale revision pins left in ci.yml: {stray}'
@pytest.mark.skipif(not openai_imports_successful(), reason='OpenAI not installed')
@pytest.mark.vcr
class TestOpenAI:
@pytest.fixture
def embedder(self, openai_api_key: str) -> Embedder:
return Embedder(OpenAIEmbeddingModel('text-embedding-3-small', provider=OpenAIProvider(api_key=openai_api_key)))
async def test_infer_model(self, openai_api_key: str):
with patch.dict(os.environ, {'OPENAI_API_KEY': openai_api_key}):
model = infer_embedding_model('openai:text-embedding-3-small')
assert isinstance(model, OpenAIEmbeddingModel)
assert model.model_name == 'text-embedding-3-small'
assert model.system == 'openai'
assert model.base_url == 'https://api.openai.com/v1/'
async def test_infer_model_azure(self):
with patch.dict(
os.environ,
{
'AZURE_OPENAI_API_KEY': 'azure-openai-api-key',
'AZURE_OPENAI_ENDPOINT': 'https://project-id.openai.azure.com/',
'OPENAI_API_VERSION': '2023-03-15-preview',
},
):
model = infer_embedding_model('azure:text-embedding-3-small')
assert isinstance(model, OpenAIEmbeddingModel)
assert model.model_name == 'text-embedding-3-small'
assert model.system == 'azure'
assert urlparse(model.base_url).hostname == 'project-id.openai.azure.com'
assert await model.max_input_tokens() is None
with pytest.raises(UserError, match='Counting tokens is not supported for non-OpenAI embedding models'):
await model.count_tokens('Hello, world!')
async def test_infer_model_gateway(self):
with patch.dict(
os.environ,
{
'PYDANTIC_AI_GATEWAY_API_KEY': 'test-api-key',
'PYDANTIC_AI_GATEWAY_BASE_URL': 'https://gateway.pydantic.dev/proxy',
},
):
model = infer_embedding_model('gateway/openai:text-embedding-3-small')
assert isinstance(model, OpenAIEmbeddingModel)
assert model.model_name == 'text-embedding-3-small'
assert model.system == 'openai'
assert urlparse(model.base_url).hostname == 'gateway.pydantic.dev'
async def test_infer_model_vllm(self):
with patch.dict(os.environ, {'VLLM_BASE_URL': 'http://localhost:8000/v1'}):
model = infer_embedding_model('vllm:intfloat/e5-mistral-7b-instruct')
assert isinstance(model, OpenAIEmbeddingModel)
assert model.model_name == 'intfloat/e5-mistral-7b-instruct'
assert model.system == 'vllm'
assert model.base_url == 'http://localhost:8000/v1/'
async def test_query(self, embedder: Embedder):
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='text-embedding-3-small',
timestamp=IsDatetime(),
provider_name='openai',
)
)
assert result.cost().total_price == snapshot(Decimal('8E-8'))
async def test_documents(self, embedder: Embedder):
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=2),
inputs=['hello', 'world'],
input_type='document',
usage=RequestUsage(input_tokens=2),
model_name='text-embedding-3-small',
timestamp=IsDatetime(),
provider_name='openai',
)
)
assert result.cost().total_price == snapshot(Decimal('4E-8'))
async def test_max_input_tokens(self, embedder: Embedder):
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(8192)
async def test_count_tokens(self, embedder: Embedder):
count = await embedder.count_tokens('Hello, world!')
assert count == snapshot(4)
async def test_embed_error(self, openai_api_key: str):
model = OpenAIEmbeddingModel('nonexistent', provider=OpenAIProvider(api_key=openai_api_key))
embedder = Embedder(model)
with pytest.raises(ModelHTTPError, match='model_not_found'):
await embedder.embed_query('Hello, world!')
async def test_response_with_no_usage(self):
mock_client = AsyncMock()
mock_embedding_item = MagicMock()
mock_embedding_item.embedding = [0.1, 0.2, 0.3]
mock_response = MagicMock()
mock_response.data = [mock_embedding_item]
mock_response.usage = None
mock_response.model = 'test-model'
mock_client.embeddings.create.return_value = mock_response
provider = OpenAIProvider(openai_client=mock_client)
model = OpenAIEmbeddingModel('test-model', provider=provider)
result = await model.embed('test', input_type='query')
assert result == snapshot(
EmbeddingResult(
embeddings=[[0.1, 0.2, 0.3]],
inputs=['test'],
input_type='query',
model_name='test-model',
provider_name='openai',
timestamp=IsDatetime(),
)
)
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
async def test_instrumentation(self, openai_api_key: str, capfire: CaptureLogfire):
model = OpenAIEmbeddingModel('text-embedding-3-small', provider=OpenAIProvider(api_key=openai_api_key))
embedder = Embedder(model, instrument=True)
await embedder.embed_query('Hello, world!', settings={'dimensions': 128})
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
span = next(span for span in spans if 'embeddings' in span['name'])
assert span == snapshot(
{
'name': 'embeddings text-embedding-3-small',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': IsInt(),
'end_time': IsInt(),
'attributes': {
'gen_ai.operation.name': 'embeddings',
'gen_ai.provider.name': 'openai',
'gen_ai.request.model': 'text-embedding-3-small',
'input_type': 'query',
'server.address': 'api.openai.com',
'inputs_count': 1,
'embedding_settings': {'dimensions': 128},
'inputs': ['Hello, world!'],
'logfire.json_schema': {
'type': 'object',
'properties': {
'input_type': {'type': 'string'},
'inputs_count': {'type': 'integer'},
'embedding_settings': {'type': 'object'},
'inputs': {'type': ['array']},
'embeddings': {'type': 'array'},
},
},
'logfire.span_type': 'span',
'logfire.msg': 'embeddings text-embedding-3-small',
'gen_ai.usage.input_tokens': 4,
'operation.cost': 8e-08,
'gen_ai.response.model': 'text-embedding-3-small',
'gen_ai.embeddings.dimension.count': 128,
},
}
)
assert capfire.get_collected_metrics() == snapshot(
[
{
'name': 'gen_ai.client.token.usage',
'description': 'Measures number of input and output tokens used',
'unit': '{token}',
'data': {
'data_points': [
{
'attributes': {
'gen_ai.provider.name': 'openai',
'gen_ai.operation.name': 'embeddings',
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.response.model': 'text-embedding-3-small',
'gen_ai.token.type': 'input',
},
'start_time_unix_nano': IsInt(),
'time_unix_nano': IsInt(),
'count': 1,
'sum': 4,
'scale': 20,
'zero_count': 0,
'positive': {'offset': 2097151, 'bucket_counts': [1]},
'negative': {'offset': 0, 'bucket_counts': [0]},
'flags': 0,
'min': 4,
'max': 4,
'exemplars': [],
}
],
'aggregation_temporality': 1,
},
},
{
'name': 'operation.cost',
'description': 'Monetary cost',
'unit': '{USD}',
'data': {
'data_points': [
{
'attributes': {
'gen_ai.provider.name': 'openai',
'gen_ai.operation.name': 'embeddings',
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.response.model': 'text-embedding-3-small',
},
'start_time_unix_nano': IsInt(),
'time_unix_nano': IsInt(),
'count': 1,
'sum': 8e-08,
'scale': 20,
'zero_count': 0,
'positive': {'offset': -24720625, 'bucket_counts': [1]},
'negative': {'offset': 0, 'bucket_counts': [0]},
'flags': 0,
'min': 8e-08,
'max': 8e-08,
'exemplars': [],
}
],
'aggregation_temporality': 1,
},
},
]
)
@pytest.mark.skipif(not cohere_imports_successful(), reason='Cohere not installed')
def test_cohere_empty_billed_units():
"""An empty SDK billing object is a defensive case that a VCR recording cannot reliably produce."""
for billed_units in (cohere.ApiMetaBilledUnits(), cohere.ApiMetaBilledUnits(input_tokens=0, output_tokens=0)):
response = EmbedByTypeResponse(
id='test',
embeddings=EmbedByTypeResponseEmbeddings(float_=[[0.1]]),
meta=cohere.ApiMeta(billed_units=billed_units),
)
assert (
cohere_embeddings._map_usage( # pyright: ignore[reportPrivateUsage]
response, 'cohere', 'https://api.cohere.com', 'embed-v4.0'
)
== RequestUsage()
)
@pytest.mark.skipif(not cohere_imports_successful(), reason='Cohere not installed')
@pytest.mark.vcr
class TestCohere:
async def test_infer_model(self, co_api_key: str):
with patch.dict(os.environ, {'CO_API_KEY': co_api_key}):
model = infer_embedding_model('cohere:embed-v4.0')
assert isinstance(model, CohereEmbeddingModel)
assert model.model_name == 'embed-v4.0'
assert model.system == 'cohere'
assert model.base_url == 'https://api.cohere.com'
assert isinstance(model._provider, CohereProvider) # type: ignore[reportAttributeAccess]
async def test_query(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(
IsList(snapshot(-0.018445116), snapshot(0.008921167), snapshot(-0.0011377502), length=1536),
length=1,
),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='embed-v4.0',
timestamp=IsDatetime(),
provider_name='cohere',
provider_response_id='0728b136-9b30-4fb5-bf9a-2c7cf36d51d3',
)
)
assert result.cost().total_price == snapshot(Decimal('4.8E-7'))
async def test_documents(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=2),
inputs=['hello', 'world'],
input_type='document',
usage=RequestUsage(input_tokens=2),
model_name='embed-v4.0',
timestamp=IsDatetime(),
provider_name='cohere',
provider_response_id='199299d7-f43d-45af-903c-347fff81bbe4',
)
)
assert result.cost().total_price == snapshot(Decimal('2.4E-7'))
async def test_max_input_tokens(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(128000)
async def test_count_tokens(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
count = await embedder.count_tokens('Hello, world!')
assert count == snapshot(4)
async def test_embed_error(self, co_api_key: str):
model = CohereEmbeddingModel('nonexistent', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
with pytest.raises(ModelHTTPError, match='not found,'):
await embedder.embed_query('Hello, world!')
async def test_embed_connect_error(self):
"""`cohere` doesn't wrap transport failures; a cassette can't replay one, so a mock transport raises it."""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError('connection refused', request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
cohere_client = AsyncClientV2(api_key='test-key', base_url='http://localhost', httpx_client=http_client)
embedder = Embedder(
CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(cohere_client=cohere_client))
)
with pytest.raises(ModelAPIError) as exc_info:
await embedder.embed_query('Hello, world!')
assert type(exc_info.value) is ModelAPIError
assert exc_info.value.message == 'connection refused'
assert isinstance(exc_info.value.__cause__, httpx.ConnectError)
async def test_query_with_cohere_truncate(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
settings: CohereEmbeddingSettings = {'cohere_truncate': 'END'}
result = await embedder.embed_query('Hello, world!', settings=settings)
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='embed-v4.0',
timestamp=IsDatetime(),
provider_name='cohere',
provider_response_id=IsStr(),
)
)
async def test_query_with_truncate(self, co_api_key: str):
model = CohereEmbeddingModel('embed-v4.0', provider=CohereProvider(api_key=co_api_key))
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!', settings={'truncate': True})
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='embed-v4.0',
timestamp=IsDatetime(),
provider_name='cohere',
provider_response_id=IsStr(),
)
)
@pytest.mark.skipif(not voyageai_installed, reason='VoyageAI not installed')
@pytest.mark.usefixtures('voyageai_imported')
@pytest.mark.vcr
class TestVoyageAI:
async def test_infer_model(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
with patch.dict(os.environ, {'VOYAGE_API_KEY': voyage_api_key}):
model = infer_embedding_model('voyageai:voyage-3.5')
assert isinstance(model, VoyageAIEmbeddingModel)
assert model.model_name == 'voyage-3.5'
assert model.system == 'voyageai'
assert model.base_url == 'https://api.voyageai.com/v1'
assert isinstance(model._provider, VoyageAIProvider) # type: ignore[reportAttributeAccess]
async def test_query(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-3.5', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=3),
model_name='voyage-3.5',
timestamp=IsDatetime(),
provider_name='voyageai',
)
)
async def test_query_voyage_4(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-4', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=3),
model_name='voyage-4',
timestamp=IsDatetime(),
provider_name='voyageai',
)
)
async def test_documents(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-3.5', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=2),
inputs=['hello', 'world'],
input_type='document',
usage=RequestUsage(),
model_name='voyage-3.5',
timestamp=IsDatetime(),
provider_name='voyageai',
)
)
async def test_max_input_tokens(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-3.5', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(32000)
async def test_embed_error(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('nonexistent', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
with pytest.raises(ModelAPIError, match='not supported'):
await embedder.embed_query('Hello, world!')
async def test_query_with_truncate(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-3.5', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!', settings={'truncate': True})
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=3),
model_name='voyage-3.5',
timestamp=IsDatetime(),
provider_name='voyageai',
)
)
async def test_query_with_voyageai_input_type(self, voyage_api_key: str):
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel, VoyageAIEmbeddingSettings
from pydantic_ai.providers.voyageai import VoyageAIProvider
model = VoyageAIEmbeddingModel('voyage-3.5', provider=VoyageAIProvider(api_key=voyage_api_key))
embedder = Embedder(model)
settings: VoyageAIEmbeddingSettings = {'voyageai_input_type': 'none'}
result = await embedder.embed_query('Hello, world!', settings=settings)
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='voyage-3.5',
timestamp=IsDatetime(),
provider_name='voyageai',
)
)
@pytest.mark.skipif(not bedrock_imports_successful(), reason='Bedrock not installed')
@pytest.mark.vcr
class TestBedrock:
async def test_infer_model(self):
with patch.dict(
os.environ,
{
'AWS_ACCESS_KEY_ID': 'test-access-key',
'AWS_SECRET_ACCESS_KEY': 'test-secret-key',
'AWS_DEFAULT_REGION': 'us-east-1',
},
):
model = infer_embedding_model('bedrock:amazon.titan-embed-text-v2:0')
assert isinstance(model, BedrockEmbeddingModel)
assert model.model_name == 'amazon.titan-embed-text-v2:0'
assert model.system == 'bedrock'
assert model.base_url == 'https://bedrock-runtime.us-east-1.amazonaws.com'
async def test_titan_v1_minimal(self, bedrock_provider: BedrockProvider):
"""Test Titan V1 with default settings (fixed 1536 dimensions)."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v1', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='amazon.titan-embed-text-v1',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
)
)
async def test_titan_v2_minimal(self, bedrock_provider: BedrockProvider):
"""Test Titan V2 with default settings (1024 dimensions, normalize=True)."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=5),
)
)
async def test_titan_v2_with_dimensions(self, bedrock_provider: BedrockProvider):
"""Test Titan V2 with custom dimensions setting."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(dimensions=256))
result = await embedder.embed_query('Test embedding dimensions')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=256), length=1),
inputs=['Test embedding dimensions'],
input_type='query',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
)
)
async def test_titan_v2_with_normalize_false(self, bedrock_provider: BedrockProvider):
"""Test Titan V2 with normalize=False (override default)."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_titan_normalize=False))
result = await embedder.embed_query('Test normalization disabled')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Test normalization disabled'],
input_type='query',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
)
)
async def test_titan_v2_with_normalize_true(self, bedrock_provider: BedrockProvider):
"""Test Titan V2 with explicit normalize=True setting."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_titan_normalize=True))
result = await embedder.embed_query('Test explicit normalization')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Test explicit normalization'],
input_type='query',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
)
)
async def test_titan_v2_multiple_texts(self, bedrock_provider: BedrockProvider):
"""Test Titan V2 document embedding (multiple texts, sequential requests)."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
# Use max_concurrency=1 to ensure deterministic request order for VCRpy
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_max_concurrency=1))
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=2),
inputs=['hello', 'world'],
input_type='document',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
)
)
@pytest.mark.parametrize('max_concurrency', [0, -1])
async def test_titan_v2_rejects_invalid_max_concurrency(
self, bedrock_provider: BedrockProvider, max_concurrency: int
):
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_max_concurrency=max_concurrency))
with (
anyio.fail_after(1),
pytest.raises(UserError, match=f'bedrock_max_concurrency must be >= 1, got {max_concurrency}'),
):
await embedder.embed_query('hello')
async def test_cohere_v3_minimal(self, bedrock_provider: BedrockProvider):
"""Test Cohere V3 with default settings (1024 dimensions, truncate=NONE)."""
model = BedrockEmbeddingModel('cohere.embed-english-v3', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='cohere.embed-english-v3',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v3_with_input_type(self, bedrock_provider: BedrockProvider):
"""Test Cohere V3 with custom input_type setting."""
model = BedrockEmbeddingModel('cohere.embed-english-v3', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_input_type='classification'))
result = await embedder.embed_query('Test input type setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Test input type setting'],
input_type='query',
model_name='cohere.embed-english-v3',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v3_with_truncate(self, bedrock_provider: BedrockProvider):
"""Test Cohere V3 with custom truncate setting."""
model = BedrockEmbeddingModel('cohere.embed-english-v3', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_truncate='END'))
result = await embedder.embed_query('Test truncation setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Test truncation setting'],
input_type='query',
model_name='cohere.embed-english-v3',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=5),
provider_response_id=IsStr(),
)
)
async def test_cohere_v3_with_base_truncate(self, bedrock_provider: BedrockProvider):
"""Test Cohere V3 with base truncate=True setting (maps to END)."""
model = BedrockEmbeddingModel('cohere.embed-multilingual-v3', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(truncate=True))
result = await embedder.embed_query('Test base truncate setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Test base truncate setting'],
input_type='query',
model_name='cohere.embed-multilingual-v3',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=6),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_minimal(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with default settings (1536 dimensions, truncate=NONE)."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_with_dimensions(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with custom dimensions setting."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(dimensions=512))
result = await embedder.embed_query('Test dimensions setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=512), length=1),
inputs=['Test dimensions setting'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=3),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_with_max_tokens(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with max_tokens setting."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_max_tokens=256))
result = await embedder.embed_query('Test max tokens setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Test max tokens setting'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_with_input_type(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with custom input_type setting."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_input_type='clustering'))
result = await embedder.embed_query('Test input type setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Test input type setting'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_with_truncate(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with custom truncate setting."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_truncate='END'))
result = await embedder.embed_query('Test truncation setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Test truncation setting'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=4),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_with_truncate_start(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 with truncate START setting."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_truncate='START'))
result = await embedder.embed_query('Test truncation start setting')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Test truncation start setting'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=5),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_truncate_priority(self, bedrock_provider: BedrockProvider):
"""Test that bedrock_cohere_truncate takes precedence over base truncate."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
# Both settings provided - model-specific should win (START over END from truncate=True)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_cohere_truncate='START', truncate=True))
result = await embedder.embed_query('Test truncate priority')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Test truncate priority'],
input_type='query',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=3),
provider_response_id=IsStr(),
)
)
async def test_cohere_v4_batch_documents(self, bedrock_provider: BedrockProvider):
"""Test Cohere V4 batch embedding (multiple texts in single request)."""
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=2),
inputs=['hello', 'world'],
input_type='document',
model_name='cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=2),
provider_response_id=IsStr(),
)
)
async def test_nova_minimal(self, bedrock_provider: BedrockProvider):
"""Test Nova with default settings (3072 dimensions, truncate=NONE)."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=19),
)
)
async def test_nova_with_dimensions(self, bedrock_provider: BedrockProvider):
"""Test Nova with custom dimensions setting."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(dimensions=256))
result = await embedder.embed_query('Test Nova dimensions')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=256), length=1),
inputs=['Test Nova dimensions'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=18),
)
)
async def test_nova_with_truncate(self, bedrock_provider: BedrockProvider):
"""Test Nova with custom truncate setting."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(
model,
settings=BedrockEmbeddingSettings(bedrock_nova_truncate='END'),
)
result = await embedder.embed_query('Test Nova truncate')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Test Nova truncate'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=19),
)
)
async def test_nova_with_truncate_start(self, bedrock_provider: BedrockProvider):
"""Test Nova with truncate START setting."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(
model,
settings=BedrockEmbeddingSettings(bedrock_nova_truncate='START'),
)
result = await embedder.embed_query('Test Nova truncate start')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Test Nova truncate start'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=20),
)
)
async def test_nova_with_base_truncate(self, bedrock_provider: BedrockProvider):
"""Test Nova with base truncate=True setting (maps to END)."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(model, settings=BedrockEmbeddingSettings(truncate=True))
result = await embedder.embed_query('Test base truncate')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Test base truncate'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=19),
)
)
async def test_nova_with_embedding_purpose(self, bedrock_provider: BedrockProvider):
"""Test Nova with custom embedding_purpose setting."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(
model,
settings=BedrockEmbeddingSettings(bedrock_nova_embedding_purpose='TEXT_RETRIEVAL'),
)
result = await embedder.embed_query('Test Nova settings')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Test Nova settings'],
input_type='query',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=22),
)
)
async def test_nova_multiple_texts(self, bedrock_provider: BedrockProvider):
"""Test Nova document embedding (multiple texts, sequential requests)."""
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
# Use max_concurrency=1 to ensure deterministic request order for VCRpy
embedder = Embedder(model, settings=BedrockEmbeddingSettings(bedrock_max_concurrency=1))
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=2),
inputs=['hello', 'world'],
input_type='document',
model_name='amazon.nova-2-multimodal-embeddings-v1:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=6),
)
)
async def test_titan_v1_max_input_tokens(self, bedrock_provider: BedrockProvider):
model = BedrockEmbeddingModel('amazon.titan-embed-text-v1', provider=bedrock_provider)
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(8192)
async def test_titan_v2_max_input_tokens(self, bedrock_provider: BedrockProvider):
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(8192)
async def test_cohere_v3_max_input_tokens(self, bedrock_provider: BedrockProvider):
model = BedrockEmbeddingModel('cohere.embed-english-v3', provider=bedrock_provider)
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(512)
async def test_cohere_v4_max_input_tokens(self, bedrock_provider: BedrockProvider):
model = BedrockEmbeddingModel('cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(128000)
async def test_nova_max_input_tokens(self, bedrock_provider: BedrockProvider):
model = BedrockEmbeddingModel('amazon.nova-2-multimodal-embeddings-v1:0', provider=bedrock_provider)
embedder = Embedder(model)
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(8192)
async def test_base_url_property(self, bedrock_provider: BedrockProvider):
"""Test that base_url property returns the endpoint URL."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
assert model.base_url is not None
assert isinstance(model.base_url, str)
async def test_regional_prefix_model_name(self, bedrock_provider: BedrockProvider):
"""Test model with regional prefix (e.g., us.amazon.titan-embed-text-v2:0) is handled correctly."""
model = BedrockEmbeddingModel('us.amazon.titan-embed-text-v2:0', provider=bedrock_provider)
# Model name preserves the regional prefix
assert model.model_name == 'us.amazon.titan-embed-text-v2:0'
# But handler uses normalized name (without prefix)
assert model._handler.model_name == 'amazon.titan-embed-text-v2:0' # pyright: ignore[reportPrivateUsage]
# max_input_tokens() works correctly with regional prefix
max_tokens = await model.max_input_tokens()
assert max_tokens == snapshot(8192)
async def test_regional_prefix_embed(self, bedrock_provider: BedrockProvider):
"""Test embedding with a regional prefix model ID using Cohere v4.
Cross-region inference profiles are supported for Cohere models on Bedrock.
"""
model = BedrockEmbeddingModel('us.cohere.embed-v4:0', provider=bedrock_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello from regional endpoint!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1536), length=1),
inputs=['Hello from regional endpoint!'],
input_type='query',
model_name='us.cohere.embed-v4:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=5),
provider_response_id=IsStr(),
)
)
async def test_inference_profile_embed(self, bedrock_provider: BedrockProvider):
# When re-recording, set AWS_ACCOUNT_ID to your real account ID
account_id = os.getenv('AWS_ACCOUNT_ID', '123456789012')
inference_profile_arn = f'arn:aws:bedrock:us-east-1:{account_id}:application-inference-profile/otnfa2ysixqd'
settings: BedrockEmbeddingSettings = {'bedrock_inference_profile': inference_profile_arn}
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider, settings=settings)
result = await model.embed('Hello, world!', input_type='document')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=1024), length=1),
inputs=['Hello, world!'],
input_type='document',
model_name='amazon.titan-embed-text-v2:0',
provider_name='bedrock',
timestamp=IsDatetime(),
usage=RequestUsage(input_tokens=5),
)
)
async def test_unsupported_model_error(self, bedrock_provider: BedrockProvider):
with pytest.raises(UserError, match='Unsupported Bedrock embedding model'):
BedrockEmbeddingModel('unsupported.model', provider=bedrock_provider)
async def test_unknown_model_max_tokens_returns_none(self, bedrock_provider: BedrockProvider):
"""Test that unknown models with valid prefixes return None for max_input_tokens."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v99:0', provider=bedrock_provider)
assert await model.max_input_tokens() is None
def test_model_with_string_provider(self, bedrock_provider: BedrockProvider):
"""Test BedrockEmbeddingModel can be created with string provider."""
with patch('pydantic_ai.embeddings.bedrock.infer_provider', return_value=bedrock_provider) as mock_infer:
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider='bedrock')
mock_infer.assert_called_once_with('bedrock')
assert model.model_name == 'amazon.titan-embed-text-v2:0'
async def test_client_error_with_status_code(self, bedrock_provider: BedrockProvider):
"""Test error handling when ClientError is raised with HTTP status code.
ResponseMetadata.HTTPHeaders is the nested dict that BedrockEmbeddingModel extracts
via metadata.get('HTTPHeaders') — verify it reaches ModelHTTPError.headers unchanged.
"""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
error_response = {
'Error': {'Code': 'ValidationException', 'Message': 'Invalid input'},
'ResponseMetadata': {
'HTTPStatusCode': 400,
'HTTPHeaders': {'retry-after': '5', 'x-amzn-requestid': 'req-abc'},
},
}
with patch.object(
model.client,
'invoke_model',
side_effect=ClientError(error_response, 'InvokeModel'), # pyright: ignore[reportArgumentType]
):
with pytest.raises(ModelHTTPError) as exc_info:
await model.embed(['test'], input_type='query')
exc = exc_info.value
assert exc.status_code == 400
assert exc.headers is not None
assert exc.headers.get('retry-after') == '5'
assert exc.headers.get('x-amzn-requestid') == 'req-abc'
async def test_client_error_without_status_code(self, bedrock_provider: BedrockProvider):
"""Test error handling when ClientError is raised without HTTP status code."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
error_response = {
'Error': {'Code': 'UnknownError', 'Message': 'Something went wrong'},
'ResponseMetadata': {}, # No HTTPStatusCode
}
with patch.object(
model.client,
'invoke_model',
side_effect=ClientError(error_response, 'InvokeModel'), # pyright: ignore[reportArgumentType]
):
with pytest.raises(ModelAPIError) as exc_info:
await model.embed(['test'], input_type='query')
assert type(exc_info.value) is ModelAPIError
async def test_client_error_on_every_concurrent_request(self, bedrock_provider: BedrockProvider):
"""A throttle that fails every concurrent request raises one `ModelHTTPError`, not an `ExceptionGroup`."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
error_response = {
'Error': {'Code': 'ThrottlingException', 'Message': 'Too many requests'},
'ResponseMetadata': {'HTTPStatusCode': 429},
}
# Every request waits for the others before failing, so all three fail rather than being cancelled.
all_sent = threading.Barrier(3, timeout=5)
def invoke_model(**kwargs: Any) -> Any:
all_sent.wait()
raise ClientError(error_response, 'InvokeModel') # pyright: ignore[reportArgumentType]
with patch.object(model.client, 'invoke_model', side_effect=invoke_model):
with pytest.raises(ModelHTTPError) as exc_info:
await model.embed(['a', 'b', 'c'], input_type='document')
assert exc_info.value.status_code == 429
assert isinstance(exc_info.value.__cause__, ClientError)
group = exc_info.value.__context__
assert isinstance(group, BaseExceptionGroup)
assert exc_info.value in group.exceptions # pyright: ignore[reportUnknownMemberType]
@pytest.mark.parametrize('model_errors', [0, 2], ids=['no-model-error', 'model-error-among-others'])
async def test_mixed_errors_on_concurrent_requests(self, bedrock_provider: BedrockProvider, model_errors: int):
"""A model error is raised whenever one of the failed requests produced one, whatever order they failed in;
when none did, the group of the other errors propagates."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
error_response = {
'Error': {'Code': 'ThrottlingException', 'Message': 'Too many requests'},
'ResponseMetadata': {'HTTPStatusCode': 429},
}
all_sent = threading.Barrier(3, timeout=5)
calls = iter(range(3))
lock = threading.Lock()
def invoke_model(**kwargs: Any) -> Any:
with lock:
call = next(calls)
all_sent.wait()
if call < model_errors:
raise ClientError(error_response, 'InvokeModel') # pyright: ignore[reportArgumentType]
raise ValueError('unexpected response body')
with patch.object(model.client, 'invoke_model', side_effect=invoke_model):
if model_errors:
with pytest.raises(ModelHTTPError):
await model.embed(['a', 'b', 'c'], input_type='document')
else:
with pytest.raises(BaseExceptionGroup) as group_info:
await model.embed(['a', 'b', 'c'], input_type='document')
exceptions: tuple[BaseException, ...] = group_info.value.exceptions
assert all(isinstance(e, ValueError) for e in exceptions)
@pytest.mark.parametrize('error_type', ['read-timeout', 'endpoint-connection'])
async def test_transport_error(self, bedrock_provider: BedrockProvider, error_type: str):
"""botocore raises transport failures as `BotoCoreError`, which maps to `ModelAPIError` like `BedrockConverseModel`."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
error_cls = ReadTimeoutError if error_type == 'read-timeout' else EndpointConnectionError
error = error_cls(endpoint_url='https://bedrock.example')
with patch.object(model.client, 'invoke_model', side_effect=error):
with pytest.raises(ModelAPIError) as exc_info:
await model.embed(['test'], input_type='query')
assert exc_info.value.__cause__ is error
async def test_count_tokens_not_implemented(self, bedrock_provider: BedrockProvider):
"""Test that count_tokens raises NotImplementedError (Bedrock doesn't support it)."""
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model)
with pytest.raises(NotImplementedError):
await embedder.count_tokens('Hello, world!')
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
async def test_instrumentation(self, bedrock_provider: BedrockProvider, capfire: CaptureLogfire):
model = BedrockEmbeddingModel('amazon.titan-embed-text-v2:0', provider=bedrock_provider)
embedder = Embedder(model, instrument=True)
await embedder.embed_query('Hello, world!', settings={'dimensions': 256})
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
span = next(span for span in spans if 'embeddings' in span['name'])
assert span == snapshot(
{
'name': 'embeddings amazon.titan-embed-text-v2:0',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': IsInt(),
'end_time': IsInt(),
'attributes': {
'gen_ai.operation.name': 'embeddings',
'gen_ai.provider.name': 'bedrock',
'gen_ai.request.model': 'amazon.titan-embed-text-v2:0',
'input_type': 'query',
'server.address': 'bedrock-runtime.us-east-1.amazonaws.com',
'inputs_count': 1,
'embedding_settings': {'dimensions': 256},
'inputs': ['Hello, world!'],
'logfire.json_schema': {
'type': 'object',
'properties': {
'input_type': {'type': 'string'},
'inputs_count': {'type': 'integer'},
'embedding_settings': {'type': 'object'},
'inputs': {'type': ['array']},
'embeddings': {'type': 'array'},
},
},
'logfire.span_type': 'span',
'logfire.msg': 'embeddings amazon.titan-embed-text-v2:0',
'gen_ai.usage.input_tokens': 5,
'gen_ai.response.model': 'amazon.titan-embed-text-v2:0',
'operation.cost': 5e-07,
'gen_ai.embeddings.dimension.count': 256,
},
}
)
@dataclass
class _GoogleTaskPrefixCase:
id: str
model_name: str
input_type: Literal['query', 'document']
inputs: list[str]
settings: GoogleEmbeddingSettings
expected_texts: list[str]
expected_task_type: str | None
expected_warning: str | None = None
# The `GoogleEmbeddingSettings(...)` calls are only evaluated when the `google` extra is installed.
_GOOGLE_TASK_PREFIX_CASES: list[_GoogleTaskPrefixCase] = (
[
_GoogleTaskPrefixCase(
id='default-query',
model_name='gemini-embedding-2',
input_type='query',
inputs=['Hello, world!'],
settings=GoogleEmbeddingSettings(),
expected_texts=['task: search result | query: Hello, world!'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='asymmetric-query',
model_name='gemini-embedding-2',
input_type='query',
inputs=['Hello, world!'],
settings=GoogleEmbeddingSettings(google_task='question answering'),
expected_texts=['task: question answering | query: Hello, world!'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='asymmetric-document-with-title',
model_name='gemini-embedding-2',
input_type='document',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='search result', google_title='Greeting'),
expected_texts=['title: Greeting | text: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='asymmetric-document-no-title',
model_name='gemini-embedding-2',
input_type='document',
inputs=['hello', 'world'],
settings=GoogleEmbeddingSettings(),
expected_texts=['title: none | text: hello', 'title: none | text: world'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='symmetric-query',
model_name='gemini-embedding-2',
input_type='query',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='classification'),
expected_texts=['task: classification | query: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='symmetric-document-ignores-title',
model_name='gemini-embedding-2',
input_type='document',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='clustering', google_title='ignored'),
expected_texts=['task: clustering | query: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='symmetric-sentence-similarity-ignores-title',
model_name='gemini-embedding-2',
input_type='document',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='sentence similarity', google_title='ignored'),
expected_texts=['task: sentence similarity | query: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='asymmetric-document-empty-title',
model_name='gemini-embedding-2',
input_type='document',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_title=''),
expected_texts=['title: none | text: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='raw-passthrough',
model_name='gemini-embedding-2',
input_type='document',
inputs=['title: custom | text: hello'],
settings=GoogleEmbeddingSettings(google_task='raw'),
expected_texts=['title: custom | text: hello'],
expected_task_type=None,
),
_GoogleTaskPrefixCase(
id='task-type-ignored-on-embedding-2',
model_name='gemini-embedding-2',
input_type='query',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='classification', google_task_type='RETRIEVAL_QUERY'),
expected_texts=['task: classification | query: hello'],
expected_task_type=None,
expected_warning='`google_task_type` is not supported by `gemini-embedding-2`',
),
_GoogleTaskPrefixCase(
id='task-ignored-on-other-model',
model_name='gemini-embedding-2-preview',
input_type='query',
inputs=['hello'],
settings=GoogleEmbeddingSettings(google_task='classification'),
expected_texts=['hello'],
expected_task_type='RETRIEVAL_QUERY',
expected_warning='`google_task` is only supported by `gemini-embedding-2`',
),
]
if google_imports_successful()
else []
)
@pytest.mark.skipif(not google_imports_successful(), reason='Google not installed')
@pytest.mark.vcr
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id) for c in _GOOGLE_TASK_PREFIX_CASES])
async def test_google_task_prefix(case: _GoogleTaskPrefixCase, gemini_api_key: str, monkeypatch: pytest.MonkeyPatch):
"""`google_task` builds the right text prefix (and `task_type`) for `gemini-embedding-2`.
Spies on `embed_content` to assert the exact text sent to the API for each
(task, input_type, title) combination, plus the warn-and-ignore behavior when
`google_task`/`google_task_type` are used on the wrong model.
"""
provider = GoogleProvider(api_key=gemini_api_key)
model = GoogleEmbeddingModel(case.model_name, provider=provider)
embedder = Embedder(model)
captured: dict[str, Any] = {}
real_embed_content = provider.client.aio.models.embed_content
async def spy(**kwargs: Any) -> Any:
captured['contents'] = kwargs['contents']
captured['config'] = kwargs['config']
return await real_embed_content(**kwargs)
monkeypatch.setattr(provider.client.aio.models, 'embed_content', spy)
async def run() -> EmbeddingResult:
if case.input_type == 'query':
return await embedder.embed_query(case.inputs, settings=case.settings)
return await embedder.embed_documents(case.inputs, settings=case.settings)
if case.expected_warning is not None:
with pytest.warns(UserWarning, match=case.expected_warning):
result = await run()
else:
result = await run()
sent_texts = [part.text for content in captured['contents'] for part in content.parts]
assert sent_texts == case.expected_texts
assert captured['config'].task_type == case.expected_task_type
assert captured['config'].title is None
assert len(result.embeddings) == len(case.inputs)
# The prefix is applied internally; the user gets their original (non-prefixed) text back.
assert result.inputs == case.inputs
@pytest.mark.skipif(not google_imports_successful(), reason='Google not installed')
@pytest.mark.skipif(
not os.getenv('CI', False), reason='Requires properly configured local google vertex config to pass'
)
@pytest.mark.vcr
async def test_google_task_prefix_vertex(
allow_model_requests: None, vertex_provider: GoogleCloudProvider, monkeypatch: pytest.MonkeyPatch
): # pragma: lax no cover
"""`google_task` builds the same `gemini-embedding-2` prefix against Google Cloud (Vertex) as against the Gemini API."""
model = GoogleEmbeddingModel('gemini-embedding-2', provider=vertex_provider)
embedder = Embedder(model)
captured: dict[str, Any] = {}
real_embed_content = vertex_provider.client.aio.models.embed_content
async def spy(**kwargs: Any) -> Any:
captured['contents'] = kwargs['contents']
captured['config'] = kwargs['config']
return await real_embed_content(**kwargs)
monkeypatch.setattr(vertex_provider.client.aio.models, 'embed_content', spy)
result = await embedder.embed_query(
'Hello, world!', settings=GoogleEmbeddingSettings(google_task='question answering')
)
sent_texts = [part.text for content in captured['contents'] for part in content.parts]
assert sent_texts == ['task: question answering | query: Hello, world!']
assert captured['config'].task_type is None
assert captured['config'].title is None
assert len(result.embeddings) == 1
@pytest.mark.skipif(not google_imports_successful(), reason='Google not installed')
@pytest.mark.vcr
class TestGoogle:
@pytest.fixture
def embedder(self, gemini_api_key: str) -> Embedder:
return Embedder(
GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
)
async def test_infer_model_google(self, gemini_api_key: str):
with patch.dict(os.environ, {'GOOGLE_API_KEY': gemini_api_key}):
model = infer_embedding_model('google:gemini-embedding-001')
assert isinstance(model, GoogleEmbeddingModel)
assert model.model_name == 'gemini-embedding-001'
assert model.system == 'google'
assert urlparse(model.base_url).hostname == 'generativelanguage.googleapis.com'
async def test_infer_model_google_cloud(self, env: TestEnv):
for name in {
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
'GEMINI_API_KEY',
}:
env.remove(name)
env.set('GOOGLE_API_KEY', 'mock-api-key')
model = infer_embedding_model('google-cloud:gemini-embedding-001')
assert isinstance(model, GoogleEmbeddingModel)
assert model.model_name == 'gemini-embedding-001'
assert model.system == 'google-cloud'
async def test_model_with_string_provider(self, gemini_api_key: str):
with patch.dict(os.environ, {'GOOGLE_API_KEY': gemini_api_key}):
model = GoogleEmbeddingModel('gemini-embedding-001', provider='google')
assert isinstance(model, GoogleEmbeddingModel)
assert model.model_name == 'gemini-embedding-001'
assert model.system == 'google'
async def test_query(self, embedder: Embedder):
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(),
model_name='gemini-embedding-2-preview',
timestamp=IsDatetime(),
provider_name='google',
)
)
async def test_documents(self, embedder: Embedder):
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=2),
inputs=['hello', 'world'],
input_type='document',
usage=RequestUsage(),
model_name='gemini-embedding-2-preview',
timestamp=IsDatetime(),
provider_name='google',
)
)
async def test_query_with_dimensions(self, embedder: Embedder):
result = await embedder.embed_query('Hello, world!', settings={'dimensions': 768})
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=768), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(),
model_name='gemini-embedding-2-preview',
timestamp=IsDatetime(),
provider_name='google',
)
)
async def test_max_input_tokens(self, embedder: Embedder):
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(8192)
async def test_count_tokens(self, embedder: Embedder):
count = await embedder.count_tokens('Hello, world!')
assert count == snapshot(5)
async def test_embed_error(self, gemini_api_key: str):
model = GoogleEmbeddingModel('nonexistent-model', provider=GoogleProvider(api_key=gemini_api_key))
embedder = Embedder(model)
with pytest.raises(ModelHTTPError, match='not found'):
await embedder.embed_query('Hello, world!')
async def test_embed_connect_error(self):
"""`google.genai` doesn't wrap transport failures; a cassette can't replay one, so a mock transport raises it."""
def handler(request: httpx2.Request) -> httpx2.Response:
raise httpx2.ConnectError('connection refused', request=request)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client:
provider = GoogleProvider(api_key='test-key', http_client=http_client, base_url='http://localhost')
embedder = Embedder(GoogleEmbeddingModel('gemini-embedding-001', provider=provider))
with pytest.raises(ModelAPIError) as exc_info:
await embedder.embed_query('Hello, world!')
assert type(exc_info.value) is ModelAPIError
assert exc_info.value.message == 'connection refused'
assert isinstance(exc_info.value.__cause__, httpx2.ConnectError)
async def test_count_tokens_error(self, gemini_api_key: str):
model = GoogleEmbeddingModel('nonexistent-model', provider=GoogleProvider(api_key=gemini_api_key))
embedder = Embedder(model)
with pytest.raises(ModelHTTPError, match='not found'):
await embedder.count_tokens('Hello, world!')
async def test_embed_error_no_http_response(self, gemini_api_key: str, mocker: MockerFixture):
"""An APIError with response=None (no HTTP response object) yields headers=None on ModelHTTPError.
This exercises the defensive `e.response is not None else None` branch in the embed
path of GoogleEmbeddingModel — the branch that handles non-HTTP errors where the SDK
raises an APIError without attaching an httpx.Response.
"""
from google.genai import errors
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
error_without_response = errors.APIError(503, {'error': {'code': 503, 'message': 'Unavailable'}})
mocker.patch.object(model._client.aio.models, 'embed_content', side_effect=error_without_response) # pyright: ignore[reportPrivateUsage]
with pytest.raises(ModelHTTPError) as exc_info:
await model.embed(['test'], input_type='query')
assert exc_info.value.status_code == 503
assert exc_info.value.headers is None
async def test_count_tokens_error_no_http_response(self, gemini_api_key: str, mocker: MockerFixture):
"""Same as above for the count_tokens path."""
from google.genai import errors
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
error_without_response = errors.APIError(503, {'error': {'code': 503, 'message': 'Unavailable'}})
mocker.patch.object(model._client.aio.models, 'count_tokens', side_effect=error_without_response) # pyright: ignore[reportPrivateUsage]
with pytest.raises(ModelHTTPError) as exc_info:
await model.count_tokens('test')
assert exc_info.value.status_code == 503
assert exc_info.value.headers is None
async def test_embed_error_with_http_response(self, gemini_api_key: str, mocker: MockerFixture):
"""An APIError with a real httpx.Response propagates its headers to ModelHTTPError.
The positive path `headers=dict(e.response.headers) if e.response is not None else None`
converts httpx.Headers to a plain lowercased dict. Verify the value reaches
ModelHTTPError.headers so a wrong-attribute regression (e.g. swapping response for None)
would be caught.
"""
import httpx
from google.genai import errors
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
req = httpx.Request('POST', 'https://generativelanguage.googleapis.com/v1beta/models')
resp = httpx.Response(429, headers={'retry-after': '10', 'x-goog-request-id': 'rid-1'}, request=req)
error_with_response = errors.APIError(429, {'error': {'code': 429, 'message': 'Rate limited'}})
error_with_response.response = resp
mocker.patch.object(model._client.aio.models, 'embed_content', side_effect=error_with_response) # pyright: ignore[reportPrivateUsage]
with pytest.raises(ModelHTTPError) as exc_info:
await model.embed(['test'], input_type='query')
exc = exc_info.value
assert exc.status_code == 429
assert exc.headers is not None
assert exc.headers.get('retry-after') == '10'
assert exc.headers.get('x-goog-request-id') == 'rid-1'
async def test_embed_error_low_status_code(self, gemini_api_key: str, mocker: MockerFixture):
"""An APIError with code < 400 is re-raised verbatim, not wrapped in ModelHTTPError.
GoogleEmbeddingModel only wraps errors with status_code >= 400. A code below
400 is a non-HTTP-error signal from the SDK; the original exception propagates.
This covers the `raise` (else) branch of `if (status_code := e.code) >= 400`.
"""
from google.genai import errors
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
low_code_error = errors.APIError(0, {'error': {'code': 0, 'message': 'Unknown'}})
mocker.patch.object(model._client.aio.models, 'embed_content', side_effect=low_code_error) # pyright: ignore[reportPrivateUsage]
with pytest.raises(errors.APIError) as exc_info:
await model.embed(['test'], input_type='query')
assert exc_info.value is low_code_error
async def test_count_tokens_error_low_status_code(self, gemini_api_key: str, mocker: MockerFixture):
"""Same as test_embed_error_low_status_code for the count_tokens path."""
from google.genai import errors
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
low_code_error = errors.APIError(0, {'error': {'code': 0, 'message': 'Unknown'}})
mocker.patch.object(model._client.aio.models, 'count_tokens', side_effect=low_code_error) # pyright: ignore[reportPrivateUsage]
with pytest.raises(errors.APIError) as exc_info:
await model.count_tokens('test')
assert exc_info.value is low_code_error
async def test_query_with_task_type(self, embedder: Embedder):
result = await embedder.embed_query(
'Hello, world!', settings=GoogleEmbeddingSettings(google_task_type='RETRIEVAL_QUERY')
)
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(),
model_name='gemini-embedding-2-preview',
timestamp=IsDatetime(),
provider_name='google',
)
)
@pytest.mark.skipif(
not os.getenv('CI', False), reason='Requires properly configured local google vertex config to pass'
)
@pytest.mark.vcr()
async def test_vertex_query(
self, allow_model_requests: None, vertex_provider: GoogleProvider
): # pragma: lax no cover
model = GoogleEmbeddingModel('gemini-embedding-001', provider=vertex_provider)
embedder = Embedder(model)
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=3072), length=1),
inputs=['Hello, world!'],
input_type='query',
usage=RequestUsage(input_tokens=4),
model_name='gemini-embedding-001',
timestamp=IsDatetime(),
provider_name='google-cloud',
)
)
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
async def test_instrumentation(self, gemini_api_key: str, capfire: CaptureLogfire):
model = GoogleEmbeddingModel('gemini-embedding-2-preview', provider=GoogleProvider(api_key=gemini_api_key))
embedder = Embedder(model, instrument=True)
await embedder.embed_query('Hello, world!', settings={'dimensions': 768})
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
span = next(span for span in spans if 'embeddings' in span['name'])
assert span == snapshot(
{
'name': 'embeddings gemini-embedding-2-preview',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': IsInt(),
'end_time': IsInt(),
'attributes': {
'gen_ai.operation.name': 'embeddings',
'gen_ai.provider.name': 'google',
'gen_ai.request.model': 'gemini-embedding-2-preview',
'input_type': 'query',
'server.address': 'generativelanguage.googleapis.com',
'inputs_count': 1,
'embedding_settings': {'dimensions': 768},
'inputs': ['Hello, world!'],
'logfire.json_schema': {
'type': 'object',
'properties': {
'input_type': {'type': 'string'},
'inputs_count': {'type': 'integer'},
'embedding_settings': {'type': 'object'},
'inputs': {'type': ['array']},
'embeddings': {'type': 'array'},
},
},
'logfire.span_type': 'span',
'logfire.msg': 'embeddings gemini-embedding-2-preview',
'gen_ai.response.model': 'gemini-embedding-2-preview',
'gen_ai.embeddings.dimension.count': 768,
},
}
)
@pytest.mark.skipif(not sentence_transformers_installed, reason='SentenceTransformers not installed')
@pytest.mark.usefixtures('sentence_transformers_imported')
class TestSentenceTransformers:
def _load_stsb_bert_tiny_model(self):
from sentence_transformers import SentenceTransformer
# The pinned commit revision lets huggingface_hub serve every model file
# straight from a warm cache without revalidating it against the Hub.
# Construction still fires a few metadata requests (model card, repo tree,
# adapter probe); those tolerate Hub HTTP errors, and connection-level
# failures fall through to the skip below. CI warms the cache out-of-band
# (see ci.yml); a cold cache downloads the model here on first use.
try:
model = SentenceTransformer(STSB_BERT_TINY_MODEL, revision=STSB_BERT_TINY_REVISION)
except (OSError, RuntimeError, httpx.HTTPError) as e:
# Skip only when the Hub is unavailable (see `_hf_hub_unavailable`) so a
# HF outage never reds the whole suite; anything else (bad pin, auth
# problem, corrupt cache, dependency mismatch) fails loudly.
if not _hf_hub_unavailable(e):
raise
pytest.skip(f'sentence-transformers test model unavailable (HF Hub): {e}')
# If the model card didn't load, `model_id` is unset and the model reports
# its name as 'unknown'. Set it explicitly so the reported name is
# deterministic (a no-op when the card did load).
model.model_card_data.model_id = STSB_BERT_TINY_MODEL
model.model_card_data.generate_widget_examples = False # Disable widget examples generation for testing
return model
@pytest.fixture(scope='session')
def stsb_bert_tiny_model(self):
return self._load_stsb_bert_tiny_model()
def test_model_unavailable(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
'sentence_transformers.SentenceTransformer', MagicMock(side_effect=httpx.ConnectTimeout('offline'))
)
skip = MagicMock(side_effect=RuntimeError('skipped'))
monkeypatch.setattr(pytest, 'skip', skip)
with pytest.raises(RuntimeError, match='skipped'):
self._load_stsb_bert_tiny_model()
skip.assert_called_once_with('sentence-transformers test model unavailable (HF Hub): offline')
@pytest.fixture
def embedder(self, stsb_bert_tiny_model: Any) -> Embedder:
from pydantic_ai.embeddings.sentence_transformers import SentenceTransformerEmbeddingModel
return Embedder(SentenceTransformerEmbeddingModel(stsb_bert_tiny_model))
async def test_embed_is_exempt_from_request_guard(self, embedder: Embedder):
"""`ALLOW_MODEL_REQUESTS`'s docstring promises this model is unaffected; pin that promise.
Inference is local, so there's no provider call to block and no recording to make.
"""
with pydantic_ai.models.override_allow_model_requests(False):
result = await embedder.embed_query('hello')
assert result.embeddings
async def test_infer_model(self):
from pydantic_ai.embeddings.sentence_transformers import SentenceTransformerEmbeddingModel
model = infer_embedding_model('sentence-transformers:all-MiniLM-L6-v2')
assert isinstance(model, SentenceTransformerEmbeddingModel)
assert model.model_name == 'all-MiniLM-L6-v2'
assert model.system == 'sentence-transformers'
assert model.base_url is None
async def test_adapter_without_downloaded_model(self, monkeypatch: pytest.MonkeyPatch):
"""VCR cannot exercise a local adapter, so cover it without a downloaded model."""
import torch
from sentence_transformers import SentenceTransformer
import pydantic_ai.embeddings.sentence_transformers as sentence_transformers_module
from pydantic_ai.embeddings.sentence_transformers import (
SentenceTransformerEmbeddingModel,
SentenceTransformersEmbeddingSettings,
)
st_model = MagicMock(spec=SentenceTransformer)
st_model.model_card_data = MagicMock()
st_model.model_card_data.model_id = 'test-model'
st_model.encode_query.return_value.tolist.return_value = [[0.1, 0.2]]
st_model.encode_document.return_value.tolist.return_value = [[0.3, 0.4], [0.5, 0.6]]
st_model.get_max_seq_length.return_value = 512
st_model.tokenize.return_value = {'input_ids': torch.tensor([[1, 2, 3]])}
def preserve_model(model: SentenceTransformer) -> SentenceTransformer:
return model
monkeypatch.setattr(sentence_transformers_module, 'deepcopy', preserve_model)
model = SentenceTransformerEmbeddingModel(
st_model,
settings=SentenceTransformersEmbeddingSettings(
dimensions=2,
sentence_transformers_batch_size=2,
sentence_transformers_device='cpu',
sentence_transformers_normalize_embeddings=True,
),
)
embedder = Embedder(model)
query_result = await embedder.embed_query('hello')
assert query_result == snapshot(
EmbeddingResult(
embeddings=[[0.1, 0.2]],
inputs=['hello'],
input_type='query',
model_name='test-model',
provider_name='sentence-transformers',
timestamp=IsDatetime(),
)
)
st_model.encode_query.assert_called_once_with(
['hello'],
show_progress_bar=False,
convert_to_numpy=True,
convert_to_tensor=False,
device='cpu',
normalize_embeddings=True,
truncate_dim=2,
batch_size=2,
)
documents_result = await embedder.embed_documents(['hello', 'world'])
assert documents_result == snapshot(
EmbeddingResult(
embeddings=[[0.3, 0.4], [0.5, 0.6]],
inputs=['hello', 'world'],
input_type='document',
model_name='test-model',
provider_name='sentence-transformers',
timestamp=IsDatetime(),
)
)
st_model.encode_document.assert_called_once_with(
['hello', 'world'],
show_progress_bar=False,
convert_to_numpy=True,
convert_to_tensor=False,
device='cpu',
normalize_embeddings=True,
truncate_dim=2,
batch_size=2,
)
assert await embedder.max_input_tokens() == 512
assert await embedder.count_tokens('hello') == 3
loaded_model = MagicMock(spec=SentenceTransformer)
loaded_model.get_max_seq_length.return_value = 256
constructor = MagicMock(return_value=loaded_model)
monkeypatch.setattr(sentence_transformers_module, 'SentenceTransformer', constructor)
lazy_embedder = Embedder(SentenceTransformerEmbeddingModel('lazy-model'))
assert await lazy_embedder.max_input_tokens() == 256
constructor.assert_called_once_with('lazy-model')
async def test_query(self, embedder: Embedder):
result = await embedder.embed_query('Hello, world!')
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=128), length=1),
inputs=['Hello, world!'],
input_type='query',
model_name='sentence-transformers-testing/stsb-bert-tiny-safetensors',
timestamp=IsDatetime(),
provider_name='sentence-transformers',
)
)
async def test_documents(self, embedder: Embedder):
result = await embedder.embed_documents(['hello', 'world'])
assert result == snapshot(
EmbeddingResult(
embeddings=IsList(IsList(IsFloat(), length=128), length=2),
inputs=['hello', 'world'],
input_type='document',
model_name='sentence-transformers-testing/stsb-bert-tiny-safetensors',
timestamp=IsDatetime(),
provider_name='sentence-transformers',
)
)
async def test_max_input_tokens(self, embedder: Embedder):
max_input_tokens = await embedder.max_input_tokens()
assert max_input_tokens == snapshot(512)
async def test_count_tokens(self, embedder: Embedder):
count = await embedder.count_tokens('Hello, world!')
assert count == snapshot(6)
@pytest.mark.skipif(
not openai_imports_successful()
or not cohere_imports_successful()
or not google_imports_successful()
or not voyageai_installed
or not bedrock_imports_successful(),
reason='some embedding package was not installed',
)
@pytest.mark.usefixtures('voyageai_imported')
def test_known_embedding_model_names(): # pragma: lax no cover
from pydantic_ai.embeddings.voyageai import LatestVoyageAIEmbeddingModelNames
# Coverage seems to be misbehaving..?
def get_model_names(model_name_type: Any) -> Iterator[str]:
for arg in get_args(model_name_type):
if isinstance(arg, str):
yield arg
else:
yield from get_model_names(arg)
openai_names = [f'openai:{n}' for n in get_model_names(LatestOpenAIEmbeddingModelNames)]
cohere_names = [f'cohere:{n}' for n in get_model_names(LatestCohereEmbeddingModelNames)]
google_names = [f'google:{n}' for n in get_model_names(LatestGoogleGLAEmbeddingModelNames)]
google_cloud_names = [f'google-cloud:{n}' for n in get_model_names(LatestGoogleVertexEmbeddingModelNames)]
voyageai_names = [f'voyageai:{n}' for n in get_model_names(LatestVoyageAIEmbeddingModelNames)]
bedrock_names = [f'bedrock:{n}' for n in get_model_names(LatestBedrockEmbeddingModelNames)]
generated_names = sorted(
openai_names + cohere_names + google_names + google_cloud_names + voyageai_names + bedrock_names
)
known_model_names = sorted(get_args(KnownEmbeddingModelName.__value__))
if generated_names != known_model_names:
errors: list[str] = []
missing_names = set(generated_names) - set(known_model_names)
if missing_names:
errors.append(f'Missing names: {missing_names}')
extra_names = set(known_model_names) - set(generated_names)
if extra_names:
errors.append(f'Extra names: {extra_names}')
raise AssertionError('\n'.join(errors))
def test_infer_model_error():
with pytest.raises(ValueError, match='You must provide a provider prefix when specifying an embedding model name'):
infer_embedding_model('nonexistent')
async def test_instrument_all():
model = TestEmbeddingModel()
embedder = Embedder(model)
def get_model():
return embedder._get_model() # pyright: ignore[reportPrivateUsage]
Embedder.instrument_all(False)
assert get_model() is model
Embedder.instrument_all()
m = get_model()
assert isinstance(m, InstrumentedEmbeddingModel)
assert m.wrapped is model
assert m.instrumentation_settings.version == InstrumentationSettings().version
assert m.model_name == model.model_name
assert m.system == model.system
assert m.base_url == model.base_url
assert m.settings == model.settings
assert (await m.embed('Hello, world!', input_type='query')).embeddings == (
await model.embed('Hello, world!', input_type='query')
).embeddings
assert await m.max_input_tokens() == await model.max_input_tokens()
assert await m.count_tokens('Hello, world!') == await model.count_tokens('Hello, world!')
options = InstrumentationSettings(version=5)
Embedder.instrument_all(options)
m = get_model()
assert isinstance(m, InstrumentedEmbeddingModel)
assert m.wrapped is model
assert m.instrumentation_settings is options
Embedder.instrument_all(False)
assert get_model() is model
class ExplicitPortEmbeddingModel(TestEmbeddingModel):
@property
def base_url(self) -> str:
return 'https://example.com:8000/v1'
class MalformedPortEmbeddingModel(TestEmbeddingModel):
@property
def base_url(self) -> str:
return 'https://example.com:notaport/v1'
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
@pytest.mark.parametrize(
'model_type,expected_server_attributes',
[
pytest.param(
ExplicitPortEmbeddingModel,
snapshot({'server.address': 'example.com', 'server.port': 8000}),
id='explicit-port',
),
pytest.param(MalformedPortEmbeddingModel, snapshot({}), id='malformed-port'),
],
)
async def test_instrumented_embedding_model_server_attributes(
model_type: type[TestEmbeddingModel], expected_server_attributes: dict[str, str | int], capfire: CaptureLogfire
):
"""A `base_url` whose port isn't an integer omits the server attributes instead of failing the request.
`urlparse` accepts the URL and only raises when `hostname`/`port` are read, so this is a unit test:
no real provider produces a `base_url` that survives client construction and fails at attribute-building.
"""
model = InstrumentedEmbeddingModel(model_type(), InstrumentationSettings())
await model.embed('Hello, world!', input_type='query')
[span] = capfire.exporter.exported_spans_as_dict()
assert {k: v for k, v in span['attributes'].items() if k.startswith('server.')} == expected_server_attributes
def test_override():
model = TestEmbeddingModel()
embedder = Embedder(model)
model2 = TestEmbeddingModel()
with embedder.override(model=model2):
assert embedder._get_model() is model2 # pyright: ignore[reportPrivateUsage]
with embedder.override():
assert embedder._get_model() is model # pyright: ignore[reportPrivateUsage]
assert embedder._get_model() is model # pyright: ignore[reportPrivateUsage]
def test_sync():
model = TestEmbeddingModel()
embedder = Embedder(model)
result = embedder.embed_query_sync('Hello, world!')
assert isinstance(result, EmbeddingResult)
result = embedder.embed_documents_sync(['hello', 'world'])
assert isinstance(result, EmbeddingResult)
result = embedder.embed_sync('Hello, world!', input_type='query')
assert isinstance(result, EmbeddingResult)
result = embedder.max_input_tokens_sync()
assert isinstance(result, int)
result = embedder.count_tokens_sync('Hello, world!')
assert isinstance(result, int)
async def test_settings():
model_settings: EmbeddingSettings = {'dimensions': 128, 'from_model': True} # pyright: ignore[reportAssignmentType]
model = TestEmbeddingModel(settings=model_settings)
assert model.settings == model_settings
await Embedder(model).embed_query('Hello, world!')
assert model.last_settings == snapshot({'dimensions': 128, 'from_model': True})
embedder_settings: EmbeddingSettings = {'dimensions': 256, 'from_embedder': True} # pyright: ignore[reportAssignmentType]
embedder = Embedder(model, settings=embedder_settings)
await embedder.embed_query('Hello, world!')
assert model.last_settings == snapshot({'dimensions': 256, 'from_model': True, 'from_embedder': True})
embed_settings: EmbeddingSettings = {'dimensions': 512, 'from_embed': True} # pyright: ignore[reportAssignmentType]
await embedder.embed_query('Hello, world!', settings=embed_settings)
assert model.last_settings == snapshot(
{'dimensions': 512, 'from_model': True, 'from_embedder': True, 'from_embed': True}
)
def test_embedder_compares_by_identity():
"""An `Embedder` is a stateful client, so two are equal only when they are the same object.
Field equality compared only `instrument`, so it made two `Embedder`s over different models equal
whenever their `instrument` matched, and left the class unhashable. No request is involved, so
this is not a VCR test.
"""
model = TestEmbeddingModel('small')
embedder = Embedder(model)
assert embedder == embedder
assert embedder != Embedder(model)
assert {embedder: 'small'}[embedder] == 'small'
def test_wrapper_embedding_model_deepcopy():
"""`deepcopy` builds the copy without `__init__`, so `wrapped` is unset when `__getattr__` runs.
`copy` probes the new instance for `__setstate__`; forwarding that to an unset `wrapped` recursed
until `RecursionError`. No request is involved, so this is not a VCR test.
"""
model = WrapperEmbeddingModel(TestEmbeddingModel('wrapped'))
copied = deepcopy(model)
assert copied is not model
assert copied.wrapped is not model.wrapped
assert copied.model_name == 'wrapped'
# Only the wrapped `TestEmbeddingModel` defines it, so reading it proves the copy still forwards.
assert copied.last_settings is None
def test_result():
result = EmbeddingResult(
embeddings=[[-1.0], [-0.5], [0.0], [0.5], [1.0]],
inputs=['a', 'b', 'c', 'd', 'e'],
input_type='document',
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
)
assert result[0] == result['a'] == snapshot([-1.0])
assert result[1] == result['b'] == snapshot([-0.5])
assert result[2] == result['c'] == snapshot([0.0])
assert result[3] == result['d'] == snapshot([0.5])
assert result[4] == result['e'] == snapshot([1.0])
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
async def test_limited_instrumentation(capfire: CaptureLogfire):
model = TestEmbeddingModel()
embedder = Embedder(model, instrument=InstrumentationSettings(include_content=False))
await embedder.embed_query('Hello, world!')
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'embeddings test',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': IsInt(),
'end_time': IsInt(),
'attributes': {
'gen_ai.operation.name': 'embeddings',
'gen_ai.provider.name': 'test',
'gen_ai.request.model': 'test',
'input_type': 'query',
'inputs_count': 1,
'logfire.json_schema': {
'type': 'object',
'properties': {
'input_type': {'type': 'string'},
'inputs_count': {'type': 'integer'},
'embedding_settings': {'type': 'object'},
},
},
'logfire.span_type': 'span',
'logfire.msg': 'embeddings test',
'gen_ai.usage.input_tokens': 2,
'gen_ai.response.model': 'test',
'gen_ai.embeddings.dimension.count': 8,
'gen_ai.response.id': IsStr(),
},
}
]
)
@pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed')
@pytest.mark.parametrize('include_content', [True, False])
async def test_instrumentation_exception_honors_include_content(capfire: CaptureLogfire, include_content: bool):
"""A failing embedding request follows `include_content` like the agent's spans do.
A provider error carries its response body in the exception message, so the exception event and
the ERROR status description on the embedding span are withheld when content capture is off.
"""
class FailingEmbeddingModel(TestEmbeddingModel):
async def embed(
self, inputs: str | Sequence[str], *, input_type: EmbedInputType, settings: EmbeddingSettings | None = None
) -> EmbeddingResult:
raise ModelHTTPError(status_code=400, model_name='failing', body='invalid input: embed-secret')
embedder = Embedder(FailingEmbeddingModel(), instrument=InstrumentationSettings(include_content=include_content))
with pytest.raises(ModelHTTPError):
await embedder.embed('hello', input_type='document')
[span] = [span for span in capfire.exporter.exported_spans if span.status.status_code is StatusCode.ERROR]
[event] = [event for event in span.events if event.name == 'exception']
attributes = dict(event.attributes or {})
assert attributes['exception.type'] == 'pydantic_ai.exceptions.ModelHTTPError'
assert attributes['exception.escaped'] == 'False'
if include_content:
assert {'exception.message', 'exception.stacktrace'} <= set(attributes)
assert 'embed-secret' in str(attributes['exception.message'])
assert span.status.description is not None
else:
assert set(attributes) == {'exception.type', 'exception.escaped'}
assert span.status.description is None
assert 'embed-secret' not in str(capfire.exporter.exported_spans)
@pytest.mark.skipif(not openai_imports_successful(), reason='openai not installed')
async def test_openai_embedding_non_json_response_body_raises_model_api_error():
"""A 200 response body that can't be decoded as JSON surfaces as `ModelAPIError`, not the raw decode error.
A mock transport stands in for a cassette because no real provider returns such a body on demand.
https://github.com/pydantic/pydantic-ai/issues/9340
"""
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=b' ', headers={'content-type': 'application/json'})
async with AsyncOpenAI(
api_key='test',
base_url='http://localhost/v1',
max_retries=0,
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)),
) as client:
model = OpenAIEmbeddingModel('text-embedding-3-small', provider=OpenAIProvider(openai_client=client))
with pytest.raises(ModelAPIError) as exc_info:
await model.embed('hello', input_type='query')
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
assert exc_info.value.message.startswith('Failed to decode response as JSON')
@pytest.mark.skipif(not google_imports_successful(), reason='google not installed')
@pytest.mark.parametrize('call', ['embed', 'count_tokens'])
async def test_google_embedding_non_json_response_body_raises_model_api_error(call: str):
"""A 200 response body that can't be decoded as JSON surfaces as `ModelAPIError`, not the raw decode error.
A mock transport stands in for a cassette because no real provider returns such a body on demand.
https://github.com/pydantic/pydantic-ai/issues/9340
"""
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=b' ', headers={'content-type': 'application/json'})
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http_client:
provider = GoogleProvider(api_key='test-key', http_client=http_client, base_url='http://localhost')
model = GoogleEmbeddingModel('gemini-embedding-001', provider=provider)
with pytest.raises(ModelAPIError) as exc_info:
if call == 'count_tokens':
await model.count_tokens('hello')
else:
await model.embed('hello', input_type='query')
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
assert exc_info.value.message.startswith('Failed to decode response as JSON')
@pytest.mark.skipif(not bedrock_imports_successful(), reason='Bedrock not installed')
@pytest.mark.parametrize('body', [b'', b'not json'], ids=['empty', 'not-json'])
async def test_bedrock_embedding_non_json_response_body_raises_model_api_error(body: bytes):
"""A 200 response body that can't be decoded as JSON surfaces as `ModelAPIError`, not the raw decode error.
The response is injected at `before-send` because no real endpoint returns such a body on demand.
https://github.com/pydantic/pydantic-ai/issues/9340
"""
provider = BedrockProvider(
region_name='us-east-1',
aws_access_key_id='AKIA6666666666666666',
aws_secret_access_key='6666666666666666666666666666666666666666',
)
model = BedrockEmbeddingModel('cohere.embed-english-v3', provider=provider)
def respond(request: AWSPreparedRequest, **_: object) -> AWSResponse:
return AWSResponse(request.url, 200, HTTPHeaders(), HTTPResponse(body=io.BytesIO(body), preload_content=False))
# botocore sends the response a `before-send` handler returns instead of the request, though the stubs type every
# handler as returning `None`.
model.client.meta.events.register_last('before-send.bedrock-runtime', respond) # pyright: ignore[reportArgumentType]
with pytest.raises(ModelAPIError) as exc_info:
await model.embed('hello', input_type='query')
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
assert exc_info.value.message.startswith('Failed to decode response as JSON')