Translate json_schema and drop non-OpenAI fields in OpenRouter adapter
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:15:24 +02:00
parent 435da49263
commit cd4551c575

View File

@@ -69,9 +69,8 @@ class OpenRouterAdapter(LLMAdapter):
"temperature": config.temperature, "temperature": config.temperature,
"max_tokens": config.max_tokens, "max_tokens": config.max_tokens,
} }
# Merge extra model_params from RunConfig
if config.model_params: if config.model_params:
payload.update(config.model_params) _merge_model_params(payload, config.model_params)
headers = { headers = {
"Authorization": f"Bearer {self._api_key}", "Authorization": f"Bearer {self._api_key}",
@@ -140,3 +139,60 @@ class OpenRouterAdapter(LLMAdapter):
else: else:
raise raise
raise last_exc # type: ignore[misc] 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.