diff --git a/backend/apps/agents/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index 20c6db99..10ca0d00 100644 --- a/backend/apps/agents/core/openai_passthrough.py +++ b/backend/apps/agents/core/openai_passthrough.py @@ -91,6 +91,9 @@ def scrub_gpt5_params(body: bytes) -> bytes: for k in P_GPT5_UNSUPPORTED_PARAMS: if parsed.pop(k, None) is not None: mutated = True + # OpenAI started rejecting reasoning_effort + function tools together on /chat/completions (live-confirmed 2026-07-08, all gpt-5.x); dropping effort loses thinking but the turn completes. Real fix = /v1/responses migration. + if "tools" in parsed and parsed.pop("reasoning_effort", None) is not None: + mutated = True return json.dumps(parsed).encode("utf-8") if mutated else body diff --git a/backend/apps/agents/proxy/anthropic_proxy.py b/backend/apps/agents/proxy/anthropic_proxy.py index 00a71885..d141968b 100644 --- a/backend/apps/agents/proxy/anthropic_proxy.py +++ b/backend/apps/agents/proxy/anthropic_proxy.py @@ -171,6 +171,9 @@ def scrub_request_for_openai_gpt5(body: bytes) -> bytes: "logprobs", "top_logprobs", "logit_bias"): if parsed.pop(p_k, None) is not None: mutated = True + # OpenAI started rejecting reasoning_effort + function tools together on /chat/completions (live-confirmed 2026-07-08, all gpt-5.x); dropping effort loses thinking but the turn completes. + if "tools" in parsed and parsed.pop("reasoning_effort", None) is not None: + mutated = True try: before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else "" p_rewrite_document_to_openai_file(parsed) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 65cd150c..f1292386 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -1716,6 +1716,15 @@ def test_gpt5_param_scrub_drops_unsupported_sampling_knobs(): 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} + # reasoning_effort + function tools together 400 on /chat/completions (live-confirmed 2026-07-08 on gpt-5.5/5.4/5.4-mini); with tools present effort must go, without tools it must stay. + combo = json.dumps({"model": "gpt-5.4-mini", "messages": [], "max_tokens": 200, + "reasoning_effort": "low", "tools": [{"type": "function", "function": {"name": "t"}}]}).encode() + solo = json.dumps({"model": "gpt-5.4-mini", "messages": [], "max_tokens": 200, + "reasoning_effort": "low"}).encode() + for fn in (scrub_request_for_openai_gpt5, scrub_gpt5_params): + assert "reasoning_effort" not in json.loads(fn(combo)), fn.__name__ + assert json.loads(fn(combo))["tools"], fn.__name__ + assert json.loads(fn(solo)).get("reasoning_effort") == "low", fn.__name__ def test_openrouter_plugin_array_matches_docs(): @@ -2801,9 +2810,10 @@ def test_sync_custom_providers_updates_existing_node_in_place(): def test_sync_custom_providers_deletes_orphaned_managed_nodes(): - """When a user removes a custom provider in Settings, the next sync - should delete the corresponding 9Router node (and its connection - cascades). Other unmanaged nodes must NOT be touched.""" + """When a user removes a custom provider in Settings (list still NON-empty), the next + sync should delete the corresponding 9Router node (its connection cascades). Unmanaged + nodes must NOT be touched. An EMPTY list never sweeps (corrupt/defaulted settings at + boot must not mass-reap live connections; see test_router_sync_guards.py).""" import asyncio from unittest.mock import patch as upatch from backend.apps.nine_router import sync_custom_providers @@ -2827,7 +2837,9 @@ def test_sync_custom_providers_deletes_orphaned_managed_nodes(): with upatch("backend.apps.nine_router.is_running", return_value=True), \ upatch("backend.apps.nine_router.httpx.AsyncClient", MockClient), \ upatch("backend.apps.nine_router.get_providers", new=lambda: p_async_return([])): - asyncio.run(sync_custom_providers([])) # empty list → delete all managed + asyncio.run(sync_custom_providers( + [{"name": "KeptProvider", "base_url": "http://localhost:9999/v1", "api_key": "k"}] + )) deletes = [c for c in state["calls"] if c[0] == "DELETE"] deleted_urls = [c[1] for c in deletes]