Skip to main content

rig_core/providers/
groq.rs

1//! Groq API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::{client::CompletionClient, providers::groq};
6//!
7//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
8//! let client = groq::Client::new("YOUR_API_KEY")?;
9//!
10//! let llama = client.completion_model(groq::LLAMA_3_1_8B_INSTANT);
11//! # Ok(())
12//! # }
13//! ```
14use bytes::Bytes;
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17
18use super::openai::{self, TranscriptionResponse};
19use crate::client::{
20    self, BearerAuth, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
21    ProviderClient,
22};
23use crate::completion::CompletionError;
24use crate::http_client::multipart::Part;
25use crate::http_client::{self, HttpClientExt, MultipartForm};
26use crate::transcription::{self, TranscriptionError};
27
28// ================================================================
29// Main Groq Client
30// ================================================================
31const GROQ_API_BASE_URL: &str = "https://api.groq.com/openai/v1";
32
33#[derive(Debug, Default, Clone, Copy)]
34pub struct GroqExt;
35#[derive(Debug, Default, Clone, Copy)]
36pub struct GroqBuilder;
37
38type GroqApiKey = BearerAuth;
39
40impl Provider for GroqExt {
41    type Builder = GroqBuilder;
42    const VERIFY_PATH: &'static str = "/models";
43}
44
45impl openai::completion::OpenAICompatibleProvider for GroqExt {
46    const PROVIDER_NAME: &'static str = "groq";
47
48    type StreamingUsage = openai::Usage;
49
50    const EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS: bool = true;
51
52    type Response = openai::CompletionResponse;
53
54    fn prepare_request(
55        &self,
56        request: &mut openai::completion::CompletionRequest,
57    ) -> Result<(), CompletionError> {
58        // Groq's provider-native tools (`browser_search`, `code_interpreter`,
59        // ...) arrive via `additional_params.tools`. Left in place they would
60        // clobber the function-tool array on serialization, so fold them into
61        // `compound_custom.enabled_tools` (deduplicated by tool type).
62        let Some(map) = request
63            .additional_params
64            .as_mut()
65            .and_then(Value::as_object_mut)
66        else {
67            return Ok(());
68        };
69        let Some(raw_tools) = map.remove("tools") else {
70            return Ok(());
71        };
72        let native_tools = serde_json::from_value::<Vec<Value>>(raw_tools).map_err(|err| {
73            CompletionError::RequestError(
74                format!("Invalid Groq `additional_params.tools` payload: {err}").into(),
75            )
76        })?;
77        apply_native_tools_to_additional_params(map, native_tools);
78
79        Ok(())
80    }
81}
82
83impl<H> Capabilities<H> for GroqExt {
84    type Completion = Capable<CompletionModel<H>>;
85    type Embeddings = Nothing;
86    type Transcription = Capable<TranscriptionModel<H>>;
87    type ModelListing = Nothing;
88    #[cfg(feature = "image")]
89    type ImageGeneration = Nothing;
90
91    #[cfg(feature = "audio")]
92    type AudioGeneration = Nothing;
93    type Rerank = Nothing;
94}
95
96impl DebugExt for GroqExt {}
97
98impl ProviderBuilder for GroqBuilder {
99    type Extension<H>
100        = GroqExt
101    where
102        H: HttpClientExt;
103    type ApiKey = GroqApiKey;
104
105    const BASE_URL: &'static str = GROQ_API_BASE_URL;
106
107    fn build<H>(
108        _builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
109    ) -> http_client::Result<Self::Extension<H>>
110    where
111        H: HttpClientExt,
112    {
113        Ok(GroqExt)
114    }
115}
116
117pub type Client<H = reqwest::Client> = client::Client<GroqExt, H>;
118pub type ClientBuilder<H = crate::markers::Missing> =
119    client::ClientBuilder<GroqBuilder, GroqApiKey, H>;
120
121/// Groq completion model, driven by the shared OpenAI Chat Completions path.
122pub type CompletionModel<H = reqwest::Client> =
123    openai::completion::GenericCompletionModel<GroqExt, H>;
124
125/// Final streaming response, shared with the OpenAI Chat Completions path.
126pub type StreamingCompletionResponse = openai::StreamingCompletionResponse;
127
128impl ProviderClient for Client {
129    type Input = String;
130    type Error = crate::client::ProviderClientError;
131
132    /// Create a new Groq client from the `GROQ_API_KEY` environment variable.
133    fn from_env() -> Result<Self, Self::Error> {
134        let api_key = crate::client::required_env_var("GROQ_API_KEY")?;
135        Self::new(&api_key).map_err(Into::into)
136    }
137
138    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
139        Self::new(&input).map_err(Into::into)
140    }
141}
142
143#[derive(Debug, Deserialize)]
144struct ApiErrorResponse {
145    message: String,
146}
147
148#[derive(Debug, Deserialize)]
149#[serde(untagged)]
150enum ApiResponse<T> {
151    Ok(T),
152    Err(ApiErrorResponse),
153}
154
155fn apply_native_tools_to_additional_params(
156    extra: &mut Map<String, Value>,
157    native_tools: Vec<Value>,
158) {
159    if native_tools.is_empty() {
160        return;
161    }
162
163    let mut compound_custom = match extra.remove("compound_custom") {
164        Some(Value::Object(map)) => map,
165        _ => Map::new(),
166    };
167
168    let mut enabled_tools = match compound_custom.remove("enabled_tools") {
169        Some(Value::Array(values)) => values,
170        _ => Vec::new(),
171    };
172
173    for native_tool in native_tools {
174        let already_enabled = enabled_tools
175            .iter()
176            .any(|existing| native_tools_match(existing, &native_tool));
177        if !already_enabled {
178            enabled_tools.push(native_tool);
179        }
180    }
181
182    compound_custom.insert("enabled_tools".to_string(), Value::Array(enabled_tools));
183    extra.insert(
184        "compound_custom".to_string(),
185        Value::Object(compound_custom),
186    );
187}
188
189fn native_tools_match(lhs: &Value, rhs: &Value) -> bool {
190    if let (Some(lhs_type), Some(rhs_type)) = (native_tool_kind(lhs), native_tool_kind(rhs)) {
191        return lhs_type == rhs_type;
192    }
193
194    lhs == rhs
195}
196
197fn native_tool_kind(value: &Value) -> Option<&str> {
198    match value {
199        Value::String(kind) => Some(kind),
200        Value::Object(map) => map.get("type").and_then(Value::as_str),
201        _ => None,
202    }
203}
204
205// ================================================================
206// Groq Completion API
207// ================================================================
208
209/// The `deepseek-r1-distill-llama-70b` model. Used for chat completion.
210pub const DEEPSEEK_R1_DISTILL_LLAMA_70B: &str = "deepseek-r1-distill-llama-70b";
211/// The `gemma2-9b-it` model. Used for chat completion.
212pub const GEMMA2_9B_IT: &str = "gemma2-9b-it";
213/// The `llama-3.1-8b-instant` model. Used for chat completion.
214pub const LLAMA_3_1_8B_INSTANT: &str = "llama-3.1-8b-instant";
215/// The `llama-3.2-11b-vision-preview` model. Used for chat completion.
216pub const LLAMA_3_2_11B_VISION_PREVIEW: &str = "llama-3.2-11b-vision-preview";
217/// The `llama-3.2-1b-preview` model. Used for chat completion.
218pub const LLAMA_3_2_1B_PREVIEW: &str = "llama-3.2-1b-preview";
219/// The `llama-3.2-3b-preview` model. Used for chat completion.
220pub const LLAMA_3_2_3B_PREVIEW: &str = "llama-3.2-3b-preview";
221/// The `llama-3.2-90b-vision-preview` model. Used for chat completion.
222pub const LLAMA_3_2_90B_VISION_PREVIEW: &str = "llama-3.2-90b-vision-preview";
223/// The `llama-3.2-70b-specdec` model. Used for chat completion.
224pub const LLAMA_3_2_70B_SPECDEC: &str = "llama-3.2-70b-specdec";
225/// The `llama-3.2-70b-versatile` model. Used for chat completion.
226pub const LLAMA_3_2_70B_VERSATILE: &str = "llama-3.2-70b-versatile";
227/// The `llama-guard-3-8b` model. Used for chat completion.
228pub const LLAMA_GUARD_3_8B: &str = "llama-guard-3-8b";
229/// The `llama3-70b-8192` model. Used for chat completion.
230pub const LLAMA_3_70B_8192: &str = "llama3-70b-8192";
231/// The `llama3-8b-8192` model. Used for chat completion.
232pub const LLAMA_3_8B_8192: &str = "llama3-8b-8192";
233/// The `mixtral-8x7b-32768` model. Used for chat completion.
234pub const MIXTRAL_8X7B_32768: &str = "mixtral-8x7b-32768";
235
236#[derive(Clone, Debug, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum ReasoningFormat {
239    Parsed,
240    Raw,
241    Hidden,
242}
243
244/// Additional parameters to send to the Groq API. Serialize this into the
245/// request's `additional_params` to set Groq's reasoning options.
246#[derive(Clone, Debug, Default, Serialize, Deserialize)]
247pub struct GroqAdditionalParameters {
248    /// The reasoning format. See Groq's API docs for more details.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub reasoning_format: Option<ReasoningFormat>,
251    /// Whether or not to include reasoning. See Groq's API docs for more details.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub include_reasoning: Option<bool>,
254    /// Any other properties not included by default on this struct (that you want to send)
255    #[serde(flatten, skip_serializing_if = "Option::is_none")]
256    pub extra: Option<Map<String, serde_json::Value>>,
257}
258
259// ================================================================
260// Groq Transcription API
261// ================================================================
262
263pub const WHISPER_LARGE_V3: &str = "whisper-large-v3";
264pub const WHISPER_LARGE_V3_TURBO: &str = "whisper-large-v3-turbo";
265pub const DISTIL_WHISPER_LARGE_V3_EN: &str = "distil-whisper-large-v3-en";
266
267#[derive(Clone)]
268pub struct TranscriptionModel<T> {
269    client: Client<T>,
270    /// Name of the model (e.g.: whisper-large-v3)
271    pub model: String,
272}
273
274impl<T> TranscriptionModel<T> {
275    pub fn new(client: Client<T>, model: impl Into<String>) -> Self {
276        Self {
277            client,
278            model: model.into(),
279        }
280    }
281}
282impl<T> transcription::TranscriptionModel for TranscriptionModel<T>
283where
284    T: HttpClientExt + Clone + Send + std::fmt::Debug + Default + 'static,
285{
286    type Response = TranscriptionResponse;
287
288    type Client = Client<T>;
289
290    fn make(client: &Self::Client, model: impl Into<String>) -> Self {
291        Self::new(client.clone(), model)
292    }
293
294    async fn transcription(
295        &self,
296        request: transcription::TranscriptionRequest,
297    ) -> Result<
298        transcription::TranscriptionResponse<Self::Response>,
299        transcription::TranscriptionError,
300    > {
301        let data = request.data;
302
303        let mut body = MultipartForm::new()
304            .text("model", self.model.clone())
305            .part(Part::bytes("file", data).filename(request.filename.clone()));
306
307        if let Some(language) = request.language {
308            body = body.text("language", language);
309        }
310
311        if let Some(prompt) = request.prompt {
312            body = body.text("prompt", prompt.clone());
313        }
314
315        if let Some(ref temperature) = request.temperature {
316            body = body.text("temperature", temperature.to_string());
317        }
318
319        if let Some(ref additional_params) = request.additional_params {
320            let params = additional_params.as_object().ok_or_else(|| {
321                TranscriptionError::RequestError(Box::new(std::io::Error::new(
322                    std::io::ErrorKind::InvalidInput,
323                    "additional transcription parameters must be a JSON object",
324                )))
325            })?;
326
327            for (key, value) in params {
328                body = body.text(key.to_owned(), value.to_string());
329            }
330        }
331
332        let req = self
333            .client
334            .post("/audio/transcriptions")?
335            .body(body)
336            .map_err(|e| TranscriptionError::HttpError(e.into()))?;
337
338        let response = self.client.send_multipart::<Bytes>(req).await?;
339
340        let status = response.status();
341        let response_body = response.into_body().into_future().await?.to_vec();
342
343        if status.is_success() {
344            match serde_json::from_slice::<ApiResponse<TranscriptionResponse>>(&response_body)? {
345                ApiResponse::Ok(response) => response.try_into(),
346                ApiResponse::Err(api_error_response) => {
347                    tracing::warn!(message = %api_error_response.message, "provider returned an error response");
348                    Err(TranscriptionError::from_http_response(
349                        status,
350                        String::from_utf8_lossy(&response_body).into_owned(),
351                    ))
352                }
353            }
354        } else {
355            Err(TranscriptionError::from_http_response(
356                status,
357                String::from_utf8_lossy(&response_body).to_string(),
358            ))
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use crate::providers::openai::completion::{
366        CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
367    };
368    use crate::{completion::CompletionRequestBuilder, test_utils::MockCompletionModel};
369
370    #[test]
371    fn groq_request_maps_output_schema_max_tokens_and_specific_tool_choice() {
372        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Return JSON")
373            .max_tokens(64)
374            .tool(crate::completion::ToolDefinition {
375                name: "choose_beta".to_string(),
376                description: "Choose beta".to_string(),
377                parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
378            })
379            .tool_choice(crate::message::ToolChoice::Specific {
380                function_names: vec!["choose_beta".to_string()],
381            })
382            .output_schema(schemars::schema_for!(serde_json::Value))
383            .build();
384
385        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
386            model: "llama-3.3-70b-versatile".to_string(),
387            request,
388            strict_tools: false,
389            tool_result_array_content: false,
390            supports_response_format: true,
391            supports_tools: true,
392        })
393        .expect("Groq request should convert");
394        let json = serde_json::to_value(request).expect("request should serialize");
395
396        assert_eq!(json["max_tokens"], 64);
397        assert_eq!(
398            json["tool_choice"],
399            serde_json::json!({"type":"function","function":{"name":"choose_beta"}})
400        );
401        // The shared path defers `response_format` while tools are present and
402        // no tool result exists yet (see `should_apply_response_format`).
403        assert_eq!(json["response_format"], serde_json::Value::Null);
404
405        let no_tools_request =
406            CompletionRequestBuilder::new(MockCompletionModel::default(), "Return JSON")
407                .output_schema(schemars::schema_for!(serde_json::Value))
408                .build();
409        let no_tools_request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
410            model: "llama-3.3-70b-versatile".to_string(),
411            request: no_tools_request,
412            strict_tools: false,
413            tool_result_array_content: false,
414            supports_response_format: true,
415            supports_tools: true,
416        })
417        .expect("request should convert");
418        let json = serde_json::to_value(no_tools_request).expect("request should serialize");
419        assert_eq!(json["response_format"]["type"], "json_schema");
420        assert_eq!(json["response_format"]["json_schema"]["strict"], true);
421    }
422
423    #[test]
424    fn groq_prepare_request_merges_native_tools_into_compound_custom() {
425        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "search")
426            .tool(crate::completion::ToolDefinition {
427                name: "local_tool".to_string(),
428                description: "A local function tool".to_string(),
429                parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
430            })
431            .additional_params(serde_json::json!({
432                "tools": [{"type": "browser_search"}, {"type": "browser_search"}],
433            }))
434            .build();
435
436        let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
437            model: "llama-3.3-70b-versatile".to_string(),
438            request,
439            strict_tools: false,
440            tool_result_array_content: false,
441            supports_response_format: true,
442            supports_tools: true,
443        })
444        .expect("request should convert");
445
446        super::GroqExt
447            .prepare_request(&mut request)
448            .expect("prepare_request should succeed");
449
450        let json = serde_json::to_value(request).expect("request should serialize");
451        assert_eq!(
452            json["compound_custom"]["enabled_tools"],
453            serde_json::json!([{"type": "browser_search"}])
454        );
455        // The rig-level function tool array must survive the native-tool merge.
456        assert_eq!(json["tools"][0]["function"]["name"], "local_tool");
457    }
458
459    #[test]
460    fn groq_reasoning_params_flatten_into_request_body() {
461        let additional_params = serde_json::to_value(super::GroqAdditionalParameters {
462            reasoning_format: Some(super::ReasoningFormat::Parsed),
463            include_reasoning: Some(true),
464            extra: None,
465        })
466        .expect("params should serialize");
467        let request =
468            CompletionRequestBuilder::new(MockCompletionModel::default(), "Think about it")
469                .additional_params(additional_params)
470                .build();
471
472        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
473            model: "llama-3.3-70b-versatile".to_string(),
474            request,
475            strict_tools: false,
476            tool_result_array_content: false,
477            supports_response_format: true,
478            supports_tools: true,
479        })
480        .expect("request should convert");
481        let json = serde_json::to_value(request).expect("request should serialize");
482
483        assert_eq!(json["reasoning_format"], "parsed");
484        assert_eq!(json["include_reasoning"], true);
485    }
486
487    #[test]
488    fn test_client_initialization() {
489        let _client =
490            crate::providers::groq::Client::new("dummy-key").expect("Client::new() failed");
491        let builder: crate::providers::groq::ClientBuilder =
492            crate::providers::groq::Client::builder().api_key("dummy-key");
493        let _client_from_builder = builder.build().expect("Client::builder() failed");
494    }
495
496    #[tokio::test]
497    async fn completion_preserves_raw_provider_error_json_on_api_error_envelope() {
498        use crate::client::CompletionClient;
499        use crate::completion::{CompletionError, CompletionModel};
500        use crate::test_utils::RecordingHttpClient;
501
502        let body = r#"{"message":"model overloaded","type":"server_error","code":"503"}"#;
503        let http_client =
504            RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
505        let client = super::Client::builder()
506            .api_key("test-key")
507            .http_client(http_client)
508            .build()
509            .expect("build client");
510        let model = client.completion_model("llama-3.3-70b-versatile");
511        let request = model.completion_request("hello").build();
512
513        let error = model
514            .completion(request)
515            .await
516            .expect_err("completion should fail with provider error envelope");
517
518        match &error {
519            CompletionError::ProviderResponse(stored) => {
520                assert_eq!(stored.body, body);
521                assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
522                assert_eq!(error.provider_response_body(), Some(body));
523                let json = error
524                    .provider_response_json()
525                    .expect("raw body should be valid JSON")
526                    .expect("parsed JSON should be present");
527                assert_eq!(json["code"], "503");
528            }
529            other => panic!("expected ProviderResponse, got {other:?}"),
530        }
531    }
532
533    #[tokio::test]
534    async fn completion_http_non_success_preserves_status_and_body() {
535        use crate::client::CompletionClient;
536        use crate::completion::{CompletionError, CompletionModel};
537        use crate::test_utils::RecordingHttpClient;
538
539        let body = r#"{"error":{"message":"service unavailable","code":"503"}}"#;
540        let http_client =
541            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
542        let client = super::Client::builder()
543            .api_key("test-key")
544            .http_client(http_client)
545            .build()
546            .expect("build client");
547        let model = client.completion_model("llama-3.3-70b-versatile");
548        let request = model.completion_request("hello").build();
549
550        let error = model
551            .completion(request)
552            .await
553            .expect_err("completion should fail with non-success status");
554
555        assert!(matches!(error, CompletionError::HttpError(_)));
556        assert_eq!(
557            error.provider_response_status(),
558            Some(http::StatusCode::SERVICE_UNAVAILABLE)
559        );
560        assert_eq!(error.provider_response_body(), Some(body));
561    }
562
563    #[tokio::test]
564    async fn transcription_http_non_success_preserves_status_and_body() {
565        use crate::client::transcription::TranscriptionClient;
566        use crate::test_utils::RecordingHttpClient;
567        use crate::transcription::{TranscriptionError, TranscriptionModel as _};
568
569        let body = r#"{"error":{"message":"bad audio","code":"400"}}"#;
570        let http_client =
571            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
572        let client = super::Client::builder()
573            .api_key("test-key")
574            .http_client(http_client)
575            .build()
576            .expect("build client");
577        let model = client.transcription_model("whisper-large-v3");
578
579        let error = match model
580            .transcription_request()
581            .data(vec![0u8; 16])
582            .send()
583            .await
584        {
585            Err(error) => error,
586            Ok(_) => panic!("transcription should fail with non-success status"),
587        };
588
589        assert!(matches!(error, TranscriptionError::HttpError(_)));
590        assert_eq!(
591            error.provider_response_status(),
592            Some(http::StatusCode::BAD_REQUEST)
593        );
594        assert_eq!(error.provider_response_body(), Some(body));
595    }
596}