feat: add provider and model routing tables, admin UI, and audit category for gateway
Implement the multi-provider routing system for the OpenAI gateway, including two new database tables (`gateway_providers`, `gateway_models`) ensured at init, a new admin page at `/admin/gateway` with full CRUD for providers and model routes, and a `"gateway"` audit category mapped to `"ai"`. The routing layer sits transparently on top of the existing single-provider default path: unmatched model names fall through unchanged, while matched routes forward to the configured provider with their own pricing economy, vision model, and context window. Cross-worker cache invalidation uses a shared `_ROUTING_CACHE` bumped via `"gateway_routing"` cache version.
This commit is contained in:
@@ -112,6 +112,47 @@ def test_model_is_forced(local_db, monkeypatch):
|
||||
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_model_route_overrides_upstream(local_db, monkeypatch):
|
||||
from devplacepy.services.openai_gateway import routing
|
||||
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
routing.provider_store.set(
|
||||
routing.ProviderIn(
|
||||
name="gwroute",
|
||||
base_url="https://routed.example/v1/chat/completions",
|
||||
api_key="routed-key",
|
||||
)
|
||||
)
|
||||
routing.model_store.set(
|
||||
routing.ModelRouteIn(
|
||||
source_model="custom-pro",
|
||||
provider="gwroute",
|
||||
target_model="vendor/pro",
|
||||
kind="chat",
|
||||
price_output_per_m=9.0,
|
||||
)
|
||||
)
|
||||
try:
|
||||
svc = GatewayService()
|
||||
cfg = svc.effective_config()
|
||||
cfg["gateway_vision_enabled"] = False
|
||||
rt = svc.runtime()
|
||||
run_async(
|
||||
rt.handle_chat(
|
||||
{"model": "custom-pro", "messages": [{"role": "user", "content": "hi"}]},
|
||||
cfg,
|
||||
("guest", "test"),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
url, body = rt._client.calls[-1]
|
||||
assert str(url) == "https://routed.example/v1/chat/completions"
|
||||
assert body["model"] == "vendor/pro"
|
||||
finally:
|
||||
routing.model_store.remove("custom-pro")
|
||||
routing.provider_store.remove("gwroute")
|
||||
|
||||
|
||||
def test_vision_rewrites_image_to_text(local_db, monkeypatch):
|
||||
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
||||
svc = GatewayService()
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.openai_gateway import routing as r
|
||||
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
||||
|
||||
|
||||
def _cleanup(providers, models):
|
||||
for name in providers:
|
||||
r.provider_store.remove(name)
|
||||
for source in models:
|
||||
r.model_store.remove(source)
|
||||
|
||||
|
||||
def test_no_routes_pass_through(local_db):
|
||||
assert r.chat_overlay("ghost-model-xyz", {}) is None
|
||||
assert r.embed_overlay("ghost-embed-xyz", {}) is None
|
||||
|
||||
|
||||
def test_chat_route_overlay_and_economy(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="utor",
|
||||
base_url="https://up.example/v1/chat/completions",
|
||||
api_key="k1",
|
||||
)
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-chat",
|
||||
provider="utor",
|
||||
target_model="vendor/big",
|
||||
kind="chat",
|
||||
context_window=64000,
|
||||
price_cache_hit_per_m=1.0,
|
||||
price_cache_miss_per_m=2.0,
|
||||
price_output_per_m=8.0,
|
||||
)
|
||||
)
|
||||
try:
|
||||
overlay = r.chat_overlay("ut-chat", {"gateway_model_context_map": "{}"})
|
||||
assert overlay["gateway_force_model"] is True
|
||||
assert overlay["gateway_model"] == "vendor/big"
|
||||
assert overlay["gateway_upstream_url"] == "https://up.example/v1/chat/completions"
|
||||
assert overlay["gateway_api_key"] == "k1"
|
||||
assert overlay["gateway_price_output_per_m"] == 8.0
|
||||
assert overlay["gateway_model_context_map"]["vendor/big"] == 64000
|
||||
pricing = pricing_from_cfg(overlay)
|
||||
assert pricing.chat_output_per_m == 8.0
|
||||
assert pricing.chat_cache_miss_per_m == 2.0
|
||||
finally:
|
||||
_cleanup(["utor"], ["ut-chat"])
|
||||
|
||||
|
||||
def test_vision_merge_overlay(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="utvis",
|
||||
base_url="https://vis.example/v1/chat/completions",
|
||||
api_key="vk",
|
||||
)
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-vision",
|
||||
provider="utvis",
|
||||
target_model="vendor/vlm",
|
||||
kind="chat",
|
||||
vision_model="vendor/describe",
|
||||
price_input_per_m=0.5,
|
||||
)
|
||||
)
|
||||
try:
|
||||
overlay = r.chat_overlay("ut-vision", {})
|
||||
assert overlay["gateway_vision_enabled"] is True
|
||||
assert overlay["gateway_vision_model"] == "vendor/describe"
|
||||
assert overlay["gateway_vision_url"] == "https://vis.example/v1/chat/completions"
|
||||
assert overlay["gateway_vision_key"] == "vk"
|
||||
assert overlay["gateway_vision_price_input_per_m"] == 0.5
|
||||
finally:
|
||||
_cleanup(["utvis"], ["ut-vision"])
|
||||
|
||||
|
||||
def test_embed_route_and_kind_isolation(local_db):
|
||||
r.provider_store.set(
|
||||
r.ProviderIn(
|
||||
name="utemb",
|
||||
base_url="https://emb.example/v1/chat/completions",
|
||||
api_key="ek",
|
||||
)
|
||||
)
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-embed",
|
||||
provider="utemb",
|
||||
target_model="vendor/embed",
|
||||
kind="embed",
|
||||
price_input_per_m=0.13,
|
||||
)
|
||||
)
|
||||
try:
|
||||
overlay = r.embed_overlay("ut-embed", {})
|
||||
assert overlay["gateway_embed_model"] == "vendor/embed"
|
||||
assert overlay["gateway_embed_url"] == "https://emb.example/v1/embeddings"
|
||||
assert overlay["gateway_embed_key"] == "ek"
|
||||
assert overlay["gateway_embed_price_input_per_m"] == 0.13
|
||||
assert r.chat_overlay("ut-embed", {}) is None
|
||||
finally:
|
||||
_cleanup(["utemb"], ["ut-embed"])
|
||||
|
||||
|
||||
def test_inactive_route_ignored(local_db):
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-off",
|
||||
target_model="vendor/x",
|
||||
kind="chat",
|
||||
is_active=False,
|
||||
)
|
||||
)
|
||||
try:
|
||||
assert r.chat_overlay("ut-off", {}) is None
|
||||
finally:
|
||||
_cleanup([], ["ut-off"])
|
||||
|
||||
|
||||
def test_blank_provider_uses_default_upstream(local_db):
|
||||
r.model_store.set(
|
||||
r.ModelRouteIn(
|
||||
source_model="ut-default",
|
||||
target_model="vendor/def",
|
||||
kind="chat",
|
||||
price_output_per_m=3.0,
|
||||
)
|
||||
)
|
||||
try:
|
||||
overlay = r.chat_overlay("ut-default", {})
|
||||
assert overlay["gateway_model"] == "vendor/def"
|
||||
assert "gateway_upstream_url" not in overlay
|
||||
assert "gateway_api_key" not in overlay
|
||||
assert overlay["gateway_price_output_per_m"] == 3.0
|
||||
finally:
|
||||
_cleanup([], ["ut-default"])
|
||||
Reference in New Issue
Block a user