From 950aba393d5dfa7057f924ad0a04521bdfbc2a22 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Mon, 6 Jul 2026 20:25:07 -0700 Subject: [PATCH] Disable Hugging Face reasoning replay Disable Hugging Face prior reasoning replay for Chat Completions while preserving streamed reasoning output. --- ARCHITECTURE.md | 3 +- README.md | 2 +- providers/huggingface/client.py | 40 ++++++++++------- pyproject.toml | 2 +- tests/providers/test_huggingface.py | 69 ++++++++++++++++++++++++++--- uv.lock | 2 +- 6 files changed, 93 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fc1d5ed..29f9f00b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -379,7 +379,8 @@ Gateway providers such as Vercel AI Gateway, Hugging Face, Cohere, and GitHub Models stay thin when their documented OpenAI-compatible Chat Completions behavior matches shared transport policy. Provider-specific gateway quirks, such as Cohere's supported `reasoning_effort` values, GitHub's API headers/catalog -filtering, and unsupported compatibility fields, stay in that provider package. +filtering, Hugging Face's disabled prior reasoning replay, and unsupported +compatibility fields, stay in that provider package. ### Adding A Provider diff --git a/README.md b/README.md index 895ad63f..8be787a0 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ Create a Hugging Face token with Inference Providers permission at [huggingface. Set `MODEL` to a Hugging Face model slug such as `huggingface/openai/gpt-oss-120b:fastest`, `huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest`, or `huggingface/deepseek-ai/DeepSeek-R1:fastest`. -Hugging Face routes through the OpenAI-compatible router at `https://router.huggingface.co/v1`. FCC uses the shared OpenAI-chat transport and preserves request `extra_body` for Hugging Face provider options. +Hugging Face routes through the OpenAI-compatible router at `https://router.huggingface.co/v1`. FCC uses the shared OpenAI-chat transport, preserves request `extra_body` for Hugging Face provider options, and does not replay prior hidden reasoning into Chat Completions because that request field is not documented by Hugging Face. New reasoning emitted by the upstream is still shown as Claude thinking. If your existing repo `.env` or `~/.fcc/.env` uses the old voice setting `HF_TOKEN`, `fcc-server`/`fcc-init` renames it to `HUGGINGFACE_API_KEY`. Explicit `FCC_ENV_FILE` files are not rewritten automatically; rename the key there manually. diff --git a/providers/huggingface/client.py b/providers/huggingface/client.py index 61ecb8af..b4952be7 100644 --- a/providers/huggingface/client.py +++ b/providers/huggingface/client.py @@ -1,19 +1,14 @@ """Hugging Face Inference Providers implementation.""" +from copy import deepcopy from typing import Any +from core.anthropic import ReasoningReplayMode, build_base_request_body +from core.anthropic.conversion import OpenAIConversionError from providers.base import ProviderConfig from providers.defaults import HUGGINGFACE_DEFAULT_BASE -from providers.transports.openai_chat import ( - OpenAIChatRequestPolicy, - OpenAIChatTransport, - build_openai_chat_request_body, -) - -_REQUEST_POLICY = OpenAIChatRequestPolicy( - provider_name="HUGGINGFACE", - include_extra_body=True, -) +from providers.exceptions import InvalidRequestError +from providers.transports.openai_chat import OpenAIChatTransport class HuggingFaceProvider(OpenAIChatTransport): @@ -30,8 +25,23 @@ class HuggingFaceProvider(OpenAIChatTransport): def _build_request_body( self, request: Any, thinking_enabled: bool | None = None ) -> dict: - return build_openai_chat_request_body( - request, - thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), - policy=_REQUEST_POLICY, - ) + """Build a Hugging Face Chat Completions body. + + Hugging Face's router documents reasoning controls for supported models + but not prior-turn reasoning replay through ``messages[].reasoning_content``. + Keep replay disabled while still allowing new streamed reasoning output + to map back to Claude thinking in the shared OpenAI-chat stream adapter. + """ + try: + body = build_base_request_body( + request, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + except OpenAIConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + + request_extra = getattr(request, "extra_body", None) + if isinstance(request_extra, dict) and request_extra: + body["extra_body"] = deepcopy(request_extra) + + return body diff --git a/pyproject.toml b/pyproject.toml index 859e5247..5bd5a6c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "3.4.5" +version = "3.4.6" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" diff --git a/tests/providers/test_huggingface.py b/tests/providers/test_huggingface.py index c19122d4..fa896f42 100644 --- a/tests/providers/test_huggingface.py +++ b/tests/providers/test_huggingface.py @@ -5,14 +5,22 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from core.anthropic import ReasoningReplayMode from providers.base import ProviderConfig from providers.huggingface import HUGGINGFACE_DEFAULT_BASE, HuggingFaceProvider class MockMessage: - def __init__(self, role, content): + def __init__(self, role, content, reasoning_content=None): self.role = role self.content = content + self.reasoning_content = reasoning_content + + +class MockBlock: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) class MockRequest: @@ -89,9 +97,7 @@ def test_init_strips_trailing_slash(huggingface_config): def test_build_request_body_keeps_max_tokens(huggingface_provider): - with patch( - "providers.transports.openai_chat.request_policy.build_base_request_body" - ) as mock_convert: + with patch("providers.huggingface.client.build_base_request_body") as mock_convert: mock_convert.return_value = { "model": "openai/gpt-oss-120b:fastest", "messages": [{"role": "user", "name": "alice", "content": "hi"}], @@ -100,17 +106,68 @@ def test_build_request_body_keeps_max_tokens(huggingface_provider): body = huggingface_provider._build_request_body(MockRequest()) + mock_convert.assert_called_once() + assert ( + mock_convert.call_args.kwargs["reasoning_replay"] + is ReasoningReplayMode.DISABLED + ) assert body["messages"][0].get("name") == "alice" assert body["max_tokens"] == 42 assert "max_completion_tokens" not in body def test_build_request_body_preserves_caller_extra_body(huggingface_provider): - req = MockRequest(extra_body={"provider": "auto", "bill_to": "my-org"}) + extra_body = {"provider": "auto", "routing": {"bill_to": "my-org"}} + req = MockRequest(extra_body=extra_body) body = huggingface_provider._build_request_body(req) - assert body["extra_body"] == {"provider": "auto", "bill_to": "my-org"} + assert body["extra_body"] == extra_body + assert body["extra_body"] is not extra_body + assert body["extra_body"]["routing"] is not extra_body["routing"] + + +def test_build_request_body_does_not_replay_prior_thinking_blocks( + huggingface_provider, +): + req = MockRequest( + system=None, + messages=[ + MockMessage( + "assistant", + [ + MockBlock(type="thinking", thinking="hidden prior thought"), + MockBlock(type="text", text="visible answer"), + ], + ) + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "reasoning_content" not in body["messages"][0] + assert "hidden prior thought" not in str(body) + + +def test_build_request_body_does_not_replay_top_level_reasoning_content( + huggingface_provider, +): + req = MockRequest( + system=None, + messages=[ + MockMessage( + "assistant", + "visible answer", + reasoning_content="hidden prior reasoning", + ) + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "hidden prior reasoning" not in str(body) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 8186be4c..a631ee71 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "3.4.5" +version = "3.4.6" source = { editable = "." } dependencies = [ { name = "aiohttp" },