test_gateway_utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import re
  2. from gpustack.gateway.utils import (
  3. RoutePrefix,
  4. build_generic_route_header_rule,
  5. build_generic_route_path_pattern,
  6. cleanup_generic_route_transformer_spec_diff,
  7. generate_model_ingress,
  8. generic_route_transformer_diff_spec,
  9. provider_registry,
  10. )
  11. from gpustack.gateway.client.extensions_higress_io_v1_api import WasmPluginSpec
  12. from gpustack.schemas.model_provider import (
  13. ModelProvider,
  14. ModelProviderTypeEnum,
  15. OpenAIConfig,
  16. OllamaConfig,
  17. )
  18. from gpustack.gateway.client.networking_higress_io_v1_api import McpBridgeRegistry
  19. def test_flattened_prefixes():
  20. assert RoutePrefix(
  21. ["/chat/completions", "/completions", "/responses"],
  22. support_legacy=True,
  23. ).flattened_prefixes() == [
  24. "/v1/chat/completions",
  25. "/v1/completions",
  26. "/v1/responses",
  27. "/v1-openai/chat/completions",
  28. "/v1-openai/completions",
  29. "/v1-openai/responses",
  30. ]
  31. assert RoutePrefix(
  32. ["/chat/completions", "/completions", "/responses"]
  33. ).flattened_prefixes() == [
  34. "/v1/chat/completions",
  35. "/v1/completions",
  36. "/v1/responses",
  37. ]
  38. def test_regex_prefixes():
  39. assert RoutePrefix(
  40. ["/chat/completions", "/completions", "/responses"],
  41. support_legacy=True,
  42. ).regex_prefixes() == [
  43. r"/(v1)(-openai)?(/chat/completions)",
  44. r"/(v1)(-openai)?(/completions)",
  45. r"/(v1)(-openai)?(/responses)",
  46. ]
  47. assert RoutePrefix(["/chat/completions", "/completions"]).regex_prefixes() == [
  48. r"/(v1)()(/chat/completions)",
  49. r"/(v1)()(/completions)",
  50. ]
  51. def test_v2_prefixes():
  52. rerank = RoutePrefix(
  53. ["/rerank"],
  54. support_legacy=False,
  55. additional_versions=["/v2"],
  56. )
  57. assert rerank.regex_prefixes() == [
  58. r"/(v1)()(/rerank)",
  59. r"/(v2)()(/rerank)",
  60. ]
  61. assert rerank.flattened_prefixes() == [
  62. "/v1/rerank",
  63. "/v2/rerank",
  64. ]
  65. def test_provider_registry_static_ip():
  66. provider = ModelProvider(
  67. id=1,
  68. name="provider-1",
  69. config=OpenAIConfig(
  70. type=ModelProviderTypeEnum.OPENAI, openaiCustomUrl="http://1.2.3.4/v1"
  71. ),
  72. proxy_url="http://proxy.example.com:8080",
  73. )
  74. reg = provider_registry(provider)
  75. assert isinstance(reg, McpBridgeRegistry)
  76. assert reg.domain == "1.2.3.4:80"
  77. assert reg.port == 80
  78. assert reg.protocol == "http"
  79. assert reg.type == "static"
  80. assert reg.name == "provider-1"
  81. assert reg.proxyName is not None
  82. assert reg.proxyName == "provider-1-proxy"
  83. def test_provider_registry_dns():
  84. provider = ModelProvider(
  85. id=2,
  86. name="provider-2",
  87. config=OpenAIConfig(
  88. type=ModelProviderTypeEnum.OPENAI,
  89. openaiCustomUrl="https://provider.example.com:8443/v1",
  90. ),
  91. )
  92. reg = provider_registry(provider)
  93. assert reg.domain == "provider.example.com"
  94. assert reg.port == 8443
  95. assert reg.protocol == "https"
  96. assert reg.type == "dns"
  97. assert reg.name == "provider-2"
  98. assert reg.proxyName is None
  99. def test_ollama_registry():
  100. provider = ModelProvider(
  101. id=3,
  102. name="provider-3",
  103. config=OllamaConfig(
  104. type=ModelProviderTypeEnum.OLLAMA,
  105. ollamaServerHost="localhost",
  106. ollamaServerPort=8080,
  107. ),
  108. )
  109. reg = provider_registry(provider)
  110. assert reg.domain == "localhost"
  111. assert reg.port == 8080
  112. assert reg.protocol == "http"
  113. assert reg.type == "dns"
  114. assert reg.name == "provider-3"
  115. provider = ModelProvider(
  116. id=3,
  117. name="provider-3",
  118. config=OllamaConfig(
  119. type=ModelProviderTypeEnum.OLLAMA,
  120. ollamaServerHost="1.2.3.4",
  121. ollamaServerPort=8080,
  122. ),
  123. )
  124. reg = provider_registry(provider)
  125. assert reg.domain == "1.2.3.4:8080"
  126. assert reg.port == 80
  127. assert reg.type == "static"
  128. assert reg.protocol == "http"
  129. # --- Generic route transformer --------------------------------------------
  130. def test_generic_route_path_pattern_boundary():
  131. """
  132. The path pattern must anchor after the id's last digit so that id=1 does
  133. not spuriously match /model/proxy/10 or /model/proxy/100/foo.
  134. """
  135. pat_1 = build_generic_route_path_pattern(1)
  136. pat_10 = build_generic_route_path_pattern(10)
  137. assert pat_1 == r"^/model/proxy/1(/.*)?$"
  138. assert pat_10 == r"^/model/proxy/10(/.*)?$"
  139. matches_for_1 = [
  140. "/model/proxy/1",
  141. "/model/proxy/1/",
  142. "/model/proxy/1/pooling",
  143. "/model/proxy/1/v1/models",
  144. "/model/proxy/1/v1/chat/completions",
  145. ]
  146. non_matches_for_1 = [
  147. "/model/proxy/10",
  148. "/model/proxy/10/foo",
  149. "/model/proxy/100/foo",
  150. "/model/proxy/2/foo",
  151. "/model/proxy/1bar",
  152. "/v1/chat/completions",
  153. ]
  154. for path in matches_for_1:
  155. assert re.match(pat_1, path), f"expected {path!r} to match id=1"
  156. for path in non_matches_for_1:
  157. assert not re.match(pat_1, path), f"expected {path!r} to NOT match id=1"
  158. def test_generic_route_header_value_after_substitution():
  159. """
  160. Higress transformer's `add` with path_pattern substitutes the match with
  161. `value` inside the full :path. We must ensure the resulting header value is
  162. the route name alone — not contaminated with the untouched path tail.
  163. """
  164. rule = build_generic_route_header_rule(1, "qwen3-0.6b")
  165. assert rule == {
  166. "key": "x-higress-llm-model",
  167. "value": "qwen3-0.6b",
  168. "path_pattern": r"^/model/proxy/1(/.*)?$",
  169. }
  170. for path in [
  171. "/model/proxy/1",
  172. "/model/proxy/1/",
  173. "/model/proxy/1/pooling",
  174. "/model/proxy/1/v1/models",
  175. ]:
  176. header_value = re.sub(rule["path_pattern"], rule["value"], path)
  177. assert (
  178. header_value == "qwen3-0.6b"
  179. ), f"path {path!r} must reduce to route name; got {header_value!r}"
  180. def _empty_transformer_spec() -> WasmPluginSpec:
  181. """Match the shape produced by generic_route_transformer_plugin(cfg)."""
  182. return WasmPluginSpec(
  183. defaultConfig={"reqRules": []},
  184. defaultConfigDisable=False,
  185. )
  186. def _first_add_headers(spec: WasmPluginSpec):
  187. rules = spec.defaultConfig.get("reqRules", [])
  188. add_block = next((r for r in rules if r.get("operate") == "add"), None)
  189. return add_block.get("headers", []) if add_block else []
  190. def test_diff_spec_add_first_route():
  191. spec = _empty_transformer_spec()
  192. rule = build_generic_route_header_rule(1, "route-one")
  193. spec = generic_route_transformer_diff_spec(
  194. spec,
  195. expected_header_rules=[rule],
  196. operating_path_pattern=build_generic_route_path_pattern(1),
  197. )
  198. assert spec.defaultConfigDisable is False
  199. assert _first_add_headers(spec) == [rule]
  200. def test_diff_spec_preserves_other_routes():
  201. spec = _empty_transformer_spec()
  202. rule_1 = build_generic_route_header_rule(1, "route-one")
  203. rule_2 = build_generic_route_header_rule(2, "route-two")
  204. spec = generic_route_transformer_diff_spec(
  205. spec, [rule_1], build_generic_route_path_pattern(1)
  206. )
  207. spec = generic_route_transformer_diff_spec(
  208. spec, [rule_2], build_generic_route_path_pattern(2)
  209. )
  210. headers = _first_add_headers(spec)
  211. assert rule_1 in headers
  212. assert rule_2 in headers
  213. # Sort is deterministic by path_pattern so diff-equal checks are stable.
  214. assert headers == sorted(headers, key=lambda h: h["path_pattern"])
  215. def test_diff_spec_update_in_place():
  216. """Changing a route's name replaces its rule, not appends a duplicate."""
  217. spec = _empty_transformer_spec()
  218. spec = generic_route_transformer_diff_spec(
  219. spec,
  220. [build_generic_route_header_rule(1, "route-one")],
  221. build_generic_route_path_pattern(1),
  222. )
  223. spec = generic_route_transformer_diff_spec(
  224. spec,
  225. [build_generic_route_header_rule(1, "route-one-renamed")],
  226. build_generic_route_path_pattern(1),
  227. )
  228. headers = _first_add_headers(spec)
  229. assert len(headers) == 1
  230. assert headers[0]["value"] == "route-one-renamed"
  231. def test_diff_spec_remove_route_keeps_siblings():
  232. spec = _empty_transformer_spec()
  233. spec = generic_route_transformer_diff_spec(
  234. spec,
  235. [build_generic_route_header_rule(1, "route-one")],
  236. build_generic_route_path_pattern(1),
  237. )
  238. spec = generic_route_transformer_diff_spec(
  239. spec,
  240. [build_generic_route_header_rule(2, "route-two")],
  241. build_generic_route_path_pattern(2),
  242. )
  243. # route 1's generic_proxy turned off → expected_header_rules is empty
  244. spec = generic_route_transformer_diff_spec(
  245. spec, [], build_generic_route_path_pattern(1)
  246. )
  247. headers = _first_add_headers(spec)
  248. assert len(headers) == 1
  249. assert headers[0]["value"] == "route-two"
  250. def test_diff_spec_does_not_flip_default_config_disable():
  251. """
  252. Toggling defaultConfigDisable rewrites Envoy's filter chain and tears down
  253. in-flight connections, so the diff must leave the flag alone regardless of
  254. whether any rules remain.
  255. """
  256. spec = _empty_transformer_spec()
  257. # Add then remove everything — flag must stay False the whole way.
  258. spec = generic_route_transformer_diff_spec(
  259. spec,
  260. [build_generic_route_header_rule(1, "route-one")],
  261. build_generic_route_path_pattern(1),
  262. )
  263. assert spec.defaultConfigDisable is False
  264. spec = generic_route_transformer_diff_spec(
  265. spec, [], build_generic_route_path_pattern(1)
  266. )
  267. assert spec.defaultConfigDisable is False
  268. assert spec.defaultConfig == {"reqRules": []}
  269. def test_diff_spec_passes_through_none():
  270. """Plugin doesn't exist yet → diff returns None so ensure_wasm_plugin can skip."""
  271. assert (
  272. generic_route_transformer_diff_spec(
  273. None, [], build_generic_route_path_pattern(1)
  274. )
  275. is None
  276. )
  277. def test_diff_spec_preserves_unrelated_req_rules():
  278. """
  279. Diff must coexist with foreign reqRules — a future contributor may add
  280. another `operate: rename` block or a separate `add` block with non-generic
  281. headers to the same plugin. Our logic identifies generic-route rules by
  282. path_pattern shape and leaves everything else alone.
  283. """
  284. spec = _empty_transformer_spec()
  285. foreign_rename_block = {
  286. "operate": "rename",
  287. "headers": [{"oldKey": "a", "newKey": "b"}],
  288. }
  289. foreign_add_header = {
  290. "key": "x-other",
  291. "value": "v",
  292. "path_pattern": "^/other/path",
  293. }
  294. spec.defaultConfig = {
  295. "reqRules": [
  296. foreign_rename_block,
  297. {"operate": "add", "headers": [foreign_add_header]},
  298. ],
  299. }
  300. spec = generic_route_transformer_diff_spec(
  301. spec,
  302. [build_generic_route_header_rule(1, "route-one")],
  303. build_generic_route_path_pattern(1),
  304. )
  305. rules = spec.defaultConfig["reqRules"]
  306. # Foreign rename block untouched.
  307. assert foreign_rename_block in rules
  308. # Foreign add header preserved (may be in its own block).
  309. assert any(
  310. r.get("operate") == "add" and foreign_add_header in r.get("headers", [])
  311. for r in rules
  312. )
  313. # Generic-route rule landed in an add block of its own.
  314. assert any(
  315. r.get("operate") == "add"
  316. and any(h.get("value") == "route-one" for h in r.get("headers", []))
  317. for r in rules
  318. )
  319. def test_cleanup_spec_diff_prunes_orphans():
  320. spec = _empty_transformer_spec()
  321. # Seed with two routes, then run cleanup expecting only route 2 to survive.
  322. spec = generic_route_transformer_diff_spec(
  323. spec,
  324. [build_generic_route_header_rule(1, "route-one")],
  325. build_generic_route_path_pattern(1),
  326. )
  327. spec = generic_route_transformer_diff_spec(
  328. spec,
  329. [build_generic_route_header_rule(2, "route-two")],
  330. build_generic_route_path_pattern(2),
  331. )
  332. spec = cleanup_generic_route_transformer_spec_diff(
  333. spec, expected_path_patterns={build_generic_route_path_pattern(2)}
  334. )
  335. headers = _first_add_headers(spec)
  336. assert len(headers) == 1
  337. assert headers[0]["value"] == "route-two"
  338. assert spec.defaultConfigDisable is False
  339. def test_cleanup_spec_diff_empties_when_no_routes_remain():
  340. spec = _empty_transformer_spec()
  341. spec = generic_route_transformer_diff_spec(
  342. spec,
  343. [build_generic_route_header_rule(1, "route-one")],
  344. build_generic_route_path_pattern(1),
  345. )
  346. spec = cleanup_generic_route_transformer_spec_diff(
  347. spec, expected_path_patterns=set()
  348. )
  349. assert spec.defaultConfig == {"reqRules": []}
  350. assert spec.defaultConfigDisable is False
  351. # --- Main ingress path rules ----------------------------------------------
  352. def test_included_proxy_route_adds_id_variant_before_legacy():
  353. """
  354. When generic_proxy is enabled, the ingress must carry both a /model/proxy/<id>/*
  355. rule (for URL-based routing) and the legacy /model/proxy/* rule (header-based).
  356. The id-based rule must list first so Higress tries the more specific match.
  357. """
  358. ingress = generate_model_ingress(
  359. ingress_name="ai-route-route-42.internal",
  360. namespace="default",
  361. route_name="my-route",
  362. destinations="100% svc.default.svc.cluster.local:80",
  363. included_proxy_route=True,
  364. )
  365. paths = [p.path for p in ingress.spec.rules[0].http.paths]
  366. id_rule = r"/()model/proxy/\d+(/|$)(.*)"
  367. legacy_rule = r"/()model/proxy(/|$)(.*)"
  368. assert id_rule in paths
  369. assert legacy_rule in paths
  370. assert paths.index(id_rule) < paths.index(
  371. legacy_rule
  372. ), "id-based rule must precede legacy rule for specificity-first matching"
  373. def test_included_proxy_route_off_has_no_proxy_paths():
  374. ingress = generate_model_ingress(
  375. ingress_name="ai-route-route-42.internal",
  376. namespace="default",
  377. route_name="my-route",
  378. destinations="100% svc.default.svc.cluster.local:80",
  379. included_proxy_route=False,
  380. )
  381. paths = [p.path for p in ingress.spec.rules[0].http.paths]
  382. assert not any("model/proxy" in p for p in paths)