1use 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
28const 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 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
121pub type CompletionModel<H = reqwest::Client> =
123 openai::completion::GenericCompletionModel<GroqExt, H>;
124
125pub type StreamingCompletionResponse = openai::StreamingCompletionResponse;
127
128impl ProviderClient for Client {
129 type Input = String;
130 type Error = crate::client::ProviderClientError;
131
132 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
205pub const DEEPSEEK_R1_DISTILL_LLAMA_70B: &str = "deepseek-r1-distill-llama-70b";
211pub const GEMMA2_9B_IT: &str = "gemma2-9b-it";
213pub const LLAMA_3_1_8B_INSTANT: &str = "llama-3.1-8b-instant";
215pub const LLAMA_3_2_11B_VISION_PREVIEW: &str = "llama-3.2-11b-vision-preview";
217pub const LLAMA_3_2_1B_PREVIEW: &str = "llama-3.2-1b-preview";
219pub const LLAMA_3_2_3B_PREVIEW: &str = "llama-3.2-3b-preview";
221pub const LLAMA_3_2_90B_VISION_PREVIEW: &str = "llama-3.2-90b-vision-preview";
223pub const LLAMA_3_2_70B_SPECDEC: &str = "llama-3.2-70b-specdec";
225pub const LLAMA_3_2_70B_VERSATILE: &str = "llama-3.2-70b-versatile";
227pub const LLAMA_GUARD_3_8B: &str = "llama-guard-3-8b";
229pub const LLAMA_3_70B_8192: &str = "llama3-70b-8192";
231pub const LLAMA_3_8B_8192: &str = "llama3-8b-8192";
233pub 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
247pub struct GroqAdditionalParameters {
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub reasoning_format: Option<ReasoningFormat>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub include_reasoning: Option<bool>,
254 #[serde(flatten, skip_serializing_if = "Option::is_none")]
256 pub extra: Option<Map<String, serde_json::Value>>,
257}
258
259pub 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 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 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 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}