1use 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
23const 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 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 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#[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 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
149pub type CompletionModel<H = reqwest::Client> =
151 openai::completion::GenericCompletionModel<GroqExt, H>;
152
153pub 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
213pub const DEEPSEEK_R1_DISTILL_LLAMA_70B: &str = "deepseek-r1-distill-llama-70b";
219pub const GEMMA2_9B_IT: &str = "gemma2-9b-it";
221pub const LLAMA_3_1_8B_INSTANT: &str = "llama-3.1-8b-instant";
223pub const LLAMA_3_2_11B_VISION_PREVIEW: &str = "llama-3.2-11b-vision-preview";
225pub const LLAMA_3_2_1B_PREVIEW: &str = "llama-3.2-1b-preview";
227pub const LLAMA_3_2_3B_PREVIEW: &str = "llama-3.2-3b-preview";
229pub const LLAMA_3_2_90B_VISION_PREVIEW: &str = "llama-3.2-90b-vision-preview";
231pub const LLAMA_3_2_70B_SPECDEC: &str = "llama-3.2-70b-specdec";
233pub const LLAMA_3_2_70B_VERSATILE: &str = "llama-3.2-70b-versatile";
235pub const LLAMA_GUARD_3_8B: &str = "llama-guard-3-8b";
237pub const LLAMA_3_70B_8192: &str = "llama3-70b-8192";
239pub const LLAMA_3_8B_8192: &str = "llama3-8b-8192";
241pub 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
255pub struct GroqAdditionalParameters {
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub reasoning_format: Option<ReasoningFormat>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub include_reasoning: Option<bool>,
262 #[serde(flatten, skip_serializing_if = "Option::is_none")]
264 pub extra: Option<Map<String, serde_json::Value>>,
265}
266
267pub 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
275pub 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 #[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 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 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 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}