From 24c8b21d02c3c16b3943b2b0733e12495cdd223f Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 12:45:23 +0530 Subject: [PATCH 01/10] [FIX] Support reasoning models in openai-compatible adapter OpenAI gpt-5 / o1 / o3 routed through the openai-compatible adapter were rejected by the upstream API with "temperature does not support 0.1" and "max_tokens not supported, use max_completion_tokens" because LiteLLM's `custom_openai` provider does not apply the reasoning-model parameter transformations that the `openai` provider does. Add an `Enable Reasoning` toggle (matching the OpenAI adapter pattern) that drops `temperature` and `max_tokens` from the top-level kwargs and routes `reasoning_effort` and `max_completion_tokens` via LiteLLM's `extra_body` (which is forwarded as-is to the upstream API). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 40 +++++++- .../adapters/llm1/static/custom_openai.json | 51 +++++++++- .../tests/test_openai_compatible_adapter.py | 99 +++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 7bc58296c8..b64ca6285e 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -351,6 +351,7 @@ class OpenAICompatibleLLMParameters(BaseChatCompletionParameters): api_key: str | None = None api_base: str + reasoning_effort: str | None = None @staticmethod def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: @@ -360,7 +361,44 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: api_key = adapter_metadata.get("api_key") if isinstance(api_key, str) and not api_key.strip(): adapter_metadata["api_key"] = None - return OpenAICompatibleLLMParameters(**adapter_metadata).model_dump() + + # Reasoning models (OpenAI gpt-5, o1, o3, ...) routed through an + # OpenAI-compatible gateway reject `temperature != 1` and `max_tokens` + # (require `max_completion_tokens`). LiteLLM's `custom_openai` provider + # is generic and does not apply these transformations, so we route the + # reasoning-only fields via `extra_body` (which LiteLLM forwards as-is) + # and strip the rejected fields from the top-level kwargs. + enable_reasoning = adapter_metadata.get("enable_reasoning", False) + has_reasoning_effort = ( + "reasoning_effort" in adapter_metadata + and adapter_metadata.get("reasoning_effort") is not None + ) + if not enable_reasoning and has_reasoning_effort: + enable_reasoning = True + + max_tokens = adapter_metadata.get("max_tokens") + reasoning_effort = adapter_metadata.get("reasoning_effort") or "medium" + + exclude_fields = {"enable_reasoning"} + if not enable_reasoning: + exclude_fields.add("reasoning_effort") + validation_metadata = { + k: v for k, v in adapter_metadata.items() if k not in exclude_fields + } + validated = OpenAICompatibleLLMParameters(**validation_metadata).model_dump() + + if enable_reasoning: + extra_body = {"reasoning_effort": reasoning_effort} + if max_tokens is not None: + extra_body["max_completion_tokens"] = max_tokens + validated["extra_body"] = extra_body + validated.pop("temperature", None) + validated.pop("max_tokens", None) + validated.pop("reasoning_effort", None) + else: + validated.pop("reasoning_effort", None) + + return validated @staticmethod def validate_model(adapter_metadata: dict[str, "Any"]) -> str: diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json index 8767ffdcf4..0628a25159 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json @@ -38,7 +38,7 @@ "multipleOf": 1, "title": "Maximum Output Tokens", "default": 4096, - "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default." + "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default. Sent as `max_completion_tokens` when Enable Reasoning is on." }, "max_retries": { "type": "number", @@ -55,6 +55,53 @@ "title": "Timeout", "default": 900, "description": "Timeout in seconds." + }, + "enable_reasoning": { + "type": "boolean", + "title": "Enable Reasoning", + "default": false, + "description": "Enable for reasoning models routed through this endpoint (e.g. OpenAI gpt-5, o1, o3). Drops `temperature` and sends `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." + } + }, + "allOf": [ + { + "if": { + "properties": { + "enable_reasoning": { + "const": true + } + } + }, + "then": { + "properties": { + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "default": "medium", + "title": "Reasoning Effort", + "description": "Sets the reasoning strength when Enable Reasoning is on." + } + }, + "required": [ + "reasoning_effort" + ] + } + }, + { + "if": { + "properties": { + "enable_reasoning": { + "const": false + } + } + }, + "then": { + "properties": {} + } } - } + ] } diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index addbf080ce..fb4c6cb078 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -87,6 +87,105 @@ def test_openai_compatible_schema_is_loadable() -> None: assert "default" not in schema["properties"]["api_base"] +def test_openai_compatible_schema_exposes_reasoning_toggle() -> None: + schema = json.loads(OpenAICompatibleLLMAdapter.get_json_schema()) + + reasoning_flag = schema["properties"]["enable_reasoning"] + assert reasoning_flag["type"] == "boolean" + assert reasoning_flag["default"] is False + + reasoning_branch = next( + branch + for branch in schema["allOf"] + if branch["if"]["properties"]["enable_reasoning"]["const"] is True + ) + effort = reasoning_branch["then"]["properties"]["reasoning_effort"] + assert effort["enum"] == ["low", "medium", "high"] + assert effort["default"] == "medium" + assert reasoning_branch["then"]["required"] == ["reasoning_effort"] + + +def test_openai_compatible_validate_routes_reasoning_via_extra_body() -> None: + # GPT-5 / o-series via custom_openai: LiteLLM does NOT auto-translate + # `max_tokens` to `max_completion_tokens` or drop `temperature`, so the + # adapter must do both — and route reasoning_effort / max_completion_tokens + # through `extra_body` (the only field LiteLLM's custom_openai forwards + # to the upstream API without rewriting). + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://api.openai.com/v1", + "api_key": "sk-test", + "model": "gpt-5", + "max_tokens": 4096, + "enable_reasoning": True, + "reasoning_effort": "high", + } + ) + + assert validated["model"] == "custom_openai/gpt-5" + assert "temperature" not in validated + assert "max_tokens" not in validated + assert "reasoning_effort" not in validated + assert "enable_reasoning" not in validated + assert validated["extra_body"] == { + "reasoning_effort": "high", + "max_completion_tokens": 4096, + } + + +def test_reasoning_omits_max_completion_tokens_when_unset() -> None: + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://api.openai.com/v1", + "model": "gpt-5", + "enable_reasoning": True, + } + ) + + assert validated["extra_body"] == {"reasoning_effort": "medium"} + assert "max_tokens" not in validated + assert "temperature" not in validated + + +def test_openai_compatible_validate_infers_reasoning_from_effort_field() -> None: + # When the adapter is re-validated (e.g. on second call), `enable_reasoning` + # may already have been consumed but `reasoning_effort` should still trigger + # the reasoning code path so the model keeps working. + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://api.openai.com/v1", + "model": "gpt-5", + "max_tokens": 2048, + "reasoning_effort": "low", + } + ) + + assert "temperature" not in validated + assert "max_tokens" not in validated + assert validated["extra_body"] == { + "reasoning_effort": "low", + "max_completion_tokens": 2048, + } + + +def test_openai_compatible_validate_no_reasoning_unchanged() -> None: + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://gateway.example.com/v1", + "api_key": "test-key", + "model": "gateway-model", + "max_tokens": 1024, + } + ) + + # Non-reasoning path must preserve temperature/max_tokens for ordinary + # OpenAI-compatible gateways (vLLM, LM Studio, etc.). + assert validated["temperature"] == 0.1 + assert validated["max_tokens"] == 1024 + assert "extra_body" not in validated + assert "reasoning_effort" not in validated + + def test_openai_compatible_adapter_uses_distinct_description_and_icon() -> None: metadata = OpenAICompatibleLLMAdapter.get_metadata() From d6d0dfd7409761c0eabd68f022365d4851c1545c Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 13:00:44 +0530 Subject: [PATCH 02/10] [FIX] Auto-detect OpenAI reasoning models in openai-compatible adapter The Enable Reasoning toggle alone is not enough: users dropping a gpt-5 or o-series model name into the adapter will not know they need to flip a switch, so the upstream API still returns `temperature does not support 0.1` / `max_tokens is not supported`. Pattern-match OpenAI's known reasoning families (gpt-5, o1, o3, o4) after stripping the `custom_openai/` / `openai/` prefixes and route the request through the reasoning code path automatically. Keep the `enable_reasoning` opt-in for gateway aliases that hide the underlying reasoning model behind a custom name. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 28 ++++++++++ .../adapters/llm1/static/custom_openai.json | 2 +- .../tests/test_openai_compatible_adapter.py | 52 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index b64ca6285e..948318a407 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -346,6 +346,27 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: return f"openai/{model}" +_OPENAI_REASONING_MODEL_PATTERN = re.compile(r"^(o1|o3|o4|gpt-5)(?:[-/]|$)") + + +def _is_openai_reasoning_model(model: str) -> bool: + """Best-effort detection of OpenAI reasoning model names. + + The check is conservative — it matches only OpenAI's known reasoning + families (o1, o3, o4, gpt-5) after stripping the LiteLLM `custom_openai/` + prefix and optional `openai/` sub-prefix. Custom gateway model aliases + that hide a reasoning model behind an unrelated name still need the + explicit `enable_reasoning` opt-in. + """ + if not model: + return False + if model.startswith("custom_openai/"): + model = model[len("custom_openai/") :] + if model.startswith("openai/"): + model = model[len("openai/") :] + return bool(_OPENAI_REASONING_MODEL_PATTERN.match(model.lower())) + + class OpenAICompatibleLLMParameters(BaseChatCompletionParameters): """See https://docs.litellm.ai/docs/providers/openai_compatible/.""" @@ -368,6 +389,9 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: # is generic and does not apply these transformations, so we route the # reasoning-only fields via `extra_body` (which LiteLLM forwards as-is) # and strip the rejected fields from the top-level kwargs. + # Auto-detect known OpenAI reasoning model names so users do not have + # to know that gpt-5 / o-series need special handling — they can still + # opt in explicitly for custom gateway aliases that hide the model id. enable_reasoning = adapter_metadata.get("enable_reasoning", False) has_reasoning_effort = ( "reasoning_effort" in adapter_metadata @@ -375,6 +399,10 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: ) if not enable_reasoning and has_reasoning_effort: enable_reasoning = True + if not enable_reasoning and _is_openai_reasoning_model( + adapter_metadata["model"] + ): + enable_reasoning = True max_tokens = adapter_metadata.get("max_tokens") reasoning_effort = adapter_metadata.get("reasoning_effort") or "medium" diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json index 0628a25159..bc823527e4 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json @@ -60,7 +60,7 @@ "type": "boolean", "title": "Enable Reasoning", "default": false, - "description": "Enable for reasoning models routed through this endpoint (e.g. OpenAI gpt-5, o1, o3). Drops `temperature` and sends `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." + "description": "Auto-detected for OpenAI reasoning families (gpt-5, o1, o3, o4). Toggle on for gateway aliases that hide the underlying reasoning model behind a custom name. Drops `temperature` and sends `max_completion_tokens` and `reasoning_effort` via `extra_body` so the upstream API accepts them." } }, "allOf": [ diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index fb4c6cb078..6a9adc079a 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -105,6 +105,58 @@ def test_openai_compatible_schema_exposes_reasoning_toggle() -> None: assert reasoning_branch["then"]["required"] == ["reasoning_effort"] +def test_openai_compatible_validate_auto_detects_reasoning_for_known_families() -> None: + # User-facing requirement: dropping a known OpenAI reasoning model name + # (gpt-5, o1, o3, o4 and their *-mini / *-nano / dated variants) into the + # adapter must "just work" — the upstream API rejects `temperature != 1` + # and `max_tokens`, and users will not know to flip a switch for that. + for model in [ + "gpt-5", + "gpt-5-mini", + "gpt-5-2024-12-01", + "o1", + "o1-mini", + "o1-preview", + "o3", + "o3-mini", + "o4-mini", + "openai/gpt-5", + ]: + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://api.openai.com/v1", + "api_key": "sk-test", + "model": model, + "max_tokens": 4096, + } + ) + + assert "temperature" not in validated, f"{model} should drop temperature" + assert "max_tokens" not in validated, f"{model} should drop max_tokens" + assert validated["extra_body"] == { + "reasoning_effort": "medium", + "max_completion_tokens": 4096, + }, f"{model} should route reasoning params via extra_body" + + +def test_openai_compatible_validate_preserves_non_reasoning_models() -> None: + # Non-reasoning OpenAI models (gpt-4o, gpt-4o-mini, gpt-3.5-turbo) and + # arbitrary gateway aliases must keep `temperature` / `max_tokens` so the + # existing vLLM / LM Studio / generic gateway path is unchanged. + for model in ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo", "gateway-model"]: + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://gateway.example.com/v1", + "model": model, + "max_tokens": 1024, + } + ) + + assert validated["temperature"] == 0.1, f"{model} should keep temperature" + assert validated["max_tokens"] == 1024, f"{model} should keep max_tokens" + assert "extra_body" not in validated, f"{model} should not set extra_body" + + def test_openai_compatible_validate_routes_reasoning_via_extra_body() -> None: # GPT-5 / o-series via custom_openai: LiteLLM does NOT auto-translate # `max_tokens` to `max_completion_tokens` or drop `temperature`, so the From 67f9deca074b7c2c1321aa23c7faf73b03735ed8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:27:08 +0000 Subject: [PATCH 03/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unstract/sdk1/src/unstract/sdk1/adapters/base1.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 948318a407..d64c7d9262 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -399,9 +399,7 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: ) if not enable_reasoning and has_reasoning_effort: enable_reasoning = True - if not enable_reasoning and _is_openai_reasoning_model( - adapter_metadata["model"] - ): + if not enable_reasoning and _is_openai_reasoning_model(adapter_metadata["model"]): enable_reasoning = True max_tokens = adapter_metadata.get("max_tokens") From ca4d399ee88744622f838a666d5e60383b6aa92e Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 14:05:01 +0530 Subject: [PATCH 04/10] [FIX] Address Greptile and CodeRabbit review on UN-3478 - base1.py: read max_tokens for extra_body from the Pydantic-validated dict so `int | None` coercion is applied before the value is forwarded to the upstream API (CodeRabbit). - custom_openai.json: anchor `allOf` `if` branches on `required: [enable_reasoning]` so the conditional does not fire vacuously when the property is absent from the submitted instance (Greptile). - test_openai_compatible_adapter.py: switch the `infers_reasoning_from_effort_field` test to a non-auto-detected model name so the test actually exercises the `has_reasoning_effort` branch instead of passing via auto-detection (Greptile). Also assert the new `required: [enable_reasoning]` clause on both schema branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- unstract/sdk1/src/unstract/sdk1/adapters/base1.py | 5 ++++- .../sdk1/adapters/llm1/static/custom_openai.json | 10 ++++++++-- .../sdk1/tests/test_openai_compatible_adapter.py | 15 ++++++++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index d64c7d9262..f38c0f919a 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -402,7 +402,6 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: if not enable_reasoning and _is_openai_reasoning_model(adapter_metadata["model"]): enable_reasoning = True - max_tokens = adapter_metadata.get("max_tokens") reasoning_effort = adapter_metadata.get("reasoning_effort") or "medium" exclude_fields = {"enable_reasoning"} @@ -414,6 +413,10 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: validated = OpenAICompatibleLLMParameters(**validation_metadata).model_dump() if enable_reasoning: + # Read max_tokens from the validated dict so Pydantic's `int | None` + # coercion (e.g. "4096" -> 4096) has already been applied before the + # value is forwarded to the upstream API via extra_body. + max_tokens = validated.get("max_tokens") extra_body = {"reasoning_effort": reasoning_effort} if max_tokens is not None: extra_body["max_completion_tokens"] = max_tokens diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json index bc823527e4..670616258c 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/custom_openai.json @@ -70,7 +70,10 @@ "enable_reasoning": { "const": true } - } + }, + "required": [ + "enable_reasoning" + ] }, "then": { "properties": { @@ -97,7 +100,10 @@ "enable_reasoning": { "const": false } - } + }, + "required": [ + "enable_reasoning" + ] }, "then": { "properties": {} diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index 6a9adc079a..6e58aac953 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -103,6 +103,13 @@ def test_openai_compatible_schema_exposes_reasoning_toggle() -> None: assert effort["enum"] == ["low", "medium", "high"] assert effort["default"] == "medium" assert reasoning_branch["then"]["required"] == ["reasoning_effort"] + # `if` must require `enable_reasoning` so the conditional does not fire + # vacuously when the property is omitted from the submitted instance + # (JSON Schema treats a `properties`-only `if` as valid in that case). + for branch in schema["allOf"]: + assert branch["if"].get("required") == ["enable_reasoning"], ( + "if/then branches must anchor on the property being present" + ) def test_openai_compatible_validate_auto_detects_reasoning_for_known_families() -> None: @@ -202,11 +209,13 @@ def test_reasoning_omits_max_completion_tokens_when_unset() -> None: def test_openai_compatible_validate_infers_reasoning_from_effort_field() -> None: # When the adapter is re-validated (e.g. on second call), `enable_reasoning` # may already have been consumed but `reasoning_effort` should still trigger - # the reasoning code path so the model keeps working. + # the reasoning code path so the model keeps working. Use a model name that + # is NOT auto-detected as a reasoning family — otherwise the test passes via + # `_is_openai_reasoning_model` without ever exercising the inference branch. validated = OpenAICompatibleLLMParameters.validate( { - "api_base": "https://api.openai.com/v1", - "model": "gpt-5", + "api_base": "https://gateway.example.com/v1", + "model": "custom-gateway-model", "max_tokens": 2048, "reasoning_effort": "low", } From 4bcc1a9f4929a2fb3601318980982ea32f8bd1a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:36:07 +0000 Subject: [PATCH 05/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unstract/sdk1/tests/test_openai_compatible_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index 6e58aac953..af81005560 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -107,9 +107,9 @@ def test_openai_compatible_schema_exposes_reasoning_toggle() -> None: # vacuously when the property is omitted from the submitted instance # (JSON Schema treats a `properties`-only `if` as valid in that case). for branch in schema["allOf"]: - assert branch["if"].get("required") == ["enable_reasoning"], ( - "if/then branches must anchor on the property being present" - ) + assert branch["if"].get("required") == [ + "enable_reasoning" + ], "if/then branches must anchor on the property being present" def test_openai_compatible_validate_auto_detects_reasoning_for_known_families() -> None: From 3c36468521991e001728df8676f4136655a536bc Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 14:15:46 +0530 Subject: [PATCH 06/10] [FIX] Address SonarCloud findings on UN-3478 - base1.py: hoist `openai/` and `custom_openai/` LiteLLM provider prefixes into `_OPENAI_PROVIDER_PREFIX` / `_CUSTOM_OPENAI_PROVIDER_PREFIX` module constants and use them in `_is_openai_reasoning_model`, `OpenAILLMParameters.validate_model`, and `OpenAICompatibleLLMParameters.validate_model`. Clears Sonar python:S1192 (the new helper pushed each literal past the duplication threshold). - test_openai_compatible_adapter.py: replace bare `== 0.1` float-equality assertions on the Pydantic-default `temperature` with a `pytest.approx(0.1)` constant. Clears Sonar python:S1244. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 21 ++++++++++++------- .../tests/test_openai_compatible_adapter.py | 11 ++++++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index f38c0f919a..4f3fa8e768 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -340,12 +340,17 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: def validate_model(adapter_metadata: dict[str, "Any"]) -> str: model = adapter_metadata.get("model", "") # Only add openai/ prefix if the model doesn't already have it - if model.startswith("openai/"): + if model.startswith(_OPENAI_PROVIDER_PREFIX): return model else: - return f"openai/{model}" + return f"{_OPENAI_PROVIDER_PREFIX}{model}" +# LiteLLM provider prefixes hoisted to module constants so the same literal is +# not duplicated across `OpenAILLMParameters`, `OpenAICompatibleLLMParameters`, +# and the reasoning-model detector below. +_OPENAI_PROVIDER_PREFIX = "openai/" +_CUSTOM_OPENAI_PROVIDER_PREFIX = "custom_openai/" _OPENAI_REASONING_MODEL_PATTERN = re.compile(r"^(o1|o3|o4|gpt-5)(?:[-/]|$)") @@ -360,10 +365,10 @@ def _is_openai_reasoning_model(model: str) -> bool: """ if not model: return False - if model.startswith("custom_openai/"): - model = model[len("custom_openai/") :] - if model.startswith("openai/"): - model = model[len("openai/") :] + if model.startswith(_CUSTOM_OPENAI_PROVIDER_PREFIX): + model = model[len(_CUSTOM_OPENAI_PROVIDER_PREFIX) :] + if model.startswith(_OPENAI_PROVIDER_PREFIX): + model = model[len(_OPENAI_PROVIDER_PREFIX) :] return bool(_OPENAI_REASONING_MODEL_PATTERN.match(model.lower())) @@ -434,9 +439,9 @@ def validate_model(adapter_metadata: dict[str, "Any"]) -> str: model = str(adapter_metadata.get("model", "")).strip() if not model: raise ValueError("model is required for the OpenAI Compatible adapter.") - if model.startswith("custom_openai/"): + if model.startswith(_CUSTOM_OPENAI_PROVIDER_PREFIX): return model - return f"custom_openai/{model}" + return f"{_CUSTOM_OPENAI_PROVIDER_PREFIX}{model}" class AzureOpenAILLMParameters(BaseChatCompletionParameters): diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index af81005560..ef9a11b7b6 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -3,11 +3,18 @@ from importlib import import_module from unittest.mock import MagicMock, patch +import pytest + from unstract.sdk1.adapters.base1 import OpenAICompatibleLLMParameters from unstract.sdk1.adapters.constants import Common from unstract.sdk1.adapters.llm1 import adapters from unstract.sdk1.adapters.llm1.openai_compatible import OpenAICompatibleLLMAdapter +# Pydantic default for `temperature` on `BaseChatCompletionParameters`. Wrapped +# in `pytest.approx` so the non-reasoning-path assertions read the value safely +# (float `==` triggers Sonar S1244 even when both sides are the same literal). +_DEFAULT_TEMPERATURE = pytest.approx(0.1) + OPENAI_COMPATIBLE_DESCRIPTION = ( "Adapter for servers that implement the OpenAI Chat Completions API " "(vLLM, LM Studio, self-hosted gateways, and third-party providers). " @@ -159,7 +166,7 @@ def test_openai_compatible_validate_preserves_non_reasoning_models() -> None: } ) - assert validated["temperature"] == 0.1, f"{model} should keep temperature" + assert validated["temperature"] == _DEFAULT_TEMPERATURE, f"{model} should keep temperature" assert validated["max_tokens"] == 1024, f"{model} should keep max_tokens" assert "extra_body" not in validated, f"{model} should not set extra_body" @@ -241,7 +248,7 @@ def test_openai_compatible_validate_no_reasoning_unchanged() -> None: # Non-reasoning path must preserve temperature/max_tokens for ordinary # OpenAI-compatible gateways (vLLM, LM Studio, etc.). - assert validated["temperature"] == 0.1 + assert validated["temperature"] == _DEFAULT_TEMPERATURE assert validated["max_tokens"] == 1024 assert "extra_body" not in validated assert "reasoning_effort" not in validated From 4cf0e470f84ee83901a7f1aab0fc4f7cf8468e3c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:47:04 +0000 Subject: [PATCH 07/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unstract/sdk1/tests/test_openai_compatible_adapter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index ef9a11b7b6..9ea0e69666 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import pytest - from unstract.sdk1.adapters.base1 import OpenAICompatibleLLMParameters from unstract.sdk1.adapters.constants import Common from unstract.sdk1.adapters.llm1 import adapters @@ -166,7 +165,9 @@ def test_openai_compatible_validate_preserves_non_reasoning_models() -> None: } ) - assert validated["temperature"] == _DEFAULT_TEMPERATURE, f"{model} should keep temperature" + assert ( + validated["temperature"] == _DEFAULT_TEMPERATURE + ), f"{model} should keep temperature" assert validated["max_tokens"] == 1024, f"{model} should keep max_tokens" assert "extra_body" not in validated, f"{model} should not set extra_body" From fbd7177962000b35db0a3a39d1123a08714485c5 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 14:25:36 +0530 Subject: [PATCH 08/10] [FIX] Respect explicit enable_reasoning=false on UN-3478 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: the inference branch ("if reasoning_effort is present, infer reasoning enabled") fired even when the user explicitly submitted `enable_reasoning: false`, silently overriding the opt-out — for example, when editing an adapter that previously had reasoning on and still has a leftover `reasoning_effort` in its stored metadata. Guard the inference branch with `"enable_reasoning" not in adapter_metadata` so it only fires on a re-validation pass where the field has been consumed/stripped, never when the user deliberately opted out. Auto-detection is intentionally NOT gated by this guard: the schema default for `enable_reasoning` is `false`, so a fresh form submission for `gpt-5` arrives as `enable_reasoning: false` and the adapter must still rescue the call. Adds a regression test using a non-auto-detected model name to isolate the inference branch from the auto-detect branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 11 +++++++++- .../tests/test_openai_compatible_adapter.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index 4f3fa8e768..f1969c729e 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -402,7 +402,16 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: "reasoning_effort" in adapter_metadata and adapter_metadata.get("reasoning_effort") is not None ) - if not enable_reasoning and has_reasoning_effort: + # Infer reasoning only when `enable_reasoning` is ABSENT (e.g. on a + # re-validation pass that already stripped the field). Skip the inference + # if the user explicitly submitted `enable_reasoning: false` with a + # leftover `reasoning_effort` — that's an explicit opt-out, not an + # implicit opt-in. + if ( + not enable_reasoning + and has_reasoning_effort + and "enable_reasoning" not in adapter_metadata + ): enable_reasoning = True if not enable_reasoning and _is_openai_reasoning_model(adapter_metadata["model"]): enable_reasoning = True diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index 9ea0e69666..5e1fc3cacf 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest + from unstract.sdk1.adapters.base1 import OpenAICompatibleLLMParameters from unstract.sdk1.adapters.constants import Common from unstract.sdk1.adapters.llm1 import adapters @@ -237,6 +238,27 @@ def test_openai_compatible_validate_infers_reasoning_from_effort_field() -> None } +def test_explicit_disable_overrides_leftover_reasoning_effort() -> None: + # Regression: when a user explicitly submits `enable_reasoning: false` + # while a leftover `reasoning_effort` is still in the stored metadata + # (e.g. the form was previously saved with reasoning on), the inference + # branch must NOT silently re-enable reasoning. Use a non-auto-detected + # model so we isolate the inference branch from the auto-detect branch. + validated = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://gateway.example.com/v1", + "model": "custom-gateway-model", + "max_tokens": 1024, + "enable_reasoning": False, + "reasoning_effort": "high", + } + ) + + assert "extra_body" not in validated + assert validated["max_tokens"] == 1024 + assert "temperature" in validated + + def test_openai_compatible_validate_no_reasoning_unchanged() -> None: validated = OpenAICompatibleLLMParameters.validate( { From 17a021c3b9c168642f32a17afae6ff4dc1cc396b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:57:52 +0000 Subject: [PATCH 09/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unstract/sdk1/tests/test_openai_compatible_adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index 5e1fc3cacf..d6e435dbad 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import pytest - from unstract.sdk1.adapters.base1 import OpenAICompatibleLLMParameters from unstract.sdk1.adapters.constants import Common from unstract.sdk1.adapters.llm1 import adapters From 6c2a574b10d27426167514f5a83ac9abf05008bf Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Fri, 22 May 2026 14:44:58 +0530 Subject: [PATCH 10/10] [FIX] Preserve reasoning state across re-validation on UN-3478 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (MAJOR): the previous fix moved `reasoning_effort` and `max_completion_tokens` into `extra_body` and stripped both `enable_reasoning` and `reasoning_effort` from the top level. Feeding that output back into `validate()` for a non-auto-detected alias lost the reasoning state entirely — the second pass emitted `temperature` and `max_tokens` and the upstream API rejected the request. Recover the original reasoning fields from `extra_body` on the re- validation pass: - Treat `extra_body.reasoning_effort` as equivalent to a top-level `reasoning_effort` when inferring `enable_reasoning`. - Fall back to `extra_body.reasoning_effort` and `extra_body.max_completion_tokens` when the top-level values are absent. Adds `test_reasoning_state_survives_revalidation_for_custom_alias` that feeds `validate()` output back into `validate()` for a custom alias and asserts the reasoning payload is identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../sdk1/src/unstract/sdk1/adapters/base1.py | 28 +++++++++++++++++-- .../tests/test_openai_compatible_adapter.py | 27 ++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index f1969c729e..c78fe5c43d 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -402,6 +402,15 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: "reasoning_effort" in adapter_metadata and adapter_metadata.get("reasoning_effort") is not None ) + # When validate() runs again on its own previous output, the reasoning + # fields live ONLY inside `extra_body` (this method strips them from + # the top level on the reasoning path). Recover them from there so + # re-validation is idempotent for non-auto-detected aliases too. + existing_extra_body = adapter_metadata.get("extra_body") + has_reasoning_extra_body = ( + isinstance(existing_extra_body, dict) + and existing_extra_body.get("reasoning_effort") is not None + ) # Infer reasoning only when `enable_reasoning` is ABSENT (e.g. on a # re-validation pass that already stripped the field). Skip the inference # if the user explicitly submitted `enable_reasoning: false` with a @@ -409,14 +418,22 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: # implicit opt-in. if ( not enable_reasoning - and has_reasoning_effort and "enable_reasoning" not in adapter_metadata + and (has_reasoning_effort or has_reasoning_extra_body) ): enable_reasoning = True if not enable_reasoning and _is_openai_reasoning_model(adapter_metadata["model"]): enable_reasoning = True - reasoning_effort = adapter_metadata.get("reasoning_effort") or "medium" + reasoning_effort = ( + adapter_metadata.get("reasoning_effort") + or ( + existing_extra_body.get("reasoning_effort") + if isinstance(existing_extra_body, dict) + else None + ) + or "medium" + ) exclude_fields = {"enable_reasoning"} if not enable_reasoning: @@ -429,8 +446,13 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: if enable_reasoning: # Read max_tokens from the validated dict so Pydantic's `int | None` # coercion (e.g. "4096" -> 4096) has already been applied before the - # value is forwarded to the upstream API via extra_body. + # value is forwarded to the upstream API via extra_body. On the + # re-validation path the original max_tokens is in extra_body, not + # at the top level — fall back to that to keep the value across + # passes. max_tokens = validated.get("max_tokens") + if max_tokens is None and isinstance(existing_extra_body, dict): + max_tokens = existing_extra_body.get("max_completion_tokens") extra_body = {"reasoning_effort": reasoning_effort} if max_tokens is not None: extra_body["max_completion_tokens"] = max_tokens diff --git a/unstract/sdk1/tests/test_openai_compatible_adapter.py b/unstract/sdk1/tests/test_openai_compatible_adapter.py index d6e435dbad..fb7da31658 100644 --- a/unstract/sdk1/tests/test_openai_compatible_adapter.py +++ b/unstract/sdk1/tests/test_openai_compatible_adapter.py @@ -237,6 +237,33 @@ def test_openai_compatible_validate_infers_reasoning_from_effort_field() -> None } +def test_reasoning_state_survives_revalidation_for_custom_alias() -> None: + # Regression: validate() strips `enable_reasoning` / `reasoning_effort` + # from the top level on the reasoning path and routes them into + # `extra_body`. Feeding that output back into validate() for a + # non-auto-detected alias (where `_is_openai_reasoning_model` cannot + # rescue) must still preserve the reasoning state — otherwise the + # second pass would emit `temperature` / `max_tokens` and the upstream + # API would reject the request. + first = OpenAICompatibleLLMParameters.validate( + { + "api_base": "https://gateway.example.com/v1", + "model": "my-gateway-alias", + "max_tokens": 4096, + "enable_reasoning": True, + "reasoning_effort": "high", + } + ) + second = OpenAICompatibleLLMParameters.validate(dict(first)) + + assert "temperature" not in second + assert "max_tokens" not in second + assert second["extra_body"] == { + "reasoning_effort": "high", + "max_completion_tokens": 4096, + } + + def test_explicit_disable_overrides_leftover_reasoning_effort() -> None: # Regression: when a user explicitly submits `enable_reasoning: false` # while a leftover `reasoning_effort` is still in the stored metadata