Skip to content

Commit 12e5671

Browse files
authored
test(phase8 #44 H3): provider-aware Hurl sync to /api/v2 + v1 ghost guard (#1675)
Per architect canonical D7-1 (msg=8b6b4bc3) the OpenAI-compatible ``/api/v1/embeddings`` and ``/api/v1/rerank`` endpoints are pinned as permanent ``/api/v1`` mounts. The other v1 LLM provider routes (``/api/v1/llm_configuration``, ``/api/v1/llm_providers/*``, ``/api/v1/available_models``, ``/api/v1/default_models``) were already removed from main when ``providers_v2_routes.py`` shipped, so the provider-aware Hurl test was hitting 404s and bringing ``e2e-http-smoke`` red on every PR. Changes: - ``tests/e2e_http/hurl/full/10_provider_llm.hurl`` — rewrite all internal provider/model/default CRUD calls to ``/api/v2/providers/*`` and ``/api/v2/default-models``. Keep ``/api/v1/embeddings`` and ``/api/v1/rerank`` calls unchanged (OpenAI-compat allowlist). Adjust the create-model body to drop ``provider_name`` (now a path param in v2) and the delete-model assertion to expect 204 (v2 pure-command contract). - ``tests/unit_test/test_provider_v2_openapi_contract.py`` — empty out ``PROVIDER_V1_GHOST_PATHS`` baseline; the v1 LLM provider routes are no longer mounted, so the inventory baseline shrinks to zero and the existing stability test now strictly forbids regrowth. - ``tests/unit_test/test_v1_ghost_guard.py`` — new negative-allowlist guard that scans ``tests/e2e_http/hurl/**`` and asserts every ``/api/v1/...`` literal matches either the OpenAI-compat permanent allowlist or the transitional pre-migration set (apikeys, audit, marketplace, prompts, settings, export, collections, chats, quotas, system, bots, test). Each follow-up G* PR shrinks ``TRANSITIONAL_V1_PREFIXES`` as it migrates the corresponding domain to ``/api/v2``.
1 parent 13f9e7c commit 12e5671

3 files changed

Lines changed: 136 additions & 29 deletions

File tree

tests/e2e_http/hurl/full/10_provider_llm.hurl

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ Content-Type: application/json
66
}
77
HTTP 200
88

9-
GET {{base_url}}/api/v1/llm_configuration
9+
GET {{base_url}}/api/v2/providers/configuration
1010
HTTP 200
1111
[Asserts]
1212
header "content-type" contains "application/json"
1313

14-
PUT {{base_url}}/api/v1/llm_providers/alibabacloud
14+
PUT {{base_url}}/api/v2/providers/alibabacloud
1515
Content-Type: application/json
1616
{
1717
"api_key": "{{alibabacloud_api_key}}"
@@ -20,7 +20,7 @@ HTTP 200
2020
[Asserts]
2121
jsonpath "$.name" == "alibabacloud"
2222

23-
PUT {{base_url}}/api/v1/llm_providers/openrouter
23+
PUT {{base_url}}/api/v2/providers/openrouter
2424
Content-Type: application/json
2525
{
2626
"api_key": "{{openrouter_api_key}}"
@@ -29,14 +29,14 @@ HTTP 200
2929
[Asserts]
3030
jsonpath "$.name" == "openrouter"
3131

32-
POST {{base_url}}/api/v1/available_models
32+
POST {{base_url}}/api/v2/providers/available-models
3333
Content-Type: application/json
3434
{}
3535
HTTP 200
3636
[Asserts]
3737
header "content-type" contains "application/json"
3838

39-
POST {{base_url}}/api/v1/available_models
39+
POST {{base_url}}/api/v2/providers/available-models
4040
Content-Type: application/json
4141
{
4242
"tag_filters": [
@@ -50,15 +50,14 @@ HTTP 200
5050
[Asserts]
5151
header "content-type" contains "application/json"
5252

53-
GET {{base_url}}/api/v1/llm_providers/openrouter/models
53+
GET {{base_url}}/api/v2/providers/openrouter/models
5454
HTTP 200
5555
[Asserts]
5656
header "content-type" contains "application/json"
5757

58-
POST {{base_url}}/api/v1/llm_providers/openrouter/models
58+
POST {{base_url}}/api/v2/providers/openrouter/models
5959
Content-Type: application/json
6060
{
61-
"provider_name": "openrouter",
6261
"api": "completion",
6362
"model": "test-org/test-model-{{run_id}}",
6463
"custom_llm_provider": "openrouter",
@@ -70,7 +69,7 @@ HTTP 200
7069
jsonpath "$.provider_name" == "openrouter"
7170
jsonpath "$.model" == "test-org/test-model-{{run_id}}"
7271

73-
PUT {{base_url}}/api/v1/llm_providers/openrouter/models/completion/test-org%2Ftest-model-{{run_id}}
72+
PUT {{base_url}}/api/v2/providers/openrouter/models/completion/test-org%2Ftest-model-{{run_id}}
7473
Content-Type: application/json
7574
{
7675
"context_window": 8192,
@@ -81,10 +80,10 @@ HTTP 200
8180
jsonpath "$.model" == "test-org/test-model-{{run_id}}"
8281
jsonpath "$.context_window" == 8192
8382

84-
DELETE {{base_url}}/api/v1/llm_providers/openrouter/models/completion/test-org%2Ftest-model-{{run_id}}
85-
HTTP 200
83+
DELETE {{base_url}}/api/v2/providers/openrouter/models/completion/test-org%2Ftest-model-{{run_id}}
84+
HTTP 204
8685

87-
PUT {{base_url}}/api/v1/default_models
86+
PUT {{base_url}}/api/v2/default-models
8887
Content-Type: application/json
8988
{
9089
"defaults": [

tests/unit_test/test_provider_v2_openapi_contract.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,16 @@ def _json_ref(spec: dict, path: str, method: str, status: str = "200") -> str:
1616
return spec["paths"][path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"]
1717

1818

19-
# Known v1 ghost paths in the provider domain. v1 LLM provider / default-models /
20-
# available-models routes are still wired in main pending the #26 final sweep;
21-
# this baseline pins the current inventory so we catch unexpected regrowth but
22-
# allow the #26 sweep to shrink it (subset semantics).
23-
PROVIDER_V1_GHOST_PATHS = frozenset(
24-
{
25-
"/api/v1/available_models",
26-
"/api/v1/default_models",
27-
"/api/v1/llm_configuration",
28-
"/api/v1/llm_provider_models",
29-
"/api/v1/llm_providers",
30-
"/api/v1/llm_providers/{provider_name}",
31-
"/api/v1/llm_providers/{provider_name}/models",
32-
"/api/v1/llm_providers/{provider_name}/models/{api}/{model}",
33-
"/api/v1/llm_providers/{provider_name}/publish",
34-
}
35-
)
19+
# v1 LLM provider / default-models / available-models routes are no longer mounted on main —
20+
# the #26 final sweep is complete and only OpenAI-compat ``/api/v1/embeddings`` + ``/api/v1/rerank``
21+
# remain under ``/api/v1`` (those live in ``llm_routes.py`` and are intentionally outside this
22+
# baseline because they are the OpenAI-compat permanent allowlist, not v1 ghosts).
23+
#
24+
# Baseline is empty: the test now strictly forbids regrowth of any internal v1 LLM/provider
25+
# CRUD paths (the OpenAI-compat allowlist is excluded by the path filter in
26+
# ``_provider_v1_ghosts`` below — it only matches ``/api/v1/llm_*`` plus the two specific
27+
# legacy default/available-models paths).
28+
PROVIDER_V1_GHOST_PATHS: frozenset[str] = frozenset()
3629

3730

3831
def _provider_v1_ghosts(spec: dict) -> set[str]:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Guard against regrowth of internal ``/api/v1`` references in e2e Hurl tests.
2+
3+
The end-state allowlist (canonical D7-1 / msg=8b6b4bc3) is:
4+
5+
- ``/api/v1/embeddings`` — OpenAI-compatible public endpoint, permanent ``/api/v1`` mount
6+
- ``/api/v1/rerank`` — OpenAI-compatible public endpoint, permanent ``/api/v1`` mount
7+
8+
Phase 8 task #44 (H3) cleaned the provider-aware Hurl suite to use
9+
``/api/v2/providers/*`` so it no longer hits dead v1 provider CRUD routes.
10+
A few other domains (apikeys, audit, marketplace, settings, prompts, chat
11+
ops, export, document upload variants) still legitimately live at
12+
``/api/v1`` until tasks #50–#52 / #47–#49 land. Those paths are listed
13+
in ``TRANSITIONAL_V1_PREFIXES`` below and each follow-up PR is expected
14+
to shrink the set as it migrates the corresponding domain to ``/api/v2``.
15+
16+
This test scans every ``.hurl`` file under ``tests/e2e_http/`` and asserts
17+
each ``/api/v1/...`` literal matches either the permanent allowlist or a
18+
transitional prefix, so a future PR can not silently re-introduce dead
19+
internal v1 CRUD calls.
20+
"""
21+
22+
import re
23+
from pathlib import Path
24+
25+
REPO_ROOT = Path(__file__).resolve().parents[2]
26+
HURL_ROOT = REPO_ROOT / "tests" / "e2e_http" / "hurl"
27+
28+
# OpenAI-compat permanent allowlist (canonical D7-1 / msg=8b6b4bc3).
29+
OPENAI_COMPAT_V1_ALLOWLIST: frozenset[str] = frozenset(
30+
{
31+
"/api/v1/embeddings",
32+
"/api/v1/rerank",
33+
}
34+
)
35+
36+
# Transitional prefixes — these v1 routes are still mounted in main and used
37+
# by e2e Hurl as long as the corresponding G* migration tasks have not landed.
38+
# Each follow-up PR (G4a audit / G4b apikeys / G4c marketplace / G3 prompts /
39+
# G1 export / G2 settings / G4d chat ops) is expected to remove the matching
40+
# entry from this set together with its Hurl rewrite.
41+
#
42+
# Anything outside both sets is an unrecognised v1 reference and must be
43+
# either migrated or moved into one of these sets in the same PR.
44+
TRANSITIONAL_V1_PREFIXES: frozenset[str] = frozenset(
45+
{
46+
"/api/v1/apikeys", # G4b → /api/v2/apikeys
47+
"/api/v1/audit-logs", # G4a → /api/v2/audit-logs
48+
"/api/v1/marketplace", # G4c → /api/v2/marketplace
49+
"/api/v1/prompts", # G3 → /api/v2/prompts
50+
"/api/v1/settings", # G2 → /api/v2/settings
51+
"/api/v1/collections", # G1 (export) + G5 (document upload variants)
52+
"/api/v1/export-tasks", # G1 → /api/v2/export-tasks
53+
"/api/v1/chats", # G4d → /api/v2/chats
54+
"/api/v1/quotas", # G5 caller cleanup (FE references only)
55+
"/api/v1/system", # G5 caller cleanup (admin default-quotas)
56+
"/api/v1/bots", # G5 caller cleanup (pytest fixtures)
57+
"/api/v1/test", # G5 caller cleanup (test fixtures)
58+
}
59+
)
60+
61+
V1_PATH_RE = re.compile(r"/api/v1/[A-Za-z0-9_\-/{}.%]+")
62+
63+
64+
def _normalise(path: str) -> str:
65+
return path.rstrip(",;)\"'")
66+
67+
68+
def _is_allowed(path: str) -> bool:
69+
if path in OPENAI_COMPAT_V1_ALLOWLIST:
70+
return True
71+
for prefix in OPENAI_COMPAT_V1_ALLOWLIST | TRANSITIONAL_V1_PREFIXES:
72+
if path == prefix or path.startswith(prefix + "/") or path.startswith(prefix + "?"):
73+
return True
74+
return False
75+
76+
77+
def test_e2e_hurl_v1_routes_match_allowlist():
78+
offenders: dict[str, set[str]] = {}
79+
for hurl in HURL_ROOT.rglob("*.hurl"):
80+
text = hurl.read_text()
81+
seen: set[str] = set()
82+
for raw_match in V1_PATH_RE.findall(text):
83+
path = _normalise(raw_match)
84+
if not _is_allowed(path):
85+
seen.add(path)
86+
if seen:
87+
offenders[str(hurl.relative_to(REPO_ROOT))] = seen
88+
89+
assert not offenders, (
90+
"tests/e2e_http/hurl/** may only reference /api/v1 paths in the OpenAI-compat "
91+
"permanent allowlist or the transitional pre-migration set "
92+
"(see TRANSITIONAL_V1_PREFIXES). Unrecognised v1 references must either be "
93+
"migrated to /api/v2 or, if they are a new mount, added to the allowlist in "
94+
f"the same PR. Offenders:\n{offenders}"
95+
)
96+
97+
98+
def test_provider_v1_crud_routes_are_gone_from_hurl():
99+
"""After H3, no Hurl test references the dead provider CRUD/config/default v1 routes."""
100+
forbidden_provider_v1 = (
101+
"/api/v1/llm_configuration",
102+
"/api/v1/llm_providers",
103+
"/api/v1/available_models",
104+
"/api/v1/default_models",
105+
)
106+
offenders: dict[str, set[str]] = {}
107+
for hurl in HURL_ROOT.rglob("*.hurl"):
108+
text = hurl.read_text()
109+
seen = {p for p in forbidden_provider_v1 if p in text}
110+
if seen:
111+
offenders[str(hurl.relative_to(REPO_ROOT))] = seen
112+
assert not offenders, (
113+
"Provider v1 CRUD/config/default routes have been removed from main; "
114+
f"Hurl tests must not reference them. Offenders:\n{offenders}"
115+
)

0 commit comments

Comments
 (0)