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.
This commit is contained in:
Federico Kamelhar 2026-05-05 23:40:16 -04:00
parent 98d12cb24d
commit c04cf12cf2
3 changed files with 151 additions and 14 deletions

View file

@ -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),
),
)

View file

@ -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

View file

@ -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