From 23576ecadf07836e6723f03e33b9851085feea49 Mon Sep 17 00:00:00 2001 From: Jonas Boos Date: Sat, 31 Oct 2026 18:33:53 +0100 Subject: [PATCH 1/2] feat(providers): add Flonno as an OpenAI-compatible provider Register Flonno (https://www.flonno.com/v1) as a JSON OpenAI-compatible provider with chat completions and native /v1/messages, FLONNO_API_KEY, pricing for qwen3.8-27b, Admin UI fields, and unit coverage. --- README.md | 1 + .../src/exception_mapping_utils/original.rs | 1 + litellm/__init__.py | 5 + litellm/constants.py | 2 + litellm/llms/openai_like/providers.json | 14 ++ ...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 +++ .../llms/openai_like/test_flonno_provider.py | 165 ++++++++++++++++++ .../public_endpoints/test_public_endpoints.py | 20 +++ .../public/assets/logos/flonno.png | Bin 0 -> 35073 bytes .../components/provider_info_helpers.test.tsx | 4 + .../src/components/provider_info_helpers.tsx | 5 + 16 files changed, 330 insertions(+) create mode 100644 tests/unit/llms/openai_like/test_flonno_provider.py create mode 100644 ui/litellm-dashboard/public/assets/logos/flonno.png diff --git a/README.md b/README.md index 3f1f21f903e..62a95927546 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) | ✅ | ✅ | ✅ | | | | | | | | +| [Flonno (`flonno`)](https://docs.litellm.ai/docs/providers/flonno) | ✅ | ✅ | | | | | | | | | | [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-rust/crates/core-utils/src/exception_mapping_utils/original.rs b/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs index 615d530af19..1c51492390b 100644 --- a/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs +++ b/litellm-rust/crates/core-utils/src/exception_mapping_utils/original.rs @@ -87,6 +87,7 @@ const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[ "parasail", "libertai", "featherless_ai", + "flonno", "nscale", "nebius", "dashscope", diff --git a/litellm/__init__.py b/litellm/__init__.py index f64531f86fb..b92715743e2 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() zerogpu_models: Final[Set[str]] = set() +flonno_models: Final[Set[str]] = set() v0_models: Set = set() morph_models: Set = set() opencode_go_models: Final[set[str]] = set() # mutable-ok: populated when the shared model catalog loads @@ -986,6 +987,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje streamlake_models.add(key) elif value.get("litellm_provider") == "zerogpu": zerogpu_models.add(key) + elif value.get("litellm_provider") == "flonno": + flonno_models.add(key) elif value.get("litellm_provider") == "v0": v0_models.add(key) elif value.get("litellm_provider") == "morph": @@ -1167,6 +1170,7 @@ model_list = list( | kyma_models | streamlake_models | zerogpu_models + | flonno_models | v0_models | morph_models | opencode_go_models @@ -1300,6 +1304,7 @@ def _build_models_by_provider() -> dict: "kyma": kyma_models, "streamlake": streamlake_models, "zerogpu": zerogpu_models, + "flonno": flonno_models, "v0": v0_models, "morph": morph_models, "opencode_go": opencode_go_models, diff --git a/litellm/constants.py b/litellm/constants.py index e21751c7fd1..bba31f8affc 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1086,6 +1086,7 @@ openai_compatible_endpoints: Final[list[str]] = [ "https://api.reka.ai/v1", "https://gigachat.devices.sberbank.ru/api/v1", "https://api.openinfer.ai/v1", + "https://www.flonno.com/v1", "https://vanchin.streamlake.ai/api/gateway/v1/endpoints", ] @@ -1136,6 +1137,7 @@ openai_compatible_providers: Final[list[str]] = [ "parasail", # Parasail - JSON-configured provider "libertai", # LibertAI - JSON-configured provider "featherless_ai", + "flonno", "nscale", "nebius", "dashscope", diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index a71a9c3bb9b..dd68f9e7f8b 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -262,6 +262,20 @@ }, "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] }, + "flonno": { + "base_url": "https://www.flonno.com/v1", + "api_key_env": "FLONNO_API_KEY", + "api_base_env": "FLONNO_API_BASE", + "provider_info": { + "supports_function_calling": true, + "supports_vision": true, + "supports_reasoning": true + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ] + }, "inferenceapis": { "base_url": "https://api.inferenceapis.com/v1", "api_key_env": "INFERENCEAPIS_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 703621b60cf..600ac37b877 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -28711,6 +28711,22 @@ "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, + "flonno/qwen3.8-27b": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3e-08, + "litellm_provider": "flonno", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://www.flonno.com/pricing.json", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": 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 700a35edb06..4f5798f471b 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -1686,6 +1686,32 @@ "generateContent": true } }, + "flonno": { + "display_name": "Flonno (`flonno`)", + "url": "https://docs.litellm.ai/docs/providers/flonno", + "endpoints": { + "chat_completions": true, + "responses": false, + "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 + } + }, "firecrawl": { "display_name": "Firecrawl (`firecrawl`)", "url": "https://docs.litellm.ai/docs/search/firecrawl", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 044b8a1de6e..2d5606b5bbc 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -1673,6 +1673,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "Flonno", + "provider_display_name": "Flonno", + "litellm_provider": "flonno", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://www.flonno.com/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": "flonno/qwen3.8-27b" + }, { "provider": "FRIENDLIAI", "provider_display_name": "Friendliai", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index f4763acaeda..1b564d4fcc3 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4604,6 +4604,7 @@ class LlmProviders(str, Enum): FIREWORKS_AI = "fireworks_ai" FRIENDLIAI = "friendliai" FEATHERLESS_AI = "featherless_ai" + FLONNO = "flonno" WATSONX = "watsonx" SCALEDOWN = "scaledown" WATSONX_TEXT = "watsonx_text" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 703621b60cf..600ac37b877 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -28711,6 +28711,22 @@ "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, + "flonno/qwen3.8-27b": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3e-08, + "litellm_provider": "flonno", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.5e-07, + "source": "https://www.flonno.com/pricing.json", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": 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 700a35edb06..4f5798f471b 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1686,6 +1686,32 @@ "generateContent": true } }, + "flonno": { + "display_name": "Flonno (`flonno`)", + "url": "https://docs.litellm.ai/docs/providers/flonno", + "endpoints": { + "chat_completions": true, + "responses": false, + "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 + } + }, "firecrawl": { "display_name": "Firecrawl (`firecrawl`)", "url": "https://docs.litellm.ai/docs/search/firecrawl", diff --git a/tests/unit/llms/openai_like/test_flonno_provider.py b/tests/unit/llms/openai_like/test_flonno_provider.py new file mode 100644 index 00000000000..45150916f3b --- /dev/null +++ b/tests/unit/llms/openai_like/test_flonno_provider.py @@ -0,0 +1,165 @@ +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +import httpx +import pytest +import respx +from openai import OpenAI + +import litellm +from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.openai_like.json_loader import JSONProviderRegistry +from litellm.proxy.auth.model_checks import get_provider_models +from litellm.types.utils import LlmProviders + +FLONNO_API_BASE: Final = "https://www.flonno.com/v1" +FLONNO_MODEL: Final = "qwen3.8-27b" +FLONNO_PREFIXED: Final = f"flonno/{FLONNO_MODEL}" + +_REPO_ROOT: Final = Path(__file__).resolve().parents[4] +_PRIMARY_PRICES: Final = json.loads((_REPO_ROOT / "model_prices_and_context_window.json").read_text()) +_BACKUP_PRICES: Final = json.loads((_REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json").read_text()) + + +class _RecordingFlonno: + def __init__(self, usage: Mapping[str, object]) -> None: + self.usage: Final = usage + self.requests: tuple[httpx.Request, ...] = () + + def respond(self, request: httpx.Request) -> httpx.Response: + self.requests = (*self.requests, request) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-flonno", + "object": "chat.completion", + "created": 1234567890, + "model": json.loads(request.content)["model"], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": dict(self.usage), + }, + ) + + def client(self) -> OpenAI: + return OpenAI( + api_key="flonno-test-key", + base_url=FLONNO_API_BASE, + http_client=httpx.Client(transport=httpx.MockTransport(self.respond)), + ) + + +def test_flonno_in_provider_list_and_json_registry(): + assert LlmProviders.FLONNO.value == "flonno" + assert "flonno" in litellm.provider_list + assert JSONProviderRegistry.exists("flonno") + provider = JSONProviderRegistry.get("flonno") + assert provider is not None + assert provider.base_url == FLONNO_API_BASE + assert provider.api_key_env == "FLONNO_API_KEY" + assert "/v1/chat/completions" in provider.supported_endpoints + assert "/v1/messages" in provider.supported_endpoints + assert "/v1/responses" not in provider.supported_endpoints + + +def test_flonno_in_openai_compatible_providers(): + from litellm.constants import openai_compatible_providers + + assert "flonno" in openai_compatible_providers + + +def test_prefixed_model_reaches_flonno_chat_completions_without_the_prefix(): + flonno: Final = _RecordingFlonno({"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}) + + with flonno.client() as client: + litellm.completion( + model=FLONNO_PREFIXED, + messages=[{"role": "user", "content": "hello"}], + client=client, + ) + + assert len(flonno.requests) == 1 + assert str(flonno.requests[0].url) == f"{FLONNO_API_BASE}/chat/completions" + assert json.loads(flonno.requests[0].content) == { + "model": FLONNO_MODEL, + "messages": [{"role": "user", "content": "hello"}], + } + + +@pytest.mark.asyncio +async def test_anthropic_messages_request_goes_to_flonno_natively( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) + with respx.mock() as upstream: + route: Final = upstream.post(f"{FLONNO_API_BASE}/messages").respond( + 200, + json={ + "id": "msg_flonno", + "type": "message", + "role": "assistant", + "model": FLONNO_MODEL, + "content": [{"type": "text", "text": "Hello from Flonno"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 4, "output_tokens": 3}, + }, + ) + response: Final = await litellm.anthropic.messages.acreate( + model=FLONNO_PREFIXED, + messages=[{"role": "user", "content": "Say hello"}], + max_tokens=32, + api_key="sk-test", + ) + + request: Final = route.calls.last.request + body: Final = json.loads(request.content) + assert request.headers["authorization"] == "Bearer sk-test" + assert body["model"] == FLONNO_MODEL + assert body["messages"] == [{"role": "user", "content": "Say hello"}] + assert response["content"][0]["text"] == "Hello from Flonno" + + +def test_flonno_key_and_base_come_from_flonno_env_vars(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FLONNO_API_KEY", "flonno-env-key") + monkeypatch.delenv("FLONNO_API_BASE", raising=False) + + assert get_llm_provider(model=FLONNO_PREFIXED) == ( + FLONNO_MODEL, + "flonno", + "flonno-env-key", + FLONNO_API_BASE, + ) + + monkeypatch.setenv("FLONNO_API_BASE", "https://flonno.internal.example/v1") + + assert get_llm_provider(model=FLONNO_PREFIXED)[3] == "https://flonno.internal.example/v1" + + +def test_flonno_wildcard_lists_every_flonno_catalog_model(): + catalog_models: Final = sorted( + key for key, row in litellm.model_cost.items() if row.get("litellm_provider") == "flonno" + ) + + assert catalog_models == [FLONNO_PREFIXED] + assert sorted(get_provider_models("flonno") or []) == catalog_models + + +def test_catalog_row_matches_its_backup_and_fits_its_context(): + row: Final = _PRIMARY_PRICES[FLONNO_PREFIXED] + + assert row == _BACKUP_PRICES[FLONNO_PREFIXED] + assert row["mode"] == "chat" + assert row["max_output_tokens"] <= row["max_input_tokens"] + assert row["input_cost_per_token"] == 3e-08 + assert row["output_cost_per_token"] == 2.5e-07 + assert litellm.get_model_info(FLONNO_PREFIXED)["litellm_provider"] == "flonno" diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 72755633376..2e823fc5072 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -456,6 +456,26 @@ def test_zerogpu_add_model_form_defaults_resolve_to_a_priced_zerogpu_model(): assert fields_by_key["api_key"]["field_type"] == "password" +def test_flonno_add_model_form_defaults_resolve_to_a_priced_flonno_model(): + import litellm + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + app_instance: Final = FastAPI() + app_instance.include_router(router) + providers: Final = TestClient(app_instance).get("/public/providers/fields").json() + + flonno: Final = next(p for p in providers if p["litellm_provider"] == "flonno") + fields_by_key: Final = {f["key"]: f for f in flonno["credential_fields"]} + placeholder: Final = flonno["default_model_placeholder"] + _, provider, _, api_base = get_llm_provider(model=placeholder) + + assert provider == "flonno" + assert api_base == fields_by_key["api_base"]["placeholder"] + assert litellm.model_cost[placeholder]["litellm_provider"] == "flonno" + assert fields_by_key["api_key"]["required"] is True + assert fields_by_key["api_key"]["field_type"] == "password" + + def test_qwen_mainland_provider_fields_carry_the_qianwen_brand(): app_instance = FastAPI() app_instance.include_router(router) diff --git a/ui/litellm-dashboard/public/assets/logos/flonno.png b/ui/litellm-dashboard/public/assets/logos/flonno.png new file mode 100644 index 0000000000000000000000000000000000000000..c3eda9fcbd1d410ce62797d87263a085a118a604 GIT binary patch literal 35073 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 Yrn=SYv~67uV&I>1TDoWRG_1n@2V&;^9{>OV literal 0 HcmV?d00001 diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 4c483434831..1da065b6ef2 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -403,6 +403,10 @@ describe("provider_info_helpers", () => { expect(getPlaceholder("ZeroGPU")).toBe("zerogpu/gpt-oss-120b"); }); + it("should return flonno/qwen3.8-27b placeholder for Flonno provider", () => { + expect(getPlaceholder("Flonno")).toBe("flonno/qwen3.8-27b"); + }); + it("should return a chatgpt/ placeholder for the CHATGPT dropdown key", () => { expect(getPlaceholder("CHATGPT")).toBe("chatgpt/gpt-5.4"); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index a8879eb570b..b6484be9684 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -31,6 +31,7 @@ import groqLogo from "../../public/assets/logos/groq.svg"; import huggingfaceLogo from "../../public/assets/logos/huggingface.svg"; import hyperbolicLogo from "../../public/assets/logos/hyperbolic.svg"; import hostingerLogo from "../../public/assets/logos/hostinger.png"; +import flonnoLogo from "../../public/assets/logos/flonno.png"; import infinityLogo from "../../public/assets/logos/infinity.png"; import jinaLogo from "../../public/assets/logos/jina.png"; import lambdaLogo from "../../public/assets/logos/lambda.svg"; @@ -121,6 +122,7 @@ export enum Providers { FalAI = "Fal AI", FEATHERLESS_AI = "Featherless Ai", FireworksAI = "Fireworks AI", + Flonno = "Flonno", FRIENDLIAI = "Friendliai", GALADRIEL = "Galadriel", GIGACHAT = "GigaChat", @@ -254,6 +256,7 @@ export const provider_map: Record = { FalAI: "fal_ai", FEATHERLESS_AI: "featherless_ai", FireworksAI: "fireworks_ai", + Flonno: "flonno", FRIENDLIAI: "friendliai", GALADRIEL: "galadriel", GIGACHAT: "gigachat", @@ -384,6 +387,7 @@ export const providerLogoMap: Partial> = { [Providers.FalAI]: falAiLogo.src, [Providers.FEATHERLESS_AI]: featherlessLogo.src, [Providers.FireworksAI]: fireworksLogo.src, + [Providers.Flonno]: flonnoLogo.src, [Providers.FRIENDLIAI]: friendliLogo.src, [Providers.GIGACHAT]: gigachatLogo.src, [Providers.GITHUB_COPILOT]: githubCopilotLogo.src, @@ -522,6 +526,7 @@ const providerPlaceholderMap: Partial> = { [Providers.ZAI]: "zai/glm-4.5", [Providers.BIGMODEL]: "bigmodel/glm-5.3", [Providers.ZeroGPU]: "zerogpu/gpt-oss-120b", + [Providers.Flonno]: "flonno/qwen3.8-27b", }; export const getPlaceholder = (selectedProvider: string): string => { From 3fe3618fc8534e12f2f5ecf86d9c6c5c600f0b93 Mon Sep 17 00:00:00 2001 From: Jonas Boos Date: Wed, 4 Nov 2026 22:12:38 +0100 Subject: [PATCH 2/2] fix(flonno): require FLONNO_API_KEY before OpenAI key fallback JSON openai_like providers resolve a None key when only OPENAI_API_KEY is set; _complete_custom_openai then substitutes the OpenAI key while keeping the Flonno base URL. Mirror the bigmodel guard so Flonno raises AuthenticationError before dispatch instead of leaking credentials. --- litellm/main.py | 4 + .../llms/openai_like/test_flonno_provider.py | 73 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/litellm/main.py b/litellm/main.py index 8cb133bf76b..af3a8649169 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2792,6 +2792,10 @@ def _complete_custom_openai( raise litellm.AuthenticationError( message="Set BIGMODEL_API_KEY or pass api_key", llm_provider="bigmodel", model=model ) + if custom_llm_provider == "flonno" and not api_key: + raise litellm.AuthenticationError( + message="Set FLONNO_API_KEY or pass api_key", llm_provider="flonno", model=model + ) organization = ( organization or (None if custom_llm_provider == "bigmodel" else litellm.organization or get_secret("OPENAI_ORGANIZATION")) diff --git a/tests/unit/llms/openai_like/test_flonno_provider.py b/tests/unit/llms/openai_like/test_flonno_provider.py index 45150916f3b..b685d09edde 100644 --- a/tests/unit/llms/openai_like/test_flonno_provider.py +++ b/tests/unit/llms/openai_like/test_flonno_provider.py @@ -83,6 +83,7 @@ def test_prefixed_model_reaches_flonno_chat_completions_without_the_prefix(): litellm.completion( model=FLONNO_PREFIXED, messages=[{"role": "user", "content": "hello"}], + api_key="flonno-test-key", client=client, ) @@ -163,3 +164,75 @@ def test_catalog_row_matches_its_backup_and_fits_its_context(): assert row["input_cost_per_token"] == 3e-08 assert row["output_cost_per_token"] == 2.5e-07 assert litellm.get_model_info(FLONNO_PREFIXED)["litellm_provider"] == "flonno" + + +def test_flonno_completion_never_borrows_openai_api_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("FLONNO_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-leak") + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setattr(litellm, "openai_key", None) + + with respx.mock(assert_all_called=False) as upstream: + route: Final = upstream.post(f"{FLONNO_API_BASE}/chat/completions").respond( + 200, + json={ + "id": "chatcmpl-flonno", + "object": "chat.completion", + "created": 1, + "model": FLONNO_MODEL, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + with pytest.raises(litellm.AuthenticationError, match="FLONNO_API_KEY"): + litellm.completion( + model=FLONNO_PREFIXED, + messages=[{"role": "user", "content": "hello"}], + ) + + assert route.call_count == 0 + + +def test_flonno_completion_uses_flonno_api_key_from_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("FLONNO_API_KEY", "flonno-env-key") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-be-used") + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setattr(litellm, "openai_key", None) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) + + with respx.mock() as upstream: + route: Final = upstream.post(f"{FLONNO_API_BASE}/chat/completions").respond( + 200, + json={ + "id": "chatcmpl-flonno", + "object": "chat.completion", + "created": 1, + "model": FLONNO_MODEL, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + response: Final = litellm.completion( + model=FLONNO_PREFIXED, + messages=[{"role": "user", "content": "hello"}], + ) + + request: Final = route.calls.last.request + assert route.call_count == 1 + assert str(request.url) == f"{FLONNO_API_BASE}/chat/completions" + assert request.headers["authorization"] == "Bearer flonno-env-key" + assert "sk-openai-should-not-be-used" not in request.headers.get("authorization", "") + assert response.choices[0].message.content == "hi"