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.
This commit is contained in:
mateo-berri 2026-09-06 02:45:56 -07:00
parent 6c7cc62c52
commit 07de96ca98
3 changed files with 30 additions and 17 deletions

View file

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

View file

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

View file

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