mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description The any-llm backend ignored `--openai-api-url`, so requests against custom OpenAI-compatible providers (vLLM, LiteLLM, xiaomimimo.com, etc.) were sent to `api.openai.com` instead of the configured URL, returning 401s. This wires the configured URL all the way through to the any-llm client. Closes #942 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made There were two layers to the bug, both fixed here: - The URL was never threaded to the backend. `create_proxy_backend()` did not accept or forward the configured OpenAI URL, so `AnyLLMBackend` was always constructed without an `api_base`. It now takes `openai_api_url` and passes it through as `api_base`, wired from `config.openai_api_url` in `server.py`. - The backend never applied it. `AnyLLMBackend.__init__` stored `self.api_base` and `self.api_key` but never used them; `AnyLLM.create()` only received the provider. Both are now forwarded to `AnyLLM.create()`, and only when set, so providers that rely on their own env-var defaults (`OPENAI_API_KEY` / `OPENAI_BASE_URL`) are unaffected. Files touched: - `headroom/providers/registry.py` — `create_proxy_backend()` gains an `openai_api_url` parameter, passed to the any-llm backend as `api_base`. - `headroom/proxy/server.py` — pass `openai_api_url=config.openai_api_url` into `create_proxy_backend()`. - `headroom/backends/anyllm.py` — forward `api_key`/`api_base` to `AnyLLM.create()` when set. Verified against `any-llm-sdk` 1.17.0, whose `AnyLLM.create(provider, api_key=None, api_base=None, ...)` accepts both parameters. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ pytest tests/test_backend_anyllm.py tests/test_provider_registry_extended.py tests/test_provider_registry.py -q tests/test_backend_anyllm.py .............. [ 43%] tests/test_provider_registry_extended.py ....... [ 65%] tests/test_provider_registry.py ........... [100%] 32 passed $ ruff check headroom/backends/anyllm.py headroom/providers/registry.py headroom/proxy/server.py tests/test_backend_anyllm.py tests/test_provider_registry_extended.py All checks passed! ``` ## Real Behavior Proof - Environment: macOS (ARM64), Python 3.13, any-llm-sdk 1.17.0 - Exact command / steps: introspected `AnyLLM.create` signature from any-llm-sdk 1.17.0 to confirm it accepts `api_base`, then ran the unit suites above which assert the URL is threaded through `create_proxy_backend` into `AnyLLM.create`. - Observed result: with `openai_api_url` set, `AnyLLMBackend` is now constructed with `api_base=<url>` and `AnyLLM.create()` receives it; previously it received only the provider and the value was dropped. - Not tested: live end-to-end request against a real custom OpenAI-compatible endpoint (no credentials available in this environment); mypy was not run locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Documentation and CHANGELOG updates are N/A: this restores intended behavior of an existing documented flag (`--openai-api-url`) rather than adding new surface. `mypy` and live end-to-end testing were not run in this environment.
This commit is contained in:
parent
90bdc676fa
commit
a7ee8a60a7
5 changed files with 122 additions and 10 deletions
|
|
@ -41,13 +41,25 @@ class AnyLLMBackend(Backend):
|
|||
)
|
||||
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
# Normalize empty-string overrides (e.g. an env var set to "") to None
|
||||
# so provider defaults stay active instead of forwarding a blank value.
|
||||
self.api_key = api_key or None
|
||||
self.api_base = api_base or None
|
||||
|
||||
# Create the AnyLLM instance once and reuse
|
||||
self.llm = AnyLLM.create(self.provider)
|
||||
# Create the AnyLLM instance once and reuse. api_key/api_base are only
|
||||
# forwarded when set so providers keep their own env-var defaults
|
||||
# (e.g. OPENAI_API_KEY / OPENAI_BASE_URL) otherwise.
|
||||
create_kwargs: dict[str, Any] = {}
|
||||
if self.api_key is not None:
|
||||
create_kwargs["api_key"] = self.api_key
|
||||
if self.api_base is not None:
|
||||
create_kwargs["api_base"] = self.api_base
|
||||
self.llm = AnyLLM.create(self.provider, **create_kwargs)
|
||||
|
||||
logger.info(f"any-llm backend initialized (provider={provider})")
|
||||
logger.info(
|
||||
f"any-llm backend initialized (provider={provider}, "
|
||||
f"api_base={self.api_base or 'default'})"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ def create_proxy_backend(
|
|||
anyllm_provider: str,
|
||||
bedrock_region: str | None,
|
||||
logger: logging.Logger,
|
||||
openai_api_url: str | None = None,
|
||||
anyllm_backend_cls: Any | None = None,
|
||||
litellm_backend_cls: Any | None = None,
|
||||
) -> Backend | None:
|
||||
|
|
@ -160,7 +161,7 @@ def create_proxy_backend(
|
|||
provider = anyllm_provider
|
||||
try:
|
||||
backend_cls = anyllm_backend_cls or _load_anyllm_backend()
|
||||
instance = cast("Backend", backend_cls(provider=provider))
|
||||
instance = cast("Backend", backend_cls(provider=provider, api_base=openai_api_url))
|
||||
logger.info("any-llm backend enabled (provider=%s)", provider)
|
||||
return instance
|
||||
except ImportError as exc:
|
||||
|
|
|
|||
|
|
@ -793,6 +793,7 @@ class HeadroomProxy(
|
|||
anyllm_provider=config.anyllm_provider,
|
||||
bedrock_region=config.bedrock_region,
|
||||
logger=logger,
|
||||
openai_api_url=config.openai_api_url,
|
||||
anyllm_backend_cls=AnyLLMBackend,
|
||||
litellm_backend_cls=LiteLLMBackend,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ def make_backend(
|
|||
|
||||
class FakeAnyLLM:
|
||||
@staticmethod
|
||||
def create(requested_provider: str):
|
||||
def create(requested_provider: str, **kwargs): # noqa: ANN003
|
||||
assert requested_provider == provider
|
||||
return fake_instance
|
||||
|
||||
|
|
@ -52,6 +52,76 @@ def make_backend(
|
|||
return anyllm.AnyLLMBackend(provider=provider.upper()), fake_instance
|
||||
|
||||
|
||||
def test_init_forwards_api_base_and_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression for #942: custom api_base/api_key must reach AnyLLM.create."""
|
||||
fake_instance = FakeAnyLLMInstance()
|
||||
create_calls: list[dict[str, object]] = []
|
||||
|
||||
class FakeAnyLLM:
|
||||
@staticmethod
|
||||
def create(requested_provider: str, **kwargs): # noqa: ANN003
|
||||
create_calls.append({"provider": requested_provider, **kwargs})
|
||||
return fake_instance
|
||||
|
||||
monkeypatch.setattr(anyllm, "ANYLLM_AVAILABLE", True)
|
||||
monkeypatch.setattr(anyllm, "AnyLLM", FakeAnyLLM)
|
||||
|
||||
backend = anyllm.AnyLLMBackend(
|
||||
provider="openai",
|
||||
api_key="sk-custom",
|
||||
api_base="https://custom-provider.example/v1",
|
||||
)
|
||||
|
||||
assert backend.api_base == "https://custom-provider.example/v1"
|
||||
assert create_calls == [
|
||||
{
|
||||
"provider": "openai",
|
||||
"api_key": "sk-custom",
|
||||
"api_base": "https://custom-provider.example/v1",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_init_omits_unset_api_base_and_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Unset overrides must not be forwarded, preserving provider env defaults."""
|
||||
fake_instance = FakeAnyLLMInstance()
|
||||
create_calls: list[dict[str, object]] = []
|
||||
|
||||
class FakeAnyLLM:
|
||||
@staticmethod
|
||||
def create(requested_provider: str, **kwargs): # noqa: ANN003
|
||||
create_calls.append({"provider": requested_provider, **kwargs})
|
||||
return fake_instance
|
||||
|
||||
monkeypatch.setattr(anyllm, "ANYLLM_AVAILABLE", True)
|
||||
monkeypatch.setattr(anyllm, "AnyLLM", FakeAnyLLM)
|
||||
|
||||
anyllm.AnyLLMBackend(provider="openai")
|
||||
|
||||
assert create_calls == [{"provider": "openai"}]
|
||||
|
||||
|
||||
def test_init_treats_empty_overrides_as_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Empty-string api_base/api_key must not be forwarded (env var set to "")."""
|
||||
fake_instance = FakeAnyLLMInstance()
|
||||
create_calls: list[dict[str, object]] = []
|
||||
|
||||
class FakeAnyLLM:
|
||||
@staticmethod
|
||||
def create(requested_provider: str, **kwargs): # noqa: ANN003
|
||||
create_calls.append({"provider": requested_provider, **kwargs})
|
||||
return fake_instance
|
||||
|
||||
monkeypatch.setattr(anyllm, "ANYLLM_AVAILABLE", True)
|
||||
monkeypatch.setattr(anyllm, "AnyLLM", FakeAnyLLM)
|
||||
|
||||
backend = anyllm.AnyLLMBackend(provider="openai", api_key="", api_base="")
|
||||
|
||||
assert backend.api_base is None
|
||||
assert backend.api_key is None
|
||||
assert create_calls == [{"provider": "openai"}]
|
||||
|
||||
|
||||
def make_choice(
|
||||
content: str = "hello", finish_reason: str = "stop", tool_calls=None, index: int = 0
|
||||
):
|
||||
|
|
|
|||
|
|
@ -102,7 +102,11 @@ def test_create_proxy_backend_uses_injected_backend_types() -> None:
|
|||
anyllm_provider="groq",
|
||||
bedrock_region=None,
|
||||
logger=logger,
|
||||
anyllm_backend_cls=lambda provider: {"kind": "anyllm", "provider": provider},
|
||||
anyllm_backend_cls=lambda provider, api_base: {
|
||||
"kind": "anyllm",
|
||||
"provider": provider,
|
||||
"api_base": api_base,
|
||||
},
|
||||
)
|
||||
litellm = create_proxy_backend(
|
||||
backend="bedrock",
|
||||
|
|
@ -116,10 +120,32 @@ def test_create_proxy_backend_uses_injected_backend_types() -> None:
|
|||
},
|
||||
)
|
||||
|
||||
assert anyllm == {"kind": "anyllm", "provider": "groq"}
|
||||
assert anyllm == {"kind": "anyllm", "provider": "groq", "api_base": None}
|
||||
assert litellm == {"kind": "litellm", "provider": "bedrock", "region": "us-east-1"}
|
||||
|
||||
|
||||
def test_create_proxy_backend_passes_openai_api_url_to_anyllm() -> None:
|
||||
"""Regression for #942: --openai-api-url must reach the any-llm backend."""
|
||||
logger = logging.getLogger("test")
|
||||
|
||||
anyllm = create_proxy_backend(
|
||||
backend="anyllm",
|
||||
anyllm_provider="openai",
|
||||
bedrock_region=None,
|
||||
logger=logger,
|
||||
openai_api_url="https://custom-provider.example/v1",
|
||||
anyllm_backend_cls=lambda provider, api_base: {
|
||||
"provider": provider,
|
||||
"api_base": api_base,
|
||||
},
|
||||
)
|
||||
|
||||
assert anyllm == {
|
||||
"provider": "openai",
|
||||
"api_base": "https://custom-provider.example/v1",
|
||||
}
|
||||
|
||||
|
||||
def test_create_proxy_backend_handles_missing_or_direct_backends(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
|
|
@ -138,7 +164,9 @@ def test_create_proxy_backend_handles_missing_or_direct_backends(
|
|||
anyllm_provider="groq",
|
||||
bedrock_region=None,
|
||||
logger=logger,
|
||||
anyllm_backend_cls=lambda provider: (_ for _ in ()).throw(ImportError("missing")),
|
||||
anyllm_backend_cls=lambda provider, api_base: (_ for _ in ()).throw(
|
||||
ImportError("missing")
|
||||
),
|
||||
)
|
||||
|
||||
assert direct is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue