Merge pull request #209 from ggozad/feat/pydantic-ai-embeddings

Use Pydantic AI embeddings
This commit is contained in:
Yiorgis Gozadinos 2025-12-26 13:32:30 +02:00 committed by GitHub
commit c44ebcd371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 506 additions and 512 deletions

View file

@ -1,6 +1,28 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Changed
- **Embeddings**: Migrated to pydantic-ai's embeddings module
- Uses pydantic-ai v1.39.0+ embeddings with instrumentation and token counting support
- Explicit `embed_query()` and `embed_documents()` API for query/document distinction
- New providers available: Cohere (`cohere:`), SentenceTransformers (`sentence-transformers:`)
- VoyageAI refactored to extend pydantic-ai's `EmbeddingModel` base class
- **Configuration**: Added `base_url` to `ModelConfig` and `EmbeddingModelConfig`
- Enables custom endpoints for OpenAI-compatible providers (vLLM, LM Studio, etc.)
- Model-level `base_url` takes precedence over provider config
### Deprecated
- **vLLM and LM Studio providers**: Use `openai` provider with `base_url` instead
- `provider: vllm``provider: openai` with `base_url: http://localhost:8000/v1`
- `provider: lm_studio``provider: openai` with `base_url: http://localhost:1234/v1`
### Removed
- Deleted obsolete embedder implementations: `ollama.py`, `openai.py`, `vllm.py`, `lm_studio.py`, `base.py`
- Removed `VLLMConfig` and `LMStudioConfig` from configuration (use `base_url` in model config instead)
## [0.22.0] - 2025-12-19 ## [0.22.0] - 2025-12-19
### Added ### Added

View file

@ -135,12 +135,6 @@ providers:
ollama: ollama:
base_url: http://localhost:11434 base_url: http://localhost:11434
vllm:
embeddings_base_url: ""
rerank_base_url: ""
qa_base_url: ""
research_base_url: ""
docling_serve: docling_serve:
base_url: http://localhost:5001 base_url: http://localhost:5001
api_key: "" api_key: ""

View file

@ -28,6 +28,7 @@ qa:
- Higher (0.8-1.0+): Creative, varied responses - Higher (0.8-1.0+): Creative, varied responses
- **max_tokens**: Maximum tokens in response - **max_tokens**: Maximum tokens in response
- **enable_thinking**: Control reasoning behavior (see below) - **enable_thinking**: Control reasoning behavior (see below)
- **base_url**: Custom endpoint for OpenAI-compatible servers (vLLM, LM Studio, etc.)
### Thinking Control ### Thinking Control
@ -67,7 +68,7 @@ See the [Pydantic AI thinking documentation](https://ai.pydantic.dev/thinking/)
## Embedding Providers ## Embedding Providers
If you use Ollama, you can use any pulled model that supports embeddings. Embedding models require three settings: `provider`, `name`, and `vector_dim`. Optionally, use `base_url` for OpenAI-compatible servers.
### Ollama (Default) ### Ollama (Default)
@ -135,41 +136,59 @@ Set your API key via environment variable:
export OPENAI_API_KEY=your-api-key export OPENAI_API_KEY=your-api-key
``` ```
### vLLM ### Cohere
For high-performance local inference, you can use vLLM to serve embedding models with OpenAI-compatible APIs: Cohere embeddings are available via pydantic-ai:
```yaml ```yaml
embeddings: embeddings:
model: model:
provider: vllm provider: cohere
name: embed-v4.0
vector_dim: 1024
```
Set your API key via environment variable:
```bash
export CO_API_KEY=your-api-key
```
### SentenceTransformers
For local embeddings using HuggingFace models:
```yaml
embeddings:
model:
provider: sentence-transformers
name: all-MiniLM-L6-v2
vector_dim: 384
```
### OpenAI-Compatible Servers (vLLM, LM Studio, etc.)
For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`:
```yaml
# vLLM example
embeddings:
model:
provider: openai
name: mixedbread-ai/mxbai-embed-large-v1 name: mixedbread-ai/mxbai-embed-large-v1
vector_dim: 512 vector_dim: 512
base_url: http://localhost:8000/v1
providers: # LM Studio example
vllm:
embeddings_base_url: http://localhost:8000
```
**Note:** You need to run a vLLM server separately with an embedding model loaded.
### LM Studio
[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible API server for running models:
```yaml
embeddings: embeddings:
model: model:
provider: lm_studio provider: openai
name: text-embedding-qwen3-embedding-4b name: text-embedding-qwen3-embedding-4b
vector_dim: 2560 vector_dim: 2560
base_url: http://localhost:1234/v1
providers:
lm_studio:
base_url: http://localhost:1234
``` ```
**Note:** LM Studio must be running with an embedding model loaded. The default URL is `http://localhost:1234`. **Note:** The `base_url` must include the `/v1` path for OpenAI-compatible endpoints.
## Question Answering Providers ## Question Answering Providers
@ -232,45 +251,28 @@ Set your API key via environment variable:
export ANTHROPIC_API_KEY=your-api-key export ANTHROPIC_API_KEY=your-api-key
``` ```
### vLLM ### OpenAI-Compatible Servers (vLLM, LM Studio, etc.)
For high-performance local inference: For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`:
```yaml ```yaml
# vLLM example
qa: qa:
model: model:
provider: vllm provider: openai
name: Qwen/Qwen3-4B # Any model with tool support in vLLM name: Qwen/Qwen3-4B
base_url: http://localhost:8002/v1
providers: # LM Studio example
vllm:
qa_base_url: http://localhost:8002
```
**Note:** You need to run a vLLM server separately with a model that supports tool calling loaded. Consult the specific model's documentation for proper vLLM serving configuration.
### LM Studio
Use LM Studio for local question answering and research:
```yaml
qa: qa:
model: model:
provider: lm_studio provider: openai
name: openai/gpt-oss-20b name: gpt-oss-20b
base_url: http://localhost:1234/v1
enable_thinking: false enable_thinking: false
research:
model:
provider: lm_studio
name: openai/gpt-oss-20b
providers:
lm_studio:
base_url: http://localhost:1234
``` ```
**Note:** LM Studio must be running with a chat model that supports tool calling loaded. **Note:** The server must be running with a model that supports tool calling. The `base_url` must include the `/v1` path.
### Other Providers ### Other Providers
@ -376,10 +378,7 @@ reranking:
model: model:
provider: vllm provider: vllm
name: mixedbread-ai/mxbai-rerank-base-v2 name: mixedbread-ai/mxbai-rerank-base-v2
base_url: http://localhost:8001
providers:
vllm:
rerank_base_url: http://localhost:8001
``` ```
**Note:** vLLM reranking uses the `/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded. Consult the specific model's documentation for proper vLLM serving configuration. **Note:** vLLM reranking uses the `/v1/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded.

View file

@ -1407,7 +1407,7 @@ class HaikuRAG:
# Generate new embeddings using contextualize for consistency # Generate new embeddings using contextualize for consistency
texts = contextualize(chunks) texts = contextualize(chunks)
embeddings = await self.chunk_repository.embedder.embed(texts) embeddings = await self.chunk_repository.embedder.embed_documents(texts)
# Build updated records # Build updated records
for chunk, embedding in zip(chunks, embeddings): for chunk, embedding in zip(chunks, embeddings):

View file

@ -12,7 +12,6 @@ from haiku.rag.config.models import (
EmbeddingModelConfig, EmbeddingModelConfig,
EmbeddingsConfig, EmbeddingsConfig,
LanceDBConfig, LanceDBConfig,
LMStudioConfig,
ModelConfig, ModelConfig,
MonitorConfig, MonitorConfig,
OllamaConfig, OllamaConfig,
@ -22,7 +21,6 @@ from haiku.rag.config.models import (
RerankingConfig, RerankingConfig,
ResearchConfig, ResearchConfig,
StorageConfig, StorageConfig,
VLLMConfig,
) )
__all__ = [ __all__ = [
@ -33,7 +31,6 @@ __all__ = [
"EmbeddingModelConfig", "EmbeddingModelConfig",
"EmbeddingsConfig", "EmbeddingsConfig",
"LanceDBConfig", "LanceDBConfig",
"LMStudioConfig",
"ModelConfig", "ModelConfig",
"MonitorConfig", "MonitorConfig",
"OllamaConfig", "OllamaConfig",
@ -43,7 +40,6 @@ __all__ = [
"RerankingConfig", "RerankingConfig",
"ResearchConfig", "ResearchConfig",
"StorageConfig", "StorageConfig",
"VLLMConfig",
"find_config_file", "find_config_file",
"generate_default_config", "generate_default_config",
"get_config", "get_config",

View file

@ -12,6 +12,7 @@ class ModelConfig(BaseModel):
Attributes: Attributes:
provider: Model provider (ollama, openai, anthropic, etc.) provider: Model provider (ollama, openai, anthropic, etc.)
name: Model name/identifier name: Model name/identifier
base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.)
enable_thinking: Control reasoning behavior (true/false/None for default) enable_thinking: Control reasoning behavior (true/false/None for default)
temperature: Sampling temperature (0.0 to 1.0+) temperature: Sampling temperature (0.0 to 1.0+)
max_tokens: Maximum tokens to generate max_tokens: Maximum tokens to generate
@ -19,6 +20,7 @@ class ModelConfig(BaseModel):
provider: str = "ollama" provider: str = "ollama"
name: str = "gpt-oss" name: str = "gpt-oss"
base_url: str | None = None
enable_thinking: bool | None = None enable_thinking: bool | None = None
temperature: float | None = None temperature: float | None = None
@ -29,14 +31,16 @@ class EmbeddingModelConfig(BaseModel):
"""Configuration for an embedding model. """Configuration for an embedding model.
Attributes: Attributes:
provider: Model provider (ollama, openai, voyageai, vllm, lm_studio) provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers)
name: Model name/identifier name: Model name/identifier
vector_dim: Vector dimensions produced by the model vector_dim: Vector dimensions produced by the model
base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.)
""" """
provider: str = "ollama" provider: str = "ollama"
name: str = "qwen3-embedding:4b" name: str = "qwen3-embedding:4b"
vector_dim: int = 2560 vector_dim: int = 2560
base_url: str | None = None
class StorageConfig(BaseModel): class StorageConfig(BaseModel):
@ -138,27 +142,14 @@ class OllamaConfig(BaseModel):
) )
class VLLMConfig(BaseModel):
embeddings_base_url: str = ""
rerank_base_url: str = ""
qa_base_url: str = ""
research_base_url: str = ""
class DoclingServeConfig(BaseModel): class DoclingServeConfig(BaseModel):
base_url: str = "http://localhost:5001" base_url: str = "http://localhost:5001"
api_key: str = "" api_key: str = ""
timeout: int = 300 timeout: int = 300
class LMStudioConfig(BaseModel):
base_url: str = "http://localhost:1234"
class ProvidersConfig(BaseModel): class ProvidersConfig(BaseModel):
ollama: OllamaConfig = Field(default_factory=OllamaConfig) ollama: OllamaConfig = Field(default_factory=OllamaConfig)
vllm: VLLMConfig = Field(default_factory=VLLMConfig)
lm_studio: LMStudioConfig = Field(default_factory=LMStudioConfig)
docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig) docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig)

View file

@ -1,13 +1,36 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic_ai.embeddings import Embedder
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
from haiku.rag.config import AppConfig, Config from haiku.rag.config import AppConfig, Config
from haiku.rag.embeddings.base import EmbedderBase
from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder
if TYPE_CHECKING: if TYPE_CHECKING:
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
class EmbedderWrapper:
"""Wrapper around pydantic-ai Embedder with explicit query/document methods."""
def __init__(self, embedder: Embedder, vector_dim: int):
self._embedder = embedder
self._vector_dim = vector_dim
async def embed_query(self, text: str) -> list[float]:
"""Embed a search query."""
result = await self._embedder.embed_query(text)
return list(result.embeddings[0])
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed documents/chunks for indexing."""
if not texts:
return []
result = await self._embedder.embed_documents(texts)
return [list(e) for e in result.embeddings]
def contextualize(chunks: list["Chunk"]) -> list[str]: def contextualize(chunks: list["Chunk"]) -> list[str]:
"""Prepare chunk content for embedding by adding context. """Prepare chunk content for embedding by adding context.
@ -53,7 +76,7 @@ async def embed_chunks(
embedder = get_embedder(config) embedder = get_embedder(config)
texts = contextualize(chunks) texts = contextualize(chunks)
embeddings = await embedder.embed(texts) embeddings = await embedder.embed_documents(texts)
return [ return [
Chunk( Chunk(
@ -71,9 +94,8 @@ async def embed_chunks(
] ]
def get_embedder(config: AppConfig = Config) -> EmbedderBase: def get_embedder(config: AppConfig = Config) -> EmbedderWrapper:
""" """Factory function to get the appropriate embedder based on the configuration.
Factory function to get the appropriate embedder based on the configuration.
Args: Args:
config: Configuration to use. Defaults to global Config. config: Configuration to use. Defaults to global Config.
@ -82,38 +104,46 @@ def get_embedder(config: AppConfig = Config) -> EmbedderBase:
An embedder instance configured according to the config. An embedder instance configured according to the config.
""" """
embedding_model = config.embeddings.model embedding_model = config.embeddings.model
provider = embedding_model.provider
model_name = embedding_model.name
vector_dim = embedding_model.vector_dim
if embedding_model.provider == "ollama": if provider == "ollama":
return OllamaEmbedder(embedding_model.name, embedding_model.vector_dim, config) # Use model-level base_url if set, otherwise fall back to providers config
base_url = embedding_model.base_url or f"{config.providers.ollama.base_url}/v1"
model = OpenAIEmbeddingModel(
model_name,
provider=OllamaProvider(base_url=base_url),
)
return EmbedderWrapper(Embedder(model), vector_dim)
if embedding_model.provider == "voyageai": if provider == "openai":
if embedding_model.base_url:
model = OpenAIEmbeddingModel(
model_name,
provider=OpenAIProvider(base_url=embedding_model.base_url),
)
return EmbedderWrapper(Embedder(model), vector_dim)
return EmbedderWrapper(Embedder(f"openai:{model_name}"), vector_dim)
if provider == "voyageai":
try: try:
from haiku.rag.embeddings.voyageai import Embedder as VoyageAIEmbedder from haiku.rag.embeddings.voyageai import VoyageAIEmbeddingModel
except ImportError: except ImportError:
raise ImportError( raise ImportError(
"VoyageAI embedder requires the 'voyageai' package. " "VoyageAI embedder requires the 'voyageai' package. "
"Please install haiku.rag with the 'voyageai' extra: " "Please install haiku.rag with the 'voyageai' extra: "
"uv pip install haiku.rag[voyageai]" "uv pip install haiku.rag[voyageai]"
) )
return VoyageAIEmbedder( model = VoyageAIEmbeddingModel(model_name)
embedding_model.name, embedding_model.vector_dim, config return EmbedderWrapper(Embedder(model), vector_dim)
if provider == "cohere":
return EmbedderWrapper(Embedder(f"cohere:{model_name}"), vector_dim)
if provider == "sentence-transformers":
return EmbedderWrapper(
Embedder(f"sentence-transformers:{model_name}"), vector_dim
) )
if embedding_model.provider == "openai": raise ValueError(f"Unsupported embedding provider: {provider}")
from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder
return OpenAIEmbedder(embedding_model.name, embedding_model.vector_dim, config)
if embedding_model.provider == "vllm":
from haiku.rag.embeddings.vllm import Embedder as VllmEmbedder
return VllmEmbedder(embedding_model.name, embedding_model.vector_dim, config)
if embedding_model.provider == "lm_studio":
from haiku.rag.embeddings.lm_studio import Embedder as LMStudioEmbedder
return LMStudioEmbedder(
embedding_model.name, embedding_model.vector_dim, config
)
raise ValueError(f"Unsupported embedding provider: {embedding_model.provider}")

View file

@ -1,25 +0,0 @@
from typing import overload
from haiku.rag.config import AppConfig, Config
class EmbedderBase:
_model: str = Config.embeddings.model.name
_vector_dim: int = Config.embeddings.model.vector_dim
_config: AppConfig = Config
def __init__(self, model: str, vector_dim: int, config: AppConfig = Config):
self._model = model
self._vector_dim = vector_dim
self._config = config
@overload
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]:
raise NotImplementedError(
"Embedder is an abstract class. Please implement the embed method in a subclass."
)

View file

@ -1,28 +0,0 @@
from typing import overload
from openai import AsyncOpenAI
from haiku.rag.embeddings.base import EmbedderBase
class Embedder(EmbedderBase): # pragma: no cover
@overload
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]:
client = AsyncOpenAI(
base_url=f"{self._config.providers.lm_studio.base_url}/v1", api_key="dummy"
)
if not text:
return []
response = await client.embeddings.create(
model=self._model,
input=text,
)
if isinstance(text, str):
return response.data[0].embedding
else:
return [item.embedding for item in response.data]

View file

@ -1,28 +0,0 @@
from typing import overload
from openai import AsyncOpenAI
from haiku.rag.embeddings.base import EmbedderBase
class Embedder(EmbedderBase):
@overload
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]:
client = AsyncOpenAI(
base_url=f"{self._config.providers.ollama.base_url}/v1", api_key="dummy"
)
if not text:
return []
response = await client.embeddings.create(
model=self._model,
input=text,
)
if isinstance(text, str):
return response.data[0].embedding
else:
return [item.embedding for item in response.data]

View file

@ -1,26 +0,0 @@
from typing import overload
from openai import AsyncOpenAI
from haiku.rag.embeddings.base import EmbedderBase
class Embedder(EmbedderBase):
@overload
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]:
client = AsyncOpenAI()
if not text:
return []
response = await client.embeddings.create(
model=self._model,
input=text,
)
if isinstance(text, str):
return response.data[0].embedding
else:
return [item.embedding for item in response.data]

View file

@ -1,29 +0,0 @@
from typing import overload
from openai import AsyncOpenAI
from haiku.rag.embeddings.base import EmbedderBase
class Embedder(EmbedderBase): # pragma: no cover
@overload
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]:
client = AsyncOpenAI(
base_url=f"{self._config.providers.vllm.embeddings_base_url}/v1",
api_key="dummy",
)
if not text:
return []
response = await client.embeddings.create(
model=self._model,
input=text,
)
if isinstance(text, str):
return response.data[0].embedding
else:
return [item.embedding for item in response.data]

View file

@ -1,27 +1,182 @@
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Literal, cast
from pydantic_ai.embeddings.base import EmbeddingModel
from pydantic_ai.embeddings.result import EmbeddingResult, EmbedInputType
from pydantic_ai.embeddings.settings import EmbeddingSettings
from pydantic_ai.exceptions import ModelAPIError
from pydantic_ai.usage import RequestUsage
try: try:
from typing import overload from voyageai.client_async import AsyncClient
from voyageai.error import VoyageError
except ImportError as _import_error:
raise ImportError(
"Please install `voyageai` to use the VoyageAI embeddings model, "
"you can use — `pip install voyageai`"
) from _import_error
from voyageai.client import Client # type: ignore LatestVoyageAIEmbeddingModelNames = Literal[
"voyage-3-large",
"voyage-3.5",
"voyage-3.5-lite",
"voyage-code-3",
"voyage-finance-2",
"voyage-law-2",
"voyage-code-2",
]
"""Latest VoyageAI embedding models.
from haiku.rag.embeddings.base import EmbedderBase See [VoyageAI Embeddings](https://docs.voyageai.com/docs/embeddings)
for available models and their capabilities.
"""
class Embedder(EmbedderBase): VoyageAIEmbeddingModelName = str | LatestVoyageAIEmbeddingModelNames
@overload """Possible VoyageAI embedding model names."""
async def embed(self, text: str) -> list[float]: ...
@overload
async def embed(self, text: list[str]) -> list[list[float]]: ...
async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: class VoyageAIEmbeddingSettings(EmbeddingSettings, total=False):
client = Client() """Settings used for a VoyageAI embedding model request.
if not text:
return []
if isinstance(text, str):
res = client.embed([text], model=self._model, output_dtype="float")
return res.embeddings[0] # type: ignore[return-value]
else:
res = client.embed(text, model=self._model, output_dtype="float")
return res.embeddings # type: ignore[return-value]
except ImportError: All fields from [`EmbeddingSettings`][pydantic_ai.embeddings.EmbeddingSettings] are supported,
pass plus VoyageAI-specific settings prefixed with `voyageai_`.
"""
# ALL FIELDS MUST BE `voyageai_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS.
voyageai_truncation: bool
"""Whether to truncate inputs that exceed the model's context length.
Defaults to True. If False, an error is raised for inputs that are too long.
"""
voyageai_output_dtype: Literal["float", "int8", "uint8", "binary", "ubinary"]
"""The output data type for embeddings.
- `'float'` (default): 32-bit floats
- `'int8'`: Signed 8-bit integers (quantized)
- `'uint8'`: Unsigned 8-bit integers (quantized)
- `'binary'`: Binary embeddings
- `'ubinary'`: Unsigned binary embeddings
"""
_MAX_INPUT_TOKENS: dict[VoyageAIEmbeddingModelName, int] = {
"voyage-3-large": 32000,
"voyage-3.5": 32000,
"voyage-3.5-lite": 32000,
"voyage-code-3": 32000,
"voyage-finance-2": 32000,
"voyage-law-2": 16000,
"voyage-code-2": 16000,
}
@dataclass(init=False)
class VoyageAIEmbeddingModel(EmbeddingModel):
"""VoyageAI embedding model implementation.
VoyageAI provides state-of-the-art embedding models optimized for
retrieval, with specialized models for code, finance, and legal domains.
Example:
```python
from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel
model = VoyageAIEmbeddingModel('voyage-3.5')
```
"""
_model_name: VoyageAIEmbeddingModelName = field(repr=False)
_client: AsyncClient = field(repr=False)
def __init__(
self,
model_name: VoyageAIEmbeddingModelName,
*,
api_key: str | None = None,
max_retries: int = 0,
timeout: int | None = None,
settings: EmbeddingSettings | None = None,
):
"""Initialize a VoyageAI embedding model.
Args:
model_name: The name of the VoyageAI model to use.
See [VoyageAI models](https://docs.voyageai.com/docs/embeddings)
for available options.
api_key: The VoyageAI API key. If not provided, uses the
`VOYAGE_API_KEY` environment variable.
max_retries: Maximum number of retries for failed requests.
timeout: Request timeout in seconds.
settings: Model-specific [`EmbeddingSettings`][pydantic_ai.embeddings.EmbeddingSettings]
to use as defaults for this model.
"""
self._model_name = model_name
self._client = AsyncClient(
api_key=api_key,
max_retries=max_retries,
timeout=timeout,
)
super().__init__(settings=settings)
@property
def model_name(self) -> VoyageAIEmbeddingModelName:
"""The embedding model name."""
return self._model_name
@property
def system(self) -> str:
"""The embedding model provider."""
return "voyageai"
async def embed(
self,
inputs: str | Sequence[str],
*,
input_type: EmbedInputType,
settings: EmbeddingSettings | None = None,
) -> EmbeddingResult:
inputs, settings = self.prepare_embed(inputs, settings)
settings = cast(VoyageAIEmbeddingSettings, settings)
voyageai_input_type = "document" if input_type == "document" else "query"
try:
response = await self._client.embed(
texts=list(inputs),
model=self.model_name,
input_type=voyageai_input_type,
truncation=settings.get("voyageai_truncation", True),
output_dtype=settings.get("voyageai_output_dtype", "float"),
output_dimension=settings.get("dimensions"),
)
except VoyageError as e:
raise ModelAPIError(model_name=self.model_name, message=str(e)) from e
return EmbeddingResult(
embeddings=response.embeddings,
inputs=inputs,
input_type=input_type,
usage=_map_usage(response.total_tokens, self.model_name),
model_name=self.model_name,
provider_name=self.system,
)
async def max_input_tokens(self) -> int | None:
return _MAX_INPUT_TOKENS.get(self.model_name)
def _map_usage(total_tokens: int, model: str) -> RequestUsage:
usage_data = {"total_tokens": total_tokens}
response_data = {"model": model, "usage": usage_data}
return RequestUsage.extract(
response_data,
provider="voyageai",
provider_url="https://api.voyageai.com",
provider_fallback="voyageai",
api_flavor="embeddings",
)

View file

@ -45,7 +45,10 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None:
try: try:
from haiku.rag.reranking.vllm import VLLMReranker from haiku.rag.reranking.vllm import VLLMReranker
reranker = VLLMReranker(config.reranking.model.name) base_url = config.reranking.model.base_url
if not base_url:
raise ValueError("vLLM reranker requires base_url in reranking.model")
reranker = VLLMReranker(config.reranking.model.name, base_url)
except ImportError: except ImportError:
reranker = None reranker = None

View file

@ -1,14 +1,13 @@
import httpx import httpx
from haiku.rag.config import Config
from haiku.rag.reranking.base import RerankerBase from haiku.rag.reranking.base import RerankerBase
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
class VLLMReranker(RerankerBase): # pragma: no cover class VLLMReranker(RerankerBase): # pragma: no cover
def __init__(self, model: str): def __init__(self, model: str, base_url: str):
self._model = model self._model = model
self._base_url = Config.providers.vllm.rerank_base_url self._base_url = base_url
async def rerank( async def rerank(
self, query: str, chunks: list[Chunk], top_n: int = 10 self, query: str, chunks: list[Chunk], top_n: int = 10

View file

@ -245,7 +245,7 @@ class ChunkRepository:
# Prepare search query based on search type # Prepare search query based on search type
if search_type == "vector": if search_type == "vector":
query_embedding = await self.embedder.embed(query) query_embedding = await self.embedder.embed_query(query)
vector_query = cast( vector_query = cast(
"LanceVectorQueryBuilder", "LanceVectorQueryBuilder",
self.store.chunks_table.search( self.store.chunks_table.search(
@ -260,7 +260,7 @@ class ChunkRepository:
results = self.store.chunks_table.search(query, query_type="fts") results = self.store.chunks_table.search(query, query_type="fts")
else: # hybrid (default) else: # hybrid (default)
query_embedding = await self.embedder.embed(query) query_embedding = await self.embedder.embed_query(query)
# Create RRF reranker # Create RRF reranker
reranker = RRFReranker() reranker = RRFReranker()
# Perform native hybrid search with RRF reranking # Perform native hybrid search with RRF reranking

View file

@ -135,11 +135,12 @@ def get_model(
model_settings, OpenAIChatModelSettings, model_config model_settings, OpenAIChatModelSettings, model_config
) )
# Use model-level base_url if set, otherwise fall back to providers config
base_url = model_config.base_url or f"{app_config.providers.ollama.base_url}/v1"
return OpenAIChatModel( return OpenAIChatModel(
model_name=model, model_name=model,
provider=OllamaProvider( provider=OllamaProvider(base_url=base_url),
base_url=f"{app_config.providers.ollama.base_url}/v1"
),
settings=model_settings, settings=model_settings,
) )
@ -159,6 +160,14 @@ def get_model(
openai_settings, OpenAIChatModelSettings, model_config openai_settings, OpenAIChatModelSettings, model_config
) )
# Use model-level base_url if set (for vLLM, LM Studio, etc.)
if model_config.base_url:
return OpenAIChatModel(
model_name=model,
provider=OpenAIProvider(base_url=model_config.base_url),
settings=openai_settings,
)
return OpenAIChatModel(model_name=model, settings=openai_settings) return OpenAIChatModel(model_name=model, settings=openai_settings)
elif provider == "anthropic": elif provider == "anthropic":
@ -264,52 +273,6 @@ def get_model(
return BedrockConverseModel(model_name=model, settings=bedrock_settings) return BedrockConverseModel(model_name=model, settings=bedrock_settings)
elif provider == "vllm":
vllm_settings = None
# Apply thinking control for gpt-oss
if model == "gpt-oss" and model_config.enable_thinking is not None:
if model_config.enable_thinking is False:
vllm_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
else:
vllm_settings = OpenAIChatModelSettings(openai_reasoning_effort="high")
vllm_settings = apply_common_settings(
vllm_settings, OpenAIChatModelSettings, model_config
)
return OpenAIChatModel(
model_name=model,
provider=OpenAIProvider(
base_url=f"{app_config.providers.vllm.research_base_url or app_config.providers.vllm.qa_base_url}/v1",
api_key="none",
),
settings=vllm_settings,
)
elif provider == "lm_studio":
model_settings = None
# Apply thinking control for gpt-oss
if model == "gpt-oss" and model_config.enable_thinking is not None:
if model_config.enable_thinking is False:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
else:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="high")
model_settings = apply_common_settings(
model_settings, OpenAIChatModelSettings, model_config
)
return OpenAIChatModel(
model_name=model,
provider=OpenAIProvider(
base_url=f"{app_config.providers.lm_studio.base_url}/v1",
api_key="dummy",
),
settings=model_settings,
)
else: else:
# For any other provider, use string format and let Pydantic AI handle it # For any other provider, use string format and let Pydantic AI handle it
return f"{provider}:{model}" return f"{provider}:{model}"

View file

@ -27,7 +27,7 @@ dependencies = [
"lancedb==0.26.0", "lancedb==0.26.0",
"pathspec>=0.12.1", "pathspec>=0.12.1",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-ai-slim[openai,fastmcp,logfire,ag-ui]==1.36.0", "pydantic-ai-slim[openai,fastmcp,logfire,ag-ui]>=1.39.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"rich>=14.2.0", "rich>=14.2.0",

View file

@ -3,20 +3,16 @@ import os
import numpy as np import numpy as np
import pytest import pytest
from haiku.rag.config import Config from haiku.rag.config import AppConfig, EmbeddingModelConfig, EmbeddingsConfig
from haiku.rag.embeddings import contextualize, embed_chunks from haiku.rag.embeddings import contextualize, embed_chunks, get_embedder
from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder
from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder
from haiku.rag.embeddings.vllm import Embedder as VLLMEmbedder
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY")) OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY"))
VOYAGEAI_AVAILABLE = bool(os.getenv("VOYAGE_API_KEY")) VOYAGEAI_AVAILABLE = bool(os.getenv("VOYAGE_API_KEY"))
VLLM_EMBEDDINGS_AVAILABLE = bool(Config.providers.vllm.embeddings_base_url)
# Calculate cosine similarity
def similarities(embeddings, test_embedding): def similarities(embeddings, test_embedding):
"""Calculate cosine similarity between embeddings and a test embedding."""
return [ return [
np.dot(embedding, test_embedding) np.dot(embedding, test_embedding)
/ (np.linalg.norm(embedding) * np.linalg.norm(test_embedding)) / (np.linalg.norm(embedding) * np.linalg.norm(test_embedding))
@ -26,35 +22,41 @@ def similarities(embeddings, test_embedding):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ollama_embedder(): async def test_ollama_embedder():
embedder = OllamaEmbedder("mxbai-embed-large", 1024) """Test Ollama embedder via pydantic-ai."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="ollama", name="mxbai-embed-large", vector_dim=1024
)
)
)
embedder = get_embedder(config)
phrases = [ phrases = [
"I enjoy eating great food.", "I enjoy eating great food.",
"Python is my favorite programming language.", "Python is my favorite programming language.",
"I love to travel and see new places.", "I love to travel and see new places.",
] ]
# Test batch embedding # Test batch embedding (documents)
embeddings = await embedder.embed(phrases) embeddings = await embedder.embed_documents(phrases)
assert isinstance(embeddings, list) assert isinstance(embeddings, list)
assert len(embeddings) == 3 assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings) assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings] embeddings = [np.array(emb) for emb in embeddings]
# Test query embedding
test_phrase = "I am going for a camping trip." test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2] assert max(sims) == sims[2]
test_phrase = "When is dinner ready?" test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0] assert max(sims) == sims[0]
test_phrase = "I work as a software developer." test_phrase = "I work as a software developer."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1] assert max(sims) == sims[1]
@ -62,35 +64,41 @@ async def test_ollama_embedder():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI API key not available") @pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI API key not available")
async def test_openai_embedder(): async def test_openai_embedder():
embedder = OpenAIEmbedder("text-embedding-3-small", 1536) """Test OpenAI embedder via pydantic-ai."""
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="openai", name="text-embedding-3-small", vector_dim=1536
)
)
)
embedder = get_embedder(config)
phrases = [ phrases = [
"I enjoy eating great food.", "I enjoy eating great food.",
"Python is my favorite programming language.", "Python is my favorite programming language.",
"I love to travel and see new places.", "I love to travel and see new places.",
] ]
# Test batch embedding # Test batch embedding (documents)
embeddings = await embedder.embed(phrases) embeddings = await embedder.embed_documents(phrases)
assert isinstance(embeddings, list) assert isinstance(embeddings, list)
assert len(embeddings) == 3 assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings) assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings] embeddings = [np.array(emb) for emb in embeddings]
# Test query embedding
test_phrase = "I am going for a camping trip." test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2] assert max(sims) == sims[2]
test_phrase = "When is dinner ready?" test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0] assert max(sims) == sims[0]
test_phrase = "I work as a software developer." test_phrase = "I work as a software developer."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1] assert max(sims) == sims[1]
@ -98,38 +106,42 @@ async def test_openai_embedder():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(not VOYAGEAI_AVAILABLE, reason="VoyageAI API key not available") @pytest.mark.skipif(not VOYAGEAI_AVAILABLE, reason="VoyageAI API key not available")
async def test_voyageai_embedder(): async def test_voyageai_embedder():
"""Test VoyageAI embedder."""
try: try:
from haiku.rag.embeddings.voyageai import Embedder as VoyageAIEmbedder config = AppConfig(
embeddings=EmbeddingsConfig(
embedder = VoyageAIEmbedder("voyage-3.5", 1024) model=EmbeddingModelConfig(
provider="voyageai", name="voyage-3.5", vector_dim=1024
)
)
)
embedder = get_embedder(config)
phrases = [ phrases = [
"I enjoy eating great food.", "I enjoy eating great food.",
"Python is my favorite programming language.", "Python is my favorite programming language.",
"I love to travel and see new places.", "I love to travel and see new places.",
] ]
# Test batch embedding # Test batch embedding (documents)
embeddings = await embedder.embed(phrases) embeddings = await embedder.embed_documents(phrases)
assert isinstance(embeddings, list) assert isinstance(embeddings, list)
assert len(embeddings) == 3 assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings) assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings] embeddings = [np.array(emb) for emb in embeddings]
# Test query embedding
test_phrase = "I am going for a camping trip." test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2] assert max(sims) == sims[2]
test_phrase = "When is dinner ready?" test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0] assert max(sims) == sims[0]
test_phrase = "I work as a software developer." test_phrase = "I work as a software developer."
test_embedding = await embedder.embed(test_phrase) test_embedding = await embedder.embed_query(test_phrase)
sims = similarities(embeddings, test_embedding) sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1] assert max(sims) == sims[1]
@ -137,44 +149,6 @@ async def test_voyageai_embedder():
pytest.skip("VoyageAI package not installed") pytest.skip("VoyageAI package not installed")
@pytest.mark.asyncio
@pytest.mark.skipif(
not VLLM_EMBEDDINGS_AVAILABLE, reason="vLLM embeddings server not configured"
)
async def test_vllm_embedder():
embedder = VLLMEmbedder("mixedbread-ai/mxbai-embed-large-v1", 512)
phrases = [
"I enjoy eating great food.",
"Python is my favorite programming language.",
"I love to travel and see new places.",
]
# Test batch embedding
embeddings = await embedder.embed(phrases)
assert isinstance(embeddings, list)
assert len(embeddings) == 3
assert all(isinstance(emb, list) for emb in embeddings)
embeddings = [np.array(emb) for emb in embeddings]
test_phrase = "I am going for a camping trip."
test_embedding = await embedder.embed(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[2]
test_phrase = "When is dinner ready?"
test_embedding = await embedder.embed(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[0]
test_phrase = "I work as a software developer."
test_embedding = await embedder.embed(test_phrase)
sims = similarities(embeddings, test_embedding)
assert max(sims) == sims[1]
def test_contextualize_with_headings(): def test_contextualize_with_headings():
"""Test that contextualize prepends headings to chunk content.""" """Test that contextualize prepends headings to chunk content."""
chunks = [ chunks = [

View file

@ -4,16 +4,14 @@ from haiku.rag.config import (
AppConfig, AppConfig,
EmbeddingModelConfig, EmbeddingModelConfig,
EmbeddingsConfig, EmbeddingsConfig,
LMStudioConfig,
OllamaConfig, OllamaConfig,
ProvidersConfig, ProvidersConfig,
VLLMConfig,
) )
from haiku.rag.embeddings import get_embedder from haiku.rag.embeddings import get_embedder
def test_embedder_uses_config_from_get_embedder(): def test_ollama_embedder_uses_config():
"""Test that embedders use the config passed to get_embedder.""" """Test that Ollama embedder uses the config passed to get_embedder."""
custom_config = AppConfig( custom_config = AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig( model=EmbeddingModelConfig(
@ -22,41 +20,16 @@ def test_embedder_uses_config_from_get_embedder():
), ),
providers=ProvidersConfig( providers=ProvidersConfig(
ollama=OllamaConfig(base_url="http://custom-ollama:8080"), ollama=OllamaConfig(base_url="http://custom-ollama:8080"),
vllm=VLLMConfig(embeddings_base_url="http://custom-vllm:9000"),
), ),
) )
embedder = get_embedder(custom_config) embedder = get_embedder(custom_config)
assert embedder._model == "custom-model"
assert embedder._vector_dim == 512 assert embedder._vector_dim == 512
assert embedder._config.providers.ollama.base_url == "http://custom-ollama:8080"
def test_vllm_embedder_uses_config():
"""Test that vllm embedder uses the config passed to get_embedder."""
custom_config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="vllm", name="custom-vllm-model", vector_dim=768
),
),
providers=ProvidersConfig(
vllm=VLLMConfig(embeddings_base_url="http://custom-vllm:9001"),
),
)
embedder = get_embedder(custom_config)
assert embedder._model == "custom-vllm-model"
assert embedder._vector_dim == 768
assert (
embedder._config.providers.vllm.embeddings_base_url == "http://custom-vllm:9001"
)
def test_openai_embedder_uses_config(): def test_openai_embedder_uses_config():
"""Test that openai embedder uses the config passed to get_embedder.""" """Test that OpenAI embedder uses the config passed to get_embedder."""
custom_config = AppConfig( custom_config = AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig( model=EmbeddingModelConfig(
@ -67,48 +40,68 @@ def test_openai_embedder_uses_config():
embedder = get_embedder(custom_config) embedder = get_embedder(custom_config)
assert embedder._model == "text-embedding-3-large"
assert embedder._vector_dim == 3072 assert embedder._vector_dim == 3072
assert embedder._config == custom_config
def test_lm_studio_embedder_uses_config(): def test_openai_embedder_with_base_url():
"""Test that lm_studio embedder uses the config passed to get_embedder.""" """Test that OpenAI embedder uses custom base_url for vLLM/LM Studio."""
custom_config = AppConfig( custom_config = AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig( model=EmbeddingModelConfig(
provider="lm_studio", name="custom-lm-studio-model", vector_dim=1024 provider="openai",
name="some-local-model",
vector_dim=768,
base_url="http://localhost:8000/v1",
),
),
)
embedder = get_embedder(custom_config)
assert embedder._vector_dim == 768
def test_cohere_embedder_uses_config():
"""Test that Cohere embedder uses the config passed to get_embedder."""
custom_config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="cohere", name="embed-v4.0", vector_dim=1024
), ),
), ),
providers=ProvidersConfig(
lm_studio=LMStudioConfig(base_url="http://custom-lmstudio:5678"),
),
) )
embedder = get_embedder(custom_config) embedder = get_embedder(custom_config)
assert embedder._model == "custom-lm-studio-model"
assert embedder._vector_dim == 1024 assert embedder._vector_dim == 1024
assert (
embedder._config.providers.lm_studio.base_url == "http://custom-lmstudio:5678"
)
@pytest.mark.skipif( def test_sentence_transformers_embedder_uses_config():
True, reason="VoyageAI is an optional dependency, may not be installed" """Test that SentenceTransformers embedder uses the config."""
)
def test_voyageai_embedder_uses_config():
"""Test that voyageai embedder uses the config passed to get_embedder."""
custom_config = AppConfig( custom_config = AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig( model=EmbeddingModelConfig(
provider="voyageai", name="voyage-large-2", vector_dim=1536 provider="sentence-transformers",
name="all-MiniLM-L6-v2",
vector_dim=384,
), ),
), ),
) )
embedder = get_embedder(custom_config) embedder = get_embedder(custom_config)
assert embedder._model == "voyage-large-2" assert embedder._vector_dim == 384
assert embedder._vector_dim == 1536
assert embedder._config == custom_config
def test_unsupported_provider_raises():
"""Test that unsupported provider raises ValueError."""
custom_config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="unsupported-provider", name="model", vector_dim=512
),
),
)
with pytest.raises(ValueError, match="Unsupported embedding provider"):
get_embedder(custom_config)

View file

@ -5,13 +5,11 @@ from datasets import Dataset
from evaluations.evaluators import LLMJudge from evaluations.evaluators import LLMJudge
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config import Config
from haiku.rag.config.models import ModelConfig from haiku.rag.config.models import ModelConfig
from haiku.rag.qa.agent import QuestionAnswerAgent from haiku.rag.qa.agent import QuestionAnswerAgent
OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY")) OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY"))
ANTHROPIC_AVAILABLE = bool(os.getenv("ANTHROPIC_API_KEY")) ANTHROPIC_AVAILABLE = bool(os.getenv("ANTHROPIC_API_KEY"))
VLLM_QA_AVAILABLE = bool(Config.providers.vllm.qa_base_url)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -87,26 +85,3 @@ async def test_qa_anthropic(qa_corpus: Dataset, temp_db_path):
assert is_equivalent, ( assert is_equivalent, (
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}" f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
) )
@pytest.mark.asyncio
@pytest.mark.skipif(not VLLM_QA_AVAILABLE, reason="vLLM QA server not configured")
async def test_qa_vllm(qa_corpus: Dataset, temp_db_path):
"""Test vLLM QA with LLM judge."""
client = HaikuRAG(temp_db_path, create=True)
qa = QuestionAnswerAgent(client, ModelConfig(provider="vllm", name="Qwen/Qwen3-4B"))
llm_judge = LLMJudge()
doc = qa_corpus[1]
await client.create_document(
content=doc["document_extracted"], uri=doc["document_id"]
)
question = doc["question"]
expected_answer = doc["answer"]
answer, _ = await qa.answer(question)
is_equivalent = await llm_judge.judge_answers(question, answer, expected_answer)
assert is_equivalent, (
f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}"
)

View file

@ -2,13 +2,12 @@ import os
import pytest import pytest
from haiku.rag.config import Config
from haiku.rag.reranking.base import RerankerBase from haiku.rag.reranking.base import RerankerBase
from haiku.rag.reranking.vllm import VLLMReranker from haiku.rag.reranking.vllm import VLLMReranker
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
COHERE_AVAILABLE = bool(os.getenv("CO_API_KEY")) COHERE_AVAILABLE = bool(os.getenv("CO_API_KEY"))
VLLM_RERANK_AVAILABLE = bool(Config.providers.vllm.rerank_base_url) VLLM_RERANK_BASE_URL = os.getenv("VLLM_RERANK_BASE_URL", "")
ZEROENTROPY_AVAILABLE = bool(os.getenv("ZEROENTROPY_API_KEY")) ZEROENTROPY_AVAILABLE = bool(os.getenv("ZEROENTROPY_API_KEY"))
chunks = [ chunks = [
@ -41,6 +40,7 @@ async def test_reranker_base():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mxbai_reranker(): async def test_mxbai_reranker():
try: try:
from haiku.rag.config import Config
from haiku.rag.config.models import ModelConfig from haiku.rag.config.models import ModelConfig
from haiku.rag.reranking.mxbai import MxBAIReranker from haiku.rag.reranking.mxbai import MxBAIReranker
@ -80,11 +80,13 @@ async def test_cohere_reranker():
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif( @pytest.mark.skipif(
not VLLM_RERANK_AVAILABLE, reason="vLLM rerank server not configured" not VLLM_RERANK_BASE_URL, reason="vLLM rerank server not configured"
) )
async def test_vllm_reranker(): async def test_vllm_reranker():
try: try:
reranker = VLLMReranker("mixedbread-ai/mxbai-rerank-base-v2") reranker = VLLMReranker(
"mixedbread-ai/mxbai-rerank-base-v2", VLLM_RERANK_BASE_URL
)
reranked = await reranker.rerank( reranked = await reranker.rerank(
"Who wrote 'To Kill a Mockingbird'?", chunks, top_n=2 "Who wrote 'To Kill a Mockingbird'?", chunks, top_n=2

View file

@ -270,20 +270,6 @@ def test_get_model_bedrock_with_thinking():
assert isinstance(result, BedrockConverseModel) assert isinstance(result, BedrockConverseModel)
def test_get_model_vllm():
"""Test get_model returns OpenAIChatModel for vLLM."""
model_config = ModelConfig(provider="vllm", name="Qwen/Qwen3-4B")
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
def test_get_model_vllm_with_thinking():
"""Test get_model configures thinking for gpt-oss on vLLM."""
model_config = ModelConfig(provider="vllm", name="gpt-oss", enable_thinking=False)
result = get_model(model_config)
assert isinstance(result, OpenAIChatModel)
def test_get_model_unknown_provider(): def test_get_model_unknown_provider():
"""Test get_model returns string format for unknown providers.""" """Test get_model returns string format for unknown providers."""
model_config = ModelConfig(provider="mistral", name="mistral-large-latest") model_config = ModelConfig(provider="mistral", name="mistral-large-latest")

92
uv.lock
View file

@ -294,30 +294,30 @@ wheels = [
[[package]] [[package]]
name = "boto3" name = "boto3"
version = "1.40.75" version = "1.42.16"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
{ name = "jmespath" }, { name = "jmespath" },
{ name = "s3transfer" }, { name = "s3transfer" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8f/2c/0a6e49612ef9868382ef76292da2dd1dee7c74a0f1ec95323e76f6e2ef4b/boto3-1.40.75.tar.gz", hash = "sha256:a5219a2f397f8616462d7908e696c281f120aa2d8458280ff24f7ddeb2108faf", size = 111629, upload-time = "2025-11-17T21:58:37.667Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/37/22c82e9d314d769a6eaf600ce5e08357927b5c6a614bfbeb1e7b7e7aa036/boto3-1.42.16.tar.gz", hash = "sha256:811391611db88c8a061f6e6fabbd7ca784ad9de04490a879f091cbaa9de7de74", size = 112834, upload-time = "2025-12-23T20:44:21.286Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/85/2b0ea3ca19447d3a681b59b712a8f7861bfd0bc0129efd8a2da09d272837/boto3-1.40.75-py3-none-any.whl", hash = "sha256:c246fb35d9978b285c5b827a20b81c9e77d52f99c9d175fbd91f14396432953f", size = 139360, upload-time = "2025-11-17T21:58:36.181Z" }, { url = "https://files.pythonhosted.org/packages/f8/93/78d175e7d40941c4b608a6701a14215aeeb1db58499dbbc40467a6fd6116/boto3-1.42.16-py3-none-any.whl", hash = "sha256:37a43d42aebd06a8f93ee801ea1b7b5181ac42a30869ef403c9dadc160a748e5", size = 140574, upload-time = "2025-12-23T20:44:20.121Z" },
] ]
[[package]] [[package]]
name = "botocore" name = "botocore"
version = "1.40.75" version = "1.42.16"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d7/11/a6a07cbe12e0161063f2dac82bb7a8f48f649b394863315cd6f3149b82ac/botocore-1.40.75.tar.gz", hash = "sha256:bf8b067209fee5a9738800d41852e113b8ebdb01bd7f1e8b4541d55ecdbdb8f3", size = 14475952, upload-time = "2025-11-17T21:58:27.24Z" } sdist = { url = "https://files.pythonhosted.org/packages/cf/eb/d91fb1fb288ba896392d68f89881f5f26bc5b51f8da28697c77f05bc44e8/botocore-1.42.16.tar.gz", hash = "sha256:29ee8555cd5d5023350405387cedcf3fe1c7f02fcb8060bf9e01602487482c25", size = 14914600, upload-time = "2025-12-23T20:44:11.025Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/29/15627031629f27230ee38bc7f55328b310794010c3039f0ecd353c06dc63/botocore-1.40.75-py3-none-any.whl", hash = "sha256:e822004688ca8035c518108e27d5b450d3ab0e0b3a73bcb8b87b80a8e5bd1910", size = 14141572, upload-time = "2025-11-17T21:58:23.896Z" }, { url = "https://files.pythonhosted.org/packages/a2/98/c7c26ff399994e2b1119cc36027aaae46b9d646a49b70a82c2622e44c94b/botocore-1.42.16-py3-none-any.whl", hash = "sha256:b1f584a0f8645c12e07bf6ec9c18e05221a789f2a9b2d3c6291deb42f8c1c542", size = 14585775, upload-time = "2025-12-23T20:44:08.092Z" },
] ]
[[package]] [[package]]
@ -1133,15 +1133,15 @@ http = [
[[package]] [[package]]
name = "genai-prices" name = "genai-prices"
version = "0.0.47" version = "0.0.49"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b8/47/f25fb84fa40142699dc54ca294628d600625eb3d90fead103a606b4e999a/genai_prices-0.0.47.tar.gz", hash = "sha256:3b8c514f0ce5818b3944a371861586ed9bfe10d02598e62c350b5bd2916d03c2", size = 54501, upload-time = "2025-11-25T18:38:17.695Z" } sdist = { url = "https://files.pythonhosted.org/packages/0d/aa/81f76b90f8d1a7dcd9297bd8bf664927ae2a1efe40fe5d1a8856dc721359/genai_prices-0.0.49.tar.gz", hash = "sha256:a7f98f1537e6f89ed54f1cd8f560806e187033dcb42554fbecd4d635567120c5", size = 57852, upload-time = "2025-12-17T10:47:29.345Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/84/d50c52d0eeadb9dbf7f2f86da9b6257e162b7c6a791f5b1009bae912c103/genai_prices-0.0.47-py3-none-any.whl", hash = "sha256:735e45950d2299276f2c00cd18075b77a124cd24ee58243f236ee29af3210594", size = 57000, upload-time = "2025-11-25T18:38:16.464Z" }, { url = "https://files.pythonhosted.org/packages/e4/1e/1d51238dd164dde10c4e3be6ad2d8f26dd34dd262117c277440e2b5dc7c0/genai_prices-0.0.49-py3-none-any.whl", hash = "sha256:dd3efbebcd865d89cd849793530729e7f7e1ca59d2b17a091ad1aa6aa76daf0d", size = 60433, upload-time = "2025-12-17T10:47:28.3Z" },
] ]
[[package]] [[package]]
@ -1389,7 +1389,7 @@ requires-dist = [
{ name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 'google'" }, { name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 'google'" },
{ name = "pydantic-ai-slim", extras = ["groq"], marker = "extra == 'groq'" }, { name = "pydantic-ai-slim", extras = ["groq"], marker = "extra == 'groq'" },
{ name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" }, { name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" },
{ name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire", "ag-ui"], specifier = "==1.36.0" }, { name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire", "ag-ui"], specifier = ">=1.39.0" },
{ name = "pydantic-ai-slim", extras = ["vertexai"], marker = "extra == 'vertexai'" }, { name = "pydantic-ai-slim", extras = ["vertexai"], marker = "extra == 'vertexai'" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
@ -3385,7 +3385,7 @@ email = [
[[package]] [[package]]
name = "pydantic-ai-slim" name = "pydantic-ai-slim"
version = "1.36.0" version = "1.39.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "genai-prices" }, { name = "genai-prices" },
@ -3396,9 +3396,9 @@ dependencies = [
{ name = "pydantic-graph" }, { name = "pydantic-graph" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/83/ba/5da826cabf9f8608ab7ad2f0fd29041bdd470454e5063591e303ca2afb9a/pydantic_ai_slim-1.36.0.tar.gz", hash = "sha256:f5845b4dbadb2b751ef67f192db786b51fa7190eee7c8dd8ad4c56877c99e2b4", size = 348985, upload-time = "2025-12-19T00:54:32.992Z" } sdist = { url = "https://files.pythonhosted.org/packages/91/cb/542ad43e06da09104ef3443556e629d9aa260f9d584da8f7a410fb3a07e5/pydantic_ai_slim-1.39.0.tar.gz", hash = "sha256:e8cea9fc8f6149347c3e1d489b0ed2d541b4789e0583819f116284145d22fa69", size = 368962, upload-time = "2025-12-24T03:34:11.306Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/a5/2315cf9941b32b8f34ff702edd83f017aac716ad5547be28fe06ef9d49d3/pydantic_ai_slim-1.36.0-py3-none-any.whl", hash = "sha256:6b04361b12b652bda0f447f948d8920fe223423507c8ad42e9b4a7ba6638dcd9", size = 455289, upload-time = "2025-12-19T00:54:25.144Z" }, { url = "https://files.pythonhosted.org/packages/2a/df/86381632be07b7df2e8e5880a1f18c6ee98122adf5848d329fee239a03b2/pydantic_ai_slim-1.39.0-py3-none-any.whl", hash = "sha256:8669d1781eba7713870bf76783e1e853577d5e55eb2986a27d49bc600889aaaf", size = 484906, upload-time = "2025-12-24T03:34:03.179Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -3432,6 +3432,7 @@ mistral = [
] ]
openai = [ openai = [
{ name = "openai" }, { name = "openai" },
{ name = "tiktoken" },
] ]
vertexai = [ vertexai = [
{ name = "google-auth" }, { name = "google-auth" },
@ -3511,7 +3512,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic-evals" name = "pydantic-evals"
version = "1.36.0" version = "1.39.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -3521,14 +3522,14 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "rich" }, { name = "rich" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/22/df/5426c81f0798b29fcb43be8e2fcec8de6fd89ae21f7610f9877bf6565dbe/pydantic_evals-1.36.0.tar.gz", hash = "sha256:6407693f111fdd60b1c27587160878dd5b21d179da8f949481df0ede75c0053a", size = 47177, upload-time = "2025-12-19T00:54:34.568Z" } sdist = { url = "https://files.pythonhosted.org/packages/20/20/ec455c7d32fde2022805870daf78581c9c493a4fcae6f32204fae5025658/pydantic_evals-1.39.0.tar.gz", hash = "sha256:6f8a754ca84afff3f2b2de9802fb0e12f69d9fc0a0411e2f7c9709fc09fb43b3", size = 47179, upload-time = "2025-12-24T03:34:12.477Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/f5/51363df66f79e6ecb502a518b5ad75362cbbe83adacd1247c93dad54a979/pydantic_evals-1.36.0-py3-none-any.whl", hash = "sha256:bc4b523d437fc20d1e05ed34fd29b6e0cb2b9516cf8db8aa0a408937650efafe", size = 56347, upload-time = "2025-12-19T00:54:26.915Z" }, { url = "https://files.pythonhosted.org/packages/f6/c1/6d43ecd3f7acb78a3f683178d40008d486c08583ca848891f000d62c142e/pydantic_evals-1.39.0-py3-none-any.whl", hash = "sha256:18470ade5fea15d17911a517e37ea98700702d9ba011ef2facb707e87eae0564", size = 56347, upload-time = "2025-12-24T03:34:05.111Z" },
] ]
[[package]] [[package]]
name = "pydantic-graph" name = "pydantic-graph"
version = "1.36.0" version = "1.39.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@ -3536,9 +3537,9 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/bc/0b/44bb922496ee99e9fc85fcca57ea25944bbe4e0b4205bdb8b8457e98e4f2/pydantic_graph-1.36.0.tar.gz", hash = "sha256:6f7309bac6841fec44eab5067413b35300804781d41d705338e140d43c65f99d", size = 58451, upload-time = "2025-12-19T00:54:35.711Z" } sdist = { url = "https://files.pythonhosted.org/packages/09/d5/2f45d1fd2ae0ba89b5a70b3bec8c2e910c4891fe0ed7e4fc896ca7e126a0/pydantic_graph-1.39.0.tar.gz", hash = "sha256:08c6f349dbbade6f4cdaaed02de4e8d75b9a37d44f8238e40a14f94f6a31761f", size = 58453, upload-time = "2025-12-24T03:34:13.766Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/df/5897a8c8367da6f5efa26a67f13f127723e006f69619ca2a8b648d066206/pydantic_graph-1.36.0-py3-none-any.whl", hash = "sha256:3337a4908056ff84e596425191fed04abc7fa381bc3b78e739470e04ead4cba1", size = 72327, upload-time = "2025-12-19T00:54:28.892Z" }, { url = "https://files.pythonhosted.org/packages/65/e2/719de1af767863359278e8b69c538dba9a7dbd19bb94111206e57ca34648/pydantic_graph-1.39.0-py3-none-any.whl", hash = "sha256:e0f89fc2c7ab111ae5f38dd2d88c5d26a0784eaabe95735c2b4087b0b512cc2d", size = 72327, upload-time = "2025-12-24T03:34:06.476Z" },
] ]
[[package]] [[package]]
@ -4217,14 +4218,14 @@ wheels = [
[[package]] [[package]]
name = "s3transfer" name = "s3transfer"
version = "0.14.0" version = "0.16.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
] ]
[[package]] [[package]]
@ -4523,6 +4524,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/0e/2c3c2972ee810595089d85a51107f45e41c0642f645c24675951c69fd648/textual_image-0.8.4-py3-none-any.whl", hash = "sha256:0f0256993348f5af619c930a4839ea190525a22a56e8d69e1cf0f8e32d59fa3b", size = 109608, upload-time = "2025-09-02T19:09:10.707Z" }, { url = "https://files.pythonhosted.org/packages/5b/0e/2c3c2972ee810595089d85a51107f45e41c0642f645c24675951c69fd648/textual_image-0.8.4-py3-none-any.whl", hash = "sha256:0f0256993348f5af619c930a4839ea190525a22a56e8d69e1cf0f8e32d59fa3b", size = 109608, upload-time = "2025-09-02T19:09:10.707Z" },
] ]
[[package]]
name = "tiktoken"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "regex" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
{ url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
{ url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
{ url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
{ url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
{ url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
{ url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
{ url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
{ url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
{ url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
{ url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
{ url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
{ url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
{ url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
{ url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
{ url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
{ url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
[[package]] [[package]]
name = "tokenizers" name = "tokenizers"
version = "0.22.1" version = "0.22.1"