From cd4551c575e56d07c73335b744d226112b7c529e Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 2 Jun 2026 14:15:24 +0200 Subject: [PATCH] Translate json_schema and drop non-OpenAI fields in OpenRouter adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter previously did a blind payload.update(config.model_params). For callers like activity-core that pass reasoning_effort, max_depth, and json_schema (Claude / llm-connect-specific fields), those leaked into the OpenAI Chat Completions request body and OpenRouter rejected the whole call with HTTP 400. CUST-WP-0045 canary on 2026-06-02 hit this — manual repro confirmed: same prompt with no model_params returns a clean 10-recommendation WSJF report in 4.5s; with model_params included, every call 400s. Replace the merge with a whitelist + translation step: - pass-through known OpenAI Chat Completions fields (top_p, stop, seed, tools, response_format, etc.) - translate json_schema into the proper response_format wrapper ({type:"json_schema", json_schema:{name,schema,strict}}) - drop documented non-OpenAI fields (reasoning_effort, max_depth) so the payload stays valid - silently drop unknown keys rather than risk another 400 The same pattern will need to apply to the OpenAI and Gemini adapters when their callers start passing provider-specific keys — left as follow-up rather than speculative refactoring. Co-Authored-By: Claude Opus 4.7 --- llm_connect/openrouter.py | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/llm_connect/openrouter.py b/llm_connect/openrouter.py index 4f9c69b..46d9aef 100644 --- a/llm_connect/openrouter.py +++ b/llm_connect/openrouter.py @@ -69,9 +69,8 @@ class OpenRouterAdapter(LLMAdapter): "temperature": config.temperature, "max_tokens": config.max_tokens, } - # Merge extra model_params from RunConfig if config.model_params: - payload.update(config.model_params) + _merge_model_params(payload, config.model_params) headers = { "Authorization": f"Bearer {self._api_key}", @@ -140,3 +139,60 @@ class OpenRouterAdapter(LLMAdapter): else: raise raise last_exc # type: ignore[misc] + + +# OpenAI Chat Completions fields that map straight through from model_params. +# Anything not in this set is provider-specific and must be either translated +# or dropped — we never blind-merge into the payload, because OpenRouter +# rejects unknown top-level fields with HTTP 400. +_OPENAI_PASSTHROUGH_FIELDS = frozenset({ + "top_p", "n", "stream", "stop", "presence_penalty", + "frequency_penalty", "logit_bias", "user", "seed", + "tools", "tool_choice", "response_format", + "logprobs", "top_logprobs", "parallel_tool_calls", +}) + +# Provider-specific model_params keys that have no OpenAI Chat Completions +# equivalent and must be silently dropped to keep payloads valid. +_DROPPED_NON_OPENAI_FIELDS = frozenset({ + "reasoning_effort", # Claude CLI / Anthropic-specific + "max_depth", # llm-connect's own depth knob + "claude_cli_path", # adapter wiring leak + "json_schema", # translated below into response_format +}) + + +def _merge_model_params(payload: Dict[str, Any], model_params: Dict[str, Any]) -> None: + """Merge RunConfig.model_params into an OpenAI Chat Completions payload. + + Pass-through whitelisted OpenAI keys, translate json_schema into the + proper response_format wrapper, drop known provider-specific fields, + and ignore anything else rather than letting it through and triggering + a 400 from OpenRouter (the failure mode that hit CUST-WP-0045 on + 2026-06-02 — reasoning_effort and a top-level json_schema were merged + into the body and the API rejected both). + """ + schema = model_params.get("json_schema") + if schema is not None and "response_format" not in payload: + if isinstance(schema, str): + try: + import json as _json + schema = _json.loads(schema) + except (ValueError, TypeError): + schema = None + if isinstance(schema, dict): + payload["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "structured_output", + "schema": schema, + "strict": True, + }, + } + + for key, value in model_params.items(): + if key in _DROPPED_NON_OPENAI_FIELDS: + continue + if key in _OPENAI_PASSTHROUGH_FIELDS: + payload[key] = value + # else: silently drop unknown keys rather than risk a 400.