From 77f39e7c0799e30663a8f2705f7732a94bf15424 Mon Sep 17 00:00:00 2001 From: jmlago Date: Sun, 20 Sep 2026 20:51:55 +0200 Subject: [PATCH 1/2] Forward explicit reasoning controls to compatible generators --- README.md | 10 +++ core | 2 +- llm_router_host.py | 2 +- provider_adapters/openai_compatible.py | 3 +- shim.py | 12 ++- tests/test_reasoning_controls.py | 111 +++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/test_reasoning_controls.py diff --git a/README.md b/README.md index 553a00b..b126dfa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ See [decision routing, provider requirements and examples](docs/DECISION-MODELS. The same decision policies can [classify context fragments for selective compaction](docs/FRAGMENT-COMPACTION.md), with generative summaries only where needed. +Generation requests on `/v1/chat/completions` and `/v1/responses` can pass +`reasoning` (an object) or `reasoning_effort` (a string) through to +OpenAI-compatible providers. For example, `"reasoning":{"effort":"low"}` +selects a lower effort when the chosen model supports it. The provider validates +supported values; omitting the fields preserves its defaults. A policy can set +`["set_param","reasoning_effort","low"]` for its generation calls, including +compaction. Generic flows carry request controls to their generation nodes; +native decision nodes receive their own independent contracts. These controls +do not translate to the native Bedrock, Anthropic or Google APIs. + Concretely it's an async FastAPI shim that runs the [`unhardcoded-engine`](https://github.com/genlayerlabs/unhardcoded-engine) core and inherits its provider selection, fallback, retry and per-provider auth. The core diff --git a/core b/core index e3c5f53..0055f63 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e3c5f53849d108192d0c32e4365d685916d2008f +Subproject commit 0055f639b0254ea846e5ad419a59eca2de5e7484 diff --git a/llm_router_host.py b/llm_router_host.py index 606778c..84048d1 100644 --- a/llm_router_host.py +++ b/llm_router_host.py @@ -385,7 +385,7 @@ async def execute_flow_async(self, flow_ir, base_contract, input_text = _last_user_text(base_contract.get("messages") or []) carry = {k: base_contract[k] for k in ("max_tokens", "tools", "tool_choice", "response_format", - "temperature", "seed", "session", "cache_hot_route") + "temperature", "seed", "reasoning", "reasoning_effort", "session", "cache_hot_route") if k in base_contract} async def run_node(nid, node, prompt): diff --git a/provider_adapters/openai_compatible.py b/provider_adapters/openai_compatible.py index 96ccf01..641b6a1 100644 --- a/provider_adapters/openai_compatible.py +++ b/provider_adapters/openai_compatible.py @@ -120,7 +120,8 @@ def _prepare_openai_call( "model": offer.get("wire_model_id") or request["served_model_id"], "messages": request.get("messages") or [], } - for field in ("tools", "response_format", "temperature", "seed", "max_tokens"): + for field in ("tools", "response_format", "temperature", "seed", "max_tokens", + "reasoning", "reasoning_effort"): v = request.get(field) if v is not None: body[field] = v diff --git a/shim.py b/shim.py index cef7a04..be75536 100644 --- a/shim.py +++ b/shim.py @@ -82,6 +82,8 @@ class ChatRequest(BaseModel): tools: list[dict] | None = None tool_choice: Any = None response_format: dict | None = None + reasoning: dict | None = None + reasoning_effort: str | None = None temperature: float | None = None seed: int | None = None max_tokens: int | None = None @@ -130,7 +132,7 @@ class DecisionsRequest(BaseModel): class ResponsesRequest(BaseModel): """Permissive OpenAI /v1/responses body. Unknown fields are kept - (extra="allow") so Responses params the shim does not read (reasoning, + (extra="allow") so Responses params the shim does not read ( include, store, parallel_tool_calls, prompt_cache_key, text, previous_response_id, …) never break the request.""" model_config = ConfigDict(extra="allow") @@ -142,6 +144,8 @@ class ResponsesRequest(BaseModel): tool_choice: Any = None stream: bool = False max_output_tokens: int | None = None + reasoning: dict | None = None + reasoning_effort: str | None = None temperature: float | None = None first_token_timeout_ms: int | None = None timeout_ms: int | None = None @@ -1304,6 +1308,8 @@ async def _handle_responses(req: ResponsesRequest, profile_name: str | None = No tools=_rapi.tools_to_chat(req.tools), tool_choice=_rapi.tool_choice_to_chat(req.tool_choice), temperature=req.temperature, + reasoning=req.reasoning, + reasoning_effort=req.reasoning_effort, max_tokens=req.max_output_tokens, first_token_timeout_ms=req.first_token_timeout_ms, timeout_ms=req.timeout_ms, @@ -1635,6 +1641,10 @@ def _request_to_contract( contract["tool_choice"] = req.tool_choice if req.response_format is not None: contract["response_format"] = req.response_format + if req.reasoning is not None: + contract["reasoning"] = req.reasoning + if req.reasoning_effort is not None: + contract["reasoning_effort"] = req.reasoning_effort if req.temperature is not None: contract["temperature"] = req.temperature if req.seed is not None: diff --git a/tests/test_reasoning_controls.py b/tests/test_reasoning_controls.py new file mode 100644 index 0000000..d56465e --- /dev/null +++ b/tests/test_reasoning_controls.py @@ -0,0 +1,111 @@ +"""Explicit generation controls must reach the OpenAI-compatible wire unchanged.""" +import copy +from pathlib import Path + +import httpx +import pytest +from fastapi.testclient import TestClient + +from provider_adapters.openai_compatible import make_async_call_provider, stream_openai_compatible +from shim import ChatRequest, _request_to_contract, create_app +from llm_router_host import LLMRouterHost +from tests.test_compact import _PIN +from tests.test_streaming import OPENAI_REQ, FakeStreamClient, FakeStreamResponse, _openai_lines + + +CONTROLS = [{"reasoning": {"enabled": False}}, {"reasoning": {"effort": "low"}}, + {"reasoning_effort": "low"}, {}] + + +@pytest.fixture +def host(): + root = Path(__file__).resolve().parents[1] + host = LLMRouterHost(router_path=root/'core/router.lua', + config_path=root/'core/config.example.lua', metrics_path=root/'core/metrics.example.lua', + env={"COMPUT3_API_KEY": "fixture-credential"}) + host.init() + return host + + +@pytest.mark.parametrize("controls", CONTROLS) +def test_chat_contract_preserves_explicit_controls(controls): + contract = _request_to_contract(ChatRequest(**controls), "default") + assert {k: contract[k] for k in ("reasoning", "reasoning_effort") if k in contract} == controls + + +@pytest.mark.parametrize("controls", CONTROLS) +@pytest.mark.asyncio +async def test_buffered_adapter_sends_controls(controls): + seen = [] + async def respond(request): + import json + seen.append(json.loads(request.content)) + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + result = await make_async_call_provider(client=client)({**OPENAI_REQ, **controls}) + assert result["ok"] + assert {k: seen[0][k] for k in ("reasoning", "reasoning_effort") if k in seen[0]} == controls + + +@pytest.mark.parametrize("controls", CONTROLS) +@pytest.mark.asyncio +async def test_streaming_adapter_sends_controls(controls): + client = FakeStreamClient(FakeStreamResponse(200, _openai_lines("ok"))) + await stream_openai_compatible({**OPENAI_REQ, **controls}, lambda _: None, client=client) + body = client.requests[0]["json"] + assert {k: body[k] for k in ("reasoning", "reasoning_effort") if k in body} == controls + + +@pytest.mark.parametrize("endpoint", ["/v1/chat/completions", "/v1/responses"]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("controls", CONTROLS[:3]) +def test_http_to_core_provider_preserves_controls(host, endpoint, stream, controls): + seen = [] + async def call(req): + seen.append(copy.deepcopy(req)) + return {"ok": True, "response": {"text": "ok", "tokens_in": 1, "tokens_out": 1}} + host.set_async_call_hook(call) + payload = {"policy_ir": _PIN, "stream": stream, **controls} + payload.update({"input": "hello"} if endpoint.endswith("responses") else + {"messages": [{"role": "user", "content": "hello"}]}) + response = TestClient(create_app(host)).post(endpoint, json=payload) + assert response.status_code == 200, response.text + assert len(seen) == 1 + assert {k: seen[0][k] for k in ("reasoning", "reasoning_effort") if k in seen[0]} == controls + + +def test_policy_can_set_reasoning_effort_before_provider_call(host): + seen = [] + async def call(req): + seen.append(req) + return {"ok": True, "response": {"text": "ok"}} + host.set_async_call_hook(call) + policy = copy.deepcopy(_PIN) + policy[4] = ["set_param", "reasoning_effort", "low"] + response = TestClient(create_app(host)).post( + "/v1/chat/completions", json={"messages": [], "policy_ir": policy}) + assert response.status_code == 200, response.text + assert seen[0]["reasoning_effort"] == "low" + + +@pytest.mark.parametrize("controls", CONTROLS[:3]) +def test_flow_controls_reach_generation_but_not_native_decisions(host, monkeypatch, controls): + from tests.test_flow_data import triage + seen = [] + async def execute(contract, **kwargs): + seen.append(copy.deepcopy(contract)) + if contract.get("protocol") == "decisions": + response = {"decision": {"model": "fixture", "answers": { + key: {"type": "choice", "choice": "support", + "probabilities": {"support": 1.0, "sales": 0.0}} for key in ("a", "b")}}} + else: + response = {"text": '{"a":"Reply A","b":"Reply B"}', "finish_reason": "stop"} + return {"ok": True, "response": response} + monkeypatch.setattr(host, "execute_async", execute) + response = TestClient(create_app(host)).post("/v1/chat/completions", json={ + "flow_ir": triage(), "flow_input": {"a": "Crash", "b": "Question"}, **controls}) + assert response.status_code == 200, response.text + assert len(seen) == 2 + assert seen[0]["protocol"] == "decisions" + assert not any(k in seen[0] for k in controls) + assert {k: seen[1][k] for k in controls} == controls From d29b7632d5e3ea67ec000e7a9d87bc7eaa7b55ef Mon Sep 17 00:00:00 2001 From: jmlago Date: Sun, 20 Sep 2026 21:46:23 +0200 Subject: [PATCH 2/2] Pin reasoning controls to merged engine commit --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 0055f63..d963280 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 0055f639b0254ea846e5ad419a59eca2de5e7484 +Subproject commit d963280211092e149051d1b200f85a40bc946563