mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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.
This commit is contained in:
parent
992a8123ac
commit
20ef10e920
14 changed files with 838 additions and 35 deletions
|
|
@ -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) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
310
tests/test_litellm/llms/openai_like/test_apodex_provider.py
Normal file
310
tests/test_litellm/llms/openai_like/test_apodex_provider.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT010": {
|
||||
"limit": 16715
|
||||
"limit": 16707
|
||||
},
|
||||
"LIT011": {
|
||||
"limit": 5593
|
||||
"limit": 5589
|
||||
},
|
||||
"LIT012": {
|
||||
"limit": 4519
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue