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 serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17use super::openai;
18use crate::client::{self, BearerAuth, DebugExt, Provider};
19use crate::completion::CompletionError;
20use crate::http_client::HttpClientExt;
21use crate::providers::internal::transcription::OpenAiTranscriptionClient;
22
23// ================================================================
24// Main Groq Client
25// ================================================================
26const GROQ_API_BASE_URL: &str = "https://api.groq.com/openai/v1";
27
28#[derive(Debug, Default, Clone, Copy)]
29pub struct GroqExt;
30#[derive(Debug, Default, Clone, Copy)]
31pub struct GroqBuilder;
32
33type GroqApiKey = BearerAuth;
34
35impl Provider for GroqExt {
36    type Builder = GroqBuilder;
37    const VERIFY_PATH: &'static str = "/models";
38}
39
40impl openai::completion::OpenAICompatibleProvider for GroqExt {
41    const PROVIDER_NAME: &'static str = "groq";
42
43    /// Groq reports its transport request id on the same `x-request-id`
44    /// header OpenAI uses (verified live; see the recorded
45    /// `response_identity_edge` fixture, where the header arrives scrubbed).
46    const REQUEST_ID_HEADER: Option<&'static str> = Some("x-request-id");
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
83client::impl_capabilities!(
84    GroqExt,
85    completion = CompletionModel<H>,
86    transcription = TranscriptionModel<H>,
87    model_listing = GroqModelLister<H>,
88);
89
90/// A Groq listing entry.
91///
92/// Groq reports its context window and output ceiling on every entry, and
93/// [`Model`](crate::model::Model) has fields for both — `max_output_tokens`
94/// exists precisely because rig used to drop a provider-reported output
95/// ceiling on the floor (rig#2322). The shared `ListModelEntry` decodes
96/// neither (Groq spells them `context_window` / `max_completion_tokens`, and
97/// the spellings differ across providers), so Groq keeps its own DTO rather
98/// than losing them.
99#[derive(Debug, serde::Deserialize)]
100struct GroqModelEntry {
101    id: String,
102    #[serde(default)]
103    name: Option<String>,
104    #[serde(default)]
105    created: Option<u64>,
106    #[serde(default)]
107    owned_by: Option<String>,
108    #[serde(default)]
109    context_window: Option<u32>,
110    #[serde(default)]
111    max_completion_tokens: Option<u32>,
112}
113
114impl From<GroqModelEntry> for crate::model::Model {
115    fn from(value: GroqModelEntry) -> Self {
116        let mut model = crate::model::Model::from_id(value.id);
117        model.name = value.name;
118        model.created_at = value.created;
119        model.owned_by = value.owned_by;
120        model.context_length = value.context_window;
121        model.max_output_tokens = value.max_completion_tokens;
122        model
123    }
124}
125
126crate::providers::internal::model_listing::impl_model_lister!(
127    /// [`ModelLister`](crate::client::ModelLister) implementation for the Groq
128    /// API (`GET /models`), the same path [`GroqExt::VERIFY_PATH`] already
129    /// uses.
130    GroqModelLister,
131    Client<H>,
132    GroqModelEntry,
133    "Groq",
134    "/models"
135);
136
137impl DebugExt for GroqExt {}
138
139client::impl_default_provider_builder!(
140    GroqBuilder => GroqExt,
141    api_key = GroqApiKey,
142    base_url = GROQ_API_BASE_URL,
143);
144
145pub type Client<H = reqwest::Client> = client::Client<GroqExt, H>;
146pub type ClientBuilder<H = crate::markers::Missing> =
147    client::ClientBuilder<GroqBuilder, GroqApiKey, H>;
148
149/// Groq completion model, driven by the shared OpenAI Chat Completions path.
150pub type CompletionModel<H = reqwest::Client> =
151    openai::completion::GenericCompletionModel<GroqExt, H>;
152
153/// Groq's provider-native terminal streaming record: the value carried by the
154/// final item of the stream returned by `CompletionModel::raw_stream`. Shared
155/// with the OpenAI Chat Completions path, usage payload included.
156pub type StreamingCompletionResponse = openai::StreamingCompletionResponse;
157
158client::impl_provider_client!(Client, input = String, api_key_env = "GROQ_API_KEY");
159
160#[cfg(test)]
161use crate::providers::openai::client::ApiResponse;
162
163fn apply_native_tools_to_additional_params(
164    extra: &mut Map<String, Value>,
165    native_tools: Vec<Value>,
166) {
167    if native_tools.is_empty() {
168        return;
169    }
170
171    let mut compound_custom = match extra.remove("compound_custom") {
172        Some(Value::Object(map)) => map,
173        _ => Map::new(),
174    };
175
176    let mut enabled_tools = match compound_custom.remove("enabled_tools") {
177        Some(Value::Array(values)) => values,
178        _ => Vec::new(),
179    };
180
181    for native_tool in native_tools {
182        let already_enabled = enabled_tools
183            .iter()
184            .any(|existing| native_tools_match(existing, &native_tool));
185        if !already_enabled {
186            enabled_tools.push(native_tool);
187        }
188    }
189
190    compound_custom.insert("enabled_tools".to_string(), Value::Array(enabled_tools));
191    extra.insert(
192        "compound_custom".to_string(),
193        Value::Object(compound_custom),
194    );
195}
196
197fn native_tools_match(lhs: &Value, rhs: &Value) -> bool {
198    if let (Some(lhs_type), Some(rhs_type)) = (native_tool_kind(lhs), native_tool_kind(rhs)) {
199        return lhs_type == rhs_type;
200    }
201
202    lhs == rhs
203}
204
205fn native_tool_kind(value: &Value) -> Option<&str> {
206    match value {
207        Value::String(kind) => Some(kind),
208        Value::Object(map) => map.get("type").and_then(Value::as_str),
209        _ => None,
210    }
211}
212
213// ================================================================
214// Groq Completion API
215// ================================================================
216
217/// The `deepseek-r1-distill-llama-70b` model. Used for chat completion.
218pub const DEEPSEEK_R1_DISTILL_LLAMA_70B: &str = "deepseek-r1-distill-llama-70b";
219/// The `gemma2-9b-it` model. Used for chat completion.
220pub const GEMMA2_9B_IT: &str = "gemma2-9b-it";
221/// The `llama-3.1-8b-instant` model. Used for chat completion.
222pub const LLAMA_3_1_8B_INSTANT: &str = "llama-3.1-8b-instant";
223/// The `llama-3.2-11b-vision-preview` model. Used for chat completion.
224pub const LLAMA_3_2_11B_VISION_PREVIEW: &str = "llama-3.2-11b-vision-preview";
225/// The `llama-3.2-1b-preview` model. Used for chat completion.
226pub const LLAMA_3_2_1B_PREVIEW: &str = "llama-3.2-1b-preview";
227/// The `llama-3.2-3b-preview` model. Used for chat completion.
228pub const LLAMA_3_2_3B_PREVIEW: &str = "llama-3.2-3b-preview";
229/// The `llama-3.2-90b-vision-preview` model. Used for chat completion.
230pub const LLAMA_3_2_90B_VISION_PREVIEW: &str = "llama-3.2-90b-vision-preview";
231/// The `llama-3.2-70b-specdec` model. Used for chat completion.
232pub const LLAMA_3_2_70B_SPECDEC: &str = "llama-3.2-70b-specdec";
233/// The `llama-3.2-70b-versatile` model. Used for chat completion.
234pub const LLAMA_3_2_70B_VERSATILE: &str = "llama-3.2-70b-versatile";
235/// The `llama-guard-3-8b` model. Used for chat completion.
236pub const LLAMA_GUARD_3_8B: &str = "llama-guard-3-8b";
237/// The `llama3-70b-8192` model. Used for chat completion.
238pub const LLAMA_3_70B_8192: &str = "llama3-70b-8192";
239/// The `llama3-8b-8192` model. Used for chat completion.
240pub const LLAMA_3_8B_8192: &str = "llama3-8b-8192";
241/// The `mixtral-8x7b-32768` model. Used for chat completion.
242pub const MIXTRAL_8X7B_32768: &str = "mixtral-8x7b-32768";
243
244#[derive(Clone, Debug, Serialize, Deserialize)]
245#[serde(rename_all = "lowercase")]
246pub enum ReasoningFormat {
247    Parsed,
248    Raw,
249    Hidden,
250}
251
252/// Additional parameters to send to the Groq API. Serialize this into the
253/// request's `additional_params` to set Groq's reasoning options.
254#[derive(Clone, Debug, Default, Serialize, Deserialize)]
255pub struct GroqAdditionalParameters {
256    /// The reasoning format. See Groq's API docs for more details.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub reasoning_format: Option<ReasoningFormat>,
259    /// Whether or not to include reasoning. See Groq's API docs for more details.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub include_reasoning: Option<bool>,
262    /// Any other properties not included by default on this struct (that you want to send)
263    #[serde(flatten, skip_serializing_if = "Option::is_none")]
264    pub extra: Option<Map<String, serde_json::Value>>,
265}
266
267// ================================================================
268// Groq Transcription API
269// ================================================================
270
271pub const WHISPER_LARGE_V3: &str = "whisper-large-v3";
272pub const WHISPER_LARGE_V3_TURBO: &str = "whisper-large-v3-turbo";
273pub const DISTIL_WHISPER_LARGE_V3_EN: &str = "distil-whisper-large-v3-en";
274
275/// Groq transcription model using the shared OpenAI-style implementation.
276pub type TranscriptionModel<T = reqwest::Client> =
277    crate::providers::internal::transcription::OpenAiTranscriptionModel<Client<T>>;
278
279impl<T> OpenAiTranscriptionClient for Client<T>
280where
281    T: HttpClientExt + Clone + 'static,
282{
283    const MODEL_IN_FORM: bool = true;
284
285    fn transcription_request(
286        &self,
287        _model: &str,
288    ) -> crate::http_client::Result<crate::http_client::Builder> {
289        self.post("/audio/transcriptions")
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use crate::providers::openai::completion::{
296        CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider, OpenAIRequestParams,
297    };
298    use crate::{completion::CompletionRequestBuilder, test_utils::MockCompletionModel};
299
300    /// An OpenAI-style nested error body (`{"error": {"message": ...}}`) on a
301    /// 2xx status must classify as the error envelope — not fail both untagged
302    /// arms and surface as a serde error that loses the provider body.
303    #[test]
304    fn nested_error_object_parses_as_the_error_envelope() {
305        #[derive(serde::Deserialize)]
306        struct Success {
307            #[allow(dead_code)]
308            choices: Vec<serde_json::Value>,
309        }
310
311        let nested = r#"{"error":{"message":"model not found","type":"invalid_request_error"}}"#;
312        match serde_json::from_str::<super::ApiResponse<Success>>(nested)
313            .expect("nested error envelope should deserialize")
314        {
315            super::ApiResponse::Err(err) => assert!(err.message.contains("model not found")),
316            super::ApiResponse::Ok(_) => panic!("error body must classify as the error envelope"),
317        }
318
319        let plain = r#"{"error":"over capacity"}"#;
320        match serde_json::from_str::<super::ApiResponse<Success>>(plain)
321            .expect("string error envelope should deserialize")
322        {
323            super::ApiResponse::Err(err) => assert_eq!(err.message, "over capacity"),
324            super::ApiResponse::Ok(_) => panic!("error body must classify as the error envelope"),
325        }
326    }
327
328    #[test]
329    fn groq_request_maps_output_schema_max_tokens_and_specific_tool_choice() {
330        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "Return JSON")
331            .max_tokens(64)
332            .tool(crate::completion::ToolDefinition {
333                name: "choose_beta".to_string(),
334                description: "Choose beta".to_string(),
335                parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
336            })
337            .tool_choice(crate::message::ToolChoice::Specific {
338                function_names: vec!["choose_beta".to_string()],
339            })
340            .output_schema(schemars::schema_for!(serde_json::Value))
341            .build();
342
343        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
344            model: "llama-3.3-70b-versatile".to_string(),
345            request,
346            strict_tools: false,
347            tool_result_array_content: false,
348            supports_response_format: true,
349            supports_tools: true,
350        })
351        .expect("Groq request should convert");
352        let json = serde_json::to_value(request).expect("request should serialize");
353
354        assert_eq!(json["max_tokens"], 64);
355        assert_eq!(
356            json["tool_choice"],
357            serde_json::json!({"type":"function","function":{"name":"choose_beta"}})
358        );
359        // The shared path defers `response_format` while tools are present and
360        // no tool result exists yet (see `should_apply_response_format`).
361        assert_eq!(json["response_format"], serde_json::Value::Null);
362
363        let no_tools_request =
364            CompletionRequestBuilder::new(MockCompletionModel::default(), "Return JSON")
365                .output_schema(schemars::schema_for!(serde_json::Value))
366                .build();
367        let no_tools_request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
368            model: "llama-3.3-70b-versatile".to_string(),
369            request: no_tools_request,
370            strict_tools: false,
371            tool_result_array_content: false,
372            supports_response_format: true,
373            supports_tools: true,
374        })
375        .expect("request should convert");
376        let json = serde_json::to_value(no_tools_request).expect("request should serialize");
377        assert_eq!(json["response_format"]["type"], "json_schema");
378        assert_eq!(json["response_format"]["json_schema"]["strict"], true);
379    }
380
381    #[tokio::test]
382    async fn transcription_routes_model_in_multipart_body() {
383        use crate::client::transcription::TranscriptionClient;
384        use crate::test_utils::RecordingHttpClient;
385        use crate::transcription::TranscriptionModel as _;
386
387        let http_client = RecordingHttpClient::new(r#"{"text":"transcribed"}"#);
388        let client = super::Client::builder()
389            .api_key("test-key")
390            .http_client(http_client.clone())
391            .build()
392            .expect("build client");
393        let model = client.transcription_model(super::WHISPER_LARGE_V3);
394
395        let response = model
396            .transcription_request()
397            .data(vec![1, 2, 3])
398            .filename(Some("audio.mp3".to_owned()))
399            .send()
400            .await
401            .expect("transcription should succeed");
402
403        assert_eq!(response.text, "transcribed");
404        let request = http_client
405            .requests()
406            .into_iter()
407            .next()
408            .expect("request should be captured");
409        assert_eq!(
410            request.uri,
411            "https://api.groq.com/openai/v1/audio/transcriptions"
412        );
413        let body = String::from_utf8_lossy(&request.body);
414        assert!(
415            body.contains("name=\"model\"\r\n\r\nwhisper-large-v3\r\n"),
416            "{body}"
417        );
418        assert!(
419            body.contains("name=\"file\"; filename=\"audio.mp3\""),
420            "{body}"
421        );
422    }
423
424    #[test]
425    fn groq_prepare_request_merges_native_tools_into_compound_custom() {
426        let request = CompletionRequestBuilder::new(MockCompletionModel::default(), "search")
427            .tool(crate::completion::ToolDefinition {
428                name: "local_tool".to_string(),
429                description: "A local function tool".to_string(),
430                parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
431            })
432            .additional_params(serde_json::json!({
433                "tools": [{"type": "browser_search"}, {"type": "browser_search"}],
434            }))
435            .build();
436
437        let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
438            model: "llama-3.3-70b-versatile".to_string(),
439            request,
440            strict_tools: false,
441            tool_result_array_content: false,
442            supports_response_format: true,
443            supports_tools: true,
444        })
445        .expect("request should convert");
446
447        super::GroqExt
448            .prepare_request(&mut request)
449            .expect("prepare_request should succeed");
450
451        let json = serde_json::to_value(request).expect("request should serialize");
452        assert_eq!(
453            json["compound_custom"]["enabled_tools"],
454            serde_json::json!([{"type": "browser_search"}])
455        );
456        // The rig-level function tool array must survive the native-tool merge.
457        assert_eq!(json["tools"][0]["function"]["name"], "local_tool");
458    }
459
460    #[test]
461    fn groq_reasoning_params_flatten_into_request_body() {
462        let additional_params = serde_json::to_value(super::GroqAdditionalParameters {
463            reasoning_format: Some(super::ReasoningFormat::Parsed),
464            include_reasoning: Some(true),
465            extra: None,
466        })
467        .expect("params should serialize");
468        let request =
469            CompletionRequestBuilder::new(MockCompletionModel::default(), "Think about it")
470                .additional_params(additional_params)
471                .build();
472
473        let request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
474            model: "llama-3.3-70b-versatile".to_string(),
475            request,
476            strict_tools: false,
477            tool_result_array_content: false,
478            supports_response_format: true,
479            supports_tools: true,
480        })
481        .expect("request should convert");
482        let json = serde_json::to_value(request).expect("request should serialize");
483
484        assert_eq!(json["reasoning_format"], "parsed");
485        assert_eq!(json["include_reasoning"], true);
486    }
487
488    #[test]
489    fn test_client_initialization() {
490        let _client =
491            crate::providers::groq::Client::new("dummy-key").expect("Client::new() failed");
492        let builder: crate::providers::groq::ClientBuilder =
493            crate::providers::groq::Client::builder().api_key("dummy-key");
494        let _client_from_builder = builder.build().expect("Client::builder() failed");
495    }
496
497    #[tokio::test]
498    async fn completion_preserves_raw_provider_error_json_on_api_error_envelope() {
499        use crate::client::CompletionClient;
500        use crate::completion::{CompletionError, CompletionModel};
501        use crate::test_utils::RecordingHttpClient;
502
503        let body = r#"{"message":"model overloaded","type":"server_error","code":"503"}"#;
504        let http_client =
505            RecordingHttpClient::with_error_response(http::StatusCode::ACCEPTED, body);
506        let client = super::Client::builder()
507            .api_key("test-key")
508            .http_client(http_client)
509            .build()
510            .expect("build client");
511        let model = client.completion_model("llama-3.3-70b-versatile");
512        let request = model.completion_request("hello").build();
513
514        let error = model
515            .completion(request)
516            .await
517            .expect_err("completion should fail with provider error envelope");
518
519        match &error {
520            CompletionError::ProviderResponse(stored) => {
521                assert_eq!(stored.body, body);
522                assert_eq!(stored.status, Some(http::StatusCode::ACCEPTED));
523                assert_eq!(error.provider_response_body(), Some(body));
524                let json = error
525                    .provider_response_json()
526                    .expect("raw body should be valid JSON")
527                    .expect("parsed JSON should be present");
528                assert_eq!(json["code"], "503");
529            }
530            other => panic!("expected ProviderResponse, got {other:?}"),
531        }
532    }
533
534    #[tokio::test]
535    async fn completion_http_non_success_preserves_status_and_body() {
536        use crate::client::CompletionClient;
537        use crate::completion::{CompletionError, CompletionModel};
538        use crate::test_utils::RecordingHttpClient;
539
540        let body = r#"{"error":{"message":"service unavailable","code":"503"}}"#;
541        let http_client =
542            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
543        let client = super::Client::builder()
544            .api_key("test-key")
545            .http_client(http_client)
546            .build()
547            .expect("build client");
548        let model = client.completion_model("llama-3.3-70b-versatile");
549        let request = model.completion_request("hello").build();
550
551        let error = model
552            .completion(request)
553            .await
554            .expect_err("completion should fail with non-success status");
555
556        // rig#2314: a provider with a request-id contract preserves its
557        // non-success responses as ProviderResponse, so the transport id has
558        // a home on the error; this mock sent no header, so the id is None.
559        assert!(matches!(error, CompletionError::ProviderResponse(_)));
560        assert_eq!(error.provider_request_id(), None);
561        assert_eq!(
562            error.provider_response_status(),
563            Some(http::StatusCode::SERVICE_UNAVAILABLE)
564        );
565        assert_eq!(error.provider_response_body(), Some(body));
566    }
567
568    #[tokio::test]
569    async fn transcription_http_non_success_preserves_status_and_body() {
570        use crate::client::transcription::TranscriptionClient;
571        use crate::test_utils::RecordingHttpClient;
572        use crate::transcription::{TranscriptionError, TranscriptionModel as _};
573
574        let body = r#"{"error":{"message":"bad audio","code":"400"}}"#;
575        let http_client =
576            RecordingHttpClient::with_error_response(http::StatusCode::BAD_REQUEST, body);
577        let client = super::Client::builder()
578            .api_key("test-key")
579            .http_client(http_client)
580            .build()
581            .expect("build client");
582        let model = client.transcription_model("whisper-large-v3");
583
584        let error = match model
585            .transcription_request()
586            .data(vec![0u8; 16])
587            .send()
588            .await
589        {
590            Err(error) => error,
591            Ok(_) => panic!("transcription should fail with non-success status"),
592        };
593
594        assert!(matches!(error, TranscriptionError::HttpError(_)));
595        assert_eq!(
596            error.provider_response_status(),
597            Some(http::StatusCode::BAD_REQUEST)
598        );
599        assert_eq!(error.provider_response_body(), Some(body));
600    }
601}