From 82408c5adb1883b89b0e53b5369aee66349b37da Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Thu, 24 Sep 2026 13:25:35 -0700 Subject: [PATCH] Handle rejected OpenAI sampling parameters (#1883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why OpenAI can reject `temperature` or `top_p` for a model's current reasoning mode, preventing otherwise valid FCC requests from completing. FCC needs to handle those explicit incompatibility errors without maintaining model-name rules or discarding settings that OpenAI accepts. ## How Send the original sampling settings, then use an OpenAI API-specific correction callback in the shared Responses transport to remove only the field explicitly rejected upstream. Recognize structured `unsupported_parameter` errors and the narrow default-only `unsupported_value` form. Both Messages and native Responses use the existing recovery path, including reasoning/history corrections, stream commitment safeguards, and the shared five-attempt limit. Corrections apply only to the current request: there is no model table or capability cache. Each subsequent request starts with its original settings, so incompatible requests can incur extra round trips. Ambiguous and unrelated validation errors keep their normal failure behavior. Bump the package version to 6.2.60.

RetriggerConfidence Score: 5/5

No outstanding findings block merging.
Summary The OpenAI API provider now preserves requested sampling settings until OpenAI explicitly rejects one, then retries without the rejected setting. Both previously reported issues are fixed.
Reviews (3) ยท Last reviewed commit: ["Correct OpenAI sampling only after expli..."](https://github.com/alishahryar1/free-claude-code/commit/c397b03337473e4625dce5e40988a456985e5473) --- pyproject.toml | 2 +- .../providers/openai_api/provider.py | 47 +- .../providers/openai_responses/transport.py | 19 + tests/providers/test_openai_api_provider.py | 8 +- tests/providers/test_openai_api_sampling.py | 488 ++++++++++++++++++ uv.lock | 2 +- 6 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 tests/providers/test_openai_api_sampling.py diff --git a/pyproject.toml b/pyproject.toml index f4907bdaee..ec02dd351b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.59" +version = "6.2.60" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/providers/openai_api/provider.py b/src/free_claude_code/providers/openai_api/provider.py index add4d23280..cc5f8ea9f2 100644 --- a/src/free_claude_code/providers/openai_api/provider.py +++ b/src/free_claude_code/providers/openai_api/provider.py @@ -1,13 +1,18 @@ """OpenAI Platform provider using the shared Responses transport.""" +import re from collections.abc import AsyncIterator, Mapping import httpx2 -from openai import AsyncOpenAI, DefaultAsyncHttpx2Client +from openai import APIError, APIStatusError, AsyncOpenAI, DefaultAsyncHttpx2Client from free_claude_code.application.model_metadata import ProviderModelInfo from free_claude_code.core.anthropic.models import MessagesRequest -from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.json_types import JsonObject +from free_claude_code.core.openai_responses import ( + OpenAIResponsesRequest, + ResponsesStreamFailure, +) from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy from free_claude_code.providers.admission import ( ProviderAdmissionController, @@ -18,6 +23,43 @@ from free_claude_code.providers.openai_responses import OpenAIResponsesTransport +def _sampling_retry_body( + error: Exception, body: JsonObject, sent_body: JsonObject +) -> JsonObject | None: + """Omit only a sampling field explicitly rejected on this outbound attempt.""" + if not isinstance(error, APIError | ResponsesStreamFailure): + return None + if isinstance(error, APIStatusError) and error.status_code != 400: + return None + detail = error.body + if not isinstance(detail, Mapping): + return None + param = detail.get("param") + if not isinstance(param, str) or param not in {"temperature", "top_p"}: + return None + if param not in body or param not in sent_body or body[param] != sent_body[param]: + return None + code = detail.get("code") + if code == "unsupported_value": + # This code also covers ordinary invalid values. Only default-only + # model incompatibility authorizes dropping the caller's setting. + message = detail.get("message") + if ( + not isinstance(message, str) + or re.fullmatch( + rf"Unsupported value: ['\"]{param}['\"] does not support .+ " + r"with this model\. Only the default \([^)]+\) value is supported\.", + message.strip(), + re.IGNORECASE, + ) + is None + ): + return None + elif code != "unsupported_parameter": + return None + return {key: value for key, value in body.items() if key != param} + + class OpenAIAPIProvider(BaseProvider): """Own public API credentials and SDK resources, independent of ChatGPT OAuth.""" @@ -56,6 +98,7 @@ def __init__( provider_name="OpenAI API", read_timeout_s=config.http_read_timeout, log_raw_sse_events=config.log_raw_sse_events, + request_correction=_sampling_retry_body, ) async def cleanup(self) -> None: diff --git a/src/free_claude_code/providers/openai_responses/transport.py b/src/free_claude_code/providers/openai_responses/transport.py index 341c3b38ce..49d3457b0f 100644 --- a/src/free_claude_code/providers/openai_responses/transport.py +++ b/src/free_claude_code/providers/openai_responses/transport.py @@ -100,12 +100,17 @@ def __init__( log_raw_sse_events: bool, endpoint_transport: httpx2.AsyncBaseTransport | None = None, event_adapter_factory: Callable[[], ResponsesEventAdapter] | None = None, + request_correction: Callable[ + [Exception, JsonObject, JsonObject], JsonObject | None + ] + | None = None, omitted_request_fields: frozenset[str] = frozenset(), tool_policy: ResponsesToolPolicy = ResponsesToolPolicy(), ) -> None: self._client = client self._endpoint_transport = endpoint_transport self._event_adapter_factory = event_adapter_factory + self._request_correction = request_correction self._omitted_request_fields = omitted_request_fields self._tool_policy = tool_policy self._admission = admission @@ -449,6 +454,9 @@ async def _run_execution( body, sent_body=sent_body, reasoning_error=raw_error, + after_common=partial( + self._provider_retry_body, raw_error, body, sent_body + ), ), ) if corrected_body is not None: @@ -523,6 +531,17 @@ async def _run_execution( raise execution.last_failure raise RuntimeError("Responses execution ended without a terminal result.") + def _provider_retry_body( + self, + error: Exception, + body: JsonObject, + sent_body: JsonObject, + _used_retry_kinds: set[str], + ) -> JsonObject | None: + if self._request_correction is None: + return None + return self._request_correction(error, body, sent_body) + async def _create_sdk_stream( self, body: JsonObject, diff --git a/tests/providers/test_openai_api_provider.py b/tests/providers/test_openai_api_provider.py index 5e9a21e735..03b2749343 100644 --- a/tests/providers/test_openai_api_provider.py +++ b/tests/providers/test_openai_api_provider.py @@ -47,10 +47,14 @@ def _complete_stream(text: str) -> str: ) -def _provider(handler: httpx2.MockTransport) -> OpenAIAPIProvider: +def _provider( + handler: httpx2.MockTransport, *, max_attempts: int = 1 +) -> OpenAIAPIProvider: return OpenAIAPIProvider( make_provider_config("platform-key", "https://api.openai.com/v1"), - admission=immediate_admission(provider_name="openai_api", max_attempts=1), + admission=immediate_admission( + provider_name="openai_api", max_attempts=max_attempts + ), transport=handler, ) diff --git a/tests/providers/test_openai_api_sampling.py b/tests/providers/test_openai_api_sampling.py new file mode 100644 index 0000000000..7701d550d0 --- /dev/null +++ b/tests/providers/test_openai_api_sampling.py @@ -0,0 +1,488 @@ +"""Sampling compatibility follows actual upstream rejections, not model IDs.""" + +import asyncio +import json +from contextlib import nullcontext +from copy import deepcopy +from typing import Any + +import httpx2 +import pytest + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, +) +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.reasoning import ReasoningPolicy +from free_claude_code.providers.openai_api.provider import OpenAIAPIProvider +from tests.providers.test_openai_api_provider import ( + _collect, + _complete_stream, + _provider, + _sse, +) +from tests.providers.test_openai_responses_transport import ( + _client, + _collect_native, + _transport, +) + + +def _request(ingress: str, model: str = "future-model"): + sampling = {"model": model, "temperature": 0.5, "top_p": 0.8} + if ingress == "messages": + return MessagesRequest.model_validate( + {**sampling, "messages": [{"role": "user", "content": "hello"}]} + ) + return OpenAIResponsesRequest.model_validate( + {**sampling, "input": "hello", "metadata": {"source": "sampling-test"}} + ) + + +def _stream( + provider: OpenAIAPIProvider, + request: MessagesRequest | OpenAIResponsesRequest, + reasoning: ReasoningPolicy = ReasoningPolicy.provider_default(), +): + if isinstance(request, MessagesRequest): + return provider.stream_messages(request, reasoning=reasoning) + return provider.stream_responses(request, reasoning=reasoning) + + +def _rejection(param: str, *, default_only: bool = False) -> dict[str, Any]: + # Captured HTTP error shapes; stream variants below are compatibility contracts. + return { + "type": "invalid_request_error", + "param": param, + "code": "unsupported_value" if default_only else "unsupported_parameter", + "message": ( + f"Unsupported value: '{param}' does not support 0.5 with this model. " + "Only the default (1) value is supported." + if default_only + else f"Unsupported parameter: '{param}' is not supported with this model." + ), + } + + +def _failure(error: Any, surface: str = "http", status: int = 400): + if surface == "http": + return httpx2.Response(status, json={"error": error}) + if surface == "flat": + payload = {**error, "type": "error", "sequence_number": 0} + elif surface == "failed": + payload = { + "type": "response.failed", + "response": {"id": "resp_rejected", "error": error}, + } + else: + payload = {"type": "error", "error": error} + return httpx2.Response( + 200, + text=_sse((payload["type"], payload)), + headers={"content-type": "text/event-stream"}, + ) + + +def _success(): + return httpx2.Response( + 200, + text=_complete_stream("hello"), + headers={"content-type": "text/event-stream"}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ingress", ["messages", "responses"]) +@pytest.mark.parametrize("model", ["future-model", "gpt-6-sol"]) +@pytest.mark.parametrize( + "reasoning", [ReasoningPolicy.provider_default(), ReasoningPolicy.prefer_off()] +) +async def test_supported_sampling_is_never_removed_proactively( + ingress, model, reasoning +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return _success() + + provider = _provider(httpx2.MockTransport(handler)) + try: + await _collect(_stream(provider, _request(ingress, model), reasoning)) + finally: + await provider.cleanup() + assert len(bodies) == 1 + assert bodies[0]["temperature"] == 0.5 + assert bodies[0]["top_p"] == 0.8 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ingress", ["messages", "responses"]) +@pytest.mark.parametrize("surface", ["http", "flat", "failed", "sdk"]) +@pytest.mark.parametrize("param", ["temperature", "top_p"]) +@pytest.mark.parametrize("default_only", [False, True]) +async def test_only_explicitly_rejected_sampling_is_removed( + ingress, surface, param, default_only +): + bodies = [] + + def handler(request): + body = json.loads(request.content) + bodies.append(body) + if param in body: + return _failure(_rejection(param, default_only=default_only), surface) + return _success() + + request = _request(ingress) + original = request.model_dump() + provider = _provider(httpx2.MockTransport(handler), max_attempts=2) + try: + output = await _collect(_stream(provider, request)) + finally: + await provider.cleanup() + assert len(bodies) == 2 + assert bodies[1] == {key: value for key, value in bodies[0].items() if key != param} + assert request.model_dump() == original + events = parse_sse_text(output) + terminal = "message_stop" if ingress == "messages" else "response.completed" + assert sum(event.event == terminal for event in events) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ingress", ["messages", "responses"]) +@pytest.mark.parametrize("order", [("temperature", "top_p"), ("top_p", "temperature")]) +@pytest.mark.parametrize("concurrent", [False, True]) +async def test_two_rejections_are_request_local(ingress, order, concurrent): + bodies = [] + both_started = asyncio.Event() + + async def handler(request): + body = json.loads(request.content) + bodies.append(body) + if concurrent and all(param in body for param in order): + if sum(all(param in item for param in order) for item in bodies) == 2: + both_started.set() + await both_started.wait() + for param in order: + if param in body: + return _failure(_rejection(param)) + return _success() + + provider = _provider(httpx2.MockTransport(handler), max_attempts=3) + request = _request(ingress) + try: + if concurrent: + await asyncio.gather( + *(_collect(_stream(provider, request)) for _ in range(2)) + ) + else: + for _ in range(2): + await _collect(_stream(provider, request)) + finally: + await provider.cleanup() + assert len(bodies) == 6 + assert sum(all(param in body for param in order) for body in bodies) == 2 + assert sum(order[0] not in body and order[1] in body for body in bodies) == 2 + assert sum(all(param not in body for param in order) for body in bodies) == 2 + assert request.temperature == 0.5 and request.top_p == 0.8 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", + [ + {**_rejection("temperature"), "param": "max_output_tokens"}, + {**_rejection("temperature"), "param": None}, + {**_rejection("temperature"), "param": ["temperature"]}, + {**_rejection("temperature"), "code": "invalid_value"}, + {**_rejection("temperature"), "code": "unsupported_value"}, + { + **_rejection("temperature"), + "code": "unsupported_value", + "message": "Must be between 0 and 2", + }, + {**_rejection("temperature", default_only=True), "param": "top_p"}, + { + **_rejection("temperature", default_only=True), + "message": "Only the default (1) value is supported.", + }, + {"message": "Sampling unsupported with this model"}, + None, + "Unsupported parameter: temperature", + ], +) +async def test_ambiguous_or_unrelated_validation_is_not_corrected(error): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return _failure(error) + + provider = _provider(httpx2.MockTransport(handler), max_attempts=3) + try: + with pytest.raises(ExecutionFailure): + await _collect(_stream(provider, _request("responses"))) + finally: + await provider.cleanup() + assert len(bodies) == 1 + assert bodies[0]["temperature"] == 0.5 and bodies[0]["top_p"] == 0.8 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status, attempts", [(401, 1), (403, 1), (422, 1), (429, 2), (500, 2)] +) +async def test_other_http_failures_do_not_remove_sampling(status, attempts): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return _failure(_rejection("temperature"), status=status) + + provider = _provider(httpx2.MockTransport(handler), max_attempts=2) + try: + with pytest.raises(ExecutionFailure): + await _collect(_stream(provider, _request("responses"))) + finally: + await provider.cleanup() + assert len(bodies) == attempts + assert all(body == bodies[0] for body in bodies) + assert bodies[0]["temperature"] == 0.5 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reasoning_first", [False, True]) +async def test_reasoning_and_sampling_corrections_compose(reasoning_first): + reasoning_error = { + "code": "unsupported_value", + "param": "reasoning.effort", + "message": "Value 'none' is not supported", + } + errors = [reasoning_error, _rejection("temperature")] + if not reasoning_first: + errors.reverse() + errors.append(_rejection("top_p")) + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + if len(bodies) <= len(errors): + return _failure(errors[len(bodies) - 1]) + return _success() + + provider = _provider(httpx2.MockTransport(handler), max_attempts=5) + try: + output = await _collect( + _stream(provider, _request("messages"), ReasoningPolicy.prefer_off()) + ) + finally: + await provider.cleanup() + assert text_content(parse_sse_text(output)) == "hello" + assert len(bodies) == 4 + for index, error in enumerate(errors): + field = "reasoning" if error["param"] == "reasoning.effort" else error["param"] + assert field in bodies[index] + assert bodies[index + 1] == { + key: value for key, value in bodies[index].items() if key != field + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("budget", [1, 2, 3]) +async def test_sampling_corrections_share_the_attempt_budget(budget): + bodies = [] + + def handler(request): + body = json.loads(request.content) + bodies.append(body) + if len(bodies) == 1: + return _failure({"code": "server_error", "message": "busy"}, status=500) + param = "temperature" if "temperature" in body else "top_p" + return _failure(_rejection(param)) + + provider = _provider(httpx2.MockTransport(handler), max_attempts=budget) + try: + with pytest.raises(ExecutionFailure): + await _collect(_stream(provider, _request("responses"))) + finally: + await provider.cleanup() + assert len(bodies) == budget + if budget > 1: + assert bodies[1] == bodies[0] + if budget > 2: + assert "temperature" not in bodies[2] and "top_p" in bodies[2] + + +@pytest.mark.asyncio +async def test_rejection_of_an_already_removed_field_stops_correction(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return _failure(_rejection("temperature")) + + provider = _provider(httpx2.MockTransport(handler), max_attempts=5) + try: + with pytest.raises(ExecutionFailure): + await _collect(_stream(provider, _request("responses"))) + finally: + await provider.cleanup() + assert len(bodies) == 2 + assert "temperature" not in bodies[1] and "top_p" in bodies[1] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("history_first", [False, True]) +async def test_history_and_sampling_corrections_preserve_each_other(history_first): + history_error = { + "code": "invalid_encrypted_content", + "param": "input[0].encrypted_content", + "message": "The encrypted content could not be verified.", + } + errors = [history_error, _rejection("temperature")] + if not history_first: + errors.reverse() + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + if len(bodies) <= 2: + return _failure(errors[len(bodies) - 1]) + return _success() + + request = OpenAIResponsesRequest( + model="future-model", + temperature=0.5, + top_p=0.8, + input=[ + { + "type": "reasoning", + "id": "rs_1", + "encrypted_content": "opaque-history", + "summary": [{"type": "summary_text", "text": "Saved reasoning"}], + }, + {"role": "user", "content": "continue"}, + ], + ) + original = request.model_dump() + provider = _provider(httpx2.MockTransport(handler), max_attempts=3) + try: + await _collect(_stream(provider, request)) + finally: + await provider.cleanup() + assert len(bodies) == 3 + expected = deepcopy(bodies[0]) + for index, error in enumerate(errors): + if error is history_error: + expected["input"][0] = { + "role": "assistant", + "content": "[Earlier reasoning summary]\nSaved reasoning", + } + else: + expected.pop("temperature") + assert bodies[index + 1] == expected + assert request.model_dump() == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ingress", ["messages", "responses"]) +@pytest.mark.parametrize("committed", [False, True]) +async def test_sampling_correction_respects_stream_commitment(ingress, committed): + bodies = [] + partial = "x" * 70_000 if committed else "discarded" + + def handler(request): + bodies.append(json.loads(request.content)) + if len(bodies) > 1: + return _success() + return httpx2.Response( + 200, + text=_sse( + ( + "response.created", + {"type": "response.created", "response": {"id": "resp_old"}}, + ), + ( + "response.output_text.delta", + {"type": "response.output_text.delta", "delta": partial}, + ), + ( + "error", + { + **_rejection("temperature"), + "type": "error", + "sequence_number": 2, + }, + ), + ), + headers={"content-type": "text/event-stream"}, + ) + + provider = _provider(httpx2.MockTransport(handler), max_attempts=3) + chunks = [] + try: + expected_error = ( + pytest.raises(ExecutionFailure) + if committed and ingress == "messages" + else nullcontext() + ) + with expected_error: + async for chunk in _stream(provider, _request(ingress)): + chunks.extend((chunk,)) + finally: + await provider.cleanup() + output = "".join(chunks) + events = parse_sse_text(output) + start = "message_start" if ingress == "messages" else "response.created" + assert sum(event.event == start for event in events) == 1 + if committed: + assert len(bodies) == 1 + assert partial in output + if ingress == "responses": + assert sum(event.event == "response.failed" for event in events) == 1 + else: + assert len(bodies) == 2 + assert partial not in output + assert "resp_old" not in output + assert "temperature" not in bodies[1] and "top_p" in bodies[1] + + +@pytest.mark.asyncio +async def test_stream_failure_without_a_parameter_is_not_guessed(): + calls = 0 + + def handler(_request): + nonlocal calls + calls += 1 + return _failure({**_rejection("temperature"), "param": None}, "failed") + + provider = _provider(httpx2.MockTransport(handler), max_attempts=3) + try: + with pytest.raises(ExecutionFailure): + await _collect(_stream(provider, _request("responses"))) + finally: + await provider.cleanup() + assert calls == 1 + + +@pytest.mark.asyncio +async def test_shared_transport_does_not_apply_openai_api_sampling_correction(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return _failure(_rejection("temperature")) + + client = _client(handler) + try: + with pytest.raises(ExecutionFailure): + await _collect_native( + _transport(client, max_attempts=3), _request("responses") + ) + finally: + await client.close() + assert len(bodies) == 1 + assert bodies[0]["temperature"] == 0.5 and bodies[0]["top_p"] == 0.8 diff --git a/uv.lock b/uv.lock index ff4df5a676..a4f67a878e 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.59" +version = "6.2.60" source = { editable = "." } dependencies = [ { name = "aiohttp" },