Skip to main content

vtcode_llm/providers/
huggingface.rs

1#![allow(
2    clippy::bind_instead_of_map,
3    clippy::collapsible_if,
4    reason = "Intentional compatibility, platform, or test-only suppression."
5)]
6
7use crate::error_display::format_llm_error;
8use crate::provider::{
9    LLMError, LLMErrorMetadata, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, MessageRole,
10    ToolDefinition,
11};
12use crate::providers::shared::{
13    NoopStreamTelemetry, StreamTelemetry, Utf8StreamDecoder, function_output_value_from_message_content,
14};
15use async_stream::try_stream;
16use async_trait::async_trait;
17use futures::StreamExt;
18use reqwest::{Client as HttpClient, Response, StatusCode};
19use serde_json::{Value, json};
20use vtcode_commons::sanitizer::sanitize_provider_diagnostic;
21use vtcode_config::TimeoutsConfig;
22use vtcode_config::constants::{env_vars, models, urls};
23use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
24
25use super::common::{
26    assistant_interleaved_history_text, ensure_model, impl_llm_client, is_minimax_m2_model, map_finish_reason_common,
27    normalize_reasoning_detail_objects, override_base_url, parse_response_openai_format, resolve_model,
28};
29use super::error_handling::{format_network_error, format_parse_error};
30
31const PROVIDER_NAME: &str = "HuggingFace";
32const PROVIDER_KEY: &str = "huggingface";
33const JSON_INSTRUCTION: &str = "Return JSON that matches the provided schema.";
34
35pub struct HuggingFaceProvider {
36    api_key: String,
37    http_client: HttpClient,
38    base_url: String,
39    model: String,
40    _timeouts: TimeoutsConfig,
41    model_behavior: Option<ModelConfig>,
42}
43
44impl HuggingFaceProvider {
45    pub fn new(api_key: String) -> Self {
46        Self::with_model_internal(api_key, models::huggingface::DEFAULT_MODEL.to_string(), None, None, None)
47    }
48
49    fn with_model(api_key: String, model: String) -> Self {
50        Self::with_model_internal(api_key, model, None, None, None)
51    }
52
53    pub fn with_timeouts(api_key: String, timeouts: TimeoutsConfig) -> Self {
54        Self::with_model_internal(api_key, models::huggingface::DEFAULT_MODEL.to_string(), None, Some(timeouts), None)
55    }
56
57    fn with_model_internal(
58        api_key: String,
59        model: String,
60        base_url: Option<String>,
61        timeouts: Option<TimeoutsConfig>,
62        model_behavior: Option<ModelConfig>,
63    ) -> Self {
64        use crate::http_client::HttpClientFactory;
65
66        let timeouts = timeouts.unwrap_or_default();
67
68        Self {
69            api_key,
70            http_client: HttpClientFactory::for_llm(&timeouts),
71            base_url: override_base_url(urls::HUGGINGFACE_API_BASE, base_url, Some(env_vars::HUGGINGFACE_BASE_URL)),
72            model,
73            _timeouts: timeouts,
74            model_behavior,
75        }
76    }
77
78    pub fn from_config(
79        api_key: Option<String>,
80        model: Option<String>,
81        base_url: Option<String>,
82        _prompt_cache: Option<PromptCachingConfig>,
83        timeouts: Option<TimeoutsConfig>,
84        _anthropic: Option<AnthropicConfig>,
85        model_behavior: Option<ModelConfig>,
86    ) -> Self {
87        let api_key_value = api_key.unwrap_or_default();
88        let model_value = resolve_model(model, models::huggingface::DEFAULT_MODEL);
89        Self::with_model_internal(api_key_value, model_value, base_url, timeouts, model_behavior)
90    }
91
92    fn normalize_model_id(&self, model: &str) -> Result<String, LLMError> {
93        let model = model.trim();
94        let lower = model.to_ascii_lowercase();
95
96        if lower.contains("minimax-m2") && !model.contains(':') {
97            return Err(LLMError::Provider {
98                message: format_llm_error(
99                    PROVIDER_NAME,
100                    "MiniMax models require explicit provider selection (:novita suffix). \n                    Use 'MiniMaxAI/MiniMax-M2.5:novita'.",
101                ),
102                metadata: None,
103            });
104        }
105
106        if lower.contains("glm-5") && !model.contains(':') {
107            return Err(LLMError::Provider {
108                message: format_llm_error(
109                    PROVIDER_NAME,
110                    "GLM models require explicit provider selection on HuggingFace.",
111                ),
112                metadata: None,
113            });
114        }
115
116        Ok(model.to_string())
117    }
118
119    fn serialize_tools_huggingface(&self, tools: &[ToolDefinition]) -> Option<Vec<Value>> {
120        crate::providers::common::serialize_tools_openai_format(tools)
121    }
122
123    fn serialize_messages_huggingface_chat(&self, request: &LLMRequest) -> Result<Vec<Value>, LLMError> {
124        use serde_json::{Map, json};
125
126        let mut messages = Vec::with_capacity(request.messages.len());
127
128        for message in request.messages.iter() {
129            message
130                .validate_for_provider(PROVIDER_KEY)
131                .map_err(|e| LLMError::InvalidRequest { message: e, metadata: None })?;
132
133            let mut message_map = Map::with_capacity(4);
134            message_map.insert("role".to_owned(), Value::String(message.role.as_generic_str().to_owned()));
135
136            if let Some(interleaved_content) = assistant_interleaved_history_text(message, &request.model) {
137                message_map.insert("content".to_owned(), Value::String(interleaved_content));
138            } else {
139                match &message.content {
140                    crate::provider::MessageContent::Text(text) => {
141                        message_map.insert("content".to_owned(), Value::String(text.clone()));
142                    }
143                    crate::provider::MessageContent::Parts(parts) => {
144                        let has_images = parts.iter().any(crate::provider::ContentPart::is_image);
145                        if has_images {
146                            let parts_json: Vec<Value> = parts
147                            .iter()
148                            .map(|part| match part {
149                                crate::provider::ContentPart::Text { text } => {
150                                    json!({ "type": "text", "text": text })
151                                }
152                                crate::provider::ContentPart::Image {
153                                    data,
154                                    mime_type,
155                                    ..
156                                } => {
157                                    json!({
158                                        "type": "image_url",
159                                        "image_url": {
160                                            "url": format!("data:{};base64,{}", mime_type, data)
161                                        }
162                                    })
163                                }
164                                crate::provider::ContentPart::File {
165                                    filename,
166                                    file_id,
167                                    file_url,
168                                    ..
169                                } => {
170                                    let fallback = filename
171                                        .clone()
172                                        .or_else(|| file_id.clone())
173                                        .or_else(|| file_url.clone())
174                                        .unwrap_or_else(|| "attached file".to_string());
175                                    json!({ "type": "text", "text": format!("[File input not directly supported: {}]", fallback) })
176                                }
177                            })
178                            .collect();
179                            message_map.insert("content".to_owned(), Value::Array(parts_json));
180                        } else {
181                            let text = message.content.as_text().into_owned();
182                            message_map.insert("content".to_owned(), Value::String(text));
183                        }
184                    }
185                }
186            }
187
188            if let Some(tool_calls) = &message.tool_calls {
189                let serialized_calls = tool_calls
190                    .iter()
191                    .filter_map(|call| {
192                        call.function.as_ref().map(|func| {
193                            json!({
194                                "id": &call.id,
195                                "type": "function",
196                                "function": {
197                                    "name": &func.name,
198                                    "arguments": &func.arguments
199                                }
200                            })
201                        })
202                    })
203                    .collect::<Vec<_>>();
204                message_map.insert("tool_calls".to_owned(), Value::Array(serialized_calls));
205            }
206
207            if let Some(tool_call_id) = &message.tool_call_id {
208                message_map.insert("tool_call_id".to_owned(), Value::String(tool_call_id.clone()));
209            }
210
211            if message.role == MessageRole::Assistant
212                && is_minimax_m2_model(&request.model)
213                && let Some(reasoning_details) = &message.reasoning_details
214                && !reasoning_details.is_empty()
215            {
216                let normalized_details = normalize_reasoning_detail_objects(reasoning_details);
217                if !normalized_details.is_empty() {
218                    message_map.insert("reasoning_details".to_owned(), Value::Array(normalized_details));
219                }
220            }
221
222            messages.push(Value::Object(message_map));
223        }
224
225        Ok(messages)
226    }
227
228    fn format_for_chat_completions(&self, request: &LLMRequest) -> Result<Value, LLMError> {
229        let mut messages = self.serialize_messages_huggingface_chat(request)?;
230        let is_glm = self.is_glm_model(&request.model);
231
232        if let Some(system) = &request.system_prompt {
233            let has_system = messages.first().and_then(|m| m.get("role")).and_then(|r| r.as_str()) == Some("system");
234            if !has_system {
235                messages.insert(
236                    0,
237                    json!({
238                        "role": "system",
239                        "content": system
240                    }),
241                );
242            }
243        }
244
245        let mut payload = json!({
246            "model": request.model,
247            "messages": messages,
248            "stream": request.stream,
249        });
250
251        if request.stream && request.tools.is_some() && is_glm {
252            payload["tool_stream"] = json!(true);
253        }
254
255        if let Some(max_tokens) = request.max_tokens {
256            payload["max_tokens"] = json!(max_tokens);
257        }
258
259        if let Some(tools) = &request.tools {
260            if let Some(serialized) = self.serialize_tools_huggingface(tools) {
261                payload["tools"] = json!(serialized);
262
263                if let Some(choice) = &request.tool_choice {
264                    payload["tool_choice"] = choice.to_provider_format("openai");
265                }
266            }
267        }
268
269        if let Some(temperature) = request.temperature {
270            payload["temperature"] = json!(super::common::sampling_param_f64(temperature));
271        }
272
273        if let Some(top_p) = request.top_p {
274            payload["top_p"] = json!(super::common::sampling_param_f64(top_p));
275        }
276
277        if let Some(top_k) = request.top_k {
278            payload["top_k"] = json!(top_k);
279        }
280
281        if let Some(effort) = request.reasoning_effort {
282            use crate::rig_adapter::RigProviderCapabilities;
283            use vtcode_config::models::Provider;
284            let supported = self.supported_reasoning_efforts(&request.model);
285            if let Some(reasoning_params) = RigProviderCapabilities::new(Provider::HuggingFace, &request.model)
286                .reasoning_parameters_for_supported_efforts(effort, supported)?
287            {
288                if let Some(params_obj) = reasoning_params.as_object() {
289                    for (k, v) in params_obj {
290                        payload[k] = v.clone();
291                    }
292                }
293            }
294        }
295
296        if request.output_format.is_some() && !is_glm {
297            payload["response_format"] = json!({ "type": "json_object" });
298        }
299
300        Ok(payload)
301    }
302
303    fn is_glm_model(&self, model: &str) -> bool {
304        let lower = model.to_ascii_lowercase();
305        lower.contains("glm")
306    }
307
308    fn is_deepseek_model(&self, model: &str) -> bool {
309        let lower = model.to_ascii_lowercase();
310        lower.contains("deepseek")
311    }
312
313    fn is_minimax_model(&self, model: &str) -> bool {
314        let lower = model.to_ascii_lowercase();
315        lower.contains("minimax")
316    }
317
318    fn is_muse_spark_open_model(&self, model: &str) -> bool {
319        let lower = model.to_ascii_lowercase();
320        lower.contains("muse-spark-open") || lower.contains("muse-spark")
321    }
322
323    fn apply_model_defaults(&self, request: &mut LLMRequest) {
324        if self.is_minimax_model(&request.model) {
325            if request.temperature.is_none() {
326                request.temperature = Some(1.0);
327            }
328            if request.top_p.is_none() {
329                request.top_p = Some(0.95);
330            }
331            if request.top_k.is_none() {
332                request.top_k = Some(40);
333            }
334        }
335    }
336
337    fn add_json_instruction(&self, payload: &mut Value) -> Result<(), LLMError> {
338        if let Some(instructions) = payload.get_mut("instructions") {
339            if let Some(text) = instructions.as_str() {
340                if !text.contains("Return JSON") {
341                    *instructions = json!(format!("{}\n\n{}", text, JSON_INSTRUCTION));
342                }
343            }
344        } else {
345            payload["instructions"] = json!(JSON_INSTRUCTION);
346        }
347
348        Ok(())
349    }
350
351    fn format_for_responses_api(&self, request: &LLMRequest) -> Result<Value, LLMError> {
352        let mut input = Vec::new();
353
354        for msg in request.messages.iter() {
355            let convert_parts = |parts: &[crate::provider::ContentPart]| -> Value {
356                let parts_json: Vec<Value> = parts
357                    .iter()
358                    .map(|part| match part {
359                        crate::provider::ContentPart::Text { text } => {
360                            json!({ "type": "input_text", "text": text })
361                        }
362                        crate::provider::ContentPart::Image { data, mime_type, .. } => {
363                            json!({
364                                "type": "input_image",
365                                "image_url": format!("data:{};base64,{}", mime_type, data)
366                            })
367                        }
368                        crate::provider::ContentPart::File { filename, file_id, file_url, .. } => {
369                            let fallback = filename
370                                .clone()
371                                .or_else(|| file_id.clone())
372                                .or_else(|| file_url.clone())
373                                .unwrap_or_else(|| "attached file".to_string());
374                            json!({
375                                "type": "input_text",
376                                "text": format!("[File input not directly supported: {}]", fallback)
377                            })
378                        }
379                    })
380                    .collect();
381                json!(parts_json)
382            };
383
384            match msg.role {
385                MessageRole::System | MessageRole::User => {
386                    if msg.role == MessageRole::System && request.system_prompt.is_some() {
387                        if let crate::provider::MessageContent::Text(text) = &msg.content {
388                            if request.system_prompt.as_ref().map(|s| s.as_ref()) == Some(text.as_str()) {
389                                continue;
390                            }
391                        }
392                    }
393
394                    let role = if msg.role == MessageRole::System {
395                        "system"
396                    } else {
397                        "user"
398                    };
399
400                    let mut message_obj = json!({
401                        "type": "message",
402                        "role": role,
403                    });
404
405                    match &msg.content {
406                        crate::provider::MessageContent::Text(text) => {
407                            message_obj["content"] = json!(text);
408                        }
409                        crate::provider::MessageContent::Parts(parts) => {
410                            message_obj["content"] = convert_parts(parts);
411                        }
412                    }
413
414                    input.push(message_obj);
415                }
416                MessageRole::Assistant => {
417                    let has_content = match &msg.content {
418                        crate::provider::MessageContent::Text(text) => !text.is_empty(),
419                        crate::provider::MessageContent::Parts(parts) => !parts.is_empty(),
420                    };
421
422                    if has_content {
423                        let mut message_obj = json!({
424                            "type": "message",
425                            "role": "assistant",
426                        });
427
428                        match &msg.content {
429                            crate::provider::MessageContent::Text(text) => {
430                                message_obj["content"] = json!(text);
431                            }
432                            crate::provider::MessageContent::Parts(parts) => {
433                                message_obj["content"] = convert_parts(parts);
434                            }
435                        }
436
437                        input.push(message_obj);
438                    }
439
440                    if let Some(tool_calls) = &msg.tool_calls {
441                        for tc in tool_calls {
442                            if let Some(func) = &tc.function {
443                                input.push(json!({
444                                    "type": "function_call",
445                                    "call_id": tc.id,
446                                    "name": func.name,
447                                    "arguments": func.arguments
448                                }));
449                            }
450                        }
451                    }
452                }
453                MessageRole::Tool => {
454                    input.push(json!({
455                        "type": "function_call_output",
456                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
457                        "output": function_output_value_from_message_content(&msg.content)
458                    }));
459                }
460            }
461        }
462
463        let mut payload = json!({
464            "model": request.model,
465            "input": input,
466            "stream": request.stream,
467        });
468
469        if let Some(system_prompt) = &request.system_prompt {
470            payload["instructions"] = json!(system_prompt);
471        }
472
473        if let Some(effort) = request.reasoning_effort {
474            use vtcode_config::types::ReasoningEffortLevel;
475            if effort != ReasoningEffortLevel::None {
476                payload["reasoning"] = json!({ "effort": effort.as_str() });
477            }
478        }
479
480        if let Some(max_tokens) = request.max_tokens {
481            payload["max_tokens"] = json!(max_tokens);
482        }
483        if let Some(temperature) = request.temperature {
484            payload["temperature"] = json!(super::common::sampling_param_f64(temperature));
485        }
486        if let Some(top_p) = request.top_p {
487            payload["top_p"] = json!(super::common::sampling_param_f64(top_p));
488        }
489        if let Some(top_k) = request.top_k {
490            payload["top_k"] = json!(top_k);
491        }
492
493        if let Some(tools) = &request.tools {
494            if let Some(serialized) = self.serialize_tools_huggingface(tools) {
495                payload["tools"] = json!(serialized);
496
497                if let Some(choice) = &request.tool_choice {
498                    payload["tool_choice"] = choice.to_provider_format("openai");
499                }
500            }
501        }
502
503        if request.output_format.is_some() || request.tools.is_some() {
504            self.add_json_instruction(&mut payload)?;
505        }
506
507        if request.output_format.is_some() && !self.is_glm_model(&request.model) {
508            payload["response_format"] = json!({ "type": "json_object" });
509        }
510
511        Ok(payload)
512    }
513
514    fn should_use_responses_api(&self, _request: &LLMRequest) -> bool {
515        false
516    }
517
518    fn format_error(&self, status: StatusCode, body: &str) -> LLMError {
519        let message = format!("HuggingFace API error ({status}): {body}");
520
521        LLMError::Provider {
522            message: format_llm_error(PROVIDER_NAME, &message),
523            metadata: Some(LLMErrorMetadata::new(
524                PROVIDER_NAME,
525                Some(status.as_u16()),
526                None,
527                None,
528                None,
529                None,
530                Some(sanitize_provider_diagnostic(body.as_bytes())),
531            )),
532        }
533    }
534
535    fn parse_responses_api_format(json: &Value, model: String) -> Result<LLMResponse, LLMError> {
536        let convenience_text = json.get("output_text").and_then(|t| t.as_str());
537
538        let json_obj = json.get("response").unwrap_or(json);
539
540        let output = json_obj.get("output").and_then(|v| v.as_array());
541
542        let output_arr = match output {
543            Some(arr) => arr,
544            None => {
545                if let Some(text) = convenience_text {
546                    return Ok(LLMResponse {
547                        content: Some(text.to_string()),
548                        tool_calls: None,
549                        model,
550                        usage: None,
551                        finish_reason: crate::provider::FinishReason::Stop,
552                        reasoning: None,
553                        reasoning_details: None,
554                        tool_references: Vec::new(),
555                        request_id: None,
556                        organization_id: None,
557                        compaction: None,
558                    });
559                }
560
561                return Err(LLMError::Provider {
562                    message: format_llm_error(PROVIDER_NAME, "Not a Responses API format"),
563                    metadata: None,
564                });
565            }
566        };
567
568        let mut content_fragments: Vec<String> = Vec::new();
569        let mut reasoning_fragments: Vec<String> = Vec::new();
570        let mut tool_calls: Vec<crate::provider::ToolCall> = Vec::new();
571
572        for item in output_arr {
573            let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
574
575            match item_type {
576                "message" => {
577                    if let Some(content_arr) = item.get("content").and_then(|c| c.as_array()) {
578                        for entry in content_arr {
579                            let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("");
580                            match entry_type {
581                                "text" | "output_text" => {
582                                    if let Some(text) = entry.get("text").and_then(|t| t.as_str()) {
583                                        if !text.is_empty() {
584                                            content_fragments.push(text.to_string());
585                                        }
586                                    }
587                                }
588                                "reasoning" => {
589                                    if let Some(text) = entry.get("text").and_then(|t| t.as_str()) {
590                                        if !text.is_empty() {
591                                            reasoning_fragments.push(text.to_string());
592                                        }
593                                    }
594                                }
595                                "function_call" | "tool_call" => {
596                                    if let Some(call) = Self::parse_responses_tool_call(entry) {
597                                        tool_calls.push(call);
598                                    }
599                                }
600                                _ => {}
601                            }
602                        }
603                    }
604                }
605                "function_call" | "tool_call" => {
606                    if let Some(call) = Self::parse_responses_tool_call(item) {
607                        tool_calls.push(call);
608                    }
609                }
610                "reasoning" => {
611                    if let Some(summary_arr) = item.get("summary").and_then(|s| s.as_array()) {
612                        for summary in summary_arr {
613                            if let Some(text) = summary.get("text").and_then(|t| t.as_str()) {
614                                if !text.is_empty() {
615                                    reasoning_fragments.push(text.to_string());
616                                }
617                            }
618                        }
619                    } else if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
620                        reasoning_fragments.push(text.to_string());
621                    }
622                }
623                _ => {}
624            }
625        }
626
627        let content = if content_fragments.is_empty() {
628            convenience_text.map(|t| t.to_string())
629        } else {
630            Some(content_fragments.join(""))
631        };
632
633        let reasoning = if reasoning_fragments.is_empty() {
634            None
635        } else {
636            Some(reasoning_fragments.join("\n\n"))
637        };
638
639        let finish_reason = if !tool_calls.is_empty() {
640            crate::provider::FinishReason::ToolCalls
641        } else {
642            crate::provider::FinishReason::Stop
643        };
644
645        let usage_value = json.get("usage").or_else(|| json_obj.get("usage"));
646        let usage = usage_value.map(|usage_value| crate::provider::Usage {
647            prompt_tokens: usage_value
648                .get("input_tokens")
649                .or_else(|| usage_value.get("prompt_tokens"))
650                .and_then(|pt| pt.as_u64())
651                .unwrap_or(0) as u32,
652            completion_tokens: usage_value
653                .get("output_tokens")
654                .or_else(|| usage_value.get("completion_tokens"))
655                .and_then(|ct| ct.as_u64())
656                .unwrap_or(0) as u32,
657            total_tokens: usage_value.get("total_tokens").and_then(|tt| tt.as_u64()).unwrap_or(0) as u32,
658            cached_prompt_tokens: None,
659            cache_creation_tokens: None,
660            cache_read_tokens: None,
661            iterations: None,
662        });
663
664        Ok(LLMResponse {
665            content,
666            tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
667            model,
668            usage,
669            finish_reason,
670            reasoning,
671            reasoning_details: None,
672            tool_references: Vec::new(),
673            request_id: None,
674            organization_id: None,
675            compaction: None,
676        })
677    }
678
679    fn parse_responses_tool_call(item: &Value) -> Option<crate::provider::ToolCall> {
680        let call_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
681        let function_obj = item.get("function").and_then(|v| v.as_object());
682        let name = function_obj.and_then(|f| f.get("name").and_then(|n| n.as_str()))?;
683        let arguments = function_obj.and_then(|f| f.get("arguments"));
684
685        let serialized = arguments.map_or("{}".to_owned(), |args| {
686            if args.is_string() {
687                args.as_str().unwrap_or("{}").to_string()
688            } else {
689                args.to_string()
690            }
691        });
692
693        Some(crate::provider::ToolCall::function(call_id.to_string(), name.to_string(), serialized))
694    }
695
696    async fn parse_response(
697        &self,
698        response: Response,
699        model: String,
700        use_responses_api: bool,
701    ) -> Result<LLMResponse, LLMError> {
702        let status = response.status();
703
704        if !status.is_success() {
705            let body = crate::providers::common::read_provider_error_body(response).await;
706            return Err(self.format_error(status, &body));
707        }
708
709        let json: Value = response.json().await.map_err(|err| format_parse_error(PROVIDER_NAME, &err))?;
710
711        if use_responses_api {
712            if json.get("output").is_some() {
713                return Self::parse_responses_api_format(&json, model);
714            }
715        }
716
717        parse_response_openai_format::<fn(&Value, &Value) -> Option<String>>(json, PROVIDER_NAME, model, false, None)
718    }
719
720    fn available_models() -> Vec<String> {
721        models::huggingface::SUPPORTED_MODELS.iter().map(|s| s.to_string()).collect()
722    }
723
724    fn get_endpoint(&self, use_responses_api: bool) -> String {
725        let base = self.base_url.trim_end_matches('/');
726        if use_responses_api {
727            format!("{base}/responses")
728        } else {
729            super::common::chat_completions_url(base)
730        }
731    }
732}
733
734#[async_trait]
735impl LLMProvider for HuggingFaceProvider {
736    fn name(&self) -> &str {
737        PROVIDER_KEY
738    }
739
740    fn supports_streaming(&self) -> bool {
741        true
742    }
743
744    fn supports_non_streaming(&self, _model: &str) -> bool {
745        // Pinned so the stream-timeout fallback cannot silently regress.
746        true
747    }
748
749    fn supports_reasoning(&self, model: &str) -> bool {
750        // Codex-inspired robustness: Setting model_supports_reasoning to false
751        // does NOT disable it for known reasoning models.
752        models::huggingface::REASONING_MODELS.contains(&model)
753            || self
754                .model_behavior
755                .as_ref()
756                .and_then(|b| b.model_supports_reasoning)
757                .unwrap_or(false)
758    }
759
760    fn supports_reasoning_effort(&self, model: &str) -> bool {
761        // Same robustness logic for reasoning effort
762        self.is_glm_model(model)
763            || self.is_deepseek_model(model)
764            || self.is_muse_spark_open_model(model)
765            || self
766                .model_behavior
767                .as_ref()
768                .and_then(|b| b.model_supports_reasoning_effort)
769                .unwrap_or(false)
770    }
771
772    fn supports_tools(&self, _model: &str) -> bool {
773        true
774    }
775
776    fn supports_parallel_tool_config(&self, _model: &str) -> bool {
777        false
778    }
779
780    fn supports_structured_output(&self, _model: &str) -> bool {
781        true
782    }
783
784    fn supports_context_caching(&self, _model: &str) -> bool {
785        false
786    }
787
788    fn effective_context_size(&self, model: &str) -> usize {
789        crate::provider::catalog_context_window("huggingface", model, 128_000)
790    }
791
792    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
793        let model = ensure_model(&mut request, &self.model);
794
795        self.apply_model_defaults(&mut request);
796        self.validate_request(&request)?;
797
798        let model_id = self.normalize_model_id(&request.model)?;
799        request.model = model_id;
800
801        let use_responses_api = self.should_use_responses_api(&request);
802        let payload = if use_responses_api {
803            self.format_for_responses_api(&request)?
804        } else {
805            self.format_for_chat_completions(&request)?
806        };
807
808        let endpoint = self.get_endpoint(use_responses_api);
809
810        let response = self
811            .http_client
812            .post(&endpoint)
813            .header("Authorization", format!("Bearer {}", self.api_key))
814            .json(&payload)
815            .send()
816            .await
817            .map_err(|err| format_network_error(PROVIDER_NAME, &err))?;
818
819        self.parse_response(response, model, use_responses_api).await
820    }
821
822    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
823        let model = ensure_model(&mut request, &self.model);
824
825        self.apply_model_defaults(&mut request);
826        self.validate_request(&request)?;
827        request.stream = true;
828
829        let model_id = self.normalize_model_id(&request.model)?;
830        request.model = model_id;
831
832        let use_responses_api = self.should_use_responses_api(&request);
833        let payload = if use_responses_api {
834            self.format_for_responses_api(&request)?
835        } else {
836            self.format_for_chat_completions(&request)?
837        };
838
839        let endpoint = self.get_endpoint(use_responses_api);
840
841        let response = self
842            .http_client
843            .post(&endpoint)
844            .header("Authorization", format!("Bearer {}", self.api_key))
845            .json(&payload)
846            .send()
847            .await
848            .map_err(|err| format_network_error(PROVIDER_NAME, &err))?;
849
850        if !response.status().is_success() {
851            let status = response.status();
852            let body = crate::providers::common::read_provider_error_body(response).await;
853            return Err(self.format_error(status, &body));
854        }
855
856        self.create_stream(response, model, use_responses_api).await
857    }
858
859    fn supported_models(&self) -> Vec<String> {
860        Self::available_models()
861    }
862
863    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
864        if request.messages.is_empty() {
865            return Err(LLMError::InvalidRequest {
866                message: format_llm_error(PROVIDER_NAME, "Messages cannot be empty"),
867                metadata: None,
868            });
869        }
870
871        if request.model.trim().is_empty() {
872            return Err(LLMError::InvalidRequest {
873                message: format_llm_error(PROVIDER_NAME, "Model identifier cannot be empty"),
874                metadata: None,
875            });
876        }
877
878        Ok(())
879    }
880}
881
882impl HuggingFaceProvider {
883    async fn create_stream(
884        &self,
885        response: Response,
886        model: String,
887        use_responses_api: bool,
888    ) -> Result<LLMStream, LLMError> {
889        let mut bytes_stream = response.bytes_stream();
890        let mut buffer = String::with_capacity(4096);
891        let mut decoder = Utf8StreamDecoder::new();
892        let mut aggregator = crate::providers::shared::StreamAggregator::new(model.clone());
893        let telemetry = NoopStreamTelemetry;
894
895        let stream = try_stream! {
896            'outer: while let Some(chunk_result) = bytes_stream.next().await {
897                let chunk = chunk_result.map_err(|err| format_network_error(PROVIDER_NAME, &err))?;
898                buffer.push_str(&decoder.push(&chunk));
899
900                if buffer.len() > 128_000 {
901                    Err(LLMError::Provider {
902                        message: format_llm_error(PROVIDER_NAME, "Stream buffer exceeded maximum size (128KB)"),
903                        metadata: None,
904                    })?;
905                }
906
907                while let Some(newline_pos) = buffer.find('\n') {
908                    // Borrow the line from `buffer` instead of copying it into a
909                    // `String` per SSE event. The `drain` is deferred until
910                    // `serde_json::from_str` produces an owned `Value`, so the
911                    // borrow (`line`/`data`) is dead before `buffer` mutates.
912                    let line = buffer[..newline_pos].trim();
913
914                    if line.is_empty() || line.starts_with(':') {
915                        buffer.drain(..=newline_pos);
916                        continue;
917                    }
918
919                    let data = match line.strip_prefix("data: ") {
920                        Some(stripped) => stripped,
921                        None => {
922                            buffer.drain(..=newline_pos);
923                            continue;
924                        }
925                    };
926
927                    if data == "[DONE]" {
928                        buffer.drain(..=newline_pos);
929                        break 'outer;
930                    }
931
932                    let event: Value = match serde_json::from_str(data) {
933                        Ok(v) => v,
934                        Err(_) => {
935                            buffer.drain(..=newline_pos);
936                            continue;
937                        }
938                    };
939
940                    // `event` is now owned; all borrows of `buffer` (via
941                    // `line`/`data`) are dead. Safe to drain the consumed line.
942                    buffer.drain(..=newline_pos);
943
944                    if use_responses_api {
945                        let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
946
947                        match event_type {
948                            "response.output_text.delta" | "output_text.delta" => {
949                                if let Some(delta) = event.get("delta").and_then(|d| d.as_str()) {
950                                    telemetry.on_content_delta(delta);
951                                    for ev in aggregator.handle_content(delta) {
952                                        yield ev;
953                                    }
954                                }
955                                continue;
956                            }
957                            "response.reasoning.delta" | "reasoning.delta" => {
958                                if let Some(delta) = event.get("delta").and_then(|d| d.as_str()) {
959                                    if let Some(d) = aggregator.handle_reasoning(delta) {
960                                        telemetry.on_reasoning_delta(&d);
961                                        yield LLMStreamEvent::Reasoning { delta: d };
962                                    }
963                                }
964                                continue;
965                            }
966                            "response.function_call_arguments.delta" | "tool_call.delta" => {
967                                telemetry.on_tool_call_delta();
968                                continue;
969                            }
970                            "response.completed" => {
971                                if let Some(response_obj) = event.get("response") {
972                                    if let Ok(response) = Self::parse_responses_api_format(response_obj, model.clone()) {
973                                        let final_agg_response = aggregator.finalize();
974                                        let mut merged_response = response;
975                                        if merged_response.content.is_none() {
976                                            merged_response.content = final_agg_response.content;
977                                        }
978                                        if merged_response.reasoning.is_none() {
979                                            merged_response.reasoning = final_agg_response.reasoning;
980                                        }
981                                        if merged_response.tool_calls.is_none() {
982                                            merged_response.tool_calls = final_agg_response.tool_calls;
983                                        }
984                                        if merged_response.usage.is_none() {
985                                            merged_response.usage = final_agg_response.usage;
986                                        }
987                                        yield LLMStreamEvent::Completed { response: Box::new(merged_response) };
988                                        return;
989                                    }
990                                }
991                                break 'outer;
992                            }
993                            "response.done" => {
994                                break 'outer;
995                            }
996                            _ => {}
997                        }
998                    }
999
1000                    if let Some(choices_arr) = event.get("choices").and_then(|c| c.as_array()) {
1001                        if let Some(choice) = choices_arr.first() {
1002                            if let Some(delta_obj) = choice.get("delta") {
1003                                if let Some(content) = delta_obj.get("content").and_then(|c| c.as_str()) {
1004                                    telemetry.on_content_delta(content);
1005                                    for ev in aggregator.handle_content(content) {
1006                                        yield ev;
1007                                    }
1008                                }
1009
1010                                if let Some(reason) = delta_obj.get("reasoning_content").and_then(|r| r.as_str()) {
1011                                    if let Some(d) = aggregator.handle_reasoning(reason) {
1012                                        telemetry.on_reasoning_delta(&d);
1013                                        yield LLMStreamEvent::Reasoning { delta: d };
1014                                    }
1015                                }
1016
1017                                if let Some(reasoning_details) = delta_obj
1018                                    .get("reasoning_details")
1019                                    .and_then(|details| details.as_array())
1020                                {
1021                                    aggregator.set_reasoning_details(reasoning_details);
1022                                }
1023
1024                                if let Some(tool_calls_arr) = delta_obj.get("tool_calls").and_then(|tc| tc.as_array()) {
1025                                    aggregator.handle_tool_calls(tool_calls_arr);
1026                                    telemetry.on_tool_call_delta();
1027                                }
1028                            }
1029
1030                            if let Some(finish_reason_str) = choice.get("finish_reason").and_then(|fr| fr.as_str()) {
1031                                aggregator.set_finish_reason(map_finish_reason_common(finish_reason_str));
1032                                if let Some(usage_value) = event.get("usage") {
1033                                    aggregator.set_usage(crate::provider::Usage {
1034                                        prompt_tokens: usage_value.get("prompt_tokens").and_then(|pt| pt.as_u64()).unwrap_or(0) as u32,
1035                                        completion_tokens: usage_value.get("completion_tokens").and_then(|ct| ct.as_u64()).unwrap_or(0) as u32,
1036                                        total_tokens: usage_value.get("total_tokens").and_then(|tt| tt.as_u64()).unwrap_or(0) as u32,
1037                                        cached_prompt_tokens: None,
1038                                        cache_creation_tokens: None,
1039                                        cache_read_tokens: None,
1040                                        iterations: None,
1041                                    });
1042                                }
1043
1044                                break 'outer;
1045                            }
1046                        }
1047                    }
1048                }
1049            }
1050
1051            yield LLMStreamEvent::Completed { response: Box::new(aggregator.finalize()) };
1052        };
1053
1054        Ok(Box::pin(stream))
1055    }
1056}
1057
1058impl_llm_client!(HuggingFaceProvider);
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::HuggingFaceProvider;
1063    use crate::provider::{LLMRequest, Message, ToolDefinition};
1064    use crate::providers::common::{is_minimax_m2_model, normalize_reasoning_detail_object};
1065    use serde_json::json;
1066    use std::sync::Arc;
1067
1068    #[test]
1069    fn minimax_model_detection_handles_variants() {
1070        assert!(is_minimax_m2_model("MiniMaxAI/MiniMax-M2.5:novita"));
1071        assert!(is_minimax_m2_model("minimax-m2.5"));
1072        assert!(!is_minimax_m2_model("deepseek-r1"));
1073    }
1074
1075    #[test]
1076    fn normalize_reasoning_detail_decodes_stringified_object() {
1077        let parsed = normalize_reasoning_detail_object(&json!("{\"type\":\"reasoning.text\",\"text\":\"step\"}"))
1078            .expect("expected a parsed reasoning detail object");
1079        assert!(parsed.is_object());
1080        assert_eq!(parsed["type"], "reasoning.text");
1081    }
1082
1083    #[test]
1084    fn serialize_messages_normalizes_minimax_reasoning_details() {
1085        let provider =
1086            HuggingFaceProvider::with_model("test-key".to_string(), "MiniMaxAI/MiniMax-M2.5:novita".to_string());
1087        let request = LLMRequest {
1088            model: "MiniMaxAI/MiniMax-M2.5:novita".to_string(),
1089            messages: vec![
1090                Message::assistant("answer".to_string())
1091                    .with_reasoning_details(Some(vec![json!("{\"type\":\"reasoning.text\",\"text\":\"chain\"}")])),
1092            ]
1093            .into(),
1094            ..Default::default()
1095        };
1096
1097        let messages = provider
1098            .serialize_messages_huggingface_chat(&request)
1099            .expect("message serialization should succeed");
1100        assert!(messages[0]["reasoning_details"].is_array());
1101        assert!(messages[0]["reasoning_details"][0].is_object());
1102    }
1103
1104    #[test]
1105    fn serialize_messages_rehydrates_glm_interleaved_history_into_content() {
1106        let provider = HuggingFaceProvider::with_model("test-key".to_string(), "zai-org/GLM-5.1:novita".into());
1107        let request = LLMRequest {
1108            model: "zai-org/GLM-5.1:novita".to_string(),
1109            messages: vec![Message::assistant("done".to_string()).with_reasoning(Some("trace".to_string()))].into(),
1110            ..Default::default()
1111        };
1112
1113        let messages = provider
1114            .serialize_messages_huggingface_chat(&request)
1115            .expect("message serialization should succeed");
1116
1117        assert_eq!(messages[0]["content"], json!("<think>trace</think>done"));
1118    }
1119
1120    #[test]
1121    fn format_for_chat_completions_keeps_apply_patch_as_function_tool() {
1122        let provider =
1123            HuggingFaceProvider::with_model("test-key".to_string(), "Qwen/Qwen3-Coder-480B-A35B-Instruct".to_string());
1124        let request = LLMRequest {
1125            model: "Qwen/Qwen3-Coder-480B-A35B-Instruct".to_string(),
1126            messages: vec![Message::user("apply a patch".to_string())].into(),
1127            tools: Some(Arc::new(vec![ToolDefinition::apply_patch("Apply patches".to_string())])),
1128            ..Default::default()
1129        };
1130
1131        let payload = provider
1132            .format_for_chat_completions(&request)
1133            .expect("payload should serialize");
1134
1135        assert_eq!(payload["tools"][0]["type"], "function");
1136        assert_eq!(payload["tools"][0]["function"]["name"], "apply_patch");
1137    }
1138}