From 33c467fad0d3554f0669ab86cf5e8a93f717ed09 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 4 Jun 2026 22:17:15 -0400 Subject: [PATCH] fix(oci): drop max_retries instead of hard-failing on OCI max_retries is a litellm-level control param (litellm applies retries itself), not a generation param OCI accepts. The provider mapped it to False and raised "param `max_retries` is not supported on OCI" whenever it was present. The litellm proxy injects max_retries on every request, so any OCI call through the proxy 500'd unless drop_params was set. Drop max_retries silently in map_openai_params. Adds a unit test (Cohere and generic) and a gateway integration test that a plain request succeeds through a proxy without drop_params. --- litellm/llms/oci/chat/transformation.py | 6 ++ .../integration/test_oci_proxy_integration.py | 92 +++++++++++++------ .../oci/chat/test_oci_chat_transformation.py | 19 +++- 3 files changed, 88 insertions(+), 29 deletions(-) diff --git a/litellm/llms/oci/chat/transformation.py b/litellm/llms/oci/chat/transformation.py index f050f9eea36..be5f0e668d2 100644 --- a/litellm/llms/oci/chat/transformation.py +++ b/litellm/llms/oci/chat/transformation.py @@ -317,6 +317,12 @@ class OCIChatConfig(BaseConfig): for key, value in {**non_default_params, **optional_params}.items(): alias = param_map.get(key) if alias is False: + # max_retries is a litellm-level control param (litellm applies + # retries itself); it is never a generation param OCI accepts, so + # drop it silently. The litellm proxy injects it on every request, + # which otherwise 500s OCI calls unless drop_params is set. + if key == "max_retries": + continue if drop_params or litellm.drop_params: continue raise OCIError( diff --git a/tests/integration/test_oci_proxy_integration.py b/tests/integration/test_oci_proxy_integration.py index 8bfcdd90486..d71148e2d67 100644 --- a/tests/integration/test_oci_proxy_integration.py +++ b/tests/integration/test_oci_proxy_integration.py @@ -41,7 +41,6 @@ from typing import Iterator import httpx import pytest - # --------------------------------------------------------------------------- # Skip gate # --------------------------------------------------------------------------- @@ -79,7 +78,9 @@ def _wait_for_health(base_url: str, proc: subprocess.Popen, deadline: float) -> except httpx.HTTPError: pass time.sleep(0.5) - raise RuntimeError(f"litellm proxy did not become ready within {STARTUP_TIMEOUT_S}s") + raise RuntimeError( + f"litellm proxy did not become ready within {STARTUP_TIMEOUT_S}s" + ) def _oci_env_from_profile() -> dict[str, str]: @@ -106,38 +107,35 @@ def _oci_env_from_profile() -> dict[str, str]: } -@pytest.fixture(scope="module") -def proxy_url() -> Iterator[str]: - oci_env = _oci_env_from_profile() - - port = _free_port() - base_url = f"http://127.0.0.1:{port}" - +def _serve(config_path: str) -> Iterator[str]: + """Boot the litellm proxy with the given config and yield its base URL.""" env = os.environ.copy() - env.update(oci_env) + env.update(_oci_env_from_profile()) # Avoid pulling in DB-backed features for this lightweight smoke run. env.pop("DATABASE_URL", None) env["STORE_MODEL_IN_DB"] = "False" + port = _free_port() + base_url = f"http://127.0.0.1:{port}" + # Prefer the `litellm` console script that lives next to the active # Python so we inherit the test virtualenv. Fall back to PATH. cli = Path(sys.executable).parent / "litellm" if not cli.exists(): cli = "litellm" - cmd = [ - str(cli), - "--config", - str(CONFIG_PATH), - "--port", - str(port), - "--host", - "127.0.0.1", - "--num_workers", - "1", - ] proc = subprocess.Popen( - cmd, + [ + str(cli), + "--config", + config_path, + "--port", + str(port), + "--host", + "127.0.0.1", + "--num_workers", + "1", + ], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -155,6 +153,27 @@ def proxy_url() -> Iterator[str]: proc.wait(timeout=5) +@pytest.fixture(scope="module") +def proxy_url() -> Iterator[str]: + yield from _serve(str(CONFIG_PATH)) + + +@pytest.fixture(scope="module") +def proxy_url_no_drop_params(tmp_path_factory) -> Iterator[str]: + """A proxy WITHOUT drop_params, to prove benign params the proxy injects + (e.g. max_retries) don't break OCI calls.""" + cfg = tmp_path_factory.mktemp("oci_nodrop") / "config.yaml" + cfg.write_text( + "model_list:\n" + " - model_name: oci-cohere-command\n" + " litellm_params:\n" + " model: oci/cohere.command-latest\n" + "general_settings:\n" + f" master_key: {MASTER_KEY}\n" + ) + yield from _serve(str(cfg)) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -206,9 +225,7 @@ def test_chat_completion_via_proxy(proxy_url: str, model: str) -> None: # Reasoning models may return empty content if their budget covers only # the thinking turn — accept either text or a non-empty reasoning field. has_content = bool(msg.get("content")) - has_reasoning = bool(msg.get("reasoning_content")) or bool( - msg.get("reasoning") - ) + has_reasoning = bool(msg.get("reasoning_content")) or bool(msg.get("reasoning")) assert has_content or has_reasoning, f"empty assistant message for {model}: {msg}" usage = body.get("usage") or {} assert usage.get("total_tokens", 0) > 0 @@ -232,7 +249,7 @@ def test_chat_completion_streaming_via_proxy(proxy_url: str, model: str) -> None continue if not line.startswith("data:"): continue - payload = line[len("data:"):].strip() + payload = line[len("data:") :].strip() if payload == "[DONE]": saw_done = True break @@ -271,4 +288,25 @@ def test_model_list_advertises_oci_models(proxy_url: str) -> None: assert r.status_code == 200, r.text advertised = {row["id"] for row in r.json()["data"]} for expected in CHAT_MODELS + ["oci-embed"]: - assert expected in advertised, f"{expected} missing from /v1/models: {advertised}" + assert ( + expected in advertised + ), f"{expected} missing from /v1/models: {advertised}" + + +def test_chat_completion_no_drop_params(proxy_url_no_drop_params: str) -> None: + """A plain chat completion succeeds through a proxy without drop_params. + + Regression for the HTTP 500 ``param `max_retries` is not supported on OCI``: + the proxy injects max_retries on every request, so without this fix any OCI + call through the proxy failed unless drop_params was set. + """ + r = httpx.post( + f"{proxy_url_no_drop_params}/v1/chat/completions", + headers=_auth_headers(), + json=_chat_payload("oci-cohere-command"), + timeout=REQUEST_TIMEOUT_S, + ) + assert r.status_code == 200, f"no-drop_params -> {r.status_code}: {r.text}" + body = r.json() + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"].get("content") is not None diff --git a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py b/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py index e0911e1ef31..1195f2db3d0 100644 --- a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py +++ b/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py @@ -1133,8 +1133,7 @@ class TestOCIStreamingSignedBody: When signed_json_body is provided, the POST must use that exact bytes object, not json.dumps(data) — otherwise the RSA-SHA256 signature is invalid. """ - import httpx - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock config = OCIChatConfig() signed_bytes = b'{"signed": true}' @@ -1293,6 +1292,22 @@ class TestOCIChatConfigErrorPaths: ) assert "audio" not in result + @pytest.mark.parametrize("model", ["cohere.command-latest", "xai.grok-4"]) + def test_map_openai_params_max_retries_dropped_without_drop_params(self, model): + """max_retries is a litellm control param, not a generation param. It + must be dropped silently (no raise) even when drop_params is False, so + the litellm proxy (which injects max_retries on every request) does not + 500 every OCI call. + """ + config = OCIChatConfig() + result = config.map_openai_params( + non_default_params={"max_retries": 3}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "max_retries" not in result + def test_transform_request_tool_choice_string_mapped(self): config = OCIChatConfig() result = config.transform_request(