From 20ef10e920d3f152e863523c5a8ea9e05f776a34 Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Sun, 16 Aug 2026 11:19:57 +0800 Subject: [PATCH] feat(providers): add Apodex as an OpenAI-compatible provider Registers apodex via providers.json with /v1/chat/completions, /v1/responses and native /v1/messages, plus price map entries for the two core models and the six deep research tiers. Apodex defaults `stream` to true on both /v1/chat/completions and /v1/responses, so a non-streaming litellm call would get SSE back and fail to parse it. Adds a `send_explicit_stream_false` special-handling flag that pins the field on the wire, and rewrites the JSON provider param mapping to build its result instead of mutating the caller's dict. --- README.md | 1 + basedpyright-code-budget.json | 4 +- litellm/constants.py | 2 + .../get_llm_provider_logic.py | 3 + litellm/llms/openai_like/README.md | 10 +- litellm/llms/openai_like/dynamic_config.py | 72 ++-- litellm/llms/openai_like/providers.json | 12 + ...odel_prices_and_context_window_backup.json | 190 +++++++++++ litellm/types/utils.py | 1 + model_prices_and_context_window.json | 190 +++++++++++ provider_endpoints_support.json | 17 + .../llms/openai_like/test_apodex_provider.py | 310 ++++++++++++++++++ .../llms/openai_like/test_json_providers.py | 57 ++++ type-discipline-budget.json | 4 +- 14 files changed, 838 insertions(+), 35 deletions(-) create mode 100644 tests/test_litellm/llms/openai_like/test_apodex_provider.py diff --git a/README.md b/README.md index 32b0160dbaa..265038aaa85 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ | [Anthropic (`anthropic`)](https://docs.litellm.ai/docs/providers/anthropic) | ✅ | ✅ | ✅ | | | | | | ✅ | | | [Anthropic Text (`anthropic_text`)](https://docs.litellm.ai/docs/providers/anthropic) | ✅ | ✅ | ✅ | | | | | | ✅ | | | [Anyscale](https://docs.litellm.ai/docs/providers/anyscale) | ✅ | ✅ | ✅ | | | | | | | | +| [Apodex (`apodex`)](https://docs.litellm.ai/docs/providers/apodex) | ✅ | ✅ | ✅ | | | | | | | | | [AssemblyAI (`assemblyai`)](https://docs.litellm.ai/docs/pass_through/assembly_ai) | ✅ | ✅ | ✅ | | | ✅ | | | | | | [Auto Router (`auto_router`)](https://docs.litellm.ai/docs/proxy/auto_routing) | ✅ | ✅ | ✅ | | | | | | | | | [AWS - Bedrock (`bedrock`)](https://docs.litellm.ai/docs/providers/bedrock) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ | diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 06010c706e3..0109927f2c6 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -99,7 +99,7 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44776 + "limit": 44774 }, "reportUnknownLambdaType": { "limit": 113 @@ -111,7 +111,7 @@ "limit": 19967 }, "reportUnknownVariableType": { - "limit": 30881 + "limit": 30879 }, "reportUnnecessaryCast": { "limit": 117 diff --git a/litellm/constants.py b/litellm/constants.py index 8f236eba327..7c99b0c9c46 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -756,6 +756,7 @@ openai_compatible_endpoints: Final[list] = [ "https://api.libertai.io/v1", "https://pinstripes.io/v1", "https://api.meta.ai/v1", + "https://api.apodex.ai/v1", ] @@ -823,6 +824,7 @@ openai_compatible_providers: Final[list] = [ "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider + "apodex", # Apodex - JSON-configured provider ] openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index dbb40913e14..9f07710007f 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -349,6 +349,9 @@ def get_llm_provider( elif endpoint == "https://api.meta.ai/v1": custom_llm_provider = "meta" dynamic_api_key = get_secret_str("META_API_KEY") + elif endpoint == "https://api.apodex.ai/v1": + custom_llm_provider = "apodex" + dynamic_api_key = get_secret_str("APODEX_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception(f"api base needs to be a string. api_base={api_base}") diff --git a/litellm/llms/openai_like/README.md b/litellm/llms/openai_like/README.md index e9aaafe48a1..fc9375f2efb 100644 --- a/litellm/llms/openai_like/README.md +++ b/litellm/llms/openai_like/README.md @@ -59,7 +59,15 @@ That's it! The provider will be automatically loaded and available. // Optional: Special handling flags "special_handling": { - "convert_content_list_to_string": true + "convert_content_list_to_string": true, + + // Send "stream": false explicitly instead of omitting it. Needed by + // providers whose /v1/chat/completions and /v1/responses default to + // streaming, where omitting the field returns SSE to a non-streaming call + "send_explicit_stream_false": true, + + // Always send "store": false on /v1/responses + "force_store_false": true } } } diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 19e29bcdcb2..52168e8bb66 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -2,7 +2,7 @@ Dynamic configuration class generator for JSON-based providers. """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Any, Final, Literal, overload from litellm._logging import verbose_logger @@ -17,6 +17,17 @@ from litellm.types.llms.openai import AllMessageValues from .json_loader import SimpleProviderConfig +def _clamp_temperature(temperature: float, n: int, constraints: Mapping[str, float]) -> float: + capped: Final = ( + min(temperature, constraints["temperature_max"]) if "temperature_max" in constraints else temperature + ) + floored: Final = max(capped, constraints["temperature_min"]) if "temperature_min" in constraints else capped + floor_for_multiple_choices: Final = constraints.get("temperature_min_with_n_gt_1") + if n > 1 and floor_for_multiple_choices is not None: + return max(floored, floor_for_multiple_choices) + return floored + + def create_config_class(provider: SimpleProviderConfig): """Generate config class dynamically from JSON configuration""" @@ -131,37 +142,36 @@ def create_config_class(provider: SimpleProviderConfig): """Apply parameter mappings and constraints""" supported_params: Final = self.get_supported_openai_params(model) + mapped: Final = { + **optional_params, + **{ + provider.param_mappings.get(param, param): value + for param, value in non_default_params.items() + if param in provider.param_mappings or param in supported_params + }, + } - # Apply supported params - for param, value in non_default_params.items(): - # Check parameter mappings first - if param in provider.param_mappings: - optional_params[provider.param_mappings[param]] = value - elif param in supported_params: - optional_params[param] = value + constrained: Final = ( + mapped + if "temperature" not in mapped + else { + **mapped, + "temperature": _clamp_temperature( + temperature=mapped["temperature"], + n=mapped.get("n", 1), + constraints=provider.constraints, + ), + } + ) - # Apply temperature constraints if present - if "temperature" in optional_params: - temp = optional_params["temperature"] - constraints: Final = provider.constraints - - # Clamp to max - if "temperature_max" in constraints: - temp = min(temp, constraints["temperature_max"]) - - # Clamp to min - if "temperature_min" in constraints: - temp = max(temp, constraints["temperature_min"]) - - # Special case: temperature_min_with_n_gt_1 - if "temperature_min_with_n_gt_1" in constraints: - n: Final = optional_params.get("n", 1) - if n > 1 and temp < constraints["temperature_min_with_n_gt_1"]: - temp = constraints["temperature_min_with_n_gt_1"] - - optional_params["temperature"] = temp - - return optional_params + # The OpenAI SDK omits `stream` entirely when it is false, which makes + # stream-by-default providers answer a non-streaming call with SSE. Pin it + # on the wire through extra_body, which the SDK merges into the request body. + if not provider.special_handling.get("send_explicit_stream_false") or constrained.get("stream"): + return constrained + requested_extra_body: Final = constrained.get("extra_body") + extra_body: Final[dict] = requested_extra_body if isinstance(requested_extra_body, dict) else {} + return {**constrained, "extra_body": {"stream": False, **extra_body}} @property def custom_llm_provider(self) -> str | None: @@ -232,6 +242,8 @@ def create_responses_config_class(provider: SimpleProviderConfig): ) -> dict: if provider.special_handling.get("force_store_false"): response_api_optional_request_params["store"] = False + if provider.special_handling.get("send_explicit_stream_false"): + response_api_optional_request_params.setdefault("stream", False) return super().transform_responses_api_request( model=model, input=input, diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index 164100d4194..18cf49e6ccc 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -175,6 +175,18 @@ "base_class": "openai_gpt", "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] }, + "apodex": { + "base_url": "https://api.apodex.ai/v1", + "api_key_env": "APODEX_API_KEY", + "api_base_env": "APODEX_API_BASE", + "param_mappings": { + "max_completion_tokens": "max_tokens" + }, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"], + "special_handling": { + "send_explicit_stream_false": true + } + }, "pinstripes": { "base_url": "https://pinstripes.io/v1", "api_key_env": "PINSTRIPES_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e6c6cab0631..9d6a66e9327 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -48296,6 +48296,196 @@ ], "supports_audio_output": true }, + "apodex/apodex-1.1": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 3e-08, + "output_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-07, + "cache_read_input_token_cost_above_200k_tokens": 6e-08, + "output_cost_per_token_above_200k_tokens": 6e-06, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "apodex/apodex-1.1-mini": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-07, + "cache_read_input_token_cost": 1e-08, + "output_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 2e-08, + "output_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "apodex/apodex-1-1-deep-research": { + "max_tokens": 65536, + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-1-deep-solve": { + "max_tokens": 65536, + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-1-deep-discover": { + "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 0.0001, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-research": { + "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 4e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-solve": { + "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-discover": { + "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 0.0001, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, "fallback_generalizations": { "rules": [ { diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 272fbabf807..61514db28fc 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3727,6 +3727,7 @@ class LlmProviders(str, Enum): PINSTRIPES = "pinstripes" DARKBLOOM = "darkbloom" META = "meta" + APODEX = "apodex" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" BEDROCK_MANTLE = "bedrock_mantle" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e6c6cab0631..9d6a66e9327 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -48296,6 +48296,196 @@ ], "supports_audio_output": true }, + "apodex/apodex-1.1": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 3e-08, + "output_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-07, + "cache_read_input_token_cost_above_200k_tokens": 6e-08, + "output_cost_per_token_above_200k_tokens": 6e-06, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "apodex/apodex-1.1-mini": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-07, + "cache_read_input_token_cost": 1e-08, + "output_cost_per_token": 1e-06, + "input_cost_per_token_above_200k_tokens": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 2e-08, + "output_cost_per_token_above_200k_tokens": 2e-06, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "apodex/apodex-1-1-deep-research": { + "max_tokens": 65536, + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-1-deep-solve": { + "max_tokens": 65536, + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-1-deep-discover": { + "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 0.0001, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-research": { + "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 4e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-solve": { + "max_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 16384, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, + "apodex/apodex-1-0-deep-discover": { + "max_tokens": 262144, + "max_input_tokens": 131072, + "max_output_tokens": 262144, + "input_cost_per_token": 1e-05, + "output_cost_per_token": 0.0001, + "litellm_provider": "apodex", + "mode": "chat", + "source": "https://platform.apodex.ai/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": false, + "supports_web_search": true + }, "fallback_generalizations": { "rules": [ { diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 0712e8e383d..ce37e5dc243 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -177,6 +177,23 @@ "interactions": true } }, + "apodex": { + "display_name": "Apodex (`apodex`)", + "url": "https://docs.litellm.ai/docs/providers/apodex", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "apertis": { "display_name": "Apertis (`apertis`)", "endpoints": { diff --git a/tests/test_litellm/llms/openai_like/test_apodex_provider.py b/tests/test_litellm/llms/openai_like/test_apodex_provider.py new file mode 100644 index 00000000000..cdcf9b4b50a --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_apodex_provider.py @@ -0,0 +1,310 @@ +""" +Tests for the Apodex provider (https://platform.apodex.ai/docs). +""" + +import json +from pathlib import Path + +import httpx +import openai +import pytest + +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +REPO_ROOT = Path(__file__).parents[4] +CORE_MODEL = "apodex/apodex-1.1" +DEEP_RESEARCH_MODEL = "apodex/apodex-1-1-deep-research" + +CHAT_RESPONSE = { + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1712345678, + "model": "apodex-1.1", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok", "reasoning_content": "let me think"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 100, + "total_tokens": 1100, + "prompt_tokens_details": {"cached_tokens": 500}, + }, +} + +STREAM_BODY = ( + b'data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1,"model":"apodex-1.1",' + b'"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}\n\n' + b"data: [DONE]\n\n" +) + + +@pytest.fixture(autouse=True) +def _apodex_env(monkeypatch: pytest.MonkeyPatch): + """Resolve models against the in-repo cost map, not the published one.""" + monkeypatch.setenv("APODEX_API_KEY", "sk-apodex-test") + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + yield + + +def _openai_client(captured: dict, *, stream: bool = False) -> openai.OpenAI: + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content) + if stream: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=STREAM_BODY) + return httpx.Response(200, json=CHAT_RESPONSE) + + return openai.OpenAI( + api_key="sk-apodex-test", + base_url="https://api.apodex.ai/v1", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + +class TestApodexRegistration: + def test_provider_enum_and_lists(self): + assert LlmProviders.APODEX.value == "apodex" + assert "apodex" in litellm.provider_list + assert "apodex" in litellm.constants.openai_compatible_providers + + def test_json_provider_config(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + apodex = JSONProviderRegistry.get("apodex") + assert apodex is not None + assert apodex.base_url == "https://api.apodex.ai/v1" + assert apodex.api_key_env == "APODEX_API_KEY" + assert apodex.api_base_env == "APODEX_API_BASE" + assert apodex.param_mappings["max_completion_tokens"] == "max_tokens" + assert apodex.supported_endpoints == ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + assert JSONProviderRegistry.supports_responses_api("apodex") is True + + def test_provider_resolution(self): + model, provider, _, api_base = litellm.get_llm_provider(model=CORE_MODEL) + assert (model, provider, api_base) == ("apodex-1.1", "apodex", "https://api.apodex.ai/v1") + + def test_api_base_autodetection(self): + _, provider, api_key, _ = litellm.get_llm_provider(model="apodex-1.1", api_base="https://api.apodex.ai/v1") + assert provider == "apodex" + assert api_key == "sk-apodex-test" + + def test_explicit_api_base_and_key_win(self): + _, provider, api_key, api_base = litellm.get_llm_provider( + model=CORE_MODEL, api_base="https://gateway.internal/v1", api_key="sk-override" + ) + assert (provider, api_key, api_base) == ("apodex", "sk-override", "https://gateway.internal/v1") + + +class TestApodexStreamDefault: + """Apodex defaults `stream` to true, so a non-streaming call must pin it to false. + + Regression guard: the OpenAI SDK omits `stream` when it is false, which would make + litellm.completion() receive SSE and fail to parse it. + """ + + def test_chat_completion_pins_stream_false(self): + captured: dict = {} + response = litellm.completion( + model=CORE_MODEL, + messages=[{"role": "user", "content": "hi"}], + client=_openai_client(captured), + ) + + assert captured["url"] == "https://api.apodex.ai/v1/chat/completions" + assert captured["body"]["stream"] is False + assert captured["body"]["model"] == "apodex-1.1" + assert response.choices[0].message.reasoning_content == "let me think" + + def test_chat_completion_streaming_sends_stream_true(self): + captured: dict = {} + chunks = list( + litellm.completion( + model=CORE_MODEL, + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=_openai_client(captured, stream=True), + ) + ) + + assert captured["body"]["stream"] is True + assert chunks + + def test_user_supplied_extra_body_is_preserved(self): + captured: dict = {} + litellm.completion( + model=CORE_MODEL, + messages=[{"role": "user", "content": "hi"}], + extra_body={"mcp_servers": [{"name": "docs", "url": "https://example.com/mcp"}]}, + client=_openai_client(captured), + ) + + assert captured["body"]["stream"] is False + assert captured["body"]["mcp_servers"] == [{"name": "docs", "url": "https://example.com/mcp"}] + + def test_responses_api_pins_stream_false(self): + captured: dict = {} + + class CapturingHandler(HTTPHandler): + def post(self, *args, **kwargs): + captured.update(url=kwargs.get("url"), body=kwargs.get("json")) + raise RuntimeError("captured") + + with pytest.raises(Exception): + litellm.responses(model=DEEP_RESEARCH_MODEL, input="hi", client=CapturingHandler()) + + assert captured["url"] == "https://api.apodex.ai/v1/responses" + assert captured["body"]["stream"] is False + assert captured["body"]["model"] == "apodex-1-1-deep-research" + + @pytest.mark.asyncio + async def test_responses_api_streaming_sends_stream_true(self): + captured: dict = {} + + class CapturingHandler(AsyncHTTPHandler): + async def post(self, *args, **kwargs): + captured.update(body=kwargs.get("json")) + raise RuntimeError("captured") + + with pytest.raises(Exception): + await litellm.aresponses(model=DEEP_RESEARCH_MODEL, input="hi", stream=True, client=CapturingHandler()) + + assert captured["body"]["stream"] is True + + def test_flag_is_opt_in_for_other_json_providers(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + pinstripes = JSONProviderRegistry.get("pinstripes") + assert pinstripes is not None + assert "send_explicit_stream_false" not in pinstripes.special_handling + + config = ProviderConfigManager.get_provider_chat_config( + model="ps/glm-4.5-air", provider=LlmProviders.PINSTRIPES + ) + params = config.map_openai_params({}, {}, "ps/glm-4.5-air", False) + assert "stream" not in params + assert "stream" not in (params.get("extra_body") or {}) + + +class TestApodexToolSupport: + """Deep research tiers reject OpenAI-style tools; core models accept them.""" + + def test_deep_research_drops_tool_params(self): + config = ProviderConfigManager.get_provider_chat_config( + model="apodex-1-1-deep-research", provider=LlmProviders.APODEX + ) + supported = config.get_supported_openai_params("apodex-1-1-deep-research") + assert "tools" not in supported + assert "tool_choice" not in supported + + def test_core_model_keeps_tool_params(self): + config = ProviderConfigManager.get_provider_chat_config(model="apodex-1.1", provider=LlmProviders.APODEX) + supported = config.get_supported_openai_params("apodex-1.1") + assert "tools" in supported + assert "tool_choice" in supported + + def test_max_completion_tokens_maps_to_max_tokens(self): + config = ProviderConfigManager.get_provider_chat_config(model="apodex-1.1", provider=LlmProviders.APODEX) + params = config.map_openai_params({"max_completion_tokens": 512}, {}, "apodex-1.1", False) + assert params["max_tokens"] == 512 + assert "max_completion_tokens" not in params + + +class TestApodexAnthropicMessages: + """Apodex serves POST /v1/messages natively, so the payload is forwarded untranslated.""" + + def test_native_passthrough_config(self): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="apodex-1.1", provider=LlmProviders.APODEX + ) + assert config is not None + assert type(config).__name__ == "JSONProviderAnthropicMessagesConfig" + assert ( + config.get_complete_url( + api_base=None, api_key=None, model="apodex-1.1", optional_params={}, litellm_params={} + ) + == "https://api.apodex.ai/v1/messages" + ) + + def test_headers_use_provider_api_key(self): + config = ProviderConfigManager.get_provider_anthropic_messages_config( + model="apodex-1.1", provider=LlmProviders.APODEX + ) + assert config is not None + headers, _ = config.validate_anthropic_messages_environment( + headers={}, model="apodex-1.1", messages=[], optional_params={}, litellm_params={} + ) + assert headers["authorization"] == "Bearer sk-apodex-test" + assert headers["anthropic-version"] == "2023-06-01" + + +class TestApodexModelMetadata: + @pytest.fixture(scope="class") + def model_cost(self) -> dict: + with open(REPO_ROOT / "model_prices_and_context_window.json") as f: + return json.load(f) + + def test_core_model_pricing(self, model_cost: dict): + info = model_cost["apodex/apodex-1.1"] + assert info["litellm_provider"] == "apodex" + assert info["mode"] == "chat" + assert info["max_input_tokens"] == 262144 + assert info["input_cost_per_token"] == 3e-07 + assert info["cache_read_input_token_cost"] == 3e-08 + assert info["output_cost_per_token"] == 3e-06 + # Requests over 200K input tokens are billed at 2x across every tier + assert info["input_cost_per_token_above_200k_tokens"] == 6e-07 + assert info["cache_read_input_token_cost_above_200k_tokens"] == 6e-08 + assert info["output_cost_per_token_above_200k_tokens"] == 6e-06 + assert info["supports_prompt_caching"] is True + assert info["supports_function_calling"] is True + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + + def test_deep_research_model_pricing(self, model_cost: dict): + info = model_cost["apodex/apodex-1-1-deep-research"] + assert info["max_input_tokens"] == 131072 + assert info["max_output_tokens"] == 65536 + assert info["input_cost_per_token"] == 5e-06 + assert info["output_cost_per_token"] == 2e-05 + assert info["supports_function_calling"] is False + assert info["supports_response_schema"] is False + assert info["supports_prompt_caching"] is False + assert info["supports_web_search"] is True + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses"] + + def test_every_apodex_model_is_registered(self, model_cost: dict): + assert {key for key in model_cost if key.startswith("apodex/")} == { + "apodex/apodex-1.1", + "apodex/apodex-1.1-mini", + "apodex/apodex-1-1-deep-research", + "apodex/apodex-1-1-deep-solve", + "apodex/apodex-1-1-deep-discover", + "apodex/apodex-1-0-deep-research", + "apodex/apodex-1-0-deep-solve", + "apodex/apodex-1-0-deep-discover", + } + + def test_backup_cost_map_in_sync(self, model_cost: dict): + with open(REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json") as f: + backup = json.load(f) + for key in (key for key in model_cost if key.startswith("apodex/")): + assert backup[key] == model_cost[key], f"{key} differs between main and backup cost maps" + + def test_cost_tracks_cached_input_separately(self): + captured: dict = {} + response = litellm.completion( + model=CORE_MODEL, + messages=[{"role": "user", "content": "hi"}], + client=_openai_client(captured), + ) + + # 500 fresh input + 500 cached input + 100 output + expected = 500 * 3e-07 + 500 * 3e-08 + 100 * 3e-06 + assert litellm.completion_cost(response, model=CORE_MODEL) == pytest.approx(expected) diff --git a/tests/test_litellm/llms/openai_like/test_json_providers.py b/tests/test_litellm/llms/openai_like/test_json_providers.py index c8743e1809d..442dd554885 100644 --- a/tests/test_litellm/llms/openai_like/test_json_providers.py +++ b/tests/test_litellm/llms/openai_like/test_json_providers.py @@ -245,6 +245,63 @@ class TestPinstripes: assert result["temperature"] == 0.7 +class TestTemperatureConstraints: + """`constraints` in providers.json clamp temperature before the request is sent.""" + + @staticmethod + def _config(constraints: dict): + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import SimpleProviderConfig + + provider = SimpleProviderConfig( + "constrained", + { + "base_url": "https://api.constrained.test/v1", + "api_key_env": "CONSTRAINED_API_KEY", + "constraints": constraints, + }, + ) + return create_config_class(provider)() + + def test_temperature_clamped_to_max(self): + config = self._config({"temperature_max": 1.0}) + result = config.map_openai_params({"temperature": 1.8}, {}, "some-model", False) + assert result["temperature"] == 1.0 + + def test_temperature_clamped_to_min(self): + config = self._config({"temperature_min": 0.1}) + result = config.map_openai_params({"temperature": 0.0}, {}, "some-model", False) + assert result["temperature"] == 0.1 + + def test_temperature_within_range_is_untouched(self): + config = self._config({"temperature_min": 0.1, "temperature_max": 1.0}) + result = config.map_openai_params({"temperature": 0.7}, {}, "some-model", False) + assert result["temperature"] == 0.7 + + def test_temperature_floor_applies_only_when_n_gt_1(self): + config = self._config({"temperature_min_with_n_gt_1": 0.3}) + + single = config.map_openai_params({"temperature": 0.0, "n": 1}, {}, "some-model", False) + assert single["temperature"] == 0.0 + + multiple = config.map_openai_params({"temperature": 0.0, "n": 2}, {}, "some-model", False) + assert multiple["temperature"] == 0.3 + + def test_no_constraints_leaves_temperature_alone(self): + config = self._config({}) + result = config.map_openai_params({"temperature": 1.9}, {}, "some-model", False) + assert result["temperature"] == 1.9 + + def test_caller_optional_params_are_not_mutated(self): + config = self._config({"temperature_max": 1.0}) + optional_params = {"temperature": 1.8} + result = config.map_openai_params({"max_tokens": 10}, optional_params, "some-model", False) + + assert result["temperature"] == 1.0 + assert result["max_tokens"] == 10 + assert optional_params == {"temperature": 1.8} + + class TestDarkbloom: def test_darkbloom_json_config_exists(self): from litellm.llms.openai_like.json_loader import JSONProviderRegistry diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 8e55b1533ea..b6752ada511 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,10 +27,10 @@ "limit": 0 }, "LIT010": { - "limit": 16715 + "limit": 16707 }, "LIT011": { - "limit": 5593 + "limit": 5589 }, "LIT012": { "limit": 4519