Skip to content

Commit ddafc18

Browse files
Reject /v1 suffix on OpenAIProvider base_url (#84)
* Reject /v1 suffix on OpenAIProvider base_url httpx joins base_url paths by appending, so an unprefixed /v1 on base_url produces a doubled /v1/v1/... wire path that some backends (Bifrost was the motivating case) accept on GET /v1/v1/models while rejecting POST /v1/v1/chat/completions. Result: the readiness probe stays green and every completion silently 405s. Raise ValueError at construction time when base_url's path ends in /v1 (with or without trailing slash) so the misconfiguration is visible at lifespan startup, not inside per-call wire failures. Trailing slashes are still normalized; other non-empty paths (proxy prefixes like /api/openai-proxy) are left alone for intentional reverse-proxy setups. Also fix docs/model-providers/index.md which wrongly showed base_url="http://localhost:8000/v1" as the canonical shape. * Catch /v1 suffix when followed by query string or fragment The previous validation called rstrip("/") on the full base_url before parsing, which is a no-op when the URL ends in a query string or fragment character (e.g., the 'c' in '?token=abc'). The parsed path's own trailing slash was then left intact, so '/v1/' slipped past the endswith('/v1') check. Strip trailing slashes from the parsed path itself before the suffix check. Adds two unit tests covering the query-string and fragment cases.
1 parent eb80430 commit ddafc18

3 files changed

Lines changed: 126 additions & 2 deletions

File tree

docs/model-providers/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ from openarmature.llm import OpenAIProvider, UserMessage
186186

187187
async def main() -> None:
188188
provider = OpenAIProvider(
189-
base_url="http://localhost:8000/v1", # any OpenAI-compatible endpoint
189+
base_url="http://localhost:8000", # any OpenAI-compatible endpoint; host root only, /v1 added by provider
190190
model="some-model",
191191
api_key="optional-for-local-servers",
192192
)

src/openarmature/llm/providers/openai.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import uuid
4747
from collections.abc import Sequence
4848
from typing import Any, Literal, cast
49+
from urllib.parse import urlparse
4950

5051
import httpx
5152
import jsonschema
@@ -111,6 +112,19 @@ class OpenAIProvider:
111112
drives the conformance fixtures by intercepting HTTP calls and
112113
returning canned responses, exercising the same wire-mapping
113114
code production traffic would.
115+
116+
**``base_url`` shape.** Pass the host root only — e.g.
117+
``"https://api.openai.com"`` or ``"http://localhost:8000"``. The
118+
provider appends ``/v1/chat/completions`` and ``/v1/models``
119+
itself. A trailing ``/v1`` on ``base_url`` raises ``ValueError``:
120+
httpx joins paths by appending, so an unprefixed ``base_url``
121+
suffix would produce a doubled ``/v1/v1/...`` wire path that
122+
silently 404/405s on most backends (some — like Bifrost — return
123+
200 for ``GET /v1/v1/models`` while rejecting ``POST
124+
/v1/v1/chat/completions``, leaving the readiness probe green and
125+
every completion broken). Trailing slashes are stripped; other
126+
non-empty paths (proxy prefixes like ``/api/openai-proxy``) are
127+
left intact for intentional proxy setups.
114128
"""
115129

116130
def __init__(
@@ -124,7 +138,7 @@ def __init__(
124138
force_prompt_augmentation_fallback: bool = False,
125139
genai_system: str = "openai",
126140
) -> None:
127-
self.base_url = base_url.rstrip("/")
141+
self.base_url = _validate_and_normalize_base_url(base_url)
128142
self.model = model
129143
# ``force_prompt_augmentation_fallback`` switches structured-output
130144
# calls from the native response_format wire path to the
@@ -589,6 +603,46 @@ def _parse_response(
589603
)
590604

591605

606+
# ---------------------------------------------------------------------------
607+
# base_url validation
608+
# ---------------------------------------------------------------------------
609+
610+
611+
# Rejects base_urls that end in /v1 or /v1/ because httpx joins paths by
612+
# appending — a base_url with a trailing /v1 produces a doubled /v1/v1/...
613+
# wire path. The failure mode is sneaky: some backends (Bifrost was the
614+
# motivating case) return 200 for GET /v1/v1/models while rejecting POST
615+
# /v1/v1/chat/completions, so the readiness probe stays green while every
616+
# completion fails. Strict rejection is safer than silent strip — it keeps
617+
# the bug visible at construction time.
618+
def _validate_and_normalize_base_url(base_url: str) -> str:
619+
"""Validate ``base_url`` and return its normalized form.
620+
621+
Strips trailing slashes. Raises :class:`ValueError` when the path
622+
component ends in ``/v1`` (with or without a trailing slash) — the
623+
provider appends ``/v1/`` segments itself, so a base_url with a
624+
``/v1`` suffix would produce a doubled path on the wire. Other
625+
non-empty paths (e.g., proxy prefixes like ``/api/openai-proxy``)
626+
are left intact.
627+
"""
628+
normalized = base_url.rstrip("/")
629+
# ``rstrip`` on the full URL is a no-op when a query string or
630+
# fragment follows the path (e.g., ``https://host/v1/?token=...``
631+
# ends in ``c`` so the URL-level rstrip leaves the parsed path's
632+
# trailing slash intact). Strip the parsed path itself so the
633+
# suffix check catches those shapes too.
634+
path = urlparse(normalized).path.rstrip("/")
635+
if path == "/v1" or path.endswith("/v1"):
636+
raise ValueError(
637+
f"OpenAIProvider base_url must not end with '/v1' — the provider "
638+
f"appends '/v1/chat/completions' and '/v1/models' itself, and "
639+
f"httpx would produce a doubled '/v1/v1/...' wire path. Pass the "
640+
f"host root instead (e.g., 'https://api.openai.com'). "
641+
f"Got: {base_url!r}"
642+
)
643+
return normalized
644+
645+
592646
# ---------------------------------------------------------------------------
593647
# Wire-format helpers
594648
# ---------------------------------------------------------------------------

tests/unit/test_llm_provider.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,76 @@ def test_validate_tools_duplicate_names_rejected() -> None:
207207
)
208208

209209

210+
# ---------------------------------------------------------------------------
211+
# OpenAIProvider base_url validation
212+
# ---------------------------------------------------------------------------
213+
214+
215+
def test_openai_provider_rejects_v1_suffix() -> None:
216+
with pytest.raises(ValueError, match=r"base_url must not end with '/v1'"):
217+
OpenAIProvider(base_url="http://localhost:8090/v1", model="m", api_key="k")
218+
219+
220+
def test_openai_provider_rejects_v1_suffix_with_trailing_slash() -> None:
221+
with pytest.raises(ValueError, match=r"base_url must not end with '/v1'"):
222+
OpenAIProvider(base_url="http://localhost:8090/v1/", model="m", api_key="k")
223+
224+
225+
def test_openai_provider_rejects_openai_cloud_with_v1() -> None:
226+
# The motivating real-world case: api.openai.com/v1 is in the
227+
# OpenAI docs as the API endpoint, but for OpenAIProvider's
228+
# base_url the /v1 must be omitted.
229+
with pytest.raises(ValueError, match=r"base_url must not end with '/v1'"):
230+
OpenAIProvider(base_url="https://api.openai.com/v1", model="gpt-4", api_key="k")
231+
232+
233+
def test_openai_provider_accepts_host_root() -> None:
234+
provider = OpenAIProvider(base_url="https://api.openai.com", model="gpt-4", api_key="k")
235+
assert provider.base_url == "https://api.openai.com"
236+
237+
238+
def test_openai_provider_accepts_host_root_with_trailing_slash() -> None:
239+
provider = OpenAIProvider(base_url="http://localhost:8090/", model="m", api_key="k")
240+
assert provider.base_url == "http://localhost:8090"
241+
242+
243+
def test_openai_provider_accepts_non_v1_path() -> None:
244+
# Proxy prefixes (Cloudflare AI Gateway, internal reverse proxies)
245+
# are intentional and left alone.
246+
provider = OpenAIProvider(
247+
base_url="https://gateway.example.com/openai-proxy",
248+
model="m",
249+
api_key="k",
250+
)
251+
assert provider.base_url == "https://gateway.example.com/openai-proxy"
252+
253+
254+
def test_openai_provider_accepts_v1_in_middle_of_path() -> None:
255+
# Only a trailing /v1 is rejected — proxies that include /v1
256+
# somewhere mid-path are intentional.
257+
provider = OpenAIProvider(
258+
base_url="https://gateway.example.com/v1/openai-proxy",
259+
model="m",
260+
api_key="k",
261+
)
262+
assert provider.base_url == "https://gateway.example.com/v1/openai-proxy"
263+
264+
265+
def test_openai_provider_rejects_v1_with_query_string() -> None:
266+
# The trailing slash on the path is followed by a query string,
267+
# so a URL-level rstrip("/") doesn't normalize it. The parsed
268+
# path's own trailing slash MUST be stripped before the suffix
269+
# check or this case slips through.
270+
with pytest.raises(ValueError, match=r"base_url must not end with '/v1'"):
271+
OpenAIProvider(base_url="https://host/v1/?token=abc", model="m", api_key="k")
272+
273+
274+
def test_openai_provider_rejects_v1_with_fragment() -> None:
275+
# Same shape as the query-string case but with a URL fragment.
276+
with pytest.raises(ValueError, match=r"base_url must not end with '/v1'"):
277+
OpenAIProvider(base_url="https://host/v1/#frag", model="m", api_key="k")
278+
279+
210280
# ---------------------------------------------------------------------------
211281
# Error categories — canonical string contract + __cause__ preservation
212282
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)