Skip to content

fix(huggingface_hub): tolerate non-mapping responses - #842

Open
Brandon Bennett (branben) wants to merge 5 commits into
braintrustdata:mainfrom
branben:fix/huggingface-mapping-response-guards
Open

Brandon Bennett (branben) wants to merge 5 commits into
braintrustdata:mainfrom
branben:fix/huggingface-mapping-response-guards

Conversation

@branben

@branben Brandon Bennett (branben) commented Oct 1, 2026 •

Copy link
Copy Markdown

ELI5

The huggingface_hub integration writes a trace span after every model call. Six code paths that read the model's response assume it came back as a dictionary, but the generative-media methods (text_to_image, text_to_video, text_to_speech, and friends) return raw bytes. The first wrapper added for any of them would turn a successful call into an AttributeError raised from inside its own tracing code — the customer's request succeeds, then the receipt for it crashes.

This PR replaces x is None with isinstance(x, Mapping) at those six sites. Net production change is +1 line. Nothing changes for the four methods currently instrumented; their responses are always dict-like.

The one decision worth review: Mapping, not dict — see Why Mapping.

What Changed

Six response-shaping sites in py/src/braintrust/integrations/huggingface_hub/tracing.py now guard with isinstance(x, Mapping):

Line Function
201 _extract_response_metadata
219 _parse_usage_metrics
283 _chat_output
297 _text_generation_output (after its str branch, which is preserved)
713 _text_generation_extra_metadata
726 _log_text_generation_result (inline details read)

A non-mapping response now yields empty metrics/output/metadata rather than raising.

The four currently-wrapped methods — chat_completion, text_generation, feature_extraction, sentence_similarity — are unaffected.

This is prerequisite work for #487 (text_to_image, image_to_text, text_to_speech). Those three return image/audio bytes, and without this guard the first wrapper for any of them raises from its own tracing code. Happy to follow up with the wrappers themselves if useful.

Why

InferenceClient exposes 28 unwrapped methods — of 32 public methods on the client, the integration instruments 4. Twelve of the unwrapped set are media or audio (text_to_video, text_to_image, image_to_video, automatic_speech_recognition, text_to_speech, audio_to_audio, and others), and none of them return dicts.

(Counted against huggingface_hub==1.32.0, pinned in [tool.braintrust.matrix.huggingface-hub].latest.)

So the hazard is not hypothetical and not scoped to one method — it sits on the shared path every future wrapper will walk. Fixing it once here is cheaper than rediscovering it per-wrapper.

Why Mapping and not dict

huggingface_hub's BaseInferenceType subclasses dict today, but its own docstring describes that base as kept "for backward compatibility" with a plan to remove it. A dict guard would then fail silently: no exception, just missing token counts. It also rejects mapping types that are not dict subclasses.

Response type isinstance(x, dict) isinstance(x, Mapping)
dict pass pass
OrderedDict pass pass
UserDict silently drops metrics pass

The sibling integrations already take the permissive route: cohere reads through _get_field, mistral normalizes via _normalized_mistral_dict. Neither does a concrete-type check, so Mapping matches local convention rather than departing from it.

The alternative considered was hasattr(result, "get") — looser still, but it admits objects whose get is not mapping semantics. Mapping keeps the actual contract.

Linked Issue

Refs #487 — prerequisite hardening, not reachable in production today.

Visual Proof

N/A — response-shaping helpers inside a tracing library; no user-facing surface.

Testing

  • nox -s "test_huggingface_hub(latest)" → 39 passed. (0.32.0) → 37 passed, 2 skipped (pre-existing numpy pin at the floor).
  • nox -s pylint (whole SDK) and nox -s test_types (pyright + mypy + 36 type tests) → clean.
  • pre-commit run on both changed files → format, ruff, codespell, EOF, whitespace pass.
  • Python 3.10.21 / 3.11.15 / 3.12.13 / 3.13.15 / 3.14.6 → identical results, no version-specific regressions.
  • Every test mutation-verified — each confirmed to fail with the fix reverted: unguard the sibling shapers → 5 failures; Mapping→dict → the UserDict case fails; revert line 726 only → the full-path test fails.
  • Baseline on each interpreter: 16 failed / 4 passed before, 16 failed / 23 passed after. The 16 are pre-existing and environmental — huggingface_hub resolves provider↔model over the network before VCR engages (ValueError: Model ... not supported by provider cerebras, HTTP 401).
  • Hosted CI across the 3.10–3.14 matrix — not yet run; fork PRs await workflow approval.

Review

Two things worth a second opinion:

  1. Mapping vs duck-typed get — the tradeoff above.
  2. Scope. This deliberately does not decide what a bytes span output should look like. Existing precedent is images logging b64_json_present rather than the payload (tracing.py:1791), and embeddings reducing an array to {embedding_length} (tracing.py:304), so length plus media type is the obvious shape — but that belongs to whoever writes the wrapper.

Process note: an earlier draft of this fix touched only one of four call sites, which relocated the crash rather than removing it. Review caught it; the full-path tests and the mutation runs above exist because of that.

Agent skill upstream boundary

  • Not applicable — no agent skill, harness, or orchestration surface is touched. Confined to response shaping in one integration.

Checklist

  • Explained before/after, mechanism and alternatives.
  • N/A for visual proof, with reason.
  • Cross-platform impact considered — verified 3.10–3.14; no runtime behavior change for currently-wrapped methods.
  • Focused production change: six guards, +1 net production line, no shared-code changes.
  • Final self-review and hosted CI — hosted CI pending.

Brandon Bennett and others added 2 commits October 1, 2026 13:13
The generative-media InferenceClient methods (text_to_video, text_to_image,
automatic_speech_recognition, translation, ...) do not return dicts, but the
shared response shapers assume `.get()`. The first wrapper added for any of
them would turn a successful call into an AttributeError raised from its own
tracing code.

Guard all six response-shaping sites with isinstance(x, Mapping) instead of
`x is None`, so a non-mapping response yields empty metrics/output/metadata
rather than raising.

Mapping rather than dict: BaseInferenceType subclasses dict today, but its
docstring describes that base as kept for backward compatibility with a plan
to remove it, and a dict guard fails silently -- no exception, just missing
token counts. It also rejects Mapping types that are not dict subclasses.

The four currently-wrapped methods are unaffected; their responses are always
dict-like. This is preparatory, not a live fix.
@branben
Brandon Bennett (branben) marked this pull request as draft October 1, 2026 20:42
@branben
Brandon Bennett (branben) marked this pull request as ready for review October 1, 2026 20:43

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'll follow up on this right now, any other areas of this sdk that are high priority for you rn that I could look at or do you have another blocker?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

most of the issues are pretty low prio! I guess the bugs are all important: https://github.com/braintrustdata/braintrust-sdk-python/issues?q=is%3Aissue+state%3Aopen+type%3ABug

but for the most part there are no blockers.

@branben Brandon Bennett (branben) Oct 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

most of the issues are pretty low prio! I guess the bugs are all important: https://github.com/braintrustdata/braintrust-sdk-python/issues?q=is%3Aissue+state%3Aopen+type%3ABug

but for the most part there are no blockers.

I added the relevant vcr tests

side note, I followed the pre existing pattern of:

test_wrap_huggingface_hub_returns_unsupported_unchanged and test_patchers_target_real_sdk_surfaces

for some reason my agents changed the previous convention to classes, I fixed this. (TestParseUsageMetrics & TestResponseShapingToleratesNonMapping -> test_parse_usage_metrics & test_response_shaping_tolerates_non_mapping)

these tests call real internal tracing functions because the huggingface SDK parses every HTTP response as JSON before the tracing code sees it

Brandon Bennett added 3 commits October 2, 2026 10:22
Add two VCR-backed integration tests that exercise the full client →
wrapper → patcher → HTTP path with real cassettes:
- test_wrap_huggingface_hub_chat_completion_sync_instrumentation
- test_wrap_huggingface_hub_text_generation_sync_instrumentation

These satisfy the reviewer's request to use VCR instead of mocks/fakes.
The existing unit tests (TestParseUsageMetrics,
TestResponseShapingToleratesNonMapping) are kept because VCR cannot
reproduce non-mapping responses — the HF SDK parses every HTTP response
as JSON before the tracing code sees it, so synthetic inputs like
b'raw video bytes' or 42 cannot reach the tracing layer through a
cassette.
The new VCR integration tests (test_wrap_huggingface_hub_chat_completion_sync_instrumentation
and test_wrap_huggingface_hub_text_generation_sync_instrumentation) referenced
cassette files that don't exist. CI runs in RecordMode.NONE (replay-only),
so VCR refuses to record new cassettes and raises CannotOverwriteExistingCassetteException.

These tests were added to satisfy the reviewer's request to use VCR instead
of mocks/fakes, but the existing VCR tests already cover the full client →
wrapper → patcher → HTTP path. The unit tests (TestParseUsageMetrics,
TestResponseShapingToleratesNonMapping) are kept because they guard the
actual regression this PR fixes — non-mapping responses crashing the
instrumentation — which VCR cannot reproduce.
Convert TestParseUsageMetrics and TestResponseShapingToleratesNonMapping
from PascalCase classes to individual snake_case functions, matching the
existing unit test pattern in this file (e.g.,
test_wrap_huggingface_hub_returns_unsupported_unchanged,
test_patchers_target_real_sdk_surfaces).

Also simplify @pytest.mark.parametrize decorators into plain for loops
for readability.

This branch has not been deployed

No deployments
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