Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Hugging Face MCP calls (`mcp_call`, `mcp_list_tools`) and their results are
no longer marked `dynamic`, as in `@ai-sdk/huggingface` 2.0.62. Without a
local tool of that name, `generate_text` now returns the call marked invalid
(`NoSuchTool`), as the AI SDK's `parseToolCall` does.
- A Hugging Face `function_call` or `mcp_call` output item with a non-empty
`output` string now also yields a tool-result holding that string, as
`@ai-sdk/huggingface` 2.0.62 does (`if (part.output)`). `function_call`
outputs were dropped in `do_generate`, and an empty `output` gave an MCP
tool-result.
- Hugging Face streams read in-stream errors as `@ai-sdk/huggingface` 2.0.62
does: an `error` event (message on the event or under `error`) or a
`response.failed` event (`response.error`) with a string message is an
error part, and the finish reason becomes `error` with the code, else the
event type, as `raw`. A malformed chunk also makes the finish reason
`error`. Before, only a top-level `error` field was read, so `error` events
carrying the message on the event and `response.failed` events passed
unnoticed, and the finish reason stayed `other`.
- Groq's `usage.raw` keeps a `null` usage field as `null`, as `@ai-sdk/groq`
4.0.52 does (`convertGroqUsage` returns the parsed object); it used to drop
the field. Applies to generate and stream.
Expand Down
160 changes: 111 additions & 49 deletions aimux-providers/src/huggingface/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,37 +91,73 @@ fn huggingface_successful_response_handler() -> aimux_provider_utils::ResponseHa
})
}

fn huggingface_stream_error(
event: &Value,
url: &str,
request_body_values: Value,
response_headers: HashMap<String, String>,
) -> Option<AiMuxError> {
let error = event.get("error").filter(|value| !value.is_null())?;
let message = error
.as_str()
.or_else(|| error.get("message").and_then(Value::as_str))
.unwrap_or("Hugging Face stream failed");
let status_code = error
.get("status")
.or_else(|| error.get("code"))
.and_then(Value::as_u64)
.and_then(|status| u16::try_from(status).ok())
.filter(|status| (400..=599).contains(status));
let provider_code = error
.get("code")
.or_else(|| error.get("type"))
.and_then(Value::as_str)
.map(str::to_owned);
Some(aimux_provider_utils::stream_error_api_call(
message,
provider_code,
status_code,
event,
url,
request_body_values,
response_headers,
))
/// An in-stream error event, read like upstream
/// `createHuggingFaceResponsesStreamError`: only `error` and `response.failed`
/// events, with a string message.
struct HuggingFaceStreamError<'a> {
message: &'a str,
event_type: &'a str,
code: Option<String>,
details: &'a Value,
}

impl<'a> HuggingFaceStreamError<'a> {
fn parse(event: &'a Value) -> Option<Self> {
let event_type = event.get("type").and_then(Value::as_str)?;
let details = match event_type {
"response.failed" => event.get("response")?.get("error")?,
"error" => event
.get("error")
.filter(|error| error.is_object())
.unwrap_or(event),
_ => return None,
};
let message = details.get("message").and_then(Value::as_str)?;
let code = details.get("code").and_then(|code| match code {
Value::String(code) => Some(code.clone()),
Value::Number(code) => Some(code.to_string()),
_ => None,
});
Some(Self {
message,
event_type,
code,
details,
})
}

/// The finish reason's `raw`: the code, else the event type.
fn raw_finish_reason(&self) -> String {
self.code
.clone()
.unwrap_or_else(|| self.event_type.to_string())
}

fn into_error(
self,
event: &Value,
url: &str,
request_body_values: Value,
response_headers: HashMap<String, String>,
) -> AiMuxError {
let status_code = self
.details
.get("status")
.or_else(|| self.details.get("code"))
.and_then(Value::as_u64)
.and_then(|status| u16::try_from(status).ok())
.filter(|status| (400..=599).contains(status));
let provider_code = Some(self.raw_finish_reason());
aimux_provider_utils::stream_error_api_call(
self.message,
provider_code,
status_code,
event,
url,
request_body_values,
response_headers,
)
}
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -234,10 +270,9 @@ impl LanguageModel for HuggingFaceResponsesModel {
first_event => first_event,
};
if let Some(Ok(event)) = first_event.as_ref()
&& let Some(error) =
huggingface_stream_error(event, &endpoint, body.clone(), response_headers.clone())
&& let Some(error) = HuggingFaceStreamError::parse(event)
{
return Err(error);
return Err(error.into_error(event, &endpoint, body.clone(), response_headers.clone()));
}
let stream = pipe_through(
futures::stream::iter(first_event).chain(sse_stream),
Expand Down Expand Up @@ -312,12 +347,17 @@ impl HuggingFaceResponsesStream {
} = *self;
match event {
Ok(parsed) => {
if let Some(error) = huggingface_stream_error(
&parsed,
&self.ctx.url,
self.ctx.request_body.clone(),
self.ctx.response_headers.clone(),
) {
if let Some(error) = HuggingFaceStreamError::parse(&parsed) {
*finish_reason = FinishReason {
unified: FinishReasonUnified::Error,
raw: Some(error.raw_finish_reason()),
};
let error = error.into_error(
&parsed,
&self.ctx.url,
self.ctx.request_body.clone(),
self.ctx.response_headers.clone(),
);
controller.enqueue(StreamPart::Error { error });
self.finished = true;
return;
Expand Down Expand Up @@ -446,9 +486,7 @@ impl HuggingFaceResponsesStream {
provider_metadata: None,
}));

if let Some(output) =
item.get("output").and_then(|v| v.as_str())
{
if let Some(output) = non_empty_output(item) {
controller.enqueue(StreamPart::ToolResult(ToolResult {
tool_call_id: call_id,
tool_name: name.clone(),
Expand Down Expand Up @@ -534,6 +572,10 @@ impl HuggingFaceResponsesStream {
}
}
Err(error) => {
*finish_reason = FinishReason {
unified: FinishReasonUnified::Error,
raw: None,
};
if error.is_recoverable_stream_error() {
controller.enqueue_error(error);
} else {
Expand Down Expand Up @@ -1008,6 +1050,18 @@ fn build_generate_content(response: &Value) -> Result<Vec<GenerateContent>, AiMu
dynamic: None,
provider_metadata: None,
}));

if let Some(output) = non_empty_output(part) {
content.push(GenerateContent::ToolResult(ToolResult {
tool_call_id: call_id.to_string(),
tool_name: name.to_string(),
result: Value::String(output.to_string()),
is_error: None,
preliminary: None,
dynamic: None,
provider_metadata: None,
}));
}
}

"mcp_call" => {
Expand All @@ -1023,18 +1077,18 @@ fn build_generate_content(response: &Value) -> Result<Vec<GenerateContent>, AiMu
tool_name: name.to_string(),
input,
provider_executed: Some(true),
dynamic: Some(true),
dynamic: None,
provider_metadata: None,
}));

if let Some(output) = part.get("output").filter(|v| !v.is_null()) {
if let Some(output) = non_empty_output(part) {
content.push(GenerateContent::ToolResult(ToolResult {
tool_call_id: id.to_string(),
tool_name: name.to_string(),
result: output.clone(),
result: Value::String(output.to_string()),
is_error: None,
preliminary: None,
dynamic: Some(true),
dynamic: None,
provider_metadata: None,
}));
}
Expand All @@ -1048,7 +1102,7 @@ fn build_generate_content(response: &Value) -> Result<Vec<GenerateContent>, AiMu
tool_name: "list_tools".to_string(),
input: json!({ "server_label": server_label }).to_string(),
provider_executed: Some(true),
dynamic: Some(true),
dynamic: None,
provider_metadata: None,
}));

Expand All @@ -1059,7 +1113,7 @@ fn build_generate_content(response: &Value) -> Result<Vec<GenerateContent>, AiMu
result: json!({ "tools": tools }),
is_error: None,
preliminary: None,
dynamic: Some(true),
dynamic: None,
provider_metadata: None,
}));
}
Expand All @@ -1072,6 +1126,14 @@ fn build_generate_content(response: &Value) -> Result<Vec<GenerateContent>, AiMu
Ok(content)
}

/// `part.output` when JavaScript reads it as true (`if (part.output)`): a
/// non-empty string.
fn non_empty_output(part: &Value) -> Option<&str> {
part.get("output")
.and_then(Value::as_str)
.filter(|output| !output.is_empty())
}

/// Parse the finish reason from a Responses API JSON body.
fn parse_finish_reason(response: &Value) -> FinishReason {
let incomplete_reason = response
Expand Down
Loading
Loading