Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/rig-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- *(providers)* [**breaking**] Collapse the hand-rolled Together, OpenRouter, and Mistral embedding models onto the shared `GenericEmbeddingModel<Ext>`, routed through a new `OpenAIEmbeddingsCompatible` path hook (mirroring `OpenAICompatibleProvider::completion_path`). Together embeddings now honor `dimensions` (via `ndims`), `encoding_format`, and `user`, which the previous implementation silently dropped. Removes the now-redundant per-provider embedding types: `together::{EmbeddingResponse, EmbeddingData, Usage}` and the `together_ai_api_types` module, `openrouter::{EncodingFormat, EmbeddingResponse, EmbeddingData}`, and `mistral::{EmbeddingResponse, EmbeddingData}`.
- *(core)* [**breaking**] Mark `PromptError`, `StructuredOutputError`, `ToolError`, `ToolSetError`, and `VectorStoreError` as non-exhaustive, requiring downstream match expressions to include a wildcard arm. Conversation memory load failures now surface as the typed `PromptError::MemoryError` variant instead of `CompletionError::RequestError`.

## [0.40.0](https://github.com/0xPlaygrounds/rig/compare/rig-core-v0.39.0...rig-core-v0.40.0) - 2026-07-10
Expand Down
4 changes: 4 additions & 0 deletions crates/rig-core/src/providers/llamafile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ impl openai::completion::OpenAICompatibleProvider for LlamafileExt {
type Response = openai::CompletionResponse;
}

// llama.cpp's `build_uri` injects the `/v1/` segment, so the default
// `/embeddings` path is correct.
impl openai::embedding::OpenAIEmbeddingsCompatible for LlamafileExt {}

impl<H> Capabilities<H> for LlamafileExt {
type Completion = Capable<openai::completion::GenericCompletionModel<LlamafileExt, H>>;
type Embeddings = Capable<openai::embedding::GenericEmbeddingModel<LlamafileExt, H>>;
Expand Down
19 changes: 7 additions & 12 deletions crates/rig-core/src/providers/mistral/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ impl crate::providers::openai::completion::OpenAICompatibleProvider for MistralE
}
}

impl crate::providers::openai::embedding::OpenAIEmbeddingsCompatible for MistralExt {
// The client base URL is the bare host, so the version segment is explicit.
Comment thread
ojhermann marked this conversation as resolved.
Outdated
fn embeddings_path(&self) -> String {
"/v1/embeddings".to_string()
}
}

impl<H> Capabilities<H> for MistralExt {
type Completion = Capable<super::CompletionModel<H>>;
type Embeddings = Capable<super::EmbeddingModel<H>>;
Expand Down Expand Up @@ -217,18 +224,6 @@ impl std::fmt::Display for Usage {
}
}

#[derive(Debug, Deserialize)]
pub struct ApiErrorResponse {
pub(crate) message: String,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ApiResponse<T> {
Ok(T),
Err(ApiErrorResponse),
}

#[cfg(test)]
mod tests {
#[test]
Expand Down
141 changes: 10 additions & 131 deletions crates/rig-core/src/providers/mistral/embedding.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
use serde::Deserialize;
use serde_json::json;
//! Mistral's embeddings endpoint is OpenAI-compatible, so [`EmbeddingModel`] is
//! a thin alias over the shared [`GenericEmbeddingModel`]. The `/v1/embeddings`
//! path is supplied by [`MistralExt`](super::client::MistralExt)'s
//! [`OpenAIEmbeddingsCompatible`](crate::providers::openai::embedding::OpenAIEmbeddingsCompatible)
//! implementation in `client.rs`.
Comment thread
ojhermann marked this conversation as resolved.
Outdated

use crate::{
embeddings::{self, EmbeddingError},
http_client::{self, HttpClientExt},
};

use super::client::{ApiResponse, Client, Usage};
use super::client::MistralExt;
use crate::providers::openai::embedding::GenericEmbeddingModel;

// ================================================================
// Mistral Embedding API
Expand All @@ -15,126 +14,6 @@ pub const MISTRAL_EMBED: &str = "mistral-embed";

pub const MAX_DOCUMENTS: usize = 1024;

#[derive(Clone)]
pub struct EmbeddingModel<T = reqwest::Client> {
client: Client<T>,
pub model: String,
ndims: usize,
}

impl<T> EmbeddingModel<T> {
pub fn new(client: Client<T>, model: impl Into<String>, ndims: usize) -> Self {
Self {
client,
model: model.into(),
ndims,
}
}

pub fn with_model(client: Client<T>, model: &str, ndims: usize) -> Self {
Self {
client,
model: model.to_string(),
ndims,
}
}
}

impl<T> embeddings::EmbeddingModel for EmbeddingModel<T>
where
T: HttpClientExt + Clone + 'static,
{
type Client = Client<T>;

const MAX_DOCUMENTS: usize = MAX_DOCUMENTS;

fn make(client: &Self::Client, model: impl Into<String>, dims: Option<usize>) -> Self {
Self::new(client.clone(), model, dims.unwrap_or_default())
}

fn ndims(&self) -> usize {
self.ndims
}

async fn embed_texts(
&self,
documents: impl IntoIterator<Item = String>,
) -> Result<Vec<embeddings::Embedding>, EmbeddingError> {
let documents = documents.into_iter().collect::<Vec<_>>();

let body = serde_json::to_vec(&json!({
"model": self.model,
"input": documents
}))?;

let req = self
.client
.post("v1/embeddings")?
.header("Content-Type", "application/json")
.body(body)
.map_err(|e| EmbeddingError::HttpError(e.into()))?;

let response = self.client.send(req).await?;

let status = response.status();
if status.is_success() {
let response_body: Vec<u8> = response.into_body().await?;
let parsed: ApiResponse<EmbeddingResponse> = serde_json::from_slice(&response_body)?;

match parsed {
ApiResponse::Ok(response) => {
tracing::debug!(target: "rig",
"Mistral embedding token usage: {}",
response.usage
);

if response.data.len() != documents.len() {
return Err(EmbeddingError::ResponseError(
"Response data length does not match input length".into(),
));
}

Ok(response
.data
.into_iter()
.zip(documents.into_iter())
.map(|(embedding, document)| embeddings::Embedding {
document,
vec: embedding
.embedding
.into_iter()
.filter_map(|n| n.as_f64())
.collect(),
})
.collect())
}
ApiResponse::Err(err) => {
tracing::warn!(message = %err.message, "provider returned an error response");
Err(EmbeddingError::from_http_response(
status,
String::from_utf8_lossy(&response_body),
))
}
}
} else {
let text = http_client::text(response).await?;
Err(EmbeddingError::from_http_response(status, text))
}
}
}

#[derive(Debug, Deserialize)]
pub struct EmbeddingResponse {
pub id: String,
pub object: String,
pub model: String,
pub usage: Usage,
pub data: Vec<EmbeddingData>,
}

#[derive(Debug, Deserialize)]
pub struct EmbeddingData {
pub object: String,
pub embedding: Vec<serde_json::Number>,
pub index: usize,
}
/// Mistral embedding model, backed by the shared OpenAI-compatible
/// [`GenericEmbeddingModel`].
Comment thread
ojhermann marked this conversation as resolved.
Outdated
pub type EmbeddingModel<H = reqwest::Client> = GenericEmbeddingModel<MistralExt, H>;
72 changes: 55 additions & 17 deletions crates/rig-core/src/providers/openai/embedding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,44 @@ pub struct EmbeddingResponse {
pub object: String,
pub data: Vec<EmbeddingData>,
pub model: String,
pub usage: Usage,
/// Token usage for the request. Optional because some OpenAI-compatible
/// providers routed through [`GenericEmbeddingModel`] omit it (e.g.
/// OpenRouter returns it only for some models, Together historically did
/// not surface it at all). OpenAI itself always populates it.
#[serde(default)]
pub usage: Option<Usage>,
}

/// Provider hook selecting the request path for the OpenAI-compatible
/// embeddings endpoint.
///
/// This mirrors [`OpenAICompatibleProvider::completion_path`] on the completion
/// side: it lets a provider that shares the OpenAI embeddings transport but
/// exposes it at a different path (typically a bare-host base URL that needs an
/// explicit `/v1` segment) reuse [`GenericEmbeddingModel`] instead of
/// hand-rolling the request flow.
///
/// It is deliberately a dedicated trait rather than a method on
/// [`OpenAICompatibleProvider`](super::completion::OpenAICompatibleProvider):
/// a provider's embeddings capability and chat-completions capability can live
/// on different provider extensions. OpenAI's own embeddings run on
/// [`OpenAIResponsesExt`](super::OpenAIResponsesExt), which is not an
/// `OpenAICompatibleProvider`, so bounding the embedding model on that trait
/// would exclude OpenAI itself.
#[doc(hidden)]
pub trait OpenAIEmbeddingsCompatible: crate::client::Provider {
/// The request path for embeddings, resolved against the client base URL by
/// [`Provider::build_uri`](crate::client::Provider::build_uri). Defaults to
/// `/embeddings`; providers whose base URL omits the API-version segment
/// override this (e.g. `/v1/embeddings`).
fn embeddings_path(&self) -> String {
"/embeddings".to_string()
}
}

impl OpenAIEmbeddingsCompatible for super::OpenAIResponsesExt {}
impl OpenAIEmbeddingsCompatible for super::OpenAICompletionsExt {}

#[derive(Debug, Deserialize, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EncodingFormat {
Expand Down Expand Up @@ -64,7 +99,7 @@ fn model_dimensions_from_identifier(identifier: &str) -> Option<usize> {
impl<Ext, H> embeddings::EmbeddingModel for GenericEmbeddingModel<Ext, H>
where
crate::client::Client<Ext, H>: HttpClientExt + Clone + std::fmt::Debug + Send + 'static,
Ext: crate::client::Provider + Clone + 'static,
Ext: OpenAIEmbeddingsCompatible + Clone + 'static,
H: Clone + Default + std::fmt::Debug + 'static,
{
const MAX_DOCUMENTS: usize = 1024;
Expand Down Expand Up @@ -124,7 +159,7 @@ where

let req = self
.client
.post("/embeddings")?
.post(self.client.ext().embeddings_path())?
.body(body)
.map_err(|e| EmbeddingError::HttpError(e.into()))?;

Expand All @@ -138,7 +173,7 @@ where
match parsed {
ApiResponse::Ok(response) => {
tracing::info!(target: "rig",
"OpenAI embedding token usage: {:?}",
"embedding token usage: {:?}",
response.usage
);

Expand All @@ -148,19 +183,22 @@ where
));
}

let usage = crate::completion::Usage {
input_tokens: response.usage.prompt_tokens as u64,
output_tokens: 0,
total_tokens: response.usage.total_tokens as u64,
cached_input_tokens: response
.usage
.prompt_tokens_details
.as_ref()
.map_or(0, |d| d.cached_tokens as u64),
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
};
let usage = response
.usage
.as_ref()
.map(|usage| crate::completion::Usage {
input_tokens: usage.prompt_tokens as u64,
output_tokens: 0,
total_tokens: usage.total_tokens as u64,
cached_input_tokens: usage
.prompt_tokens_details
.as_ref()
.map_or(0, |d| d.cached_tokens as u64),
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
})
.unwrap_or_else(crate::completion::Usage::new);

let embeddings = response
.data
Expand Down
16 changes: 4 additions & 12 deletions crates/rig-core/src/providers/openrouter/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ impl<H> Capabilities<H> for OpenRouterExt {
type Rerank = Nothing;
}

// OpenRouter's base URL already includes `/api/v1`, so the default
// `/embeddings` path is correct.
impl crate::providers::openai::embedding::OpenAIEmbeddingsCompatible for OpenRouterExt {}

impl DebugExt for OpenRouterExt {}

impl ProviderBuilder for OpenRouterExtBuilder {
Expand Down Expand Up @@ -82,18 +86,6 @@ impl ProviderClient for Client {
}
}

#[derive(Debug, Deserialize)]
pub(crate) struct ApiErrorResponse {
pub message: String,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ApiResponse<T> {
Ok(T),
Err(ApiErrorResponse),
}

#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Usage {
pub prompt_tokens: usize,
Expand Down
Loading