generated from coulomb/repo-seed
Translate json_schema and drop non-OpenAI fields in OpenRouter adapter
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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user