mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-11 12:27:13 +00:00
fix(endpoints): normalize URL handling (#4338)
This commit is contained in:
@@ -38,6 +38,16 @@ def test_unknown_public_host_gets_no_affinity_fields(monkeypatch):
|
||||
assert payload == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://10.example-cloud.com/v1",
|
||||
"https://172.16.example-cloud.com/v1",
|
||||
"https://192.168.example-cloud.com/v1",
|
||||
])
|
||||
def test_private_prefix_dns_host_gets_no_affinity_fields(monkeypatch, url):
|
||||
payload = _affinity_fields(url, monkeypatch)
|
||||
assert payload == {}
|
||||
|
||||
|
||||
def test_localhost_server_gets_affinity_fields(monkeypatch):
|
||||
payload = _affinity_fields("http://localhost:8080/v1", monkeypatch)
|
||||
assert payload == {"session_id": "sess-123", "cache_prompt": True}
|
||||
|
||||
@@ -264,7 +264,7 @@ class TestProbeSingleModel:
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
captured["url"] = url
|
||||
return _resp(200, json={"choices": [{"message": {"content": "OK"}}]})
|
||||
|
||||
@@ -274,11 +274,31 @@ class TestProbeSingleModel:
|
||||
assert "latency_ms" in result
|
||||
assert captured["url"] == "https://api.example.com/v1/chat/completions"
|
||||
|
||||
@pytest.mark.parametrize("base,api_key,model_id", [
|
||||
("https://api.example.com/v1", "key", "gpt-4o"),
|
||||
("http://localhost:11434/v1", None, "llama3.2"),
|
||||
("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5"),
|
||||
])
|
||||
def test_completion_probe_uses_llm_verify(self, monkeypatch, base, api_key, model_id):
|
||||
_patch_resolve(monkeypatch)
|
||||
marker = object()
|
||||
captured = {}
|
||||
monkeypatch.setattr(model_routes, "llm_verify", lambda: marker)
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
captured["verify"] = verify
|
||||
return _resp(200, json={"choices": [{"message": {"content": "OK"}}]})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
result = _probe_single_model(base, api_key, model_id)
|
||||
assert result["status"] == "ok"
|
||||
assert captured["verify"] is marker
|
||||
|
||||
def test_extracts_dict_error_message(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "post",
|
||||
lambda url, headers=None, json=None, timeout=None: _resp(
|
||||
lambda url, headers=None, json=None, timeout=None, verify=None: _resp(
|
||||
400, json={"error": {"message": "model not found"}}),
|
||||
)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "ghost")
|
||||
@@ -289,7 +309,7 @@ class TestProbeSingleModel:
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "post",
|
||||
lambda url, headers=None, json=None, timeout=None: _resp(
|
||||
lambda url, headers=None, json=None, timeout=None, verify=None: _resp(
|
||||
403, json={"error": "forbidden"}),
|
||||
)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "m")
|
||||
@@ -299,7 +319,7 @@ class TestProbeSingleModel:
|
||||
def test_timeout(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
raise httpx.TimeoutException("timed out")
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
@@ -310,7 +330,7 @@ class TestProbeSingleModel:
|
||||
def test_transport_error_is_fail(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
@@ -322,7 +342,7 @@ class TestProbeSingleModel:
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
captured.update(url=url, headers=headers, payload=json)
|
||||
return _resp(200, json={"content": [{"type": "text", "text": "OK"}]})
|
||||
|
||||
@@ -337,7 +357,7 @@ class TestProbeSingleModel:
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
def fake_post(url, headers=None, json=None, timeout=None, verify=None):
|
||||
captured["payload"] = json
|
||||
return _resp(200, json={"content": []})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for endpoint_resolver — pure functions tested directly."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.endpoint_resolver import (
|
||||
_first_chat_model,
|
||||
_endpoint_hidden_models,
|
||||
@@ -45,6 +47,9 @@ class TestBuildChatUrl:
|
||||
def test_openai_style(self):
|
||||
assert build_chat_url("https://api.openai.com/v1") == "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
def test_pathless_openai_style_adds_v1(self):
|
||||
assert build_chat_url("https://api.openai.com") == "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
def test_anthropic_style(self):
|
||||
assert build_chat_url("https://api.anthropic.com") == "https://api.anthropic.com/v1/messages"
|
||||
|
||||
@@ -66,14 +71,35 @@ class TestBuildChatUrl:
|
||||
def test_ollama_v1_preserves_openai_compat(self):
|
||||
assert build_chat_url("http://nas:11434/v1") == "http://nas:11434/v1/chat/completions"
|
||||
|
||||
@pytest.mark.parametrize("bad_base", [
|
||||
"https://api.example.com/v1?token=abc",
|
||||
"https://api.example.com/v1#fragment",
|
||||
"http://localhost:1234?",
|
||||
])
|
||||
def test_rejects_query_or_fragment_base(self, bad_base):
|
||||
with pytest.raises(ValueError, match="query or fragment"):
|
||||
build_chat_url(bad_base)
|
||||
|
||||
|
||||
class TestBuildModelsUrl:
|
||||
def test_openai_models(self):
|
||||
assert build_models_url("https://api.openai.com/v1") == "https://api.openai.com/v1/models"
|
||||
|
||||
def test_pathless_openai_models_adds_v1(self):
|
||||
assert build_models_url("https://api.openai.com") == "https://api.openai.com/v1/models"
|
||||
|
||||
def test_ollama_tags(self):
|
||||
assert build_models_url("https://ollama.com/api") == "https://ollama.com/api/tags"
|
||||
|
||||
@pytest.mark.parametrize("bad_base", [
|
||||
"https://api.example.com/v1?token=abc",
|
||||
"https://api.example.com/v1#fragment",
|
||||
"http://localhost:1234?",
|
||||
])
|
||||
def test_rejects_query_or_fragment_base(self, bad_base):
|
||||
with pytest.raises(ValueError, match="query or fragment"):
|
||||
build_models_url(bad_base)
|
||||
|
||||
|
||||
class TestBuildHeaders:
|
||||
def test_no_key(self):
|
||||
|
||||
@@ -87,11 +87,60 @@ async def _call(json_data, status=200):
|
||||
return await integrations.execute_api_call("test_integ", "GET", "/items")
|
||||
|
||||
|
||||
async def _call_with_integration(integration, path="/items"):
|
||||
mock_resp = _make_response({"ok": True})
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with (
|
||||
patch.object(integrations, "_find_integration", return_value=integration),
|
||||
patch("httpx.AsyncClient", return_value=mock_client),
|
||||
):
|
||||
result = await integrations.execute_api_call("test_integ", "GET", path)
|
||||
return result, mock_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_call_rejects_stored_base_url_with_query_without_requesting():
|
||||
integration = {**DUMMY_INTEGRATION, "base_url": "http://api.example.com/api?token=abc"}
|
||||
result, mock_client = await _call_with_integration(integration)
|
||||
|
||||
assert result == {
|
||||
"error": "Integration base URL must not include query or fragment",
|
||||
"exit_code": 1,
|
||||
}
|
||||
mock_client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_call_joins_path_under_configured_base_path():
|
||||
integration = {**DUMMY_INTEGRATION, "base_url": "http://api.example.com/root"}
|
||||
result, mock_client = await _call_with_integration(integration, "/v1/items?limit=1")
|
||||
|
||||
assert result.get("exit_code") == 0
|
||||
mock_client.request.assert_called_once()
|
||||
assert mock_client.request.call_args.args[:2] == (
|
||||
"GET",
|
||||
"http://api.example.com/root/v1/items?limit=1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_call_rejects_path_fragment_without_requesting():
|
||||
result, mock_client = await _call_with_integration(DUMMY_INTEGRATION, "/items#fragment")
|
||||
|
||||
assert result == {"error": "Path must not contain a fragment", "exit_code": 1}
|
||||
mock_client.request.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_json_list_returns_valid_json_with_sentinel():
|
||||
"""A JSON list whose serialized form exceeds 12000 chars must be truncated
|
||||
|
||||
@@ -83,6 +83,27 @@ def test_create_integration_rejects_blank_base_url_without_persisting(integratio
|
||||
assert integrations.load_integrations() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("base_url", "message"), [
|
||||
("ftp://example.test", "Integration base URL must be an HTTP(S) URL"),
|
||||
("https://example.test/api?token=abc", "Integration base URL must not include query or fragment"),
|
||||
("https://example.test/api#fragment", "Integration base URL must not include query or fragment"),
|
||||
])
|
||||
def test_create_integration_rejects_invalid_base_url_without_persisting(
|
||||
integrations_routes, base_url, message
|
||||
):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
create_integration = endpoint("/api/auth/integrations", "POST")
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(create_integration(
|
||||
_JsonRequest({"name": "Example", "base_url": base_url}, session_cookie)
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == message
|
||||
assert integrations.load_integrations() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_name", ["", " "])
|
||||
def test_update_integration_rejects_blank_name_without_changing_existing(integrations_routes, blank_name):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
@@ -127,3 +148,32 @@ def test_update_integration_rejects_blank_base_url_without_changing_existing(int
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "Integration base URL is required"
|
||||
assert integrations.load_integrations()[0]["base_url"] == "https://example.test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("base_url", "message"), [
|
||||
("ftp://example.test", "Integration base URL must be an HTTP(S) URL"),
|
||||
("https://example.test/api?token=abc", "Integration base URL must not include query or fragment"),
|
||||
("https://example.test/api#fragment", "Integration base URL must not include query or fragment"),
|
||||
])
|
||||
def test_update_integration_rejects_invalid_base_url_without_changing_existing(
|
||||
integrations_routes, base_url, message
|
||||
):
|
||||
endpoint, session_cookie, http_exception = integrations_routes
|
||||
update_integration = endpoint("/api/auth/integrations/{integration_id}", "PUT")
|
||||
integrations.save_integrations([
|
||||
{
|
||||
"id": "existing",
|
||||
"name": "Original",
|
||||
"base_url": "https://example.test",
|
||||
}
|
||||
])
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(update_integration(
|
||||
integration_id="existing",
|
||||
request=_JsonRequest({"base_url": base_url}, session_cookie),
|
||||
))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == message
|
||||
assert integrations.load_integrations()[0]["base_url"] == "https://example.test"
|
||||
|
||||
@@ -17,6 +17,7 @@ This module pins both behaviors so future refactors don't regress them.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import endpoint_resolver, llm_core
|
||||
|
||||
@@ -90,6 +91,19 @@ def test_build_models_url_preserves_explicit_non_v1_path(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"http://localhost:1234?",
|
||||
"http://localhost:1234#fragment",
|
||||
"http://localhost:1234/v1?token=abc",
|
||||
])
|
||||
def test_build_models_url_rejects_query_or_fragment_base(monkeypatch, base_url):
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
|
||||
_neutralize_provider_detection(monkeypatch)
|
||||
|
||||
with pytest.raises(ValueError, match="query or fragment"):
|
||||
endpoint_resolver.build_models_url(base_url)
|
||||
|
||||
|
||||
# ── list_model_ids: parse LM Studio's response ─────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ class TestIsLocalEndpoint:
|
||||
def test_private_10(self):
|
||||
assert is_local_endpoint("http://10.0.0.5:8000/v1/chat/completions") is True
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"10.example-cloud.com",
|
||||
"172.16.example-cloud.com",
|
||||
"192.168.example-cloud.com",
|
||||
])
|
||||
def test_private_prefix_dns_names_are_remote(self, host):
|
||||
assert is_local_endpoint(f"https://{host}/v1/chat/completions") is False
|
||||
|
||||
def test_tailscale_100(self):
|
||||
# 100.64.0.0/10 is the CGNAT range Tailscale uses.
|
||||
assert is_local_endpoint("http://100.64.0.1:5000/v1/chat/completions") is True
|
||||
|
||||
@@ -419,6 +419,14 @@ class TestClassifyEndpoint:
|
||||
def test_private_10(self):
|
||||
assert _classify_endpoint("http://10.0.0.5:8000") == "local"
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"10.example-cloud.com",
|
||||
"172.16.example-cloud.com",
|
||||
"192.168.example-cloud.com",
|
||||
])
|
||||
def test_private_prefix_dns_names_are_api(self, host):
|
||||
assert _classify_endpoint(f"https://{host}/v1") == "api"
|
||||
|
||||
def test_public_api(self):
|
||||
assert _classify_endpoint("https://api.openai.com/v1") == "api"
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ PROVIDER_CASES = [
|
||||
("openai", "https://api.openai.com/v1",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("openai_pathless", "https://api.openai.com",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/models"),
|
||||
("anthropic", "https://api.anthropic.com",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"https://api.anthropic.com/v1/models"),
|
||||
|
||||
Reference in New Issue
Block a user