mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
fix(oci): drop non-function tools under drop_params and gate reasoning_effort on declared levels (#40732)
* fix(oci): skip non-function tools and gate reasoning_effort on models that reject it Fixes #31449. Two OCI GENERIC request-building defects that made Grok-on-OCI unusable for tool-using coding-agent clients (Codex over the Responses API, Claude Code over the Anthropic Messages API): 1. `adapt_tool_definition_to_oci_standard` raised `OCIError("OCI only supports function tools")` for any tool whose type is not "function", aborting the whole request when a client mixed a built-in/server tool in with its function tools. Non-function tools are now skipped with a warning; if none remain, `tools`/`toolChoice` are omitted from the request instead of sending an empty array. 2. The GENERIC param map forwarded `reasoning_effort` to every non-Cohere model, but OCI rejects it for all served xAI Grok models (`Model grok-4.20 does not support parameter reasoningEffort`) and for OpenAI's non-reasoning models (`Unrecognized request argument supplied: reasoning_effort` on gpt-4o / gpt-4.1); gpt-5, the o-series, gpt-oss-* and Meta/Google models accept it. `_model_supports_reasoning_effort` encodes that, `get_supported_openai_params` stops advertising the param for those models, and `map_openai_params` follows the standard unsupported-param contract (dropped under drop_params, clear error otherwise) so `drop_params` / `additional_drop_params` now work as documented instead of the 400 surfacing from the service. Behaviour verified against OCI GenAI in us-chicago-1 (2026-09-11) per model. The two existing reasoning_effort normalisation tests move from a Grok model to openai.gpt-5, which is the case they exercise. 21 new tests; the OCI suite is 406 passed. * fix(oci): drive reasoning_effort gating from the model catalog and drop tool_choice for skipped tools Review follow-ups on #40732: - `_model_supports_reasoning_effort` now reads the `supports_reasoning` flag of the model's `oci/` catalog entry through `get_model_info` instead of hardcoding xAI / gpt-oss branches. Catalogued models without the flag (xAI Grok, Meta Llama) no longer receive `reasoningEffort`; models absent from the catalog keep the historical pass-through. - Catalog: flag `oci/google.gemini-2.5-{flash,pro,flash-lite}` as reasoning models and add `oci/openai.gpt-oss-120b` / `oci/openai.gpt-oss-20b` (pricing from Oracle's public price list, 128K context per OCI docs). - `_model_uses_max_completion_tokens` routes on the `openai.*` prefix only. OCI-hosted Gemini accepts `maxCompletionTokens` but ignores it (the output cap silently disappears) and Llama rejects it with 400, so the catalog's reasoning flag must not drive that switch. - After tool adaptation, a `FUNCTION` tool_choice naming a tool that was skipped (built-in / server tools) is dropped with a warning instead of being sent to OCI, which rejects the unmatched choice. Verified live against OCI GenAI (us-chicago-1): Grok with mixed tools plus a forced built-in now completes and still calls the function tool; Gemini and gpt-oss forward reasoningEffort with maxTokens honoured; Llama drops it under drop_params; gpt-5 keeps maxCompletionTokens. * style(oci): use an immutable set for the tool-name lookup Keeps the type-discipline gate (LIT002, mutable collections) within its budget; the lookup is read-only. * fix(oci): fail closed when a forced tool_choice names a tool OCI will not receive Review follow-ups on #40732: - When tool_choice forces a function that is not among the adapted tools (a skipped built-in or a misspelled name), send `toolChoice: NONE` instead of removing the choice. Falling back to the service default would let the model call any other supplied function even though the caller forced a specific one, which an agent processing untrusted content must not do. - Move the #31449 regression tests into the mapped test module (`test_oci_chat_transformation.py`) instead of a separate file. Verified live against OCI GenAI: Grok and Llama with a forced skipped built-in now complete with no tool call; a forced kept function is still honoured. * test(oci): point reasoning_effort comments at the in-module gating tests The referenced module was folded into this file. * fix(oci): drop non-function tools only under drop_params and gate reasoning_effort on declared levels Non-function tools now follow the unsupported-param contract: a 400 naming drop_params without it, and dropped with a warning under it, with a forced dropped tool turning tool calls off. reasoning_effort is gated on the model's reasoning_effort_levels catalog entry, so OCI Llama keeps forwarding it and the Grok 4.20 models OCI serves, now in the catalog with their Oracle prices, stop sending a param the service rejects * fix(oci): gate reasoning_effort on oci/xai.grok-4.20, which rejects it * fix(oci): disable tool calls when tool_choice forces a dropped custom tool by name * fix(oci): drop tool_choice that forces a dropped tool and keep a kept function's name * fix(oci): keep Gemini 2.5 Flash Lite unflagged for reasoning so released proxies keep its max_tokens cap * fix(oci): answer 400 when a request depends on a dropped tool and leave Gemini max_tokens routing to its own PR Under drop_params, non-function tools still drop when function tools remain and tool_choice does not force a dropped tool. A request whose every tool is dropped, or whose tool_choice forces one, now answers 400 instead of a tool-less reply the model can fill with invented results, or a swap to another tool. The maxTokens routing change for OCI Gemini goes back to base, since it is outside this fix and an open PR carries it. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
ba3fce2bda
commit
68759a239f
5 changed files with 503 additions and 8 deletions
|
|
@ -14,8 +14,10 @@ from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import DEFAULT_OCI_CHAT_MAX_TOKENS
|
||||
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
|
|
@ -49,6 +51,7 @@ from litellm.llms.oci.common_utils import (
|
|||
sign_oci_request,
|
||||
validate_oci_environment,
|
||||
)
|
||||
from litellm.router_utils.reasoning_effort_capability import declared_reasoning_efforts_for_model
|
||||
from litellm.types.llms.oci import (
|
||||
CohereChatRequest,
|
||||
OCIChatRequestPayload,
|
||||
|
|
@ -182,6 +185,75 @@ def _normalize_tool_choice(selected_params: dict) -> None:
|
|||
)
|
||||
|
||||
|
||||
_TOOL_CHOICE_MODES: Final = frozenset({"auto", "none", "required", "any"})
|
||||
_TOOLS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
|
||||
_JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _as_json_object(value: object) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _JSON_OBJECT_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _forced_tool_name(tool_choice: object) -> str | None:
|
||||
if isinstance(tool_choice, str):
|
||||
return None if tool_choice.lower() in _TOOL_CHOICE_MODES else tool_choice
|
||||
choice: Final = _as_json_object(tool_choice)
|
||||
if choice is None:
|
||||
return None
|
||||
name: Final = (_as_json_object(choice.get("function")) or choice).get("name")
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
choice_type: Final = choice.get("type")
|
||||
if isinstance(choice_type, str) and choice_type.lower() not in _TOOL_CHOICE_MODES:
|
||||
return choice_type
|
||||
return None
|
||||
|
||||
|
||||
def _tool_identifiers(tool: Mapping[str, object]) -> tuple[object, ...]:
|
||||
nested: Final = _as_json_object(tool.get(str(tool.get("type")))) or {}
|
||||
return (tool.get("type"), tool.get("name"), nested.get("name"))
|
||||
|
||||
|
||||
def _without_non_function_tools(params: Mapping[str, object], drop_params: bool) -> Mapping[str, object]:
|
||||
if params.get("tools") is None:
|
||||
return params
|
||||
try:
|
||||
tools: Final = _TOOLS_ADAPTER.validate_python(params["tools"])
|
||||
except ValidationError:
|
||||
return params
|
||||
dropped: Final = tuple(tool for tool in tools if tool.get("type") != "function")
|
||||
if not dropped:
|
||||
return params
|
||||
dropped_types: Final = sorted(str(tool.get("type")) for tool in dropped)
|
||||
if not drop_params:
|
||||
raise OCIError(
|
||||
status_code=400,
|
||||
message=f"OCI only supports function tools, got {dropped_types}; set drop_params=True to skip them",
|
||||
)
|
||||
kept: Final = [tool for tool in tools if tool.get("type") == "function"]
|
||||
if not kept:
|
||||
raise OCIError(
|
||||
status_code=400,
|
||||
message=f"OCI only supports function tools, and every tool in this request is one of {dropped_types}",
|
||||
)
|
||||
forced: Final = _forced_tool_name(params.get("tool_choice"))
|
||||
forces_dropped_tool: Final = (
|
||||
forced is not None
|
||||
and any(forced in _tool_identifiers(tool) for tool in dropped)
|
||||
and not any(forced in _tool_identifiers(tool) for tool in kept)
|
||||
)
|
||||
if forces_dropped_tool:
|
||||
raise OCIError(
|
||||
status_code=400,
|
||||
message=f"OCI only supports function tools, and tool_choice forces `{forced}`, one of {dropped_types}",
|
||||
)
|
||||
verbose_logger.warning("OCI only supports function tools; dropping tools of type %s", dropped_types)
|
||||
return {**params, "tools": kept}
|
||||
|
||||
|
||||
def _normalize_response_format(selected_params: dict, vendor: OCIVendors) -> None:
|
||||
rf: Final = selected_params.get("responseFormat")
|
||||
if not isinstance(rf, dict) or "type" not in rf:
|
||||
|
|
@ -229,6 +301,11 @@ def _normalize_response_format(selected_params: dict, vendor: OCIVendors) -> Non
|
|||
selected_params["responseFormat"] = {"type": "JSON_OBJECT" if fmt == "JSON" else fmt}
|
||||
|
||||
|
||||
def _model_supports_reasoning_effort(model: str) -> bool:
|
||||
name: Final = model[4:] if model.lower().startswith("oci/") else model
|
||||
return declared_reasoning_efforts_for_model(name, "oci") != ()
|
||||
|
||||
|
||||
def get_vendor_from_model(model: str) -> OCIVendors:
|
||||
"""Return the OCI vendor enum for a model name.
|
||||
|
||||
|
|
@ -322,7 +399,11 @@ class OCIChatConfig(BaseConfig):
|
|||
# honoured and advertising it would be misleading. Callers that gate on
|
||||
# this list strip n=1 (a no-op, matching what map_openai_params does);
|
||||
# callers that bypass it have n=1 dropped there. Both paths converge.
|
||||
return [key for key, value in param_map.items() if value]
|
||||
return [
|
||||
key
|
||||
for key, value in param_map.items()
|
||||
if value and (key != "reasoning_effort" or _model_supports_reasoning_effort(model))
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
|
|
@ -337,8 +418,21 @@ class OCIChatConfig(BaseConfig):
|
|||
self.openai_to_oci_cohere_param_map if vendor == OCIVendors.COHERE else self.openai_to_oci_generic_param_map
|
||||
)
|
||||
|
||||
for key, value in {**non_default_params, **optional_params}.items():
|
||||
request_params: Final = _without_non_function_tools(
|
||||
{**non_default_params, **optional_params}, drop_params or litellm.drop_params
|
||||
)
|
||||
for key, value in request_params.items():
|
||||
alias = param_map.get(key)
|
||||
if key == "reasoning_effort" and alias and not _model_supports_reasoning_effort(model):
|
||||
if drop_params or litellm.drop_params:
|
||||
continue
|
||||
raise OCIError(
|
||||
status_code=400,
|
||||
message=(
|
||||
f"param `reasoning_effort` is not supported on OCI model `{model}` "
|
||||
"(the model rejects reasoningEffort); set drop_params=True to omit it"
|
||||
),
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -44194,6 +44194,7 @@
|
|||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_response_schema": false
|
||||
|
|
@ -44210,6 +44211,72 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"oci/xai.grok-4.20-0309-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"oci/xai.grok-4.20-0309-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/xai.grok-4.20-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"oci/xai.grok-4.20-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/xai.grok-code-fast-1": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "oci",
|
||||
|
|
@ -44267,6 +44334,32 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"oci/openai.gpt-oss-120b": {
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/openai.gpt-oss-20b": {
|
||||
"input_cost_per_token": 7e-08,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-07,
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/cohere.embed-english-v3.0": {
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "oci",
|
||||
|
|
|
|||
|
|
@ -44194,6 +44194,7 @@
|
|||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_response_schema": false
|
||||
|
|
@ -44210,6 +44211,72 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"oci/xai.grok-4.20-0309-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"oci/xai.grok-4.20-0309-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/xai.grok-4.20-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"oci/xai.grok-4.20-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
|
||||
"input_cost_per_token": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 1000000,
|
||||
"max_tokens": 1000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 5e-06,
|
||||
"reasoning_effort_levels": [],
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/xai.grok-code-fast-1": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "oci",
|
||||
|
|
@ -44267,6 +44334,32 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"oci/openai.gpt-oss-120b": {
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/openai.gpt-oss-20b": {
|
||||
"input_cost_per_token": 7e-08,
|
||||
"litellm_provider": "oci",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-07,
|
||||
"source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"oci/cohere.embed-english-v3.0": {
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "oci",
|
||||
|
|
|
|||
|
|
@ -389,10 +389,8 @@ async def test_async_tool_use(m: _M, oci_params):
|
|||
# Reasoning-effort tests (reasoning models only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Reasoning model that accepts the `reasoningEffort` parameter on OCI.
|
||||
# Not every reasoning model does — xai.grok-4-fast-reasoning, for example,
|
||||
# rejects it with a 400.
|
||||
_REASONING_MODEL = "xai.grok-3-mini"
|
||||
# Reasons at LOW and accepts reasoningEffort NONE on OCI (gpt-oss and Gemini Pro reject NONE, Grok 4.20 the param).
|
||||
_REASONING_MODEL = "google.gemini-2.5-flash"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
|
||||
|
|
|
|||
|
|
@ -1078,15 +1078,19 @@ class TestOCIReasoningEffort:
|
|||
return body["chatRequest"]
|
||||
|
||||
def test_reasoning_effort_lowercase_uppercased(self):
|
||||
# gpt-5 accepts reasoningEffort; OCI's Grok 4.20 models reject it
|
||||
# (see TestReasoningEffortGating at the end of this module).
|
||||
chat_request = self._build_chat_request(
|
||||
"xai.grok-4-fast-reasoning",
|
||||
"openai.gpt-5",
|
||||
{"reasoning_effort": "low"},
|
||||
)
|
||||
assert chat_request.get("reasoningEffort") == "LOW"
|
||||
|
||||
def test_reasoning_effort_disable_mapped_to_none(self):
|
||||
# gpt-5 accepts reasoningEffort; OCI's Grok 4.20 models reject it
|
||||
# (see TestReasoningEffortGating at the end of this module).
|
||||
chat_request = self._build_chat_request(
|
||||
"xai.grok-4-fast-reasoning",
|
||||
"openai.gpt-5",
|
||||
{"reasoning_effort": "disable"},
|
||||
)
|
||||
assert chat_request.get("reasoningEffort") == "NONE"
|
||||
|
|
@ -1947,3 +1951,216 @@ class TestOCIStreamWrapperTerminalChunk:
|
|||
)
|
||||
|
||||
assert [chunk.choices[0].finish_reason for chunk in chunks] == [None, "stop"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BerriAI/litellm#31449: mixed tool lists and reasoning_effort gating
|
||||
#
|
||||
# 1. Non-function tools answer 400 without ``drop_params`` and are dropped
|
||||
# with it, unless the request depends on them: when no function tool is
|
||||
# left, or ``tool_choice`` forces a dropped tool, it still answers 400.
|
||||
# 2. ``reasoning_effort`` is withheld from OCI models whose catalog entry
|
||||
# declares no reasoning effort levels (xAI Grok 4.20), so ``drop_params``
|
||||
# works and callers get a clear error otherwise.
|
||||
# ---------------------------------------------------------------------------
|
||||
from litellm.llms.oci.chat.transformation import get_vendor_from_model
|
||||
|
||||
FUNCTION_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Weather for a city",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
BUILTIN_TOOL = {"type": "web_search_preview"}
|
||||
SERVER_TOOL = {"type": "computer_use_preview", "display_width": 1024}
|
||||
FORCE_BUILTIN = {"type": "function", "function": {"name": "web_search_preview"}}
|
||||
FORCE_FUNCTION = {"type": "function", "function": {"name": "get_weather"}}
|
||||
|
||||
GPT_OSS_MODELS = ["openai.gpt-oss-120b", "openai.gpt-oss-20b"]
|
||||
|
||||
|
||||
def _oci_tool_params(model, tools, tool_choice, drop_params):
|
||||
request = {"tools": tools} if tool_choice is None else {"tools": tools, "tool_choice": tool_choice}
|
||||
config = OCIChatConfig()
|
||||
mapped = config.map_openai_params(
|
||||
non_default_params=request,
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
return config._get_optional_params(get_vendor_from_model(model), mapped, model=model)
|
||||
|
||||
|
||||
class TestNonFunctionTools:
|
||||
@pytest.mark.parametrize("model", ["xai.grok-4.20", "cohere.command-latest"])
|
||||
@pytest.mark.parametrize("non_function_tool", [BUILTIN_TOOL, SERVER_TOOL, {"name": "untyped"}])
|
||||
def test_rejected_without_drop_params(self, monkeypatch, model, non_function_tool):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(OCIError, match="drop_params") as exc_info:
|
||||
_oci_tool_params(model, [non_function_tool, FUNCTION_TOOL], None, drop_params=False)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_global_drop_params_drops_them(self, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", True)
|
||||
params = _oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, FUNCTION_TOOL], "auto", drop_params=False)
|
||||
assert [tool.name for tool in params["tools"]] == ["get_weather"]
|
||||
|
||||
@pytest.mark.parametrize("model", ["xai.grok-4.20", "cohere.command-latest"])
|
||||
def test_drop_params_keeps_function_tools_only(self, model):
|
||||
params = _oci_tool_params(model, [BUILTIN_TOOL, FUNCTION_TOOL, SERVER_TOOL], None, drop_params=True)
|
||||
assert [tool.name for tool in params["tools"]] == ["get_weather"]
|
||||
|
||||
def test_drop_params_keeps_unforced_tool_choice(self):
|
||||
params = _oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, FUNCTION_TOOL], "required", drop_params=True)
|
||||
assert params["toolChoice"] == {"type": "REQUIRED"}
|
||||
|
||||
@pytest.mark.parametrize("tool_choice", [None, "auto", "required"])
|
||||
def test_drop_params_rejects_a_request_with_no_function_tool_left(self, tool_choice):
|
||||
with pytest.raises(OCIError, match="every tool") as exc_info:
|
||||
_oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, SERVER_TOOL], tool_choice, drop_params=True)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_choice",
|
||||
[FORCE_BUILTIN, "web_search_preview", {"type": "web_search_preview"}],
|
||||
)
|
||||
def test_forcing_a_dropped_tool_is_rejected(self, tool_choice):
|
||||
with pytest.raises(OCIError, match="tool_choice forces `web_search_preview`") as exc_info:
|
||||
_oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, FUNCTION_TOOL], tool_choice, drop_params=True)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dropped_tool", "tool_choice"),
|
||||
[
|
||||
({"type": "bash_20250124", "name": "bash"}, {"type": "function", "function": {"name": "bash"}}),
|
||||
({"type": "custom", "custom": {"name": "code_exec"}}, "code_exec"),
|
||||
({"type": "custom", "custom": {"name": "code_exec"}}, {"type": "function", "function": {"name": "code_exec"}}),
|
||||
],
|
||||
)
|
||||
def test_forcing_a_dropped_tool_by_name_is_rejected(self, dropped_tool, tool_choice):
|
||||
with pytest.raises(OCIError, match="tool_choice forces") as exc_info:
|
||||
_oci_tool_params("xai.grok-4.20", [dropped_tool, FUNCTION_TOOL], tool_choice, drop_params=True)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize("tool_choice", [FORCE_FUNCTION, "get_weather"])
|
||||
def test_forcing_a_kept_tool_is_preserved(self, tool_choice):
|
||||
params = _oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, FUNCTION_TOOL], tool_choice, drop_params=True)
|
||||
assert params["toolChoice"] == {"type": "FUNCTION", "name": "get_weather"}
|
||||
|
||||
def test_forcing_a_kept_function_sharing_a_dropped_tool_name_is_preserved(self):
|
||||
web_search_function = {**FUNCTION_TOOL, "function": {**FUNCTION_TOOL["function"], "name": "web_search"}}
|
||||
force_web_search = {"type": "function", "function": {"name": "web_search"}}
|
||||
params = _oci_tool_params(
|
||||
"xai.grok-4.20", [{"type": "web_search"}, web_search_function], force_web_search, drop_params=True
|
||||
)
|
||||
assert [tool.name for tool in params["tools"]] == ["web_search"]
|
||||
assert params["toolChoice"] == {"type": "FUNCTION", "name": "web_search"}
|
||||
|
||||
def test_misspelled_function_choice_is_left_for_the_service(self):
|
||||
params = _oci_tool_params("xai.grok-4.20", [BUILTIN_TOOL, FUNCTION_TOOL], "get_weathr", drop_params=True)
|
||||
assert params["toolChoice"] == {"type": "FUNCTION", "name": "get_weathr"}
|
||||
|
||||
def test_function_only_tools_are_untouched_without_drop_params(self, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
params = _oci_tool_params("xai.grok-4.20", [FUNCTION_TOOL], FORCE_FUNCTION, drop_params=False)
|
||||
assert [tool.name for tool in params["tools"]] == ["get_weather"]
|
||||
assert params["toolChoice"] == {"type": "FUNCTION", "name": "get_weather"}
|
||||
|
||||
|
||||
SERVED_GROK_4_20_MODELS = [
|
||||
"xai.grok-4.20-reasoning",
|
||||
"xai.grok-4.20-non-reasoning",
|
||||
"xai.grok-4.20-0309-reasoning",
|
||||
"xai.grok-4.20-0309-non-reasoning",
|
||||
]
|
||||
|
||||
|
||||
class TestReasoningEffortGating:
|
||||
@pytest.mark.parametrize("model", SERVED_GROK_4_20_MODELS + ["xai.grok-4.20"])
|
||||
def test_models_declaring_no_levels_are_gated(self, local_model_cost_map, model):
|
||||
supported = OCIChatConfig().get_supported_openai_params(model)
|
||||
assert "reasoning_effort" not in supported
|
||||
assert "tools" in supported
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"meta.llama-3.3-70b-instruct",
|
||||
"meta.llama-4-scout-17b-16e-instruct",
|
||||
"xai.grok-4.20-multi-agent",
|
||||
"openai.gpt-5",
|
||||
"openai.gpt-oss-120b",
|
||||
"google.gemini-2.5-flash",
|
||||
"google.gemini-2.5-flash-lite",
|
||||
],
|
||||
)
|
||||
def test_models_declaring_nothing_are_forwarded(self, local_model_cost_map, model):
|
||||
assert "reasoning_effort" in OCIChatConfig().get_supported_openai_params(model)
|
||||
|
||||
def test_models_absent_from_catalog_are_forwarded(self, local_model_cost_map):
|
||||
assert "oci/meta.llama-9-future-instruct" not in litellm.model_cost
|
||||
assert "reasoning_effort" in OCIChatConfig().get_supported_openai_params("meta.llama-9-future-instruct")
|
||||
|
||||
def test_gate_is_catalog_driven(self, local_model_cost_map, monkeypatch):
|
||||
entry = {**litellm.model_cost["oci/meta.llama-3.3-70b-instruct"], "reasoning_effort_levels": []}
|
||||
monkeypatch.setitem(litellm.model_cost, "oci/meta.llama-3.3-70b-instruct", entry)
|
||||
assert "reasoning_effort" not in OCIChatConfig().get_supported_openai_params("meta.llama-3.3-70b-instruct")
|
||||
|
||||
@pytest.mark.parametrize("model", SERVED_GROK_4_20_MODELS)
|
||||
def test_reasoning_effort_dropped_under_drop_params(self, local_model_cost_map, model):
|
||||
result = OCIChatConfig().map_openai_params(
|
||||
non_default_params={"reasoning_effort": "low", "temperature": 0.1},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=True,
|
||||
)
|
||||
assert "reasoningEffort" not in result
|
||||
assert "reasoning_effort" not in result
|
||||
assert result["temperature"] == 0.1
|
||||
|
||||
def test_reasoning_effort_raises_without_drop_params_for_gated_grok(self, local_model_cost_map, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "drop_params", False)
|
||||
with pytest.raises(OCIError, match=r"reasoning_effort.*xai\.grok-4\.20-reasoning"):
|
||||
OCIChatConfig().map_openai_params(
|
||||
non_default_params={"reasoning_effort": "low"},
|
||||
optional_params={},
|
||||
model="xai.grok-4.20-reasoning",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model", ["openai.gpt-5", "google.gemini-2.5-flash", "openai.gpt-oss-120b", "meta.llama-3.3-70b-instruct"]
|
||||
)
|
||||
def test_reasoning_effort_forwarded_when_not_gated(self, local_model_cost_map, model):
|
||||
result = OCIChatConfig().map_openai_params(
|
||||
non_default_params={"reasoning_effort": "low"},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["reasoningEffort"] == "low"
|
||||
|
||||
|
||||
def _oci_chat_request(model, optional_params):
|
||||
return OCIChatConfig().transform_request(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
optional_params={**BASE_OCI_PARAMS, **optional_params},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)["chatRequest"]
|
||||
|
||||
|
||||
class TestOpenAIPrefixMaxTokensKey:
|
||||
@pytest.mark.parametrize("model", GPT_OSS_MODELS)
|
||||
def test_gpt_oss_keeps_max_tokens(self, local_model_cost_map, model):
|
||||
chat_request = _oci_chat_request(model, {"max_tokens": 64})
|
||||
assert chat_request["maxTokens"] == 64
|
||||
assert "maxCompletionTokens" not in chat_request
|
||||
|
||||
def test_gpt5_still_routes_to_max_completion_tokens(self, local_model_cost_map):
|
||||
chat_request = _oci_chat_request("openai.gpt-5", {"max_tokens": 64})
|
||||
assert chat_request["maxCompletionTokens"] == 64
|
||||
assert "maxTokens" not in chat_request
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue