From 124b128ed134552d2694beef9a13deca875f4b8c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 16:58:26 -0700 Subject: [PATCH] [eric] proxy: fix non-Anthropic first-msg 400s (Gemini allowlist + gpt-5 sampling strip) --- .../apps/agents/core/openai_passthrough.py | 43 ++++--- backend/apps/agents/proxy/anthropic_proxy.py | 107 ++++++++++++------ backend/tests/test_v2_invariants.py | 74 ++++++++++++ 3 files changed, 175 insertions(+), 49 deletions(-) diff --git a/backend/apps/agents/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index 72a635f0..dc5ac2fd 100644 --- a/backend/apps/agents/core/openai_passthrough.py +++ b/backend/apps/agents/core/openai_passthrough.py @@ -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(): diff --git a/backend/apps/agents/proxy/anthropic_proxy.py b/backend/apps/agents/proxy/anthropic_proxy.py index 98ca7b9c..b20f2049 100644 --- a/backend/apps/agents/proxy/anthropic_proxy.py +++ b/backend/apps/agents/proxy/anthropic_proxy.py @@ -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) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 5ca346c8..9842674e 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -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: "..."}}]`