mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(apodex): route deep research through responses
This commit is contained in:
parent
eb42c9ea06
commit
4ace5c8db3
5 changed files with 137 additions and 10 deletions
|
|
@ -37,9 +37,9 @@ from ..utils import is_reasoning_auto_summary_enabled
|
|||
from .interceptors import get_messages_interceptors
|
||||
from .utils import AnthropicMessagesRequestUtils, mock_response
|
||||
|
||||
# Providers that are routed directly to the OpenAI Responses API instead of
|
||||
# Providers that are routed directly to a Responses API instead of
|
||||
# going through chat/completions.
|
||||
_RESPONSES_API_PROVIDERS: Final = frozenset({"openai"})
|
||||
_RESPONSES_API_PROVIDERS: Final = frozenset({"apodex", "openai"})
|
||||
|
||||
|
||||
def _should_route_to_responses_api(custom_llm_provider: str | None) -> bool:
|
||||
|
|
|
|||
|
|
@ -109,9 +109,7 @@ class ApodexChatConfig(OpenAIGPTConfig):
|
|||
# request body by the SDK, so it survives that drop.
|
||||
requested_extra_body: Final = renamed.get("extra_body")
|
||||
extra_body: Final = (
|
||||
requested_extra_body
|
||||
if isinstance(requested_extra_body, Mapping)
|
||||
else {} # mutable-ok: JSON request body
|
||||
requested_extra_body if isinstance(requested_extra_body, Mapping) else {} # mutable-ok: JSON request body
|
||||
)
|
||||
return { # mutable-ok: JSON request body
|
||||
**renamed,
|
||||
|
|
|
|||
|
|
@ -14,20 +14,34 @@ Ref: https://platform.apodex.ai/docs/responses-api
|
|||
https://platform.apodex.ai/docs/models
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from time import time
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
|
||||
from litellm.types.llms.openai import (
|
||||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
from ..common_utils import get_apodex_api_key, is_deep_research_model
|
||||
from ..common_utils import get_apodex_api_base, get_apodex_api_key, is_deep_research_model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
# Rejected by the core models with HTTP 400: there is no server-side conversation
|
||||
# to resume and requests are always executed inline.
|
||||
_STATEFUL_PARAMS: Final = ("previous_response_id", "background")
|
||||
_CANCEL_RESPONSE_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
class ApodexResponsesConfig(OpenAIResponsesAPIConfig):
|
||||
|
|
@ -53,6 +67,68 @@ class ApodexResponsesConfig(OpenAIResponsesAPIConfig):
|
|||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
litellm_params: dict, # mutable-ok: matches the base-class signature
|
||||
) -> str:
|
||||
resolved_base: Final = get_apodex_api_base(api_base).rstrip("/")
|
||||
return f"{resolved_base}/responses"
|
||||
|
||||
def transform_cancel_response_api_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIResponse:
|
||||
payload: Final = _CANCEL_RESPONSE_ADAPTER.validate_json(raw_response.content)
|
||||
normalized_response: Final = httpx.Response(
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
json={
|
||||
**payload,
|
||||
"created_at": payload.get("created_at", int(time())),
|
||||
"output": payload.get("output", []),
|
||||
},
|
||||
)
|
||||
return super().transform_cancel_response_api_response(
|
||||
raw_response=normalized_response,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
def transform_streaming_response(
|
||||
self,
|
||||
model: str,
|
||||
parsed_chunk: dict, # mutable-ok: matches the base-class signature
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ResponsesAPIStreamingResponse:
|
||||
swarm: Final = parsed_chunk.get("swarm")
|
||||
swarm_data: Final = swarm.get("data") if isinstance(swarm, dict) else None
|
||||
if (
|
||||
parsed_chunk.get("type") != "response.swarm.llm_delta"
|
||||
or not isinstance(swarm_data, dict)
|
||||
or swarm_data.get("channel") != "output_text"
|
||||
or not isinstance(swarm_data.get("delta"), str)
|
||||
):
|
||||
return super().transform_streaming_response(
|
||||
model=model,
|
||||
parsed_chunk=parsed_chunk,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
response_id: Final = str(parsed_chunk.get("response_id", ""))
|
||||
return super().transform_streaming_response(
|
||||
model=model,
|
||||
parsed_chunk={
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": f"msg_{response_id}",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": swarm_data["delta"],
|
||||
"sequence_number": parsed_chunk.get("sequence_number", 0),
|
||||
},
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: matches the base-class signature
|
||||
inherited: Final = super().get_supported_openai_params(model)
|
||||
if is_deep_research_model(model):
|
||||
|
|
|
|||
|
|
@ -58,10 +58,17 @@ class TestNativePassthroughRouting:
|
|||
|
||||
@pytest.mark.parametrize("model", DEEP_RESEARCH_MODELS)
|
||||
def test_deep_research_models_fall_back_to_translation(self, model: str):
|
||||
"""No native config means LiteLLM translates to chat completions, which works,
|
||||
instead of forwarding to a path Apodex does not serve for these tiers."""
|
||||
"""No native config means LiteLLM uses a protocol translation instead of
|
||||
forwarding to a path Apodex does not serve for these tiers."""
|
||||
assert _messages_config(model) is None
|
||||
|
||||
def test_deep_research_translation_uses_responses_api(self):
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
_should_route_to_responses_api,
|
||||
)
|
||||
|
||||
assert _should_route_to_responses_api("apodex") is True
|
||||
|
||||
|
||||
class TestNativePassthroughRequest:
|
||||
def test_url_targets_the_native_messages_path(self):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Research tiers keep server-side state, so the parameter contract is keyed off
|
|||
the model rather than applied provider-wide.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
|
@ -80,6 +81,17 @@ class TestConfigSelection:
|
|||
def test_request_targets_the_apodex_responses_url(self):
|
||||
assert _capture(model=CORE_MODEL, input="hi")["url"] == "https://api.apodex.ai/v1/responses"
|
||||
|
||||
def test_polling_without_model_resolution_targets_apodex(self):
|
||||
config = _responses_config("apodex-1-1-deep-research")
|
||||
assert config.get_complete_url(api_base=None, litellm_params={}) == "https://api.apodex.ai/v1/responses"
|
||||
|
||||
def test_polling_honours_an_explicit_api_base(self):
|
||||
config = _responses_config("apodex-1-1-deep-research")
|
||||
assert (
|
||||
config.get_complete_url(api_base="https://gateway.apodex.test/v1/", litellm_params={})
|
||||
== "https://gateway.apodex.test/v1/responses"
|
||||
)
|
||||
|
||||
def test_request_honours_an_api_base_override(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("APODEX_API_BASE", "https://env.apodex.test/v1")
|
||||
assert _capture(model=CORE_MODEL, input="hi")["url"] == "https://env.apodex.test/v1/responses"
|
||||
|
|
@ -175,3 +187,37 @@ class TestDeepResearchKeepsState:
|
|||
)
|
||||
assert "background" in supported
|
||||
assert "previous_response_id" in supported
|
||||
|
||||
def test_minimal_cancel_response_is_normalized(self):
|
||||
config = _responses_config("apodex-1-1-deep-research")
|
||||
response = config.transform_cancel_response_api_response(
|
||||
raw_response=httpx.Response(
|
||||
200,
|
||||
json={"id": "resp_1", "object": "response", "status": "cancelled"},
|
||||
),
|
||||
logging_obj=None,
|
||||
)
|
||||
|
||||
assert response.id == "resp_1"
|
||||
assert response.status == "cancelled"
|
||||
assert response.output == []
|
||||
assert response.created_at > 0
|
||||
|
||||
def test_deep_research_output_delta_is_normalized(self):
|
||||
config = _responses_config("apodex-1-1-deep-research")
|
||||
event = config.transform_streaming_response(
|
||||
model="apodex-1-1-deep-research",
|
||||
parsed_chunk={
|
||||
"type": "response.swarm.llm_delta",
|
||||
"response_id": "w_123",
|
||||
"sequence_number": 12,
|
||||
"swarm": {
|
||||
"agent_id": "reporter",
|
||||
"data": {"channel": "output_text", "delta": "final answer"},
|
||||
},
|
||||
},
|
||||
logging_obj=None,
|
||||
)
|
||||
assert event.type == "response.output_text.delta"
|
||||
assert event.item_id == "msg_w_123"
|
||||
assert event.delta == "final answer"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue