mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge e5d6e28b92 into c36acd6e29
This commit is contained in:
commit
9ed3da4c67
11 changed files with 362 additions and 0 deletions
|
|
@ -404,6 +404,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [Fal AI (`fal_ai`)](https://docs.litellm.ai/docs/providers/fal_ai) | ✅ | ✅ | ✅ | | ✅ | | | | | |
|
||||
| [Featherless AI (`featherless_ai`)](https://docs.litellm.ai/docs/providers/featherless_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Fireworks AI (`fireworks_ai`)](https://docs.litellm.ai/docs/providers/fireworks_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Fortytwo (`fortytwo`)](https://docs.litellm.ai/docs/providers/fortytwo) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [FriendliAI (`friendliai`)](https://docs.litellm.ai/docs/providers/friendliai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Galadriel (`galadriel`)](https://docs.litellm.ai/docs/providers/galadriel) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [GitHub Copilot (`github_copilot`)](https://docs.litellm.ai/docs/providers/github_copilot) | ✅ | ✅ | ✅ | ✅ | | | | | | |
|
||||
|
|
|
|||
|
|
@ -689,6 +689,7 @@ openinfer_models: Final[Set[str]] = set()
|
|||
kyma_models: Final[set[str]] = set() # mutable-ok: populated when the shared model catalog loads
|
||||
streamlake_models: Final[Set[str]] = set()
|
||||
wally_models: Final[Set[str]] = set()
|
||||
fortytwo_models: Final[Set[str]] = set()
|
||||
zerogpu_models: Final[Set[str]] = set()
|
||||
v0_models: Set = set()
|
||||
morph_models: Set = set()
|
||||
|
|
@ -987,6 +988,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
|
|||
streamlake_models.add(key)
|
||||
elif value.get("litellm_provider") == "wally":
|
||||
wally_models.add(key)
|
||||
elif value.get("litellm_provider") == "fortytwo":
|
||||
fortytwo_models.add(key)
|
||||
elif value.get("litellm_provider") == "zerogpu":
|
||||
zerogpu_models.add(key)
|
||||
elif value.get("litellm_provider") == "v0":
|
||||
|
|
@ -1169,6 +1172,7 @@ model_list = list(
|
|||
| kyma_models
|
||||
| streamlake_models
|
||||
| wally_models
|
||||
| fortytwo_models
|
||||
| zerogpu_models
|
||||
| v0_models
|
||||
| morph_models
|
||||
|
|
@ -1303,6 +1307,7 @@ def _build_models_by_provider() -> dict:
|
|||
"kyma": kyma_models,
|
||||
"streamlake": streamlake_models,
|
||||
"wally": wally_models,
|
||||
"fortytwo": fortytwo_models,
|
||||
"zerogpu": zerogpu_models,
|
||||
"v0": v0_models,
|
||||
"morph": morph_models,
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,7 @@ openai_compatible_endpoints: Final[list[str]] = [
|
|||
"https://api-inference.modelscope.cn/v1",
|
||||
"https://api.moonshot.ai/v1",
|
||||
"https://api.publicai.co/v1",
|
||||
"https://api.fortytwo.network/v1",
|
||||
"https://api.aiand.com/v1",
|
||||
"https://api.synthetic.new/openai/v1",
|
||||
"https://serverless.tensormesh.ai/v1",
|
||||
|
|
@ -1137,6 +1138,7 @@ openai_compatible_providers: Final[list[str]] = [
|
|||
"novita",
|
||||
"meta_llama",
|
||||
"publicai", # PublicAI - JSON-configured provider
|
||||
"fortytwo",
|
||||
"aiand", # ai& - JSON-configured provider
|
||||
"synthetic", # Synthetic - JSON-configured provider
|
||||
"tensormesh", # Tensormesh - JSON-configured provider
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@
|
|||
"convert_content_list_to_string": true
|
||||
}
|
||||
},
|
||||
"fortytwo": {
|
||||
"base_url": "https://api.fortytwo.network/v1",
|
||||
"api_key_env": "FORTYTWO_API_KEY",
|
||||
"api_base_env": "FORTYTWO_API_BASE",
|
||||
"provider_info": {
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true
|
||||
},
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
]
|
||||
},
|
||||
"helicone": {
|
||||
"base_url": "https://ai-gateway.helicone.ai/",
|
||||
"api_key_env": "HELICONE_API_KEY"
|
||||
|
|
|
|||
|
|
@ -29168,6 +29168,22 @@
|
|||
"output_cost_per_token": 0.0,
|
||||
"source": "https://fireworks.ai/pricing"
|
||||
},
|
||||
"fortytwo/qwen/qwen3.8-27b": {
|
||||
"cache_read_input_token_cost": 8.5e-08,
|
||||
"input_cost_per_token": 4.25e-07,
|
||||
"litellm_provider": "fortytwo",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 235929,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.55e-06,
|
||||
"source": "https://docs.fortytwo.network/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
|
|||
|
|
@ -5509,6 +5509,32 @@
|
|||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"fortytwo": {
|
||||
"display_name": "Fortytwo (`fortytwo`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/fortytwo",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,34 @@
|
|||
],
|
||||
"default_model_placeholder": "aiand/openai/gpt-oss-120b"
|
||||
},
|
||||
{
|
||||
"provider": "FORTYTWO",
|
||||
"provider_display_name": "Fortytwo",
|
||||
"litellm_provider": "fortytwo",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "https://api.fortytwo.network/v1",
|
||||
"tooltip": null,
|
||||
"required": false,
|
||||
"field_type": "text",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": true,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "fortytwo/qwen/qwen3.8-27b"
|
||||
},
|
||||
{
|
||||
"provider": "SCALEDOWN",
|
||||
"provider_display_name": "ScaleDown",
|
||||
|
|
|
|||
|
|
@ -4773,6 +4773,7 @@ class LlmProviders(str, Enum):
|
|||
LANGFLOW = "langflow"
|
||||
MICROSOFT_365_COPILOT = "microsoft_365_copilot"
|
||||
MINIMAX = "minimax"
|
||||
FORTYTWO = "fortytwo"
|
||||
SYNTHETIC = "synthetic"
|
||||
APERTIS = "apertis"
|
||||
NANOGPT = "nano-gpt"
|
||||
|
|
|
|||
|
|
@ -29168,6 +29168,22 @@
|
|||
"output_cost_per_token": 0.0,
|
||||
"source": "https://fireworks.ai/pricing"
|
||||
},
|
||||
"fortytwo/qwen/qwen3.8-27b": {
|
||||
"cache_read_input_token_cost": 8.5e-08,
|
||||
"input_cost_per_token": 4.25e-07,
|
||||
"litellm_provider": "fortytwo",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 235929,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.55e-06,
|
||||
"source": "https://docs.fortytwo.network/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
|
|||
|
|
@ -5509,6 +5509,32 @@
|
|||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"fortytwo": {
|
||||
"display_name": "Fortytwo (`fortytwo`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/fortytwo",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoints": {
|
||||
|
|
|
|||
229
tests/unit/llms/openai_like/test_fortytwo_provider.py
Normal file
229
tests/unit/llms/openai_like/test_fortytwo_provider.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import json
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.proxy.auth.model_checks import get_provider_models
|
||||
|
||||
MODEL: Final = "fortytwo/qwen/qwen3.8-27b"
|
||||
API_BASE: Final = "https://api.fortytwo.network/v1"
|
||||
MESSAGES: Final = [{"role": "user", "content": "hello"}]
|
||||
USAGE: Final = {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 200,
|
||||
"total_tokens": 1200,
|
||||
"prompt_tokens_details": {"cached_tokens": 400},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fortytwo_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setenv("FORTYTWO_API_KEY", "fortytwo-unit-test-key")
|
||||
monkeypatch.delenv("FORTYTWO_API_BASE", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def _completion_body() -> dict[str, object]:
|
||||
return {
|
||||
"id": "chatcmpl-fortytwo-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "qwen/qwen3.8-27b",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "answer", "reasoning": "reasoning text"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": USAGE,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [None, "https://fortytwo-unit.invalid/v1"])
|
||||
def test_completion_uses_fortytwo_environment_and_strips_only_provider_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch, override: str | None
|
||||
) -> None:
|
||||
if override is not None:
|
||||
monkeypatch.setenv("FORTYTWO_API_BASE", override)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-unit-key")
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{override or API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
response: Final = litellm.completion(model=MODEL, messages=MESSAGES, num_retries=0)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers["authorization"] == "Bearer fortytwo-unit-test-key"
|
||||
assert json.loads(request.content) == {"model": "qwen/qwen3.8-27b", "messages": MESSAGES}
|
||||
assert response.choices[0].message.content == "answer"
|
||||
assert response.choices[0].message.reasoning_content == "reasoning text"
|
||||
|
||||
|
||||
def test_fortytwo_key_in_generic_openai_environment_is_used_when_own_key_is_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("FORTYTWO_API_KEY")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "fortytwo-key-in-openai-env")
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
response: Final = litellm.completion(model=MODEL, messages=MESSAGES, num_retries=0)
|
||||
|
||||
assert len(route.calls) == 1
|
||||
assert route.calls.last.request.headers["authorization"] == "Bearer fortytwo-key-in-openai-env"
|
||||
assert json.loads(route.calls.last.request.content) == {"model": "qwen/qwen3.8-27b", "messages": MESSAGES}
|
||||
assert response.choices[0].message.content == "answer"
|
||||
|
||||
|
||||
def test_rejected_key_preserves_authentication_failure() -> None:
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(
|
||||
401,
|
||||
json={"error": {"message": "Invalid API key", "type": "authentication_error", "code": "invalid_api_key"}},
|
||||
)
|
||||
with pytest.raises(litellm.AuthenticationError):
|
||||
litellm.completion(model=MODEL, messages=MESSAGES, num_retries=0)
|
||||
assert len(route.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit_name", ["max_tokens", "max_completion_tokens"])
|
||||
def test_output_limit_reaches_upstream(limit_name: str) -> None:
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
litellm.completion(model=MODEL, messages=MESSAGES, num_retries=0, **{limit_name: 17})
|
||||
assert json.loads(route.calls.last.request.content) == {
|
||||
"model": "qwen/qwen3.8-27b",
|
||||
"messages": MESSAGES,
|
||||
limit_name: 17,
|
||||
}
|
||||
|
||||
|
||||
def test_stream_preserves_reasoning_answer_and_final_usage() -> None:
|
||||
events: Final = (
|
||||
{"choices": [{"index": 0, "delta": {"role": "assistant", "reasoning": "think"}, "finish_reason": None}]},
|
||||
{"choices": [{"index": 0, "delta": {"content": "answer"}, "finish_reason": None}]},
|
||||
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
|
||||
{"choices": [], "usage": USAGE},
|
||||
)
|
||||
wire: Final = (
|
||||
"".join(
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"id": "chatcmpl-fortytwo-test",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1234567890,
|
||||
"model": "qwen/qwen3.8-27b",
|
||||
**event,
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
for event in events
|
||||
)
|
||||
+ "data: [DONE]\n\n"
|
||||
)
|
||||
with respx.mock() as upstream:
|
||||
upstream.post(f"{API_BASE}/chat/completions").respond(
|
||||
200, text=wire, headers={"content-type": "text/event-stream"}
|
||||
)
|
||||
chunks: Final = tuple(
|
||||
litellm.completion(
|
||||
model=MODEL, messages=MESSAGES, stream=True, stream_options={"include_usage": True}, num_retries=0
|
||||
)
|
||||
)
|
||||
assert (
|
||||
"".join(getattr(chunk.choices[0].delta, "reasoning_content", "") or "" for chunk in chunks if chunk.choices)
|
||||
== "think"
|
||||
)
|
||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "answer"
|
||||
assert chunks[-1].usage.prompt_tokens == USAGE["prompt_tokens"]
|
||||
assert chunks[-1].usage.completion_tokens == USAGE["completion_tokens"]
|
||||
|
||||
|
||||
def test_tool_call_request_and_response_preserve_function_contract() -> None:
|
||||
tool: Final = {
|
||||
"type": "function",
|
||||
"function": {"name": "city", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}}},
|
||||
}
|
||||
tool_call: Final = {
|
||||
"id": "call-city",
|
||||
"type": "function",
|
||||
"function": {"name": "city", "arguments": '{"name":"London"}'},
|
||||
}
|
||||
body: Final = {
|
||||
**_completion_body(),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": None, "tool_calls": [tool_call]},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
],
|
||||
}
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=body)
|
||||
response: Final = litellm.completion(
|
||||
model=MODEL, messages=MESSAGES, tools=[tool], tool_choice="required", num_retries=0
|
||||
)
|
||||
assert json.loads(route.calls.last.request.content) == {
|
||||
"model": "qwen/qwen3.8-27b",
|
||||
"messages": MESSAGES,
|
||||
"tools": [tool],
|
||||
"tool_choice": "required",
|
||||
}
|
||||
assert response.choices[0].message.tool_calls[0].model_dump() == tool_call
|
||||
|
||||
|
||||
def test_response_cost_uses_cached_and_uncached_catalogue_rates() -> None:
|
||||
with respx.mock() as upstream:
|
||||
upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
response: Final = litellm.completion(model=MODEL, messages=MESSAGES, num_retries=0)
|
||||
rates: Final = litellm.model_cost[MODEL]
|
||||
expected: Final = (
|
||||
600 * rates["input_cost_per_token"]
|
||||
+ 400 * rates["cache_read_input_token_cost"]
|
||||
+ 200 * rates["output_cost_per_token"]
|
||||
)
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_responses_call_uses_chat_bridge_and_keeps_responses_shape() -> None:
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
response: Final = litellm.responses(model=MODEL, input="hello", num_retries=0)
|
||||
assert json.loads(route.calls.last.request.content)["model"] == "qwen/qwen3.8-27b"
|
||||
assert response.output[-1].content[0].text == "answer"
|
||||
assert response.usage.input_tokens == USAGE["prompt_tokens"]
|
||||
assert response.usage.output_tokens == USAGE["completion_tokens"]
|
||||
|
||||
|
||||
def test_messages_call_uses_chat_bridge_and_keeps_messages_shape() -> None:
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
response: Final = litellm.anthropic.messages.create(model=MODEL, messages=MESSAGES, max_tokens=17)
|
||||
assert json.loads(route.calls.last.request.content)["model"] == "qwen/qwen3.8-27b"
|
||||
assert response["content"][-1] == {"type": "text", "text": "answer"}
|
||||
assert response["usage"]["input_tokens"] + response["usage"]["cache_read_input_tokens"] == USAGE["prompt_tokens"]
|
||||
assert response["usage"]["output_tokens"] == USAGE["completion_tokens"]
|
||||
|
||||
|
||||
def test_discovery_and_bundled_catalogue_match_runtime_model() -> None:
|
||||
root: Final = Path(__file__).resolve().parents[4]
|
||||
cost_map: Final = json.loads((root / "model_prices_and_context_window.json").read_text())
|
||||
bundled: Final = json.loads((root / "litellm/model_prices_and_context_window_backup.json").read_text())
|
||||
assert cost_map[MODEL] == bundled[MODEL] == litellm.model_cost[MODEL]
|
||||
assert MODEL in get_provider_models(provider="fortytwo")
|
||||
assert MODEL in litellm.model_list
|
||||
assert MODEL in litellm.models_by_provider["fortytwo"]
|
||||
assert get_llm_provider("publicai/a-test-model")[0:2] == ("a-test-model", "publicai")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["none", "minimal", "low", "medium", "high", "xhigh", "max"])
|
||||
def test_reasoning_effort_is_forwarded_without_remapping(effort: str) -> None:
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post(f"{API_BASE}/chat/completions").respond(200, json=_completion_body())
|
||||
litellm.completion(model=MODEL, messages=MESSAGES, reasoning_effort=effort, num_retries=0)
|
||||
assert json.loads(route.calls.last.request.content)["reasoning_effort"] == effort
|
||||
Loading…
Add table
Reference in a new issue