From 144dce78158aa11d4e1d867a3a118b1b1f28f750 Mon Sep 17 00:00:00 2001 From: Daniel Varela Date: Tue, 3 Nov 2026 14:17:44 +0100 Subject: [PATCH] feat(providers): add Lokutor as an OpenAI-compatible audio provider Registers lokutor in providers.json (speech and transcription at https://api.lokutor.com/v1). speech() now uses the key get_llm_provider resolved for openai-compatible providers, and transcription forwards language/response_format to JSON-configured providers that serve /v1/audio/transcriptions. --- README.md | 1 + .../src/exception_mapping_utils/original.rs | 1 + litellm/__init__.py | 5 + litellm/constants.py | 1 + litellm/llms/openai_like/providers.json | 6 ++ litellm/main.py | 3 +- .../provider_endpoints_support_backup.json | 26 +++++ .../provider_create_fields.json | 28 ++++++ litellm/types/utils.py | 1 + litellm/utils.py | 7 ++ provider_endpoints_support.json | 26 +++++ .../llms/openai_like/test_lokutor_provider.py | 99 +++++++++++++++++++ 12 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 tests/unit/llms/openai_like/test_lokutor_provider.py diff --git a/README.md b/README.md index c4aeee80fb4..bc17c7dc841 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th | [LiteLLM Proxy (`litellm_proxy`)](https://docs.litellm.ai/docs/providers/litellm_proxy) | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | [Llamafile (`llamafile`)](https://docs.litellm.ai/docs/providers/llamafile) | ✅ | ✅ | ✅ | | | | | | | | | [LM Studio (`lm_studio`)](https://docs.litellm.ai/docs/providers/lm_studio) | ✅ | ✅ | ✅ | | | | | | | | +| [Lokutor (`lokutor`)](https://docs.litellm.ai/docs/providers/lokutor) | | | | | | ✅ | ✅ | | | | | [Maritalk (`maritalk`)](https://docs.litellm.ai/docs/providers/maritalk) | ✅ | ✅ | ✅ | | | | | | | | | [Meta - Llama API (`meta_llama`)](https://docs.litellm.ai/docs/providers/meta_llama) | ✅ | ✅ | ✅ | | | | | | | | | [Mistral AI API (`mistral`)](https://docs.litellm.ai/docs/providers/mistral) | ✅ | ✅ | ✅ | ✅ | | | | | | | 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..85bfe73d718 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 @@ -114,6 +114,7 @@ const OPENAI_COMPATIBLE_PROVIDERS: &[&str] = &[ "cognition", "scx-ai", "zerogpu", + "lokutor", ]; impl ExceptionFamily { diff --git a/litellm/__init__.py b/litellm/__init__.py index 102c4d1440f..e35cbcfc363 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() +lokutor_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") == "lokutor": + lokutor_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 + | lokutor_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, + "lokutor": lokutor_models, "v0": v0_models, "morph": morph_models, "opencode_go": opencode_go_models, diff --git a/litellm/constants.py b/litellm/constants.py index 97631b9997a..2dbe91ac393 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1129,6 +1129,7 @@ openai_compatible_providers: Final[list[str]] = [ "lm_studio", "galadriel", "zerogpu", + "lokutor", # Lokutor - JSON-configured provider (audio speech and transcription) "github_copilot", # GitHub Copilot Chat API "chatgpt", # ChatGPT subscription API "novita", diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index a71a9c3bb9b..43555e65c87 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -58,6 +58,12 @@ "max_completion_tokens": "max_tokens" } }, + "lokutor": { + "base_url": "https://api.lokutor.com/v1", + "api_key_env": "LOKUTOR_API_KEY", + "api_base_env": "LOKUTOR_API_BASE", + "supported_endpoints": ["/v1/audio/speech", "/v1/audio/transcriptions"] + }, "zerogpu": { "base_url": "https://api.zerogpu.ai/v1", "api_key_env": "ZEROGPU_API_KEY", diff --git a/litellm/main.py b/litellm/main.py index 45177f4223e..3ca8d765f6f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -9095,7 +9095,8 @@ def speech( # set API KEY api_key = ( api_key - or litellm.api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there + or dynamic_api_key # key resolved by get_llm_provider for openai-compatible providers (e.g. LOKUTOR_API_KEY) + or litellm.api_key or litellm.openai_key or get_secret("OPENAI_API_KEY") ) diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 40ea9d15ff4..d478ba6c63d 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -4541,6 +4541,32 @@ "generateContent": true } }, + "lokutor": { + "display_name": "Lokutor (`lokutor`)", + "url": "https://docs.litellm.ai/docs/providers/lokutor", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": true, + "audio_speech": true, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "zerogpu": { "display_name": "ZeroGPU (`zerogpu`)", "url": "https://docs.litellm.ai/docs/providers/zerogpu", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index aa7554c4a5a..2c7a45a1d9b 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -4369,6 +4369,34 @@ ], "default_model_placeholder": "zai/glm-4.5" }, + { + "provider": "Lokutor", + "provider_display_name": "Lokutor", + "litellm_provider": "lokutor", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api.lokutor.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": "lokutor/tts-1" + }, { "provider": "ZeroGPU", "provider_display_name": "ZeroGPU", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 57f4d971eb3..6896115854c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4751,6 +4751,7 @@ class LlmProviders(str, Enum): INFERENCEAPIS = "inferenceapis" HOSTINGER_ROUTER = "hostinger_router" ZEROGPU = "zerogpu" + LOKUTOR = "lokutor" DARKBLOOM = "darkbloom" META = "meta" SAIL = "sail" diff --git a/litellm/utils.py b/litellm/utils.py index d881289deed..60c32167ab5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4484,11 +4484,13 @@ def get_optional_params_transcription( **kwargs, ): from litellm.constants import OPENAI_TRANSCRIPTION_PARAMS + from litellm.llms.openai_like.json_loader import JSONProviderRegistry # retrieve all parameters passed to the function passed_params: Final = locals().copy() passed_params.pop("OPENAI_TRANSCRIPTION_PARAMS") + passed_params.pop("JSONProviderRegistry") passed_params.pop("model") custom_llm_provider = passed_params.pop("custom_llm_provider") drop_params = normalize_drop_params(passed_params.pop("drop_params")) @@ -4569,6 +4571,11 @@ def get_optional_params_transcription( raise UnsupportedParamsError(message=message, llm_provider="bedrock") case mapped_params: optional_params = dict(mapped_params) + elif provider_config is None and JSONProviderRegistry.serves_endpoint( + custom_llm_provider, model, "/v1/audio/transcriptions" + ): + # JSON-configured OpenAI-compatible provider that serves /audio/transcriptions: it takes the OpenAI form fields as-is + optional_params = non_default_params elif provider_config is not None: # custom audio transcription config supported_params = provider_config.get_supported_openai_params(model=model) _check_valid_arg(supported_params=supported_params) diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 40ea9d15ff4..d478ba6c63d 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -4541,6 +4541,32 @@ "generateContent": true } }, + "lokutor": { + "display_name": "Lokutor (`lokutor`)", + "url": "https://docs.litellm.ai/docs/providers/lokutor", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": true, + "audio_speech": true, + "rerank": false, + "ocr": false, + "search": false, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "zerogpu": { "display_name": "ZeroGPU (`zerogpu`)", "url": "https://docs.litellm.ai/docs/providers/zerogpu", diff --git a/tests/unit/llms/openai_like/test_lokutor_provider.py b/tests/unit/llms/openai_like/test_lokutor_provider.py new file mode 100644 index 00000000000..6827abb20b7 --- /dev/null +++ b/tests/unit/llms/openai_like/test_lokutor_provider.py @@ -0,0 +1,99 @@ +import json +from typing import Final + +import httpx +import pytest +import respx +from openai import AsyncOpenAI + +import litellm +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + +LOKUTOR_API_BASE: Final = "https://api.lokutor.com/v1" +WAV_BYTES: Final = b"RIFF\x24\x00\x00\x00WAVEfmt " + + +@pytest.fixture(autouse=True) +def _lokutor_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LOKUTOR_API_KEY", "lokutor-test-key") + monkeypatch.delenv("LOKUTOR_API_BASE", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + +def test_prefixed_model_resolves_to_lokutor_base_and_env_key(): + model, provider, api_key, api_base = get_llm_provider(model="lokutor/tts-1") + + assert (model, provider, api_key, api_base) == ("tts-1", "lokutor", "lokutor-test-key", LOKUTOR_API_BASE) + + +def test_lokutor_is_a_registered_openai_compatible_provider(): + assert "lokutor" in litellm.openai_compatible_providers + assert litellm.LlmProviders("lokutor") is litellm.LlmProviders.LOKUTOR + + +def test_speech_posts_to_lokutor_audio_speech_with_lokutor_key(): + with respx.mock() as upstream: + route: Final = upstream.post(f"{LOKUTOR_API_BASE}/audio/speech").respond( + 200, content=WAV_BYTES, headers={"content-type": "audio/wav"} + ) + + response: Final = litellm.speech( + model="lokutor/tts-1", + voice="alloy", + input="Hello from Lokutor.", + ) + + assert response.content == WAV_BYTES + request: Final = route.calls.last.request + assert request.headers["authorization"] == "Bearer lokutor-test-key" + assert json.loads(request.content) == { + "model": "tts-1", + "input": "Hello from Lokutor.", + "voice": "alloy", + } + + +def test_explicit_api_key_wins_over_the_provider_env_key(): + with respx.mock() as upstream: + route: Final = upstream.post(f"{LOKUTOR_API_BASE}/audio/speech").respond(200, content=WAV_BYTES) + + litellm.speech(model="lokutor/tts-1", voice="F1", input="Hi", api_key="explicit-key") + + assert route.calls.last.request.headers["authorization"] == "Bearer explicit-key" + + +def test_transcription_posts_multipart_to_lokutor_audio_transcriptions(): + with respx.mock() as upstream: + route: Final = upstream.post(f"{LOKUTOR_API_BASE}/audio/transcriptions").respond( + 200, json={"text": "hello from lokutor"} + ) + + response: Final = litellm.transcription( + model="lokutor/whisper-1", + file=("hello.wav", WAV_BYTES, "audio/wav"), + language="en", + ) + + assert response.text == "hello from lokutor" + request: Final = route.calls.last.request + assert request.headers["authorization"] == "Bearer lokutor-test-key" + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="language"' in request.content + assert b"\r\n\r\nen\r\n" in request.content + assert b"hello.wav" in request.content + + +@pytest.mark.asyncio +async def test_async_speech_uses_lokutor_endpoint(): + requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, content=WAV_BYTES, headers={"content-type": "audio/wav"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client: Final = AsyncOpenAI(api_key="lokutor-test-key", base_url=LOKUTOR_API_BASE, http_client=http_client) + response: Final = await litellm.aspeech(model="lokutor/tts-1", voice="nova", input="Hola", client=client) + + assert response.content == WAV_BYTES + assert str(requests[0].url) == f"{LOKUTOR_API_BASE}/audio/speech"