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 true
747 }
748
749 fn supports_reasoning(&self, model: &str) -> bool {
750 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 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 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 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}