From 24c8b21d02c3c16b3943b2b0733e12495cdd223f Mon Sep 17 00:00:00 2001
From: pk-zipstack <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 .../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 <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 .../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 <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 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 <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 .../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 <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 .../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 <praveen@zipstack.com>
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) <noreply@anthropic.com>
---
 .../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
