|
8 | 8 | from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict |
9 | 9 |
|
10 | 10 | import httpx |
11 | | -from openai import APIConnectionError, AsyncOpenAI, NotFoundError, OpenAI, Stream |
| 11 | +from openai import ( |
| 12 | + APIConnectionError, |
| 13 | + AsyncOpenAI, |
| 14 | + BadRequestError, |
| 15 | + NotFoundError, |
| 16 | + OpenAI, |
| 17 | + Stream, |
| 18 | +) |
12 | 19 | from openai.lib.streaming.chat import ChatCompletionStream |
13 | 20 | from openai.types.chat import ( |
14 | 21 | ChatCompletion, |
@@ -506,27 +513,40 @@ def _call_completions( |
506 | 513 | messages=messages, tools=tools |
507 | 514 | ) |
508 | 515 |
|
509 | | - try: |
| 516 | + def dispatch(params: dict[str, Any]) -> str | Any: |
510 | 517 | if self._effective_stream(): |
511 | 518 | return self._handle_streaming_completion( |
512 | | - params=completion_params, |
| 519 | + params=params, |
513 | 520 | available_functions=available_functions, |
514 | 521 | from_task=from_task, |
515 | 522 | from_agent=from_agent, |
516 | 523 | response_model=response_model, |
517 | 524 | ) |
518 | | - |
519 | 525 | return self._handle_completion( |
520 | | - params=completion_params, |
| 526 | + params=params, |
521 | 527 | available_functions=available_functions, |
522 | 528 | from_task=from_task, |
523 | 529 | from_agent=from_agent, |
524 | 530 | response_model=response_model, |
525 | 531 | ) |
| 532 | + |
| 533 | + try: |
| 534 | + return dispatch(completion_params) |
526 | 535 | except Exception as e: |
527 | | - if self.custom_openai or not self._is_responses_only_error( |
528 | | - e.__cause__ or e |
529 | | - ): |
| 536 | + cause = e.__cause__ or e |
| 537 | + |
| 538 | + if self._rejects_reasoning_effort_with_tools(cause): |
| 539 | + retry_params = self._reasoning_effort_none_params(completion_params) |
| 540 | + if retry_params is not None: |
| 541 | + logging.debug( |
| 542 | + 'Retrying %r with reasoning_effort="none": function tools ' |
| 543 | + "and reasoning effort cannot be combined on " |
| 544 | + '/v1/chat/completions. Use api="responses" to keep both.', |
| 545 | + self.model, |
| 546 | + ) |
| 547 | + return dispatch(retry_params) |
| 548 | + |
| 549 | + if self.custom_openai or not self._is_responses_only_error(cause): |
530 | 550 | raise |
531 | 551 | self._remember_responses_only_model() |
532 | 552 | logging.debug( |
@@ -625,27 +645,34 @@ async def _acall_completions( |
625 | 645 | messages=messages, tools=tools |
626 | 646 | ) |
627 | 647 |
|
628 | | - try: |
| 648 | + async def dispatch(params: dict[str, Any]) -> str | Any: |
629 | 649 | if self._effective_stream(): |
630 | 650 | return await self._ahandle_streaming_completion( |
631 | | - params=completion_params, |
| 651 | + params=params, |
632 | 652 | available_functions=available_functions, |
633 | 653 | from_task=from_task, |
634 | 654 | from_agent=from_agent, |
635 | 655 | response_model=response_model, |
636 | 656 | ) |
637 | | - |
638 | 657 | return await self._ahandle_completion( |
639 | | - params=completion_params, |
| 658 | + params=params, |
640 | 659 | available_functions=available_functions, |
641 | 660 | from_task=from_task, |
642 | 661 | from_agent=from_agent, |
643 | 662 | response_model=response_model, |
644 | 663 | ) |
| 664 | + |
| 665 | + try: |
| 666 | + return await dispatch(completion_params) |
645 | 667 | except Exception as e: |
646 | | - if self.custom_openai or not self._is_responses_only_error( |
647 | | - e.__cause__ or e |
648 | | - ): |
| 668 | + cause = e.__cause__ or e |
| 669 | + |
| 670 | + if self._rejects_reasoning_effort_with_tools(cause): |
| 671 | + retry_params = self._reasoning_effort_none_params(completion_params) |
| 672 | + if retry_params is not None: |
| 673 | + return await dispatch(retry_params) |
| 674 | + |
| 675 | + if self.custom_openai or not self._is_responses_only_error(cause): |
649 | 676 | raise |
650 | 677 | self._remember_responses_only_model() |
651 | 678 | return await self._acall_responses( |
@@ -1694,6 +1721,46 @@ def _model_not_found_message(self, error: Exception) -> str: |
1694 | 1721 | ) |
1695 | 1722 | return f"Model {self.model} not found: {error}" |
1696 | 1723 |
|
| 1724 | + @staticmethod |
| 1725 | + def _rejects_reasoning_effort_with_tools(error: BaseException) -> bool: |
| 1726 | + """Whether a 400 is OpenAI refusing `reasoning_effort` alongside tools. |
| 1727 | +
|
| 1728 | + GPT-5.6 applies a server-side `reasoning_effort` default and then rejects |
| 1729 | + it when function tools are present, so a payload carrying no |
| 1730 | + `reasoning_effort` at all still fails: |
| 1731 | +
|
| 1732 | + "Function tools with reasoning_effort are not supported for |
| 1733 | + gpt-5.6-sol in /v1/chat/completions. To use function tools, use |
| 1734 | + /v1/responses or set reasoning_effort to 'none'." |
| 1735 | +
|
| 1736 | + Matched on the structured `param` field plus the message so the unrelated |
| 1737 | + "Unsupported value" 400 that o1/o3 return for `reasoning_effort="none"` |
| 1738 | + doesn't look recoverable. |
| 1739 | + """ |
| 1740 | + if not isinstance(error, BadRequestError): |
| 1741 | + return False |
| 1742 | + body = getattr(error, "body", None) |
| 1743 | + source = None |
| 1744 | + if isinstance(body, dict): |
| 1745 | + inner = body.get("error") |
| 1746 | + source = inner if isinstance(inner, dict) else body |
| 1747 | + if not source or source.get("param") != "reasoning_effort": |
| 1748 | + return False |
| 1749 | + message = str(source.get("message") or "").lower() |
| 1750 | + return "function tools" in message and "reasoning_effort" in message |
| 1751 | + |
| 1752 | + def _reasoning_effort_none_params( |
| 1753 | + self, params: dict[str, Any] |
| 1754 | + ) -> dict[str, Any] | None: |
| 1755 | + """Params with an explicit `reasoning_effort="none"`, or None if already set. |
| 1756 | +
|
| 1757 | + Removing the key is not enough: absence means "use the server default", |
| 1758 | + which is what the request was rejected for in the first place. |
| 1759 | + """ |
| 1760 | + if params.get("reasoning_effort") == "none": |
| 1761 | + return None |
| 1762 | + return {**params, "reasoning_effort": "none"} |
| 1763 | + |
1697 | 1764 | def _effective_api(self) -> str: |
1698 | 1765 | """Which OpenAI API to actually use for this model. |
1699 | 1766 |
|
@@ -1960,6 +2027,11 @@ def _handle_completion( |
1960 | 2027 | logging.error(f"Context window exceeded: {e}") |
1961 | 2028 | raise LLMContextLengthExceededError(str(e)) from e |
1962 | 2029 |
|
| 2030 | + # `_call_completions` retries this one, so reporting a failed call |
| 2031 | + # here would surface an error the caller never experiences. |
| 2032 | + if self._rejects_reasoning_effort_with_tools(e): |
| 2033 | + raise |
| 2034 | + |
1963 | 2035 | error_msg = f"OpenAI API call failed: {e!s}" |
1964 | 2036 | logging.error(error_msg) |
1965 | 2037 | self._emit_call_failed_event( |
@@ -2383,6 +2455,11 @@ async def _ahandle_completion( |
2383 | 2455 | logging.error(f"Context window exceeded: {e}") |
2384 | 2456 | raise LLMContextLengthExceededError(str(e)) from e |
2385 | 2457 |
|
| 2458 | + # `_call_completions` retries this one, so reporting a failed call |
| 2459 | + # here would surface an error the caller never experiences. |
| 2460 | + if self._rejects_reasoning_effort_with_tools(e): |
| 2461 | + raise |
| 2462 | + |
2386 | 2463 | error_msg = f"OpenAI API call failed: {e!s}" |
2387 | 2464 | logging.error(error_msg) |
2388 | 2465 | self._emit_call_failed_event( |
|
0 commit comments