[eric] proxy: fix non-Anthropic first-msg 400s (Gemini allowlist + gpt-5 sampling strip)

This commit is contained in:
ciregenz
2026-06-14 16:58:26 -07:00
parent 9086ef5af2
commit 124b128ed1
3 changed files with 175 additions and 49 deletions
+29 -14
View File
@@ -42,26 +42,41 @@ def _is_gpt5(model: str) -> bool:
return any(m.startswith(p) for p in _GPT5_PREFIXES)
def _scrub_max_tokens(body: bytes) -> bytes:
"""Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises."""
# GPT-5 reasoning models reject sampling knobs: temperature must be the default
# (only 1 is allowed), and top_p / penalties / logprobs are unsupported outright.
# 9Router 0.3.60 is pinned and forwards whatever the user's picked model carried,
# so we strip them at this last hop before OpenAI or the whole request 400s.
_GPT5_UNSUPPORTED_PARAMS = (
"top_p", "top_k", "frequency_penalty", "presence_penalty",
"logprobs", "top_logprobs", "logit_bias",
)
def _scrub_gpt5_params(body: bytes) -> bytes:
"""For GPT-5: rename max_tokens→max_completion_tokens and drop the sampling
params the reasoning models reject. Bytes in/out, never raises."""
if not body:
return body
try:
parsed = json.loads(body)
except Exception:
return body
if not isinstance(parsed, dict):
if not isinstance(parsed, dict) or not _is_gpt5(str(parsed.get("model") or "")):
return body
model = str(parsed.get("model") or "")
if not _is_gpt5(model):
return body
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
return json.dumps(parsed).encode("utf-8")
if "max_tokens" in parsed and "max_completion_tokens" in parsed:
parsed.pop("max_tokens", None)
return json.dumps(parsed).encode("utf-8")
return body
mutated = False
if "max_tokens" in parsed:
if "max_completion_tokens" not in parsed:
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
else:
parsed.pop("max_tokens", None)
mutated = True
if "temperature" in parsed and parsed["temperature"] != 1:
parsed.pop("temperature", None)
mutated = True
for k in _GPT5_UNSUPPORTED_PARAMS:
if parsed.pop(k, None) is not None:
mutated = True
return json.dumps(parsed).encode("utf-8") if mutated else body
@openai_passthrough.router.api_route(
@@ -70,7 +85,7 @@ def _scrub_max_tokens(body: bytes) -> bytes:
)
async def passthrough(rest: str, request: Request):
body = await request.body()
body = _scrub_max_tokens(body)
body = _scrub_gpt5_params(body)
forward_headers: dict[str, str] = {}
for k, v in request.headers.items():
+72 -35
View File
@@ -35,44 +35,72 @@ _GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires.
_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod.
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"additionalProperties",
"propertyNames",
"patternProperties",
"exclusiveMinimum",
"exclusiveMaximum",
"const",
"prefill",
"enumTitles",
"title",
"examples",
"default",
"readOnly",
"writeOnly",
"deprecated",
# Gemini's function_declarations validator accepts only a small OpenAPI subset.
# A denylist was whack-a-mole: every new JSON Schema construct that slipped
# through (union `type`, anyOf, $comment, format, ...) was a fresh prod 400 with
# zero tokens in. We invert it: keep ONLY the keys Gemini is known to accept, and
# fold the two "optional" encodings Anthropic emits (a union `type` list, and an
# anyOf whose other branch is `{"type":"null"}`) into the `nullable` flag Gemini
# actually understands. Everything dropped is advisory; the model still reads it
# from `description`. The win is structural: an unknown future key can't 400 us.
_GEMINI_ALLOWED_SCHEMA_KEYS = {
"type", "description", "nullable", "enum", "items", "properties",
"required", "minimum", "maximum", "minItems", "maxItems",
}
_GEMINI_NULL_TYPES = {"null", None}
def _scrub_gemini_schema(node):
"""Recursive in-place strip of Gemini-rejected JSON Schema fields."""
if isinstance(node, dict):
for k in list(node.keys()):
if k in _GEMINI_FORBIDDEN_SCHEMA_KEYS:
node.pop(k, None)
continue
node[k] = _scrub_gemini_schema(node[k])
return node
def _normalize_schema_for_gemini(node):
"""Allowlist-rewrite a JSON Schema node into the subset Gemini accepts.
Returns a NEW node (callers must assign the result); folds union/anyOf
nullability into `nullable`. Never raises on odd input."""
if isinstance(node, list):
for i, v in enumerate(node):
node[i] = _scrub_gemini_schema(v)
return [_normalize_schema_for_gemini(v) for v in node]
if not isinstance(node, dict):
return node
return node
nullable = bool(node.get("nullable"))
# Gemini can't represent unions; collapse anyOf/oneOf/allOf to one branch.
# A bare {"type": "null"} member just means the field is nullable.
for combiner in ("anyOf", "oneOf", "allOf"):
branches = node.get(combiner)
if isinstance(branches, list) and branches:
picked = None
for b in branches:
if isinstance(b, dict) and b.get("type") in _GEMINI_NULL_TYPES and len(b) == 1:
nullable = True
elif picked is None:
picked = b
base = _normalize_schema_for_gemini(picked) if isinstance(picked, dict) else {}
if nullable and isinstance(base, dict):
base["nullable"] = True
return base
out = {}
t = node.get("type")
if isinstance(t, list): # ["string", "null"] -> "string" + nullable
non_null = [x for x in t if x not in _GEMINI_NULL_TYPES]
if len(non_null) != len(t):
nullable = True
t = non_null[0] if non_null else None
if t is not None:
out["type"] = t
for k, v in node.items():
if k in ("type", "nullable") or k not in _GEMINI_ALLOWED_SCHEMA_KEYS:
continue
if k == "properties" and isinstance(v, dict):
out[k] = {pk: _normalize_schema_for_gemini(pv) for pk, pv in v.items()}
elif k == "items":
out[k] = _normalize_schema_for_gemini(v)
else:
out[k] = v
if nullable:
out["nullable"] = True
return out
# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out.
@@ -151,6 +179,15 @@ def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
elif "max_tokens" in parsed and "max_completion_tokens" in parsed:
parsed.pop("max_tokens", None)
mutated = True
# GPT-5 reasoning models reject sampling knobs (temperature must be 1, top_p
# and penalties unsupported); the wire carries them for the user's picked model.
if "temperature" in parsed and parsed["temperature"] != 1:
parsed.pop("temperature", None)
mutated = True
for _k in ("top_p", "top_k", "frequency_penalty", "presence_penalty",
"logprobs", "top_logprobs", "logit_bias"):
if parsed.pop(_k, None) is not None:
mutated = True
try:
before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else ""
_rewrite_document_to_openai_file(parsed)
@@ -272,9 +309,9 @@ def _scrub_request_for_gemini(body: bytes) -> bytes:
if not isinstance(t, dict):
continue
if isinstance(t.get("input_schema"), (dict, list)):
_scrub_gemini_schema(t["input_schema"])
t["input_schema"] = _normalize_schema_for_gemini(t["input_schema"])
if isinstance(t.get("parameters"), (dict, list)):
_scrub_gemini_schema(t["parameters"])
t["parameters"] = _normalize_schema_for_gemini(t["parameters"])
try:
if isinstance(parsed, dict):
_rewrite_document_to_image(parsed)
+74
View File
@@ -1482,6 +1482,80 @@ def test_gemini_translated_block_matches_9router_image_url_filter():
assert block["image_url"]["url"].startswith("data:application/pdf;base64,")
def test_gemini_schema_normalizer_allowlists_and_folds_nullable():
"""Gemini's function_declarations validator 400s (zero tokens in) on JSON
Schema constructs the old denylist kept missing: union `type`, anyOf/oneOf/
allOf, $comment, format, additionalProperties, title. The normalizer keeps
only the keys Gemini accepts and folds the two nullable encodings Anthropic
emits (union type, anyOf-with-null) into the `nullable` flag Gemini groks.
Live-confirmed against the Gemini API 2026-06-14."""
import json
from backend.apps.agents.proxy.anthropic_proxy import (
_normalize_schema_for_gemini, _scrub_request_for_gemini,
)
# union type -> single type + nullable
assert _normalize_schema_for_gemini({"type": ["string", "null"], "description": "d"}) == \
{"type": "string", "description": "d", "nullable": True}
# anyOf-with-null -> chosen branch + nullable, allowed constraint preserved
assert _normalize_schema_for_gemini({"anyOf": [{"type": "integer", "minimum": 0}, {"type": "null"}]}) == \
{"type": "integer", "minimum": 0, "nullable": True}
# forbidden keys dropped, enum kept
assert _normalize_schema_for_gemini({
"type": "object", "additionalProperties": False, "title": "T",
"properties": {"u": {"type": "string", "format": "uri", "$comment": "x", "minLength": 2},
"d": {"type": "string", "enum": ["a", "b"]}},
"required": ["u"],
}) == {"type": "object",
"properties": {"u": {"type": "string"}, "d": {"type": "string", "enum": ["a", "b"]}},
"required": ["u"]}
# End to end: no Gemini-rejected key survives a realistic tool payload.
FORBIDDEN = {"$schema", "$ref", "additionalProperties", "title", "default", "$comment",
"format", "pattern", "minLength", "maxLength", "anyOf", "oneOf", "allOf", "const"}
body = json.dumps({"model": "gemini-3.1-pro-preview", "tools": [{
"name": "q", "input_schema": {
"type": "object", "additionalProperties": False, "$schema": "x",
"properties": {
"filter": {"anyOf": [{"type": "object", "properties": {"q": {"type": "string"}}},
{"type": "null"}]},
"size": {"type": ["integer", "null"], "minimum": 1, "default": 10},
"url": {"type": "string", "format": "uri", "$comment": "c"}},
"required": ["filter"]}}]}).encode()
schema = json.loads(_scrub_request_for_gemini(body))["tools"][0]["input_schema"]
seen, stack = set(), [schema]
while stack:
n = stack.pop()
if isinstance(n, dict):
seen |= set(n.keys()); stack += list(n.values())
elif isinstance(n, list):
stack += n
assert seen.isdisjoint(FORBIDDEN), f"forbidden keys survived: {seen & FORBIDDEN}"
def test_gpt5_param_scrub_drops_unsupported_sampling_knobs():
"""GPT-5 reasoning models 400 on max_tokens, temperature!=1, top_p, and the
penalty/logprobs family. Both the proxy and the passthrough must strip them.
Live-confirmed the 400s against the OpenAI API 2026-06-14."""
import json
from backend.apps.agents.proxy.anthropic_proxy import _scrub_request_for_openai_gpt5
from backend.apps.agents.core.openai_passthrough import _scrub_gpt5_params
dirty = json.dumps({"model": "gpt-5", "messages": [{"role": "user", "content": "hi"}],
"max_tokens": 200, "temperature": 0, "top_p": 0.9,
"frequency_penalty": 0.5, "presence_penalty": 0.1, "logprobs": True}).encode()
for fn in (_scrub_request_for_openai_gpt5, _scrub_gpt5_params):
out = json.loads(fn(dirty))
assert out.get("max_completion_tokens") == 200 and "max_tokens" not in out, fn.__name__
for k in ("temperature", "top_p", "frequency_penalty", "presence_penalty", "logprobs"):
assert k not in out, f"{fn.__name__} left {k}"
# temperature==1 is the one allowed value; don't over-strip it
assert json.loads(_scrub_gpt5_params(json.dumps(
{"model": "gpt-5", "temperature": 1}).encode())).get("temperature") == 1
# non-gpt-5 models are untouched
assert json.loads(_scrub_gpt5_params(json.dumps(
{"model": "gpt-4o", "temperature": 0, "top_p": 0.5}).encode())) == \
{"model": "gpt-4o", "temperature": 0, "top_p": 0.5}
def test_openrouter_plugin_array_matches_docs():
"""Per https://openrouter.ai/docs/features/multimodal/pdfs, the
plugins array shape is `[{id:"file-parser", pdf:{engine: "..."}}]`