mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 01:54:52 +02:00
[eric] proxy: fix non-Anthropic first-msg 400s (Gemini allowlist + gpt-5 sampling strip)
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "..."}}]`
|
||||
|
||||
Reference in New Issue
Block a user