From 07de96ca98994289d3ee717aeb368425b66fe142 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:45:56 -0700 Subject: [PATCH] fix(oci): keep an aborted tool turn on stop instead of tool_calls The finish-reason upgrade fired on every reason that normalized to stop, so a Cohere turn OCI ended with ERROR, ERROR_TOXIC, ERROR_LIMIT, USER_CANCEL or CANCELLED after streaming a tool delta came back as tool_calls and invited the client to run a call the provider had just blocked. Gate the upgrade on the reasons that mean the turn ran to completion, which is the only case it was ever needed for: GENERIC already sends tool_calls itself. --- litellm/llms/oci/chat/generic.py | 16 ++++++-------- .../oci/chat/test_oci_cohere_tool_calls.py | 21 +++++++++++++++++++ .../llms/oci/chat/test_oci_generic_chat.py | 10 +++------ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/litellm/llms/oci/chat/generic.py b/litellm/llms/oci/chat/generic.py index 38b6684374c..85cd063cf68 100644 --- a/litellm/llms/oci/chat/generic.py +++ b/litellm/llms/oci/chat/generic.py @@ -252,22 +252,18 @@ _OCI_FINISH_REASONS: Final = MappingProxyType( _OPENAI_FINISH_REASONS: Final = frozenset({"stop", "length", "tool_calls", "content_filter", "function_call"}) +_OCI_RAN_TO_COMPLETION_REASONS: Final = frozenset({"COMPLETE", "stop"}) + def _normalize_oci_finish_reason(raw: str | None, *, has_tool_calls: bool = False) -> str | None: - """Map an OCI finish reason to its OpenAI-standard equivalent. - - OCI uses two vocabularies. Cohere sends ``COMPLETE`` / ``MAX_TOKENS`` / - ``TOOL_CALL(S)`` plus a long tail of error and cancel reasons, while GENERIC - already sends the OpenAI names and has to be passed through rather than - collapsed. Anything outside both sets becomes ``"stop"``, ``None`` stays - ``None`` so intermediate stream chunks stay open, and ``has_tool_calls`` - upgrades ``stop`` to ``tool_calls`` for a Cohere tool turn, which OCI still - labels ``COMPLETE``. + """Cohere speaks ``COMPLETE`` / ``MAX_TOKENS`` / ``TOOL_CALL(S)`` plus a tail of error and + cancel reasons while GENERIC already speaks OpenAI, and only a turn that ran to completion + may be upgraded to ``tool_calls``, so an aborted one never invites a client to run the call. """ if raw is None: return None normalized: Final = _OCI_FINISH_REASONS.get(raw, raw if raw in _OPENAI_FINISH_REASONS else "stop") - return "tool_calls" if has_tool_calls and normalized == "stop" else normalized + return "tool_calls" if has_tool_calls and raw in _OCI_RAN_TO_COMPLETION_REASONS else normalized def _synthesize_oci_tool_call_id(position: int, name: str, arguments: str) -> str: diff --git a/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py b/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py index 0b6b375cbf1..995cc1ce562 100644 --- a/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py +++ b/tests/test_litellm/llms/oci/chat/test_oci_cohere_tool_calls.py @@ -502,6 +502,27 @@ class TestOCICohereToolCalls: assert chunks[-1].choices[0].finish_reason == "tool_calls" + @pytest.mark.parametrize( + "aborted_reason", ["ERROR", "ERROR_TOXIC", "ERROR_LIMIT", "USER_CANCEL", "CANCELLED"] + ) + def test_cohere_aborted_tool_turn_still_reports_stop(self, aborted_reason: str): + """A turn OCI cut short is not a tool turn, however many tool deltas already went out.""" + wrapper = OCIStreamWrapper( + completion_stream=MagicMock(), model="cohere.command-a-03-2025", logging_obj=MagicMock() + ) + tool_calls_event = { + "apiFormat": "COHERE", + "text": "I will use the get_weather tool.", + "toolCalls": [{"name": "get_weather", "parameters": {"city": "Paris"}}], + } + + chunks = [ + wrapper.chunk_creator(f"data: {json.dumps(event)}") + for event in (tool_calls_event, {"apiFormat": "COHERE", "finishReason": aborted_reason}) + ] + + assert chunks[-1].choices[0].finish_reason == "stop" + def test_cohere_non_streaming_tool_call_reports_tool_calls(self): """Same COMPLETE-on-a-tool-turn story on the non-streaming Cohere response.""" body = { 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 d81ec0c6812..0f43a54b18e 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 @@ -472,13 +472,9 @@ class TestGpt5MaxCompletionTokens: class TestGenericFinishReasonFidelity: - """OCI GENERIC sends the OpenAI finish-reason names verbatim. - - Captured against ``oci/meta.llama-3.3-70b-instruct`` in ``us-chicago-1``: a - tool-calling turn ends with ``"finishReason": "tool_calls"`` and a - max-token cut-off with ``"finishReason": "length"``, both lowercase. - Collapsing either to ``stop`` leaves an agent loop with no signal to run - the tool and hides truncation from the caller. + """``_TOOL_CALL_WIRE`` was captured against ``oci/meta.llama-3.3-70b-instruct`` in + ``us-chicago-1``, where a GENERIC tool-calling turn ends with ``"finishReason": "tool_calls"`` + and a max-token cut-off with ``"finishReason": "length"``, both already lowercase. """ _TOOL_CALL_WIRE = [