1use crate::client::{self, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder};
16use crate::client::{BearerAuth, ProviderClient};
17use crate::http_client::{self, HttpClientExt};
18
19const HYPERBOLIC_API_BASE_URL: &str = "https://api.hyperbolic.xyz";
23
24#[derive(Debug, Default, Clone, Copy)]
25pub struct HyperbolicExt;
26#[derive(Debug, Default, Clone, Copy)]
27pub struct HyperbolicBuilder;
28
29type HyperbolicApiKey = BearerAuth;
30
31impl Provider for HyperbolicExt {
32 type Builder = HyperbolicBuilder;
33
34 const VERIFY_PATH: &'static str = "/models";
35}
36
37impl<H> Capabilities<H> for HyperbolicExt {
38 type Completion = Capable<CompletionModel<H>>;
39 type Embeddings = Nothing;
40 type Transcription = Nothing;
41 type ModelListing = Nothing;
42 #[cfg(feature = "image")]
43 type ImageGeneration = Capable<ImageGenerationModel<H>>;
44 #[cfg(feature = "audio")]
45 type AudioGeneration = Capable<AudioGenerationModel<H>>;
46 type Rerank = Nothing;
47}
48
49impl DebugExt for HyperbolicExt {}
50
51impl crate::providers::openai::completion::OpenAICompatibleProvider for HyperbolicExt {
52 const PROVIDER_NAME: &'static str = "hyperbolic";
53
54 const SUPPORTS_RESPONSE_FORMAT: bool = false;
57
58 const SUPPORTS_TOOLS: bool = false;
61
62 type StreamingUsage = crate::providers::openai::Usage;
63
64 type Response = crate::providers::openai::CompletionResponse;
65
66 fn finalize_request_body(
67 &self,
68 body: &mut serde_json::Value,
69 ) -> Result<(), crate::completion::CompletionError> {
70 if let Some(messages) = body
73 .get_mut("messages")
74 .and_then(serde_json::Value::as_array_mut)
75 {
76 crate::providers::openai::completion::sanitize_plain_text_history(
77 messages, None, false, false,
78 );
79 }
80
81 Ok(())
82 }
83
84 fn completion_path(&self, _model: &str) -> String {
87 "/v1/chat/completions".to_string()
88 }
89}
90
91impl ProviderBuilder for HyperbolicBuilder {
92 type Extension<H>
93 = HyperbolicExt
94 where
95 H: HttpClientExt;
96 type ApiKey = HyperbolicApiKey;
97
98 const BASE_URL: &'static str = HYPERBOLIC_API_BASE_URL;
99
100 fn build<H>(
101 _builder: &crate::client::ClientBuilder<Self, Self::ApiKey, H>,
102 ) -> http_client::Result<Self::Extension<H>>
103 where
104 H: HttpClientExt,
105 {
106 Ok(HyperbolicExt)
107 }
108}
109
110pub type Client<H = reqwest::Client> = client::Client<HyperbolicExt, H>;
111pub type ClientBuilder<H = crate::markers::Missing> =
112 client::ClientBuilder<HyperbolicBuilder, HyperbolicApiKey, H>;
113
114impl ProviderClient for Client {
115 type Input = HyperbolicApiKey;
116 type Error = crate::client::ProviderClientError;
117
118 fn from_env() -> Result<Self, Self::Error> {
120 let api_key = crate::client::required_env_var("HYPERBOLIC_API_KEY")?;
121 Self::new(&api_key).map_err(Into::into)
122 }
123
124 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
125 Self::new(input).map_err(Into::into)
126 }
127}
128
129#[cfg(any(feature = "image", feature = "audio"))]
130use serde::Deserialize;
131
132#[cfg(any(feature = "image", feature = "audio"))]
133#[derive(Debug, Deserialize)]
134struct ApiErrorResponse {
135 message: String,
136}
137
138#[cfg(any(feature = "image", feature = "audio"))]
139#[derive(Debug, Deserialize)]
140#[serde(untagged)]
141enum ApiResponse<T> {
142 Ok(T),
143 Err(ApiErrorResponse),
144}
145
146pub const LLAMA_3_1_8B: &str = "meta-llama/Meta-Llama-3.1-8B-Instruct";
152pub const LLAMA_3_3_70B: &str = "meta-llama/Llama-3.3-70B-Instruct";
154pub const LLAMA_3_1_70B: &str = "meta-llama/Meta-Llama-3.1-70B-Instruct";
156pub const LLAMA_3_70B: &str = "meta-llama/Meta-Llama-3-70B-Instruct";
158pub const HERMES_3_70B: &str = "NousResearch/Hermes-3-Llama-3.1-70b";
160pub const DEEPSEEK_2_5: &str = "deepseek-ai/DeepSeek-V2.5";
162pub const QWEN_2_5_72B: &str = "Qwen/Qwen2.5-72B-Instruct";
164pub const LLAMA_3_2_3B: &str = "meta-llama/Llama-3.2-3B-Instruct";
166pub const QWEN_2_5_CODER_32B: &str = "Qwen/Qwen2.5-Coder-32B-Instruct";
168pub const QWEN_QWQ_PREVIEW_32B: &str = "Qwen/QwQ-32B-Preview";
170pub const DEEPSEEK_R1_ZERO: &str = "deepseek-ai/DeepSeek-R1-Zero";
172pub const DEEPSEEK_R1: &str = "deepseek-ai/DeepSeek-R1";
174
175pub type CompletionModel<H = reqwest::Client> =
177 crate::providers::openai::completion::GenericCompletionModel<HyperbolicExt, H>;
178
179pub type CompletionResponse = crate::providers::openai::CompletionResponse;
181
182#[cfg(feature = "image")]
187pub use image_generation::*;
188
189#[cfg(feature = "image")]
190#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
191mod image_generation {
192 use super::{ApiResponse, Client};
193 use crate::http_client::HttpClientExt;
194 use crate::image_generation;
195 use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
196 use crate::json_utils::merge_inplace;
197 use base64::Engine;
198 use base64::prelude::BASE64_STANDARD;
199 use serde::Deserialize;
200 use serde_json::json;
201
202 pub const SDXL1_0_BASE: &str = "SDXL1.0-base";
203 pub const SD2: &str = "SD2";
204 pub const SD1_5: &str = "SD1.5";
205 pub const SSD: &str = "SSD";
206 pub const SDXL_TURBO: &str = "SDXL-turbo";
207 pub const SDXL_CONTROLNET: &str = "SDXL-ControlNet";
208 pub const SD1_5_CONTROLNET: &str = "SD1.5-ControlNet";
209
210 #[derive(Clone)]
211 pub struct ImageGenerationModel<T> {
212 client: Client<T>,
213 pub model: String,
214 }
215
216 impl<T> ImageGenerationModel<T> {
217 pub(crate) fn new(client: Client<T>, model: impl Into<String>) -> Self {
218 Self {
219 client,
220 model: model.into(),
221 }
222 }
223
224 pub fn with_model(client: Client<T>, model: &str) -> Self {
225 Self {
226 client,
227 model: model.into(),
228 }
229 }
230 }
231
232 #[derive(Clone, Deserialize)]
233 pub struct Image {
234 image: String,
235 }
236
237 #[derive(Clone, Deserialize)]
238 pub struct ImageGenerationResponse {
239 images: Vec<Image>,
240 }
241
242 impl TryFrom<ImageGenerationResponse>
243 for image_generation::ImageGenerationResponse<ImageGenerationResponse>
244 {
245 type Error = ImageGenerationError;
246
247 fn try_from(value: ImageGenerationResponse) -> Result<Self, Self::Error> {
248 let image = value
249 .images
250 .first()
251 .ok_or_else(|| ImageGenerationError::ResponseError("missing image data".into()))?;
252 let data = BASE64_STANDARD
253 .decode(&image.image)
254 .map_err(|err| ImageGenerationError::ResponseError(err.to_string()))?;
255
256 Ok(Self {
257 image: data,
258 response: value,
259 })
260 }
261 }
262
263 impl<T> image_generation::ImageGenerationModel for ImageGenerationModel<T>
264 where
265 T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
266 {
267 type Response = ImageGenerationResponse;
268
269 type Client = Client<T>;
270
271 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
272 Self::new(client.clone(), model)
273 }
274
275 async fn image_generation(
276 &self,
277 generation_request: ImageGenerationRequest,
278 ) -> Result<image_generation::ImageGenerationResponse<Self::Response>, ImageGenerationError>
279 {
280 let mut request = json!({
281 "model_name": self.model,
282 "prompt": generation_request.prompt,
283 "height": generation_request.height,
284 "width": generation_request.width,
285 });
286
287 if let Some(params) = generation_request.additional_params {
288 merge_inplace(&mut request, params);
289 }
290
291 let body = serde_json::to_vec(&request)?;
292
293 let request = self
294 .client
295 .post("/v1/image/generation")?
296 .header("Content-Type", "application/json")
297 .body(body)
298 .map_err(|e| ImageGenerationError::HttpError(e.into()))?;
299
300 let response = self.client.send::<_, bytes::Bytes>(request).await?;
301
302 let status = response.status();
303 let response_body = response.into_body().into_future().await?.to_vec();
304
305 if !status.is_success() {
306 return Err(ImageGenerationError::from_http_response(
307 status,
308 String::from_utf8_lossy(&response_body),
309 ));
310 }
311
312 match serde_json::from_slice::<ApiResponse<ImageGenerationResponse>>(&response_body)? {
313 ApiResponse::Ok(response) => response.try_into(),
314 ApiResponse::Err(err) => {
315 tracing::warn!(message = %err.message, "provider returned an error response");
316 Err(ImageGenerationError::from_http_response(
317 status,
318 String::from_utf8_lossy(&response_body),
319 ))
320 }
321 }
322 }
323 }
324}
325
326#[cfg(feature = "audio")]
330pub use audio_generation::*;
331
332#[cfg(feature = "audio")]
333#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
334mod audio_generation {
335 use super::{ApiResponse, Client};
336 use crate::audio_generation;
337 use crate::audio_generation::{AudioGenerationError, AudioGenerationRequest};
338 use crate::http_client::{self, HttpClientExt};
339 use base64::Engine;
340 use base64::prelude::BASE64_STANDARD;
341 use bytes::Bytes;
342 use serde::Deserialize;
343 use serde_json::json;
344
345 #[derive(Clone)]
346 pub struct AudioGenerationModel<T> {
347 client: Client<T>,
348 pub language: String,
349 }
350
351 #[derive(Clone, Deserialize)]
352 pub struct AudioGenerationResponse {
353 audio: String,
354 }
355
356 impl TryFrom<AudioGenerationResponse>
357 for audio_generation::AudioGenerationResponse<AudioGenerationResponse>
358 {
359 type Error = AudioGenerationError;
360
361 fn try_from(value: AudioGenerationResponse) -> Result<Self, Self::Error> {
362 let data = BASE64_STANDARD
363 .decode(&value.audio)
364 .map_err(|err| AudioGenerationError::ResponseError(err.to_string()))?;
365
366 Ok(Self {
367 audio: data,
368 response: value,
369 })
370 }
371 }
372
373 impl<T> audio_generation::AudioGenerationModel for AudioGenerationModel<T>
374 where
375 T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
376 {
377 type Response = AudioGenerationResponse;
378 type Client = Client<T>;
379
380 fn make(client: &Self::Client, language: impl Into<String>) -> Self {
381 Self {
382 client: client.clone(),
383 language: language.into(),
384 }
385 }
386
387 async fn audio_generation(
388 &self,
389 request: AudioGenerationRequest,
390 ) -> Result<audio_generation::AudioGenerationResponse<Self::Response>, AudioGenerationError>
391 {
392 let request = json!({
393 "language": self.language,
394 "speaker": request.voice,
395 "text": request.text,
396 "speed": request.speed
397 });
398
399 let body = serde_json::to_vec(&request)?;
400
401 let req = self
402 .client
403 .post("/v1/audio/generation")?
404 .body(body)
405 .map_err(http_client::Error::from)?;
406
407 let response = self.client.send::<_, Bytes>(req).await?;
408 let status = response.status();
409 let response_body = response.into_body().into_future().await?.to_vec();
410
411 if !status.is_success() {
412 return Err(AudioGenerationError::from_http_response(
413 status,
414 String::from_utf8_lossy(&response_body),
415 ));
416 }
417
418 match serde_json::from_slice::<ApiResponse<AudioGenerationResponse>>(&response_body)? {
419 ApiResponse::Ok(response) => response.try_into(),
420 ApiResponse::Err(err) => {
421 tracing::warn!(message = %err.message, "provider returned an error response");
422 Err(AudioGenerationError::from_http_response(
423 status,
424 String::from_utf8_lossy(&response_body),
425 ))
426 }
427 }
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 #[test]
435 fn hyperbolic_prepare_request_drops_tools_and_tool_choice() {
436 use crate::providers::openai::completion::{
437 CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider,
438 OpenAIRequestParams,
439 };
440
441 let request = crate::completion::CompletionRequestBuilder::new(
442 crate::test_utils::MockCompletionModel::default(),
443 "hello",
444 )
445 .tool(crate::completion::ToolDefinition {
446 name: "lookup".to_string(),
447 description: "Lookup".to_string(),
448 parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
449 })
450 .tool_choice(crate::message::ToolChoice::Required)
451 .output_schema(schemars::schema_for!(serde_json::Value))
452 .build();
453
454 let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
455 model: "meta-llama/Meta-Llama-3.1-8B-Instruct".to_string(),
456 request,
457 strict_tools: false,
458 tool_result_array_content: false,
459 supports_response_format: super::HyperbolicExt::SUPPORTS_RESPONSE_FORMAT,
460 supports_tools: false,
461 })
462 .expect("request should convert");
463 super::HyperbolicExt
464 .prepare_request(&mut request)
465 .expect("prepare_request should succeed");
466
467 let body = serde_json::to_value(request).expect("request should serialize");
468 assert!(body.get("tools").is_none());
469 assert!(body.get("tool_choice").is_none());
470 assert!(body.get("response_format").is_none());
471 }
472
473 #[test]
474 fn test_client_initialization() {
475 let _client =
476 crate::providers::hyperbolic::Client::new("dummy-key").expect("Client::new() failed");
477 let builder: crate::providers::hyperbolic::ClientBuilder =
478 crate::providers::hyperbolic::Client::builder().api_key("dummy-key");
479 let _client_from_builder = builder.build().expect("Client::builder() failed");
480 }
481
482 #[tokio::test]
483 async fn completion_non_success_preserves_status_and_body() {
484 use crate::client::CompletionClient;
485 use crate::completion::{CompletionError, CompletionModel};
486 use crate::test_utils::RecordingHttpClient;
487
488 let body = r#"{"error":{"message":"boom"}}"#;
489 let http_client =
490 RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
491 let client = super::Client::builder()
492 .api_key("test-key")
493 .http_client(http_client)
494 .build()
495 .expect("build client");
496 let model = client.completion_model(super::LLAMA_3_1_8B);
497 let request = model.completion_request("hello").build();
498
499 let error = model
500 .completion(request)
501 .await
502 .expect_err("completion should fail with non-success status");
503
504 assert!(matches!(error, CompletionError::HttpError(_)));
505 assert_eq!(
506 error.provider_response_status(),
507 Some(http::StatusCode::SERVICE_UNAVAILABLE)
508 );
509 assert_eq!(error.provider_response_body(), Some(body));
510 }
511
512 #[tokio::test]
513 async fn completion_2xx_error_envelope_preserves_status_and_body() {
514 use crate::client::CompletionClient;
515 use crate::completion::{CompletionError, CompletionModel};
516 use crate::test_utils::RecordingHttpClient;
517
518 let body = r#"{"message":"boom"}"#;
519 let http_client = RecordingHttpClient::new(body); let client = super::Client::builder()
521 .api_key("test-key")
522 .http_client(http_client)
523 .build()
524 .expect("build client");
525 let model = client.completion_model(super::LLAMA_3_1_8B);
526 let request = model.completion_request("hello").build();
527
528 let error = model
529 .completion(request)
530 .await
531 .expect_err("completion should fail with provider error envelope");
532
533 match &error {
534 CompletionError::ProviderResponse(stored) => {
535 assert_eq!(stored.body, body);
536 assert_eq!(stored.status, Some(http::StatusCode::OK));
537 }
538 other => panic!("expected ProviderResponse, got {other:?}"),
539 }
540 }
541
542 #[cfg(feature = "image")]
543 #[tokio::test]
544 async fn image_generation_non_success_preserves_status_and_body() {
545 use crate::client::image_generation::ImageGenerationClient;
546 use crate::image_generation::{
547 ImageGenerationError, ImageGenerationModel as _, ImageGenerationRequest,
548 };
549 use crate::test_utils::RecordingHttpClient;
550
551 let body = r#"{"error":{"message":"boom"}}"#;
552 let http_client =
553 RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
554 let client = super::Client::builder()
555 .api_key("test-key")
556 .http_client(http_client)
557 .build()
558 .expect("build client");
559 let model = client.image_generation_model(super::SDXL1_0_BASE);
560
561 let request = ImageGenerationRequest {
562 prompt: "draw a cat".to_string(),
563 width: 256,
564 height: 256,
565 additional_params: None,
566 };
567
568 let error = model
569 .image_generation(request)
570 .await
571 .err()
572 .expect("image generation should fail with non-success status");
573
574 assert!(matches!(error, ImageGenerationError::HttpError(_)));
575 assert_eq!(
576 error.provider_response_status(),
577 Some(http::StatusCode::SERVICE_UNAVAILABLE)
578 );
579 assert_eq!(error.provider_response_body(), Some(body));
580 }
581
582 #[cfg(feature = "image")]
583 #[tokio::test]
584 async fn image_generation_2xx_error_envelope_preserves_status_and_body() {
585 use crate::client::image_generation::ImageGenerationClient;
586 use crate::image_generation::{
587 ImageGenerationError, ImageGenerationModel as _, ImageGenerationRequest,
588 };
589 use crate::test_utils::RecordingHttpClient;
590
591 let body = r#"{"message":"boom"}"#;
592 let http_client = RecordingHttpClient::new(body); let client = super::Client::builder()
594 .api_key("test-key")
595 .http_client(http_client)
596 .build()
597 .expect("build client");
598 let model = client.image_generation_model(super::SDXL1_0_BASE);
599
600 let request = ImageGenerationRequest {
601 prompt: "draw a cat".to_string(),
602 width: 256,
603 height: 256,
604 additional_params: None,
605 };
606
607 let error = model
608 .image_generation(request)
609 .await
610 .err()
611 .expect("image generation should fail with provider error envelope");
612
613 match &error {
614 ImageGenerationError::ProviderResponse(stored) => {
615 assert_eq!(stored.body, body);
616 assert_eq!(stored.status, Some(http::StatusCode::OK));
617 }
618 other => panic!("expected ProviderResponse, got {other:?}"),
619 }
620 }
621
622 #[cfg(feature = "audio")]
623 #[tokio::test]
624 async fn audio_generation_non_success_preserves_status_and_body() {
625 use crate::audio_generation::{
626 AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
627 };
628 use crate::client::audio_generation::AudioGenerationClient;
629 use crate::test_utils::RecordingHttpClient;
630
631 let body = r#"{"error":{"message":"boom"}}"#;
632 let http_client =
633 RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
634 let client = super::Client::builder()
635 .api_key("test-key")
636 .http_client(http_client)
637 .build()
638 .expect("build client");
639 let model = client.audio_generation_model("EN");
640
641 let request = AudioGenerationRequest {
642 text: "hello".to_string(),
643 voice: "default".to_string(),
644 speed: 1.0,
645 additional_params: None,
646 };
647
648 let error = model
649 .audio_generation(request)
650 .await
651 .err()
652 .expect("audio generation should fail with non-success status");
653
654 assert!(matches!(error, AudioGenerationError::HttpError(_)));
655 assert_eq!(
656 error.provider_response_status(),
657 Some(http::StatusCode::SERVICE_UNAVAILABLE)
658 );
659 assert_eq!(error.provider_response_body(), Some(body));
660 }
661
662 #[cfg(feature = "audio")]
663 #[tokio::test]
664 async fn audio_generation_2xx_error_envelope_preserves_status_and_body() {
665 use crate::audio_generation::{
666 AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
667 };
668 use crate::client::audio_generation::AudioGenerationClient;
669 use crate::test_utils::RecordingHttpClient;
670
671 let body = r#"{"message":"boom"}"#;
672 let http_client = RecordingHttpClient::new(body); let client = super::Client::builder()
674 .api_key("test-key")
675 .http_client(http_client)
676 .build()
677 .expect("build client");
678 let model = client.audio_generation_model("EN");
679
680 let request = AudioGenerationRequest {
681 text: "hello".to_string(),
682 voice: "default".to_string(),
683 speed: 1.0,
684 additional_params: None,
685 };
686
687 let error = model
688 .audio_generation(request)
689 .await
690 .err()
691 .expect("audio generation should fail with provider error envelope");
692
693 match &error {
694 AudioGenerationError::ProviderResponse(stored) => {
695 assert_eq!(stored.body, body);
696 assert_eq!(stored.status, Some(http::StatusCode::OK));
697 }
698 other => panic!("expected ProviderResponse, got {other:?}"),
699 }
700 }
701}