mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
98d12cb24d
commit
c04cf12cf2
3 changed files with 151 additions and 14 deletions
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue