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::BearerAuth;
16use crate::client::{self, DebugExt, Provider};
17
18// ================================================================
19// Main Hyperbolic Client
20// ================================================================
21const HYPERBOLIC_API_BASE_URL: &str = "https://api.hyperbolic.xyz";
22
23#[derive(Debug, Default, Clone, Copy)]
24pub struct HyperbolicExt;
25#[derive(Debug, Default, Clone, Copy)]
26pub struct HyperbolicBuilder;
27
28type HyperbolicApiKey = BearerAuth;
29
30impl Provider for HyperbolicExt {
31    type Builder = HyperbolicBuilder;
32
33    const VERIFY_PATH: &'static str = "/models";
34}
35
36client::impl_capabilities!(
37    HyperbolicExt,
38    completion = CompletionModel<H>,
39    image_generation = ImageGenerationModel<H>,
40    audio_generation = AudioGenerationModel<H>,
41);
42
43impl DebugExt for HyperbolicExt {}
44
45impl crate::providers::openai::completion::OpenAICompatibleProvider for HyperbolicExt {
46    const PROVIDER_NAME: &'static str = "hyperbolic";
47
48    // Hyperbolic's structured-output support is unverified; keep the
49    // pre-migration behavior of dropping `output_schema` with a warning.
50    const SUPPORTS_RESPONSE_FORMAT: bool = false;
51
52    // Hyperbolic does not support tool calling; `tools`/`tool_choice` are
53    // dropped with a warning during request conversion.
54    const SUPPORTS_TOOLS: bool = false;
55
56    type StreamingUsage = crate::providers::openai::Usage;
57
58    type Response = crate::providers::openai::CompletionResponse;
59
60    fn finalize_request_body(
61        &self,
62        body: &mut serde_json::Value,
63    ) -> Result<(), crate::completion::CompletionError> {
64        // Strip tool-exchange remnants that shared chat histories may carry;
65        // content-part arrays are kept as-is for Hyperbolic's vision models.
66        if let Some(messages) = body
67            .get_mut("messages")
68            .and_then(serde_json::Value::as_array_mut)
69        {
70            crate::providers::openai::completion::sanitize_plain_text_history(
71                messages, None, false, false,
72            );
73        }
74
75        Ok(())
76    }
77
78    // The client base URL is the bare host; image/audio generation build
79    // their own v1 paths.
80    fn completion_path(&self, _model: &str) -> String {
81        "/v1/chat/completions".to_string()
82    }
83}
84
85client::impl_default_provider_builder!(
86    HyperbolicBuilder => HyperbolicExt,
87    api_key = HyperbolicApiKey,
88    base_url = HYPERBOLIC_API_BASE_URL,
89);
90
91pub type Client<H = reqwest::Client> = client::Client<HyperbolicExt, H>;
92pub type ClientBuilder<H = crate::markers::Missing> =
93    client::ClientBuilder<HyperbolicBuilder, HyperbolicApiKey, H>;
94
95client::impl_provider_client!(
96    Client,
97    input = HyperbolicApiKey,
98    api_key_env = "HYPERBOLIC_API_KEY",
99);
100
101#[cfg(feature = "audio")]
102use crate::providers::openai::client::ApiResponse;
103
104// ================================================================
105// Hyperbolic Completion API
106// ================================================================
107
108/// Meta Llama 3.1b Instruct model with 8B parameters.
109pub const LLAMA_3_1_8B: &str = "meta-llama/Meta-Llama-3.1-8B-Instruct";
110/// Meta Llama 3.3b Instruct model with 70B parameters.
111pub const LLAMA_3_3_70B: &str = "meta-llama/Llama-3.3-70B-Instruct";
112/// Meta Llama 3.1b Instruct model with 70B parameters.
113pub const LLAMA_3_1_70B: &str = "meta-llama/Meta-Llama-3.1-70B-Instruct";
114/// Meta Llama 3 Instruct model with 70B parameters.
115pub const LLAMA_3_70B: &str = "meta-llama/Meta-Llama-3-70B-Instruct";
116/// Hermes 3 Instruct model with 70B parameters.
117pub const HERMES_3_70B: &str = "NousResearch/Hermes-3-Llama-3.1-70b";
118/// Deepseek v2.5 model.
119pub const DEEPSEEK_2_5: &str = "deepseek-ai/DeepSeek-V2.5";
120/// Qwen 2.5 model with 72B parameters.
121pub const QWEN_2_5_72B: &str = "Qwen/Qwen2.5-72B-Instruct";
122/// Meta Llama 3.2b Instruct model with 3B parameters.
123pub const LLAMA_3_2_3B: &str = "meta-llama/Llama-3.2-3B-Instruct";
124/// Qwen 2.5 Coder Instruct model with 32B parameters.
125pub const QWEN_2_5_CODER_32B: &str = "Qwen/Qwen2.5-Coder-32B-Instruct";
126/// Preview (latest) version of Qwen model with 32B parameters.
127pub const QWEN_QWQ_PREVIEW_32B: &str = "Qwen/QwQ-32B-Preview";
128/// Deepseek R1 Zero model.
129pub const DEEPSEEK_R1_ZERO: &str = "deepseek-ai/DeepSeek-R1-Zero";
130/// Deepseek R1 model.
131pub const DEEPSEEK_R1: &str = "deepseek-ai/DeepSeek-R1";
132
133/// Hyperbolic completion model, driven by the shared OpenAI Chat Completions path.
134pub type CompletionModel<H = reqwest::Client> =
135    crate::providers::openai::completion::GenericCompletionModel<HyperbolicExt, H>;
136
137/// Raw completion payload, shared with the OpenAI Chat Completions path.
138pub type CompletionResponse = crate::providers::openai::CompletionResponse;
139
140// =======================================
141// Hyperbolic Image Generation API
142// =======================================
143
144#[cfg(feature = "image")]
145pub use image_generation::*;
146
147#[cfg(feature = "image")]
148#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
149mod image_generation {
150    use super::HyperbolicExt;
151    use crate::image_generation;
152    use crate::image_generation::{ImageGenerationError, ImageGenerationRequest};
153    use crate::json_utils::merge_inplace;
154    use crate::providers::internal::image_generation::{
155        GenericImageGenerationModel, JsonImageGenerationProvider, decode_base64_image,
156    };
157    use serde::Deserialize;
158    use serde_json::json;
159
160    pub const SDXL1_0_BASE: &str = "SDXL1.0-base";
161    pub const SD2: &str = "SD2";
162    pub const SD1_5: &str = "SD1.5";
163    pub const SSD: &str = "SSD";
164    pub const SDXL_TURBO: &str = "SDXL-turbo";
165    pub const SDXL_CONTROLNET: &str = "SDXL-ControlNet";
166    pub const SD1_5_CONTROLNET: &str = "SD1.5-ControlNet";
167
168    /// Hyperbolic image generation model.
169    pub type ImageGenerationModel<T> = GenericImageGenerationModel<HyperbolicExt, T>;
170
171    #[derive(Clone, Deserialize)]
172    pub struct Image {
173        image: String,
174    }
175
176    #[derive(Clone, Deserialize)]
177    pub struct ImageGenerationResponse {
178        images: Vec<Image>,
179    }
180
181    impl TryFrom<ImageGenerationResponse>
182        for image_generation::ImageGenerationResponse<ImageGenerationResponse>
183    {
184        type Error = ImageGenerationError;
185
186        fn try_from(value: ImageGenerationResponse) -> Result<Self, Self::Error> {
187            decode_base64_image(
188                value,
189                |response| response.images.first().map(|image| image.image.as_str()),
190                "missing image data",
191                None,
192            )
193        }
194    }
195
196    impl JsonImageGenerationProvider for HyperbolicExt {
197        const IMAGE_GENERATION_PATH: &'static str = "/v1/image/generation";
198        type Response = ImageGenerationResponse;
199
200        fn image_generation_request_body(
201            model: &str,
202            generation_request: ImageGenerationRequest,
203        ) -> Result<serde_json::Value, ImageGenerationError> {
204            let mut request = json!({
205                "model_name": model,
206                "prompt": generation_request.prompt,
207                "height": generation_request.height,
208                "width": generation_request.width,
209            });
210
211            if let Some(params) = generation_request.additional_params {
212                merge_inplace(&mut request, params);
213            }
214
215            Ok(request)
216        }
217    }
218}
219
220// ======================================
221// Hyperbolic Audio Generation API
222// ======================================
223#[cfg(feature = "audio")]
224pub use audio_generation::*;
225
226#[cfg(feature = "audio")]
227#[cfg_attr(docsrs, doc(cfg(feature = "image")))]
228mod audio_generation {
229    use super::{ApiResponse, Client};
230    use crate::audio_generation;
231    use crate::audio_generation::{AudioGenerationError, AudioGenerationRequest};
232    use crate::http_client::{self, HttpClientExt};
233    use base64::Engine;
234    use base64::prelude::BASE64_STANDARD;
235    use bytes::Bytes;
236    use serde::Deserialize;
237    use serde_json::json;
238
239    #[derive(Clone)]
240    pub struct AudioGenerationModel<T> {
241        client: Client<T>,
242        pub language: String,
243    }
244
245    #[derive(Clone, Deserialize)]
246    pub struct AudioGenerationResponse {
247        audio: String,
248    }
249
250    impl TryFrom<AudioGenerationResponse>
251        for audio_generation::AudioGenerationResponse<AudioGenerationResponse>
252    {
253        type Error = AudioGenerationError;
254
255        fn try_from(value: AudioGenerationResponse) -> Result<Self, Self::Error> {
256            let data = BASE64_STANDARD
257                .decode(&value.audio)
258                .map_err(|err| AudioGenerationError::ResponseError(err.to_string()))?;
259
260            Ok(Self {
261                audio: data,
262                response: value,
263            })
264        }
265    }
266
267    impl<T> audio_generation::AudioGenerationModel for AudioGenerationModel<T>
268    where
269        T: HttpClientExt + Clone + Default + std::fmt::Debug + Send + 'static,
270    {
271        type Response = AudioGenerationResponse;
272        type Client = Client<T>;
273
274        fn make(client: &Self::Client, language: impl Into<String>) -> Self {
275            Self {
276                client: client.clone(),
277                language: language.into(),
278            }
279        }
280
281        async fn audio_generation(
282            &self,
283            request: AudioGenerationRequest,
284        ) -> Result<audio_generation::AudioGenerationResponse<Self::Response>, AudioGenerationError>
285        {
286            let request = json!({
287                "language": self.language,
288                "speaker": request.voice,
289                "text": request.text,
290                "speed": request.speed
291            });
292
293            let body = serde_json::to_vec(&request)?;
294
295            let req = self
296                .client
297                .post("/v1/audio/generation")?
298                .body(body)
299                .map_err(http_client::Error::from)?;
300
301            let response = self.client.send::<_, Bytes>(req).await?;
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(AudioGenerationError::from_http_response(
307                    status,
308                    String::from_utf8_lossy(&response_body),
309                ));
310            }
311
312            match serde_json::from_slice::<ApiResponse<AudioGenerationResponse>>(&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(AudioGenerationError::from_http_response(
317                        status,
318                        String::from_utf8_lossy(&response_body),
319                    ))
320                }
321            }
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    #[test]
329    fn hyperbolic_prepare_request_drops_tools_and_tool_choice() {
330        use crate::providers::openai::completion::{
331            CompletionRequest as OpenAICompletionRequest, OpenAICompatibleProvider,
332            OpenAIRequestParams,
333        };
334
335        let request = crate::completion::CompletionRequestBuilder::new(
336            crate::test_utils::MockCompletionModel::default(),
337            "hello",
338        )
339        .tool(crate::completion::ToolDefinition {
340            name: "lookup".to_string(),
341            description: "Lookup".to_string(),
342            parameters: serde_json::json!({"type":"object","properties":{},"required":[]}),
343        })
344        .tool_choice(crate::message::ToolChoice::Required)
345        .output_schema(schemars::schema_for!(serde_json::Value))
346        .build();
347
348        let mut request = OpenAICompletionRequest::try_from(OpenAIRequestParams {
349            model: "meta-llama/Meta-Llama-3.1-8B-Instruct".to_string(),
350            request,
351            strict_tools: false,
352            tool_result_array_content: false,
353            supports_response_format: super::HyperbolicExt::SUPPORTS_RESPONSE_FORMAT,
354            supports_tools: false,
355        })
356        .expect("request should convert");
357        super::HyperbolicExt
358            .prepare_request(&mut request)
359            .expect("prepare_request should succeed");
360
361        let body = serde_json::to_value(request).expect("request should serialize");
362        assert!(body.get("tools").is_none());
363        assert!(body.get("tool_choice").is_none());
364        assert!(body.get("response_format").is_none());
365    }
366
367    #[test]
368    fn test_client_initialization() {
369        let _client =
370            crate::providers::hyperbolic::Client::new("dummy-key").expect("Client::new() failed");
371        let builder: crate::providers::hyperbolic::ClientBuilder =
372            crate::providers::hyperbolic::Client::builder().api_key("dummy-key");
373        let _client_from_builder = builder.build().expect("Client::builder() failed");
374    }
375
376    #[tokio::test]
377    async fn completion_non_success_preserves_status_and_body() {
378        use crate::client::CompletionClient;
379        use crate::completion::{CompletionError, CompletionModel};
380        use crate::test_utils::RecordingHttpClient;
381
382        let body = r#"{"error":{"message":"boom"}}"#;
383        let http_client =
384            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
385        let client = super::Client::builder()
386            .api_key("test-key")
387            .http_client(http_client)
388            .build()
389            .expect("build client");
390        let model = client.completion_model(super::LLAMA_3_1_8B);
391        let request = model.completion_request("hello").build();
392
393        let error = model
394            .completion(request)
395            .await
396            .expect_err("completion should fail with non-success status");
397
398        assert!(matches!(error, CompletionError::HttpError(_)));
399        assert_eq!(
400            error.provider_response_status(),
401            Some(http::StatusCode::SERVICE_UNAVAILABLE)
402        );
403        assert_eq!(error.provider_response_body(), Some(body));
404    }
405
406    #[tokio::test]
407    async fn completion_2xx_error_envelope_preserves_status_and_body() {
408        use crate::client::CompletionClient;
409        use crate::completion::{CompletionError, CompletionModel};
410        use crate::test_utils::RecordingHttpClient;
411
412        let body = r#"{"message":"boom"}"#;
413        let http_client = RecordingHttpClient::new(body); // 200 OK
414        let client = super::Client::builder()
415            .api_key("test-key")
416            .http_client(http_client)
417            .build()
418            .expect("build client");
419        let model = client.completion_model(super::LLAMA_3_1_8B);
420        let request = model.completion_request("hello").build();
421
422        let error = model
423            .completion(request)
424            .await
425            .expect_err("completion should fail with provider error envelope");
426
427        match &error {
428            CompletionError::ProviderResponse(stored) => {
429                assert_eq!(stored.body, body);
430                assert_eq!(stored.status, Some(http::StatusCode::OK));
431            }
432            other => panic!("expected ProviderResponse, got {other:?}"),
433        }
434    }
435
436    #[cfg(feature = "image")]
437    #[tokio::test]
438    async fn image_generation_non_success_preserves_status_and_body() {
439        use crate::client::image_generation::ImageGenerationClient;
440        use crate::image_generation::{
441            ImageGenerationError, ImageGenerationModel as _, ImageGenerationRequest,
442        };
443        use crate::test_utils::RecordingHttpClient;
444
445        let body = r#"{"error":{"message":"boom"}}"#;
446        let http_client =
447            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
448        let client = super::Client::builder()
449            .api_key("test-key")
450            .http_client(http_client)
451            .build()
452            .expect("build client");
453        let model = client.image_generation_model(super::SDXL1_0_BASE);
454
455        let request = ImageGenerationRequest {
456            prompt: "draw a cat".to_string(),
457            width: 256,
458            height: 256,
459            additional_params: None,
460        };
461
462        let error = model
463            .image_generation(request)
464            .await
465            .err()
466            .expect("image generation should fail with non-success status");
467
468        assert!(matches!(error, ImageGenerationError::HttpError(_)));
469        assert_eq!(
470            error.provider_response_status(),
471            Some(http::StatusCode::SERVICE_UNAVAILABLE)
472        );
473        assert_eq!(error.provider_response_body(), Some(body));
474    }
475
476    #[cfg(feature = "image")]
477    #[tokio::test]
478    async fn image_generation_2xx_error_envelope_preserves_status_and_body() {
479        use crate::client::image_generation::ImageGenerationClient;
480        use crate::image_generation::{
481            ImageGenerationError, ImageGenerationModel as _, ImageGenerationRequest,
482        };
483        use crate::test_utils::RecordingHttpClient;
484
485        let body = r#"{"message":"boom"}"#;
486        let http_client = RecordingHttpClient::new(body); // 200 OK
487        let client = super::Client::builder()
488            .api_key("test-key")
489            .http_client(http_client)
490            .build()
491            .expect("build client");
492        let model = client.image_generation_model(super::SDXL1_0_BASE);
493
494        let request = ImageGenerationRequest {
495            prompt: "draw a cat".to_string(),
496            width: 256,
497            height: 256,
498            additional_params: None,
499        };
500
501        let error = model
502            .image_generation(request)
503            .await
504            .err()
505            .expect("image generation should fail with provider error envelope");
506
507        match &error {
508            ImageGenerationError::ProviderResponse(stored) => {
509                assert_eq!(stored.body, body);
510                assert_eq!(stored.status, Some(http::StatusCode::OK));
511            }
512            other => panic!("expected ProviderResponse, got {other:?}"),
513        }
514    }
515
516    #[cfg(feature = "audio")]
517    #[tokio::test]
518    async fn audio_generation_non_success_preserves_status_and_body() {
519        use crate::audio_generation::{
520            AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
521        };
522        use crate::client::audio_generation::AudioGenerationClient;
523        use crate::test_utils::RecordingHttpClient;
524
525        let body = r#"{"error":{"message":"boom"}}"#;
526        let http_client =
527            RecordingHttpClient::with_error_response(http::StatusCode::SERVICE_UNAVAILABLE, body);
528        let client = super::Client::builder()
529            .api_key("test-key")
530            .http_client(http_client)
531            .build()
532            .expect("build client");
533        let model = client.audio_generation_model("EN");
534
535        let request = AudioGenerationRequest {
536            text: "hello".to_string(),
537            voice: "default".to_string(),
538            speed: 1.0,
539            additional_params: None,
540        };
541
542        let error = model
543            .audio_generation(request)
544            .await
545            .err()
546            .expect("audio generation should fail with non-success status");
547
548        assert!(matches!(error, AudioGenerationError::HttpError(_)));
549        assert_eq!(
550            error.provider_response_status(),
551            Some(http::StatusCode::SERVICE_UNAVAILABLE)
552        );
553        assert_eq!(error.provider_response_body(), Some(body));
554    }
555
556    #[cfg(feature = "audio")]
557    #[tokio::test]
558    async fn audio_generation_2xx_error_envelope_preserves_status_and_body() {
559        use crate::audio_generation::{
560            AudioGenerationError, AudioGenerationModel as _, AudioGenerationRequest,
561        };
562        use crate::client::audio_generation::AudioGenerationClient;
563        use crate::test_utils::RecordingHttpClient;
564
565        let body = r#"{"message":"boom"}"#;
566        let http_client = RecordingHttpClient::new(body); // 200 OK
567        let client = super::Client::builder()
568            .api_key("test-key")
569            .http_client(http_client)
570            .build()
571            .expect("build client");
572        let model = client.audio_generation_model("EN");
573
574        let request = AudioGenerationRequest {
575            text: "hello".to_string(),
576            voice: "default".to_string(),
577            speed: 1.0,
578            additional_params: None,
579        };
580
581        let error = model
582            .audio_generation(request)
583            .await
584            .err()
585            .expect("audio generation should fail with provider error envelope");
586
587        match &error {
588            AudioGenerationError::ProviderResponse(stored) => {
589                assert_eq!(stored.body, body);
590                assert_eq!(stored.status, Some(http::StatusCode::OK));
591            }
592            other => panic!("expected ProviderResponse, got {other:?}"),
593        }
594    }
595}