Skip to content

fix: Hugging Face stream errors end with an error finish - #307

Merged
eric8810 merged 9 commits into
masterfrom
fix/hugging-face-stream-errors-end-with-an-error-finish
Oct 25, 2026
Merged

eric8810 merged 9 commits into
masterfrom
fix/hugging-face-stream-errors-end-with-an-error-finish

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the 3 behaviour differences in Hugging Face found while porting the upstream tests. Each difference is its own commit (listed below) and adds a test that fails on master and a CHANGELOG Fixed entry. They were separate PRs (#305, #306 and this one). To keep one fix PR per vendor, the others are closed and this PR carries all of them. The Hugging Face test PR (#303) stacks on this branch. Part of the #276 list.

# Commit Difference Was
1 26cf0a83 Hugging Face MCP calls are not dynamic #305
2 fcdc9c0a Hugging Face output items yield a tool-result #306
3 a3519880 Hugging Face stream errors end with an error finish #307

1. Hugging Face MCP calls are not dynamic

Before: Hugging Face do_generate marked every mcp_call / mcp_list_tools tool-call and tool-result dynamic: true. @ai-sdk/huggingface 2.0.62 sets no dynamic flag on them.

After: the four parts carry no dynamic flag, as upstream. Without a local tool of that name, generate_text now returns the MCP call marked invalid (NoSuchTool), which is what the AI SDK's parseToolCall does for a provider-executed call that is not dynamic.

Found while porting the upstream Hugging Face tests (#303, case doGenerate › basic text response › should handle MCP tools with annotations).

How it works today

HuggingFaceResponsesModel::do_generate                 aimux-providers/src/huggingface/responses.rs:163   // upstream: HuggingFaceResponsesLanguageModel.doGenerate  huggingface-responses-language-model.ts
└─ build_generate_content(&response)                   responses.rs:933                                   // upstream: for (const part of response.output) :220
   ├─ "mcp_call"                                        responses.rs:1013                                  // upstream: case 'mcp_call' :264
   │  ├─ RawToolCall { provider_executed: true, dynamic: Some(true) }   :1026                              //   { type: 'tool-call', providerExecuted: true }   no dynamic
   │  └─ ToolResult { dynamic: Some(true) }            :1037                                              //   { type: 'tool-result' }                          no dynamic
   └─ "mcp_list_tools"                                  responses.rs:1043                                  // upstream: case 'mcp_list_tools' :284
      ├─ RawToolCall { dynamic: Some(true) }           :1051
      └─ ToolResult { dynamic: Some(true) }            :1062
generate_text → parse_tool_call                         aimux-core/src/parse_tool_call.rs:160              // upstream: parseToolCall
   provider_executed && dynamic → skip the tool lookup  :168   → valid call although no local tool exists

What this PR changes

build_generate_content                                 responses.rs:933
├─ "mcp_call"        RawToolCall / ToolResult { dynamic: None }      :1026 / :1037   ← upstream :264-281
└─ "mcp_list_tools"  RawToolCall / ToolResult { dynamic: None }      :1051 / :1062   ← upstream :284-301
generate_text → parse_tool_call                         parse_tool_call.rs:160
   provider_executed, not dynamic, no local tool → returned as an invalid call (NoSuchTool), as upstream parseToolCall

Tests: should_handle_mcp_tools_with_annotations (tests/huggingface_responses_test.rs) asserted that the call passes Core validation without local tools; it now expects the invalid call. The full upstream case is in #303.

Docs: CHANGELOG Fixed.

Note: A caller that relied on the MCP call passing validation without a local tool now gets an invalid tool call, the same result the AI SDK gives.

2. Hugging Face output items yield a tool-result

Before: a non-streaming Hugging Face function_call item with an output gave only the tool-call; the output was dropped. An mcp_call whose output was an empty string still gave a tool-result.

After: both follow upstream's if (part.output): a non-empty output string adds a tool-result holding that string, an empty or missing one adds nothing. The stream's function_call uses the same check.

Found while porting the upstream Hugging Face tests (#303, case tool calls › should handle function call tool responses).

How it works today

HuggingFaceResponsesModel::do_generate                 aimux-providers/src/huggingface/responses.rs:163
└─ build_generate_content(&response)                   responses.rs:933                     // upstream: huggingface-responses-language-model.ts:220
   ├─ "function_call"                                   responses.rs:995                     // upstream: case 'function_call' :304
   │  └─ RawToolCall only                                                                     //   if (part.output) push tool-result { result: part.output } :312
   └─ "mcp_call"                                        responses.rs:1013                    // upstream: case 'mcp_call' :264
      └─ if part.get("output") is not null → ToolResult { result: output.clone() }   :1030   //   if (part.output) :273   ('' is false)
HuggingFaceResponsesModel::do_stream → transform_event  responses.rs:306
└─ output_item.done › "function_call"                   responses.rs:422
   └─ if output is a string → ToolResult               :450                                 // upstream: if (value.item.output) :489

What this PR changes

build_generate_content                                 responses.rs:931
├─ "function_call"   if let Some(output) = non_empty_output(part)    :1010   ← new ToolResult { result: Value::String(output) }
└─ "mcp_call"        if let Some(output) = non_empty_output(part)    :1040   ← empty string gives no tool-result
transform_event › output_item.done › "function_call"
   if let Some(output) = non_empty_output(item)        :449
non_empty_output(part) -> Option<&str>                 :1087   ← `part.output` when JavaScript reads it as true: a non-empty string

Tests (tests/huggingface_responses_test.rs): should_handle_function_call_tool_responses asserted the dropped output; it now expects tool-call, tool-result, text. mcp_call_with_empty_output_has_no_tool_result is new. The full upstream case is in #303.

Docs: CHANGELOG Fixed.

Note: The mcp_call result is now always the output string (upstream's result: part.output); before, a non-string JSON output was passed through as is.

3. Hugging Face stream errors end with an error finish

Before: the Hugging Face stream read an in-stream error only from a top-level error field. An error event that carries its message on the event itself ({"type":"error","code":"503","message":…}) and every response.failed event (response.error) passed unnoticed. After an error part or a chunk that fails to parse, the finish reason stayed other.

After: errors are read as upstream createHuggingFaceResponsesStreamError does: only error and response.failed events, details from response.error, the event's error object or the event itself, and a string message. Such an event is an error part, and the finish reason becomes { unified: error, raw: code, else the event type }. A chunk that fails to parse sets { unified: error } with no raw value. An error as the first event is still returned by do_stream as Err (the registered SSE pre-read).

Found while porting the upstream Hugging Face tests (#303, cases doStream › should handle streaming errors and preserves $expectedType stream errors).

How it works today

HuggingFaceResponsesModel::do_stream                   aimux-providers/src/huggingface/responses.rs:216   // upstream: doStream  huggingface-responses-language-model.ts
├─ first event peek                                     responses.rs:232
│  └─ huggingface_stream_error(event) → return Err     :94                                                 // registered SSE pre-read (docs/aisdk-architecture-alignment.md)
└─ HuggingFaceResponsesStream::transform_event          responses.rs:306                                   // upstream: transform(chunk, controller) :397
   ├─ Ok(parsed) → huggingface_stream_error(&parsed)    :94
   │  └─ event.get("error") non-null → message = error string or error.message   ← misses {type:'error', message} and response.failed
   │     enqueue Error, finish_reason unchanged (other)                          // upstream: createHuggingFaceResponsesStreamError :562-590
   │                                                                             //           finishReason = { unified: 'error', raw: String(code) ?? type } :406-419
   └─ Err(parse error) → enqueue_error, finish_reason unchanged   :536           // upstream: !chunk.success → finishReason = { unified: 'error' } :396-404

What this PR changes

do_stream                                              responses.rs:252
├─ first event: HuggingFaceStreamError::parse(event)    :273   still returns Err (SSE pre-read)
└─ transform_event                                      :341
   ├─ HuggingFaceStreamError::parse(&parsed)            :350
   │  ├─ parse: type must be "error" | "response.failed"   :105   ← upstream :562-590
   │  │  details = response.error | event.error (object) | event; message must be a string; code string or number
   │  ├─ finish_reason = { Error, raw: raw_finish_reason() }   :352   ← code, else the event type (upstream :406-419)
   │  └─ into_error(..) → stream_error_api_call(message, provider_code, status, ..)   :136
   └─ Err(parse error) → finish_reason = { Error, raw: None }   :576  ← upstream :396-404

A later response.completed still sets the finish reason again, as upstream.

Tests (tests/huggingface_responses_test.rs): reads_mid_stream_error_events sends each event kind after response.created; malformed_chunk_ends_with_an_error_finish checks the parse-error finish. Upstream's own cases put the error first, which aimux returns from do_stream; they are ported in #303 (the first-event one as a registered exclusion).

Docs: CHANGELOG Fixed.

Note: An event with a top-level error field but another type (not error or response.failed) is no longer treated as an error, as upstream.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change
  • New provider
  • New / updated binding
  • Docs / RFC

Checklist

  • cargo +1.99 fmt -p aimux-providers -- --check passes
  • cargo +1.99 clippy -p aimux-providers --lib --tests -- -D warnings passes
  • cargo +1.99 test -p aimux-providers --test huggingface_responses_test passes (35, after merging master)
  • scripts/check_provider_boundaries.sh passes
  • Updated relevant docs (CHANGELOG)
  • Commit messages follow Conventional Commits

Notes for reviewers

🤖 Generated with Claude Code

https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5

cunninghamcard-bit and others added 3 commits October 20, 2026 06:46
…ce@2.0.62

doGenerate pushes mcp_call and mcp_list_tools as provider-executed
tool-calls and tool-results without a dynamic flag
(huggingface-responses-language-model.ts:264-301). aimux set
dynamic: true on all four parts. Without a local tool of that name, Core
now returns the call marked invalid, as parseToolCall does.

Tests: should_handle_mcp_tools_with_annotations
(tests/huggingface_responses_test.rs) now expects the invalid call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
…ggingface@2.0.62

doGenerate pushes a tool-result after a function_call or mcp_call when
part.output is truthy, with the output string as the result
(huggingface-responses-language-model.ts:273, :312). aimux dropped the
function_call output and, for mcp_call, also gave a tool-result for an
empty string. Both now follow `if (part.output)`, and so does the
stream's function_call (:489).

Tests: should_handle_function_call_tool_responses now expects the
tool-result; mcp_call_with_empty_output_has_no_tool_result is new
(tests/huggingface_responses_test.rs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
…sdk/huggingface@2.0.62

createHuggingFaceResponsesStreamError (huggingface-responses-language-model.ts:562-590)
reads only `error` and `response.failed` events: the details are
response.error for response.failed, else the event's error object or the
event itself, and the message must be a string. The transform then sets
finishReason { unified: 'error', raw: code or type } (:406-419); a chunk
that fails to parse sets { unified: 'error' } (:396-404).

aimux read only a top-level `error` field, so an `error` event with the
message on the event and every `response.failed` event went unnoticed,
and the finish reason stayed other. A first-event error is still returned
by do_stream (registered SSE pre-read).

Tests: reads_mid_stream_error_events, malformed_chunk_ends_with_an_error_finish
(tests/huggingface_responses_test.rs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
The three behaviour fixes now live in their own PRs (#305, #306, #307);
this branch stacks on the last one. responses.rs and CHANGELOG.md are the
same on both sides; the old test file keeps the markers and takes the
two aimux tests the fix PRs added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
cunninghamcard-bit pushed a commit that referenced this pull request Oct 20, 2026
The test branch now stacks on the fix PRs #305-#307, which add two
aimux-own tests at the end of tests/huggingface_responses_test.rs. That
file is already merged into
src/huggingface/tests/responses/responses_language_model.rs, so the two
tests land there at the same places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW
…nish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review October 24, 2026 10:10

@eric8810 eric8810 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stream error → error part → finish 的顺序和 message 校验符合 pinned huggingface 2.0.62;但本 PR 还顺带改了 function_call tool-result 和 mcp_call/mcp_list_tools 的 dynamic 字段,测试也重写了。行为本身正确,建议在 PR 描述说明或拆成独立 PR,便于定位回归。

…nish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5

Copy link
Copy Markdown
Contributor Author
eric 的意见(原话) 处理
「stream error → error part → finish 的顺序和 message 校验符合 pinned huggingface 2.0.62」 无需改动
「但本 PR 还顺带改了 function_call tool-result 和 mcp_call/mcp_list_tools 的 dynamic 字段,测试也重写了」 这两处是有意放进来的:按 #348 合入的 RFC-0045 规则「每个服务商一个 fix PR,每处差异一个提交」,原来的 #305、#306 已关闭并入本 PR。三处差异各自是一个提交,各带一个在 master 上失败的测试和一条 CHANGELOG:26cf0a83(MCP 调用不带 dynamic,原 #305)、fcdc9c0a(function_call 的 output 产生 tool-result,原 #306)、a3519880(流错误以 error finish 结束)。测试只改了对应用例的期望,另加了新用例,没有整体重写
「行为本身正确,建议在 PR 描述说明或拆成独立 PR,便于定位回归」 选了「在描述说明」:PR 描述开头的表格列出三处差异和对应提交,下面每处各有 Before/After 和调用栈,可以逐个提交审、出问题时按提交 revert。标题沿用分支名没改(分支名与标题一致的约定),所以只写了第三处

另:master 前进后本分支又和 CHANGELOG 冲突,合并 master 的提交检查通过后推送。


Generated by Claude Code


Generated by Claude Code

…ream-errors-end-with-an-error-finish

# Conflicts:
#	CHANGELOG.md
…ream-errors-end-with-an-error-finish

# Conflicts:
#	CHANGELOG.md
…ream-errors-end-with-an-error-finish

# Conflicts:
#	CHANGELOG.md

@eric8810 eric8810 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三项 HF 行为差异已在 PR body 按 commit 拆分并补 Before/After;error/response.failed parse、finish raw、malformed chunk 与 @ai-sdk/huggingface@2.0.62 对齐,新增测试通过。Approve。

@eric8810
eric8810 merged commit dc728b2 into master Oct 25, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants