Skip to content

Commit daabe1a

Browse files
mergify[bot]nathan-weinbergcdoernclaude
authored
fix(inference): strip duplicate /v1 prefix in vLLM Anthropic message URLs (backport #6294) (#6295)
## Summary - Strip trailing `/v1` from the vLLM base URL before constructing Anthropic Messages API paths, fixing a double `/v1/v1/` that caused 404s - Add `_get_base_url_without_version()` helper matching the existing Ollama adapter pattern, and use it in `anthropic_messages()`, `anthropic_count_tokens()`, and `rerank()` - Add 6 regression tests covering URL construction with various base URL formats Closes #6290 ## Test plan - [x] Existing vLLM unit tests pass (33/33) - [x] New `TestBaseUrlVersionStripping` parametrized tests verify `/v1` stripping for base URLs with/without trailing `/v1` and trailing slash - [x] New integration-style tests verify `anthropic_messages()` and `anthropic_count_tokens()` construct `http://localhost:8000/v1/messages` (not `/v1/v1/messages`) when `base_url=http://localhost:8000/v1` - [x] Pre-commit hooks pass (ruff, mypy, all project checks) ```bash uv run pytest tests/unit/providers/inference/test_remote_vllm.py -v --tb=short ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)<hr>This is an automatic backport of pull request #6294 done by [Mergify](https://mergify.com). --------- Signed-off-by: Charlie Doern <cdoern@redhat.com> Co-authored-by: Nathan Weinberg <31703736+nathan-weinberg@users.noreply.github.com> Co-authored-by: Charlie Doern <cdoern@redhat.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6762882 commit daabe1a

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

src/ogx/providers/remote/inference/vllm/vllm.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,21 @@ async def _wrap_chunks() -> AsyncIterator[OpenAIChatCompletionChunkWithReasoning
233233

234234
return _wrap_chunks()
235235

236+
def _get_base_url_without_version(self) -> str:
237+
"""Get the base URL with any trailing /v1 suffix removed."""
238+
base_url = str(self.get_base_url()).rstrip("/")
239+
if base_url.endswith("/v1"):
240+
base_url = base_url[:-3]
241+
return base_url
242+
236243
async def anthropic_messages(
237244
self,
238245
params: AnthropicCreateMessageRequest,
239246
) -> AnthropicMessageResponse | AsyncIterator[AnthropicStreamEvent]:
240247
"""Handle Anthropic Messages via native /v1/messages endpoint."""
241248
import json
242249

243-
url = f"{self.get_base_url()}/v1/messages"
250+
url = f"{self._get_base_url_without_version()}/v1/messages"
244251
body = params.model_dump(exclude_none=True)
245252
body["model"] = params.model
246253
headers = {
@@ -282,7 +289,7 @@ async def anthropic_count_tokens(
282289
params: AnthropicCountTokensRequest,
283290
) -> AnthropicCountTokensResponse:
284291
"""Forward count_tokens to vLLM's /v1/messages/count_tokens endpoint."""
285-
url = f"{self.get_base_url()}/v1/messages/count_tokens"
292+
url = f"{self._get_base_url_without_version()}/v1/messages/count_tokens"
286293
body = params.model_dump(exclude_none=True)
287294
body["model"] = params.model
288295
headers = {
@@ -367,7 +374,7 @@ def format_item(
367374
# "To indicate that the rerank API is not part of the standard OpenAI API,
368375
# we have located it at `/rerank`. Please update your client accordingly.
369376
# (Note: Conforms to JinaAI rerank API)" - vLLM 0.15.1
370-
endpoint = self.get_base_url().replace("/v1", "") + "/rerank" # TODO: find a better solution
377+
endpoint = self._get_base_url_without_version() + "/rerank"
371378

372379
headers: dict[str, str] = {}
373380
api_key = self._get_api_key_from_config_or_provider_data()

tests/unit/providers/inference/test_remote_vllm.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,86 @@ async def test_rerank_uses_provider_data_api_key(self):
625625
assert headers.get("Authorization") == "Bearer provider-data-token"
626626

627627

628+
class TestBaseUrlVersionStripping:
629+
"""Regression tests for double /v1/v1/ path bug (issue #6290)."""
630+
631+
@pytest.mark.parametrize(
632+
"base_url, expected",
633+
[
634+
("http://localhost:8000/v1", "http://localhost:8000"),
635+
("http://localhost:8000/v1/", "http://localhost:8000"),
636+
("http://localhost:8000", "http://localhost:8000"),
637+
("http://localhost:8000/", "http://localhost:8000"),
638+
],
639+
)
640+
def test_get_base_url_without_version(self, base_url, expected):
641+
config = VLLMInferenceAdapterConfig(base_url=base_url)
642+
adapter = VLLMInferenceAdapter(config=config)
643+
assert adapter._get_base_url_without_version() == expected
644+
645+
async def test_anthropic_messages_no_double_v1(self):
646+
config = VLLMInferenceAdapterConfig(base_url="http://localhost:8000/v1")
647+
adapter = VLLMInferenceAdapter(config=config)
648+
await adapter.initialize()
649+
adapter.get_request_provider_data = MagicMock(return_value=None)
650+
651+
with patch("httpx.AsyncClient") as mock_client_class:
652+
mock_response = MagicMock()
653+
mock_response.raise_for_status.return_value = None
654+
mock_response.json.return_value = {
655+
"id": "msg_test",
656+
"type": "message",
657+
"role": "assistant",
658+
"content": [{"type": "text", "text": "hello"}],
659+
"model": "test-model",
660+
"stop_reason": "end_turn",
661+
"usage": {"input_tokens": 10, "output_tokens": 5},
662+
}
663+
mock_client_instance = MagicMock()
664+
mock_client_instance.post = AsyncMock(return_value=mock_response)
665+
mock_client_class.return_value.__aenter__.return_value = mock_client_instance
666+
667+
from ogx_api.messages.models import AnthropicCreateMessageRequest
668+
669+
request = AnthropicCreateMessageRequest(
670+
model="test-model",
671+
max_tokens=100,
672+
messages=[{"role": "user", "content": "hi"}],
673+
stream=False,
674+
)
675+
await adapter.anthropic_messages(request)
676+
677+
url_called = mock_client_instance.post.call_args[0][0]
678+
assert url_called == "http://localhost:8000/v1/messages"
679+
assert "/v1/v1/" not in url_called
680+
681+
async def test_anthropic_count_tokens_no_double_v1(self):
682+
config = VLLMInferenceAdapterConfig(base_url="http://localhost:8000/v1")
683+
adapter = VLLMInferenceAdapter(config=config)
684+
await adapter.initialize()
685+
adapter.get_request_provider_data = MagicMock(return_value=None)
686+
687+
with patch("httpx.AsyncClient") as mock_client_class:
688+
mock_response = MagicMock()
689+
mock_response.raise_for_status.return_value = None
690+
mock_response.json.return_value = {"input_tokens": 10}
691+
mock_client_instance = MagicMock()
692+
mock_client_instance.post = AsyncMock(return_value=mock_response)
693+
mock_client_class.return_value.__aenter__.return_value = mock_client_instance
694+
695+
from ogx_api.messages.models import AnthropicCountTokensRequest
696+
697+
request = AnthropicCountTokensRequest(
698+
model="test-model",
699+
messages=[{"role": "user", "content": "hi"}],
700+
)
701+
await adapter.anthropic_count_tokens(request)
702+
703+
url_called = mock_client_instance.post.call_args[0][0]
704+
assert url_called == "http://localhost:8000/v1/messages/count_tokens"
705+
assert "/v1/v1/" not in url_called
706+
707+
628708
class TestFairnessHeaderPropagation:
629709
"""Tests for llm-d fairness header injection via _get_extra_request_headers."""
630710

0 commit comments

Comments
 (0)