-
Notifications
You must be signed in to change notification settings - Fork 229
feat: add first-class OpenAI-compatible custom endpoint path #1198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7ec2136
feat: add first-class OpenAI-compatible custom endpoint path
njbrake 77fc0bf
fix: address review findings on the openai-compatible custom path
njbrake e37abaf
tests: add live integration coverage for the custom OpenAI-compatible…
njbrake eeef2e9
docs: recommend create_openai_compatible over the openai api_base wor…
njbrake 701d473
docs: drop the workaround comparison note from the quickstart
njbrake File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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") | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
nameis accepted and copied toPROVIDER_NAME, producing blank provider metadata and error messages. Validate the identifier before creating the dynamic subclass and add an error-case test.Proposed validation
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.15.21)
[warning] 207-207: Dynamically typed expressions (typing.Any) are disallowed in
**kwargs(ANN401)
🤖 Prompt for AI Agents