From c04cf12cf2a5d857dbc5f9dcf4c1770ebd094aeb Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Tue, 5 May 2026 23:40:16 -0400 Subject: [PATCH] fix(oci): route GPT-5 family to maxCompletionTokens GPT-5 / GPT-5-mini / GPT-5-nano / GPT-5.5 on OCI reject "maxTokens" with HTTP 400: Invalid 'maxTokens': Unsupported parameter: 'maxTokens' is not supported with this model. Use 'maxCompletionTokens' instead. (Same convention as OpenAI's reasoning-API contract.) Add a model-aware rename in OCIChatConfig._get_optional_params so the request payload uses maxCompletionTokens when the model id starts with openai.gpt-5. Regular Llama / Cohere / Gemini / GPT-4.x continue to use maxTokens unchanged. Also widen OCIChatRequestPayload to carry the new optional field so it survives Pydantic serialization. Verified live against OCI us-chicago-1: - openai.gpt-5, gpt-5-mini, gpt-5-nano, gpt-5.5 all return 200 - Full feature sweep on gpt-5.5 (basic, system, multi-turn, streaming, tools, usage) all green - meta.llama-3.3-70b-instruct still uses maxTokens (no regression) 4 new unit tests cover the helper, the routing in both pre- and post-translation states, and Pydantic serialization. --- litellm/llms/oci/chat/transformation.py | 46 ++++++- litellm/types/llms/oci.py | 2 + .../llms/oci/chat/test_oci_generic_chat.py | 117 ++++++++++++++++-- 3 files changed, 151 insertions(+), 14 deletions(-) diff --git a/litellm/llms/oci/chat/transformation.py b/litellm/llms/oci/chat/transformation.py index 22163cb126f..ddd9836c96a 100644 --- a/litellm/llms/oci/chat/transformation.py +++ b/litellm/llms/oci/chat/transformation.py @@ -83,6 +83,20 @@ else: STREAMING_TIMEOUT = 60 * 5 +def _model_uses_max_completion_tokens(model: str) -> bool: + """Return True for OCI-hosted models that require ``maxCompletionTokens``. + + GPT-5 family (and related reasoning-mode models) on OCI reject ``maxTokens`` + with HTTP 400 and require ``maxCompletionTokens`` per OpenAI's reasoning-API + convention. The model id we receive is the OCI model id, e.g. + ``openai.gpt-5``, ``openai.gpt-5-mini``, ``openai.gpt-5.5``. + """ + name = (model or "").lower() + if name.startswith("oci/"): + name = name[4:] + return name.startswith("openai.gpt-5") or name == "openai.gpt-5" + + def get_vendor_from_model(model: str) -> OCIVendors: """Return the OCI vendor enum for a model name. @@ -259,7 +273,9 @@ class OCIChatConfig(BaseConfig): base = get_oci_base_url(optional_params, api_base or litellm.api_base) return f"{base}/{OCI_API_VERSION}/actions/chat" - def _get_optional_params(self, vendor: OCIVendors, optional_params: dict) -> Dict: + def _get_optional_params( + self, vendor: OCIVendors, optional_params: dict, model: str = "" + ) -> Dict: param_map = ( self.openai_to_oci_cohere_param_map if vendor == OCIVendors.COHERE @@ -267,11 +283,33 @@ class OCIChatConfig(BaseConfig): ) selected_params: Dict = {} + # OpenAI GPT-5+ family on OCI rejects "maxTokens" and requires + # "maxCompletionTokens" instead. Route to the correct field per OCI's + # /20231130/Chat schema for these models. Verified against live OCI: + # error reads `Invalid 'maxTokens': Unsupported parameter ... Use + # 'maxCompletionTokens' instead.` on openai.gpt-5*, gpt-5.5, etc. + max_tokens_key = ( + "maxCompletionTokens" + if model and _model_uses_max_completion_tokens(model) + else "maxTokens" + ) + for openai_key, oci_key in param_map.items(): if oci_key and openai_key in optional_params: - selected_params[oci_key] = optional_params[openai_key] # type: ignore[index] + target = max_tokens_key if oci_key == "maxTokens" else oci_key + selected_params[target] = optional_params[openai_key] # type: ignore[index] for oci_value in param_map.values(): + if oci_value == "maxTokens": + # Handled below via max_tokens_key — covers the case where + # map_openai_params already pre-translated "max_tokens" to the + # alias "maxTokens" in optional_params. + if ( + "maxTokens" in optional_params + and max_tokens_key not in selected_params + ): + selected_params[max_tokens_key] = optional_params["maxTokens"] + continue if ( oci_value and oci_value in optional_params @@ -384,7 +422,7 @@ class OCIChatConfig(BaseConfig): message=_extract_text_content(user_messages[-1]["content"]), chatHistory=adapt_messages_to_cohere_standard(messages), preambleOverride=preamble_override, - **self._get_optional_params(OCIVendors.COHERE, optional_params), + **self._get_optional_params(OCIVendors.COHERE, optional_params, model), ) data = OCICompletionPayload( compartmentId=oci_compartment_id, @@ -398,7 +436,7 @@ class OCIChatConfig(BaseConfig): chatRequest=OCIChatRequestPayload( apiFormat=vendor.value, messages=adapt_messages_to_generic_oci_standard(messages), - **self._get_optional_params(vendor, optional_params), + **self._get_optional_params(vendor, optional_params, model), ), ) diff --git a/litellm/types/llms/oci.py b/litellm/types/llms/oci.py index 95ef8d4fd72..ee5228ab123 100644 --- a/litellm/types/llms/oci.py +++ b/litellm/types/llms/oci.py @@ -95,6 +95,8 @@ class OCIChatRequestPayload(BaseModel): isStream: bool = False numGenerations: Optional[int] = None maxTokens: Optional[int] = None + # GPT-5+ on OCI rejects maxTokens and requires maxCompletionTokens. + maxCompletionTokens: Optional[int] = None temperature: Optional[float] = None topP: Optional[float] = None stop: Optional[List[str]] = None diff --git a/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py b/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py index 4f02ce8f504..eb1bd7bd111 100644 --- a/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py +++ b/tests/test_litellm/llms/oci/chat/test_oci_generic_chat.py @@ -19,7 +19,6 @@ from litellm.llms.oci.chat.generic import ( from litellm.llms.oci.chat.transformation import OCIChatConfig, OCIStreamWrapper from litellm.llms.oci.common_utils import OCIError - # --------------------------------------------------------------------------- # adapt_messages_to_generic_oci_standard_content_message — error paths # --------------------------------------------------------------------------- @@ -65,7 +64,12 @@ class TestGenericContentMessageErrors: def test_image_url_as_dict(self): msg = adapt_messages_to_generic_oci_standard_content_message( "user", - [{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}], + [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + } + ], ) assert msg.content[0].imageUrl.url == "https://example.com/img.png" @@ -88,14 +92,26 @@ class TestGenericToolCallErrors: with pytest.raises(OCIError, match="only supports function tool calls"): adapt_messages_to_generic_oci_standard_tool_call( "assistant", - [{"type": "database", "id": "x", "function": {"name": "f", "arguments": "{}"}}], + [ + { + "type": "database", + "id": "x", + "function": {"name": "f", "arguments": "{}"}, + } + ], ) def test_non_string_id_raises(self): with pytest.raises(OCIError, match="id.*must be a string"): adapt_messages_to_generic_oci_standard_tool_call( "assistant", - [{"type": "function", "id": 123, "function": {"name": "f", "arguments": "{}"}}], + [ + { + "type": "function", + "id": 123, + "function": {"name": "f", "arguments": "{}"}, + } + ], ) def test_non_dict_function_raises(self): @@ -109,7 +125,13 @@ class TestGenericToolCallErrors: with pytest.raises(OCIError, match="function.name.*must be a string"): adapt_messages_to_generic_oci_standard_tool_call( "assistant", - [{"type": "function", "id": "c1", "function": {"name": 5, "arguments": "{}"}}], + [ + { + "type": "function", + "id": "c1", + "function": {"name": 5, "arguments": "{}"}, + } + ], ) def test_non_string_arguments_raises(self): @@ -144,9 +166,7 @@ class TestGenericMessageAdaptation: adapt_messages_to_generic_oci_standard(messages) def test_tool_result_non_string_tool_call_id_raises(self): - messages = [ - {"role": "tool", "content": "result", "tool_call_id": 999} - ] + messages = [{"role": "tool", "content": "result", "tool_call_id": 999}] with pytest.raises(OCIError, match="string `tool_call_id`"): adapt_messages_to_generic_oci_standard(messages) @@ -179,7 +199,9 @@ class TestHandleGenericResponse: "chatResponse": { "apiFormat": "GENERIC", "timeCreated": "2024-01-01T00:00:00Z", - "choices": [{"message": message, "finishReason": "COMPLETE", "index": 0}], + "choices": [ + {"message": message, "finishReason": "COMPLETE", "index": 0} + ], "usage": {"promptTokens": 5, "completionTokens": 5, "totalTokens": 10}, }, } @@ -256,7 +278,12 @@ class TestHandleGenericStreamChunk: "index": 0, "message": { "role": "ASSISTANT", - "content": [{"type": "IMAGE", "imageUrl": {"url": "https://example.com/img.png"}}], + "content": [ + { + "type": "IMAGE", + "imageUrl": {"url": "https://example.com/img.png"}, + } + ], }, } with pytest.raises(OCIError, match="image content"): @@ -300,3 +327,73 @@ class TestOCIStreamWrapperChunkCreator: w = self._wrapper() with pytest.raises(ValueError, match="not a string"): w.chunk_creator({"already": "parsed"}) + + +# --------------------------------------------------------------------------- +# GPT-5 family: maxCompletionTokens routing +# +# Regression guard: OCI rejects "maxTokens" for openai.gpt-5* models with HTTP +# 400 ("Use 'maxCompletionTokens' instead.") — verified against live OCI. +# --------------------------------------------------------------------------- + + +class TestGpt5MaxCompletionTokens: + def test_helper_detects_gpt5_family(self): + from litellm.llms.oci.chat.transformation import ( + _model_uses_max_completion_tokens, + ) + + assert _model_uses_max_completion_tokens("openai.gpt-5") is True + assert _model_uses_max_completion_tokens("openai.gpt-5-mini") is True + assert _model_uses_max_completion_tokens("openai.gpt-5-nano") is True + assert _model_uses_max_completion_tokens("openai.gpt-5.5") is True + assert _model_uses_max_completion_tokens("oci/openai.gpt-5") is True + + assert _model_uses_max_completion_tokens("openai.gpt-4o") is False + assert _model_uses_max_completion_tokens("openai.gpt-4.1") is False + assert _model_uses_max_completion_tokens("meta.llama-3.3-70b-instruct") is False + assert _model_uses_max_completion_tokens("cohere.command-latest") is False + assert _model_uses_max_completion_tokens("") is False + + def test_gpt5_routes_max_tokens_to_max_completion_tokens(self): + from litellm.llms.oci.chat.transformation import OCIChatConfig, OCIVendors + + cfg = OCIChatConfig() + # Both shapes optional_params can take after upstream map_openai_params: + # 1. openai-side key still present + out_a = cfg._get_optional_params( + OCIVendors.GENERIC, {"max_tokens": 64}, model="openai.gpt-5" + ) + assert out_a.get("maxCompletionTokens") == 64 + assert "maxTokens" not in out_a + + # 2. already pre-translated to OCI alias + out_b = cfg._get_optional_params( + OCIVendors.GENERIC, {"maxTokens": 64}, model="openai.gpt-5.5" + ) + assert out_b.get("maxCompletionTokens") == 64 + assert "maxTokens" not in out_b + + def test_non_gpt5_keeps_max_tokens(self): + from litellm.llms.oci.chat.transformation import OCIChatConfig, OCIVendors + + cfg = OCIChatConfig() + out = cfg._get_optional_params( + OCIVendors.GENERIC, + {"max_tokens": 64}, + model="meta.llama-3.3-70b-instruct", + ) + assert out.get("maxTokens") == 64 + assert "maxCompletionTokens" not in out + + def test_payload_serializes_max_completion_tokens(self): + from litellm.types.llms.oci import OCIChatRequestPayload + + payload = OCIChatRequestPayload( + apiFormat="GENERIC", + messages=[], + maxCompletionTokens=64, + ) + dumped = payload.model_dump(exclude_none=True) + assert dumped["maxCompletionTokens"] == 64 + assert "maxTokens" not in dumped