Skip to main content

rig_core/providers/
hyperbolic.rs

1//! Hyperbolic Inference API client and Rig integration
2//!
3//! # Example
4//! ```no_run
5//! use rig_core::{client::CompletionClient, providers::hyperbolic};
6//!
7//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
8//! let client = hyperbolic::Client::new("YOUR_API_KEY")?;
9//!
10//! let llama_3_1_8b = client.completion_model(hyperbolic::LLAMA_3_1_8B);
11//! # Ok(())
12//! # }
13//! ```
14
15use crate::client::{self, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder};
16use crate::client::{BearerAuth, ProviderClient};
17use crate::http_client::{self, HttpClientExt};
18
19// ================================================================
20// Main Hyperbolic Client
21// ================================================================
22const 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    // Hyperbolic's structured-output support is unverified; keep the
55    // pre-migration behavior of dropping `output_schema` with a warning.
56    const SUPPORTS_RESPONSE_FORMAT: bool = false;
57
58    // Hyperbolic does not support tool calling; `tools`/`tool_choice` are
59    // dropped with a warning during request conversion.
60    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        // Strip tool-exchange remnants that shared chat histories may carry;
71        // content-part arrays are kept as-is for Hyperbolic's vision models.
72        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    // The client base URL is the bare host; image/audio generation build
85    // their own v1 paths.
86    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    /// Create a new Hyperbolic client from the `HYPERBOLIC_API_KEY` environment variable.
119    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
146// ================================================================
147// Hyperbolic Completion API
148// ================================================================
149
150/// Meta Llama 3.1b Instruct model with 8B parameters.
151pub const LLAMA_3_1_8B: &str = "meta-llama/Meta-Llama-3.1-8B-Instruct";
152/// Meta Llama 3.3b Instruct model with 70B parameters.
153pub const LLAMA_3_3_70B: &str = "meta-llama/Llama-3.3-70B-Instruct";
154/// Meta Llama 3.1b Instruct model with 70B parameters.
155pub const LLAMA_3_1_70B: &str = "meta-llama/Meta-Llama-3.1-70B-Instruct";
156/// Meta Llama 3 Instruct model with 70B parameters.
157pub const LLAMA_3_70B: &str = "meta-llama/Meta-Llama-3-70B-Instruct";
158/// Hermes 3 Instruct model with 70B parameters.
159pub const HERMES_3_70B: &str = "NousResearch/Hermes-3-Llama-3.1-70b";
160/// Deepseek v2.5 model.
161pub const DEEPSEEK_2_5: &str = "deepseek-ai/DeepSeek-V2.5";
162/// Qwen 2.5 model with 72B parameters.
163pub const QWEN_2_5_72B: &str = "Qwen/Qwen2.5-72B-Instruct";
164/// Meta Llama 3.2b Instruct model with 3B parameters.
165pub const LLAMA_3_2_3B: &str = "meta-llama/Llama-3.2-3B-Instruct";
166/// Qwen 2.5 Coder Instruct model with 32B parameters.
167pub const QWEN_2_5_CODER_32B: &str = "Qwen/Qwen2.5-Coder-32B-Instruct";
168/// Preview (latest) version of Qwen model with 32B parameters.
169pub const QWEN_QWQ_PREVIEW_32B: &str = "Qwen/QwQ-32B-Preview";
170/// Deepseek R1 Zero model.
171pub const DEEPSEEK_R1_ZERO: &str = "deepseek-ai/DeepSeek-R1-Zero";
172/// Deepseek R1 model.
173pub const DEEPSEEK_R1: &str = "deepseek-ai/DeepSeek-R1";
174
175/// Hyperbolic completion model, driven by the shared OpenAI Chat Completions path.
176pub type CompletionModel<H = reqwest::Client> =
177    crate::providers::openai::completion::GenericCompletionModel<HyperbolicExt, H>;
178
179/// Raw completion payload, shared with the OpenAI Chat Completions path.
180pub type CompletionResponse = crate::providers::openai::CompletionResponse;
181
182// =======================================
183// Hyperbolic Image Generation API
184// =======================================
185
186#[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// ======================================
327// Hyperbolic Audio Generation API
328// ======================================
329#[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); // 200 OK
520        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); // 200 OK
593        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); // 200 OK
673        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}