Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ pip install 'any-llm-sdk[all]' # All supported providers

See our [list of supported providers](https://docs.mozilla.ai/any-llm/providers/) to choose which ones you need.

Using an OpenAI-compatible gateway or local server that isn't listed? You don't need a dedicated provider entry: see [Custom OpenAI-compatible Endpoints](https://docs.mozilla.ai/any-llm/quickstart#custom-openai-compatible-endpoints).

### Setting Up API Keys

Set environment variables for your chosen providers:
Expand Down
22 changes: 22 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ print(response.choices[0].message.content)

**Finding model names:** Check the [providers page](providers.md) for provider IDs, or use the [`list_models`](api/list-models.md) API to see available models for your provider.

## Custom OpenAI-compatible Endpoints

If your gateway or server speaks the OpenAI API but is not one of the [supported providers](providers.md), point any-llm at it directly with `AnyLLM.create_openai_compatible`. The provider reports the name you give it rather than reporting itself as `openai`, and is used exactly like any other provider instance:

```python
from any_llm import AnyLLM

llm = AnyLLM.create_openai_compatible(
name="mygateway",
api_base="https://mygateway.example/v1",
api_key="your-key", # optional for keyless local servers
)

response = llm.completion(
model="some-model",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
```

Capability flags follow the OpenAI-compatible defaults. A capability the endpoint does not implement fails either locally with `NotImplementedError` (for flag-gated capabilities such as batch) or with the endpoint's own error for calls that any-llm forwards. Use this whenever you need an OpenAI-compatible endpoint that any-llm does not ship a dedicated provider for.

## Streaming

For the [providers that support streaming](providers.md), you can enable it by passing `stream=True`:
Expand Down
2 changes: 2 additions & 0 deletions scripts/generate_provider_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

`any-llm` supports multiple providers. Provider source code is in [`src/any_llm/providers/`](https://github.com/mozilla-ai/any-llm/tree/main/src/any_llm/providers).

Is your endpoint OpenAI-compatible but not listed below? You are not blocked: use [`AnyLLM.create_openai_compatible`](quickstart.md#custom-openai-compatible-endpoints). Prefer it over pointing the `openai` provider at a custom `api_base`, which misreports the provider identity as `openai`, silently sends any `OPENAI_API_KEY` in your environment to the custom endpoint, and rejects keyless local servers.

"""


Expand Down
34 changes: 34 additions & 0 deletions src/any_llm/any_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,40 @@ def create(
"""
return cls._create_provider(provider, api_key=api_key, api_base=api_base, **kwargs)

@classmethod
def create_openai_compatible(cls, name: str, api_base: str, api_key: str | None = None, **kwargs: Any) -> AnyLLM:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject empty custom provider names.

An empty or whitespace-only name is accepted and copied to PROVIDER_NAME, producing blank provider metadata and error messages. Validate the identifier before creating the dynamic subclass and add an error-case test.

Proposed validation
     def create_openai_compatible(cls, name: str, api_base: str, api_key: str | None = None, **kwargs: Any) -> AnyLLM:
+        if not name.strip():
+            raise ValueError("name must not be empty")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def create_openai_compatible(cls, name: str, api_base: str, api_key: str | None = None, **kwargs: Any) -> AnyLLM:
def create_openai_compatible(cls, name: str, api_base: str, api_key: str | None = None, **kwargs: Any) -> AnyLLM:
if not name.strip():
raise ValueError("name must not be empty")
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 207-207: Dynamically typed expressions (typing.Any) are disallowed in **kwargs

(ANN401)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/any_llm/any_llm.py` at line 207, Update create_openai_compatible to
reject name values that are empty or contain only whitespace before creating the
dynamic subclass or assigning PROVIDER_NAME, raising the established validation
error type. Add a focused error-case test covering blank and whitespace-only
names.

"""Create a provider for an arbitrary OpenAI-compatible endpoint.

This is the supported way to use any-llm with a gateway that does not have a
dedicated provider entry. The returned provider reports ``name`` as its
identity rather than masquerading as ``openai``, and is usable exactly like a
provider from ``AnyLLM.create`` (``.completion(...)``, ``.list_models()``, ...).

Args:
name: Identifier for the endpoint (e.g. ``"mygateway"``). Reported as the provider name.
api_base: Base URL of the OpenAI-compatible endpoint (e.g. ``"https://mygateway.example/v1"``).
api_key: API key, if the endpoint requires one. Optional for keyless local servers.
**kwargs: Additional arguments forwarded to the underlying OpenAI client.

Returns:
A provider instance bound to the given endpoint.

"""
from any_llm.providers.openai.custom import OpenAICompatibleProvider

if not name.strip():
msg = "name must be a non-empty identifier for the endpoint."
raise ValueError(msg)

# Mint a per-name subclass so the chosen identity flows through both instance
# access (self.PROVIDER_NAME) and the get_provider_metadata() classmethod, which
# reads cls.PROVIDER_NAME. The class __name__ stays stable for metadata.class_name.
provider_cls = cast(
"type[OpenAICompatibleProvider]",
type("OpenAICompatibleProvider", (OpenAICompatibleProvider,), {"PROVIDER_NAME": name}),
)
return provider_cls(api_base=api_base, api_key=api_key, **kwargs)

@classmethod
def _create_provider(
cls, provider_key: str | LLMProvider, api_key: str | None = None, api_base: str | None = None, **kwargs: Any
Expand Down
50 changes: 50 additions & 0 deletions src/any_llm/providers/openai/custom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from __future__ import annotations

import os
from typing import Any

from typing_extensions import override

from any_llm.providers.openai.base import BaseOpenAIProvider


class OpenAICompatibleProvider(BaseOpenAIProvider):
"""Point any-llm at an arbitrary OpenAI-compatible endpoint under its own name.

Unlike the built-in providers, this one is configured entirely at construction
time: the caller supplies the endpoint's base URL, instead of relying on a
registered provider entry. It is the supported path for any OpenAI-compatible
gateway that any-llm does not ship a dedicated provider for, so nobody is blocked
from using their endpoint.

Capability flags follow the OpenAI-compatible defaults from ``BaseOpenAIProvider``
and are reported in the provider metadata. A capability the endpoint does not
implement fails either locally with ``NotImplementedError`` (for flag-gated
capabilities such as batch) or with the endpoint's own error for calls that
any-llm forwards.

Prefer ``AnyLLM.create_openai_compatible(...)``, which reports the caller's chosen
name as the provider identity (via a per-name subclass) rather than masquerading as
``openai``. Constructing this class directly reports the generic ``openai_compatible``
identity.
"""

PROVIDER_NAME = "openai_compatible"
PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/api-reference"
ENV_API_KEY_NAME = "OPENAI_COMPATIBLE_API_KEY"

def __init__(self, api_base: str, api_key: str | None = None, **kwargs: Any) -> None:
if not api_base:
msg = "OpenAICompatibleProvider requires an explicit api_base pointing at the endpoint."
raise ValueError(msg)
# Bind the endpoint per instance so the client targets the caller's URL rather
# than the (unset) class default.
self.API_BASE = api_base
super().__init__(api_key=api_key, api_base=api_base, **kwargs)

@override
def _verify_and_set_api_key(self, api_key: str | None = None) -> str | None:
# Custom endpoints may be keyless (local servers) or keyed (hosted gateways).
# Fall back to the env var when set, then to a placeholder so the OpenAI client
# accepts the value; never raise, so nobody is blocked from using their endpoint.
return api_key or os.getenv(self.ENV_API_KEY_NAME) or "no-key-required"
2 changes: 2 additions & 0 deletions tests/docs/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,15 @@ def mock_completion_side_effect(*args, **kwargs): # type: ignore[no-untyped-def
with (
patch("any_llm.any_llm.AnyLLM.split_model_provider") as mock_split,
patch("any_llm.any_llm.AnyLLM.create") as mock_create,
patch("any_llm.any_llm.AnyLLM.create_openai_compatible") as mock_create_compatible,
patch("any_llm.completion") as mock_completion,
patch("any_llm.embedding") as mock_embedding,
patch("any_llm.moderation") as mock_moderation,
patch("os.environ.get") as mock_env_get,
):
mock_split.return_value = (LLMProvider.OPENAI, "gpt-5")
mock_create.return_value = mock_provider
mock_create_compatible.return_value = mock_provider
mock_completion.side_effect = mock_completion_side_effect
mock_embedding.return_value = mock_embedding_result
mock_moderation.return_value = mock_moderation_result
Expand Down
63 changes: 63 additions & 0 deletions tests/integration/test_openai_compatible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import os

import pytest

from any_llm import AnyLLM
from any_llm.types.completion import ChatCompletion, ChatCompletionChunk

MODEL_ID = "gpt-5-nano"


def _create_custom_provider() -> AnyLLM:
"""Reach OpenAI's real endpoint through the custom path, not the openai provider.

This exercises the full create_openai_compatible stack (auth, base-URL binding,
identity reporting) against a live endpoint using a key CI already holds. The
verification bar matches the community-provider policy: completion, streaming,
and list_models.
"""
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
pytest.skip("OPENAI_API_KEY not set, skipping custom-path integration test")
return AnyLLM.create_openai_compatible(
name="custom-openai",
api_base="https://api.openai.com/v1",
api_key=api_key,
timeout=10,
)


@pytest.mark.asyncio
async def test_custom_path_completion() -> None:
llm = _create_custom_provider()
result = await llm.acompletion(
model=MODEL_ID,
messages=[{"role": "user", "content": "Hello"}],
)
assert isinstance(result, ChatCompletion)
assert result.choices[0].message.content
assert llm.PROVIDER_NAME == "custom-openai"
assert llm.get_provider_metadata().name == "custom-openai"


@pytest.mark.asyncio
async def test_custom_path_streaming() -> None:
llm = _create_custom_provider()
chunks = []
async for chunk in await llm.acompletion(
model=MODEL_ID,
messages=[{"role": "user", "content": "Hello"}],
stream=True,
):
assert isinstance(chunk, ChatCompletionChunk)
chunks.append(chunk)
content = "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices)
assert content


@pytest.mark.asyncio
async def test_custom_path_list_models() -> None:
llm = _create_custom_provider()
models = await llm.alist_models()
assert len(models) > 0
assert all(model.id for model in models)
89 changes: 89 additions & 0 deletions tests/unit/providers/test_openai_compatible_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import pytest

from any_llm.any_llm import AnyLLM
from any_llm.constants import LLMProvider
from any_llm.exceptions import UnsupportedProviderError
from any_llm.providers.openai.custom import OpenAICompatibleProvider


def test_factory_returns_openai_compatible_provider() -> None:
provider = AnyLLM.create_openai_compatible(name="mygateway", api_base="https://mygateway.example/v1")
assert isinstance(provider, OpenAICompatibleProvider)


def test_reports_custom_identity_not_openai() -> None:
provider = AnyLLM.create_openai_compatible(name="mygateway", api_base="https://mygateway.example/v1")
assert provider.PROVIDER_NAME == "mygateway"


def test_metadata_reports_custom_identity() -> None:
# get_provider_metadata is a classmethod reading cls.PROVIDER_NAME; the per-name
# subclass must make it report the caller's name, not the generic default.
provider = AnyLLM.create_openai_compatible(name="mygateway", api_base="https://mygateway.example/v1")
metadata = provider.get_provider_metadata()
assert metadata.name == "mygateway"
assert metadata.class_name == "OpenAICompatibleProvider"


def test_api_base_is_bound_to_client() -> None:
provider = OpenAICompatibleProvider(api_base="https://mygateway.example/v1")
assert str(provider.client.base_url).rstrip("/") == "https://mygateway.example/v1"


def test_empty_api_base_raises() -> None:
with pytest.raises(ValueError, match="api_base"):
AnyLLM.create_openai_compatible(name="mygateway", api_base="")


@pytest.mark.parametrize("name", ["", " "])
def test_blank_name_raises(name: str) -> None:
with pytest.raises(ValueError, match="name"):
AnyLLM.create_openai_compatible(name=name, api_base="https://mygateway.example/v1")


def test_keyless_endpoint_uses_placeholder(monkeypatch: pytest.MonkeyPatch) -> None:
# A keyless local server must not be blocked by MissingApiKeyError.
monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False)
provider = OpenAICompatibleProvider(api_base="http://localhost:8000/v1")
assert provider.client.api_key == "no-key-required"


def test_explicit_api_key_used() -> None:
provider = OpenAICompatibleProvider(api_base="https://mygateway.example/v1", api_key="explicit-key")
assert provider.client.api_key == "explicit-key"


def test_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_COMPATIBLE_API_KEY", "env-key")
provider = OpenAICompatibleProvider(api_base="https://mygateway.example/v1")
assert provider.client.api_key == "env-key"


def test_explicit_api_key_takes_precedence_over_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_COMPATIBLE_API_KEY", "env-key")
provider = OpenAICompatibleProvider(api_base="https://mygateway.example/v1", api_key="explicit-key")
assert provider.client.api_key == "explicit-key"


def test_capability_flags_follow_openai_compatible_defaults() -> None:
provider = AnyLLM.create_openai_compatible(name="mygateway", api_base="https://mygateway.example/v1")
assert provider.SUPPORTS_COMPLETION
assert provider.SUPPORTS_COMPLETION_STREAMING
assert provider.SUPPORTS_LIST_MODELS
assert not provider.SUPPORTS_RESPONSES
assert not provider.SUPPORTS_BATCH


def test_client_kwargs_are_forwarded() -> None:
provider = AnyLLM.create_openai_compatible(
name="mygateway", api_base="https://mygateway.example/v1", api_key="k", timeout=12.5
)
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.client.timeout == 12.5
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_custom_name_is_not_an_enum_member() -> None:
# The custom path deliberately lives outside the provider enum: a custom endpoint is
# represented by an instance, not a registered provider key.
with pytest.raises(UnsupportedProviderError):
LLMProvider.from_string("mygateway")