From e5d6e28b927190f88291b13cc2cc75f26a2e71eb Mon Sep 17 00:00:00 2001 From: Oleg Bantysh Date: Wed, 4 Nov 2026 22:08:28 +0400 Subject: [PATCH] feat(providers): add Fortytwo as an OpenAI-compatible provider --- README.md | 1 + litellm/__init__.py | 5 + litellm/constants.py | 2 + litellm/llms/openai_like/providers.json | 12 + ...odel_prices_and_context_window_backup.json | 16 ++ .../provider_endpoints_support_backup.json | 26 ++ .../provider_create_fields.json | 28 +++ litellm/types/utils.py | 1 + model_prices_and_context_window.json | 16 ++ provider_endpoints_support.json | 26 ++ .../openai_like/test_fortytwo_provider.py | 229 ++++++++++++++++++ 11 files changed, 362 insertions(+) create mode 100644 tests/unit/llms/openai_like/test_fortytwo_provider.py diff --git a/README.md b/README.md index c4aeee80fb4..3c29152195e 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,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) | ✅ | ✅ | ✅ | ✅ | | | | | | | diff --git a/litellm/__init__.py b/litellm/__init__.py index c1f4bb6a3df..8f7435fa1ee 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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, diff --git a/litellm/constants.py b/litellm/constants.py index 32b47bc6b46..729bad8f01e 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1066,6 +1066,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", @@ -1136,6 +1137,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 diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index c89ede6e77e..0f34d90cc3e 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -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" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 157c79ca388..0dab4dd4add 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -29053,6 +29053,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, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 672a2739964..d4621e5a62f 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -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": { diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 9d4ede7b90d..7fdf1cc6de3 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ee80b63f93b..d8455da0738 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4748,6 +4748,7 @@ class LlmProviders(str, Enum): LANGFLOW = "langflow" MICROSOFT_365_COPILOT = "microsoft_365_copilot" MINIMAX = "minimax" + FORTYTWO = "fortytwo" SYNTHETIC = "synthetic" APERTIS = "apertis" NANOGPT = "nano-gpt" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 157c79ca388..0dab4dd4add 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -29053,6 +29053,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, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 672a2739964..d4621e5a62f 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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": { diff --git a/tests/unit/llms/openai_like/test_fortytwo_provider.py b/tests/unit/llms/openai_like/test_fortytwo_provider.py new file mode 100644 index 00000000000..8235ca24845 --- /dev/null +++ b/tests/unit/llms/openai_like/test_fortytwo_provider.py @@ -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