forked from retoor/devplacepy
AI gateway: - Add a generic, admin-selectable `client_profile` field on gateway_providers (e.g. "opencode") so a provider needing special request headers (OpenCode Zen's client-identity spoofing) is configured like any other provider, not hardcoded by name. - Track per-(provider, model) reliability/speed/latency health in memory, seeded from the existing gateway_usage_ledger at startup - purely observational, never influences routing. - New Stats tab on /admin/gateway: request volume, latency, per-model breakdowns, and reliability weight, charted with a vendored Chart.js and devplace's own theme tokens. - Record which model a failed request actually fell back to (fallback_used_route), surfaced in the Recent Failures table. - Stop excluding context_length errors from fallback, and skip a primary attempt outright when its known context window is already too small for the estimated request size, going straight to the fallback. - gateway_usage_ledger's provider/fallback_used_route columns and indexes are ensured centrally in database/schema.py's init_db(), the single point of truth for this table's schema. - Non-OpenAI upstream routing and client-model passthrough; trust only the upstream's own X-Gateway-Model header for served-model attribution. Devii agent: - Fix a real lockup: plan/verify tools could be individually disabled via the admin tool toggles while still being required by the protocol gate, permanently bricking any task that needed tools. They can no longer be disabled, and the gate now also checks the tool is actually offered. - Fix compaction being silently calibrated for a 1M-token model while running a much smaller one: context budget is now percentage-based and the summarizer's own request is sized to fit the real model. - Give a specific, actionable retry message when plan()'s own arguments get cut off by the output limit, and tighten its schema to discourage overlong plans. Other: - Backup service: offload completed backups to a remote Hetzner Storage Box. - Container manager: fix orphan blob leaks from sync races, add a two-phase plan/execute `system prune` CLI command. - Admin gateway UI: replace the JS-rendered model/provider tables with server-rendered forms and pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhmEkvutuwtzFVcLbTrhdo
1945 lines
65 KiB
Python
1945 lines
65 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
from starlette.requests import Request
|
|
from tests.conftest import BASE_URL, run_async
|
|
from devplacepy.database import get_table, set_setting
|
|
from devplacepy.utils import generate_uid
|
|
import devplacepy.services.openai_gateway.gateway as gwmod
|
|
from devplacepy.services.openai_gateway import GatewayService
|
|
class FakeResp_openai_gateway:
|
|
def __init__(
|
|
self,
|
|
status=200,
|
|
payload=None,
|
|
ctype="application/json",
|
|
content=b"",
|
|
extra_headers=None,
|
|
):
|
|
self.status_code = status
|
|
self._payload = payload
|
|
self.text = json.dumps(payload) if payload is not None else ""
|
|
self.headers = {"content-type": ctype, **(extra_headers or {})}
|
|
self.content = content
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
async def aread(self):
|
|
return self.content or self.text.encode()
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
async def aiter_lines(self):
|
|
payload = self._payload or {}
|
|
chunk_id = payload.get("id", "x")
|
|
model = payload.get("model", "")
|
|
try:
|
|
content = payload["choices"][0]["message"].get("content") or ""
|
|
except (KeyError, IndexError, TypeError):
|
|
content = ""
|
|
try:
|
|
reasoning = payload["choices"][0]["message"].get("reasoning") or ""
|
|
except (KeyError, IndexError, TypeError):
|
|
reasoning = ""
|
|
|
|
def frame(delta=None, finish=None, usage=None):
|
|
body = {"id": chunk_id, "object": "chat.completion.chunk", "model": model}
|
|
if usage is not None:
|
|
body["choices"] = []
|
|
body["usage"] = usage
|
|
else:
|
|
body["choices"] = [{"index": 0, "delta": delta or {}, "finish_reason": finish}]
|
|
return f"data: {json.dumps(body)}"
|
|
|
|
yield frame({"role": "assistant"})
|
|
for i in range(0, len(reasoning), 5):
|
|
yield frame({"reasoning": reasoning[i : i + 5]})
|
|
for i in range(0, len(content), 5):
|
|
yield frame({"content": content[i : i + 5]})
|
|
yield frame({}, finish="stop")
|
|
if payload.get("usage"):
|
|
yield frame(usage=payload["usage"])
|
|
yield "data: [DONE]"
|
|
class FakeRequest:
|
|
def __init__(self, method, url, json_body):
|
|
self.method = method
|
|
self.url = url
|
|
self.json_body = json_body
|
|
self.extensions = {}
|
|
class FakeClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": body.get("model"),
|
|
"choices": [{"message": {"content": "hi there"}}],
|
|
}
|
|
)
|
|
|
|
async def post(self, url, headers=None, json=None, timeout=None):
|
|
self.calls.append((url, json))
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": json.get("model"),
|
|
"choices": [{"message": {"content": "hi there"}}],
|
|
}
|
|
)
|
|
|
|
async def request(self, method, url, headers=None, content=None):
|
|
return FakeResp_openai_gateway(payload={"ok": True})
|
|
|
|
async def aclose(self):
|
|
pass
|
|
def _make_request_openai_gateway(headers=None, cookies=None):
|
|
headers = headers or {}
|
|
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
|
if cookies:
|
|
raw.append(
|
|
(b"cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()).encode())
|
|
)
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/openai/v1/chat/completions",
|
|
"query_string": b"",
|
|
"headers": raw,
|
|
"state": {},
|
|
}
|
|
)
|
|
def _make_admin_openai_gateway(role="Admin"):
|
|
username = f"gw_{generate_uid()[:8]}"
|
|
api_key = generate_uid()
|
|
get_table("users").insert(
|
|
{
|
|
"uid": generate_uid(),
|
|
"username": username,
|
|
"terms_version": "1",
|
|
"email": f"{username}@t.dev",
|
|
"api_key": api_key,
|
|
"role": role,
|
|
"is_active": True,
|
|
}
|
|
)
|
|
return username, api_key
|
|
def _config(page, **fields):
|
|
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
|
|
|
|
|
|
def test_model_is_forced(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
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",
|
|
"default",
|
|
)
|
|
)
|
|
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()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = True
|
|
cfg["gateway_vision_key"] = "" # no key -> placeholder text, still rewrites to str
|
|
rt = svc.runtime()
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is this"},
|
|
{"type": "image_url", "image_url": {"url": "http://x/y.png"}},
|
|
],
|
|
}
|
|
]
|
|
run_async(rt.handle_chat({"messages": msgs}, cfg, ("guest", "test"), "test", "default"))
|
|
sent = rt._client.calls[-1][1]["messages"][0]["content"]
|
|
assert isinstance(sent, str) and "vision" in sent.lower()
|
|
|
|
|
|
def test_streaming_emits_sse(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def drain():
|
|
out = []
|
|
async for chunk in resp.body_iterator:
|
|
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
|
return "".join(out)
|
|
|
|
body = run_async(drain())
|
|
assert "chat.completion.chunk" in body
|
|
assert "[DONE]" in body
|
|
|
|
|
|
def test_authorize_static_access_key(local_db):
|
|
set_setting("gateway_require_auth", "1")
|
|
set_setting("gateway_access_key", "topsecret")
|
|
try:
|
|
svc = GatewayService()
|
|
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": "topsecret"})) is True
|
|
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": "wrong"})) is False
|
|
finally:
|
|
set_setting("gateway_access_key", "")
|
|
set_setting("gateway_require_auth", "1")
|
|
|
|
|
|
def test_authorize_admin_and_user_toggles(local_db):
|
|
set_setting("gateway_require_auth", "1")
|
|
set_setting("gateway_access_key", "")
|
|
set_setting("gateway_allow_admins", "1")
|
|
set_setting("gateway_allow_users", "0")
|
|
try:
|
|
_, admin_key = _make_admin_openai_gateway(role="Admin")
|
|
_, member_key = _make_admin_openai_gateway(role="Member")
|
|
svc = GatewayService()
|
|
|
|
assert (
|
|
svc.authorize(_make_request_openai_gateway(headers={"Authorization": f"Bearer {admin_key}"}))
|
|
is True
|
|
)
|
|
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": member_key})) is False
|
|
|
|
set_setting("gateway_allow_users", "1")
|
|
assert svc.authorize(_make_request_openai_gateway(headers={"X-API-KEY": member_key})) is True
|
|
finally:
|
|
set_setting("gateway_allow_admins", "1")
|
|
set_setting("gateway_allow_users", "1")
|
|
set_setting("gateway_require_auth", "1")
|
|
set_setting("gateway_access_key", "")
|
|
|
|
|
|
def test_authorize_require_auth_off_is_open(local_db):
|
|
set_setting("gateway_require_auth", "0")
|
|
try:
|
|
svc = GatewayService()
|
|
assert svc.authorize(_make_request_openai_gateway()) is True
|
|
finally:
|
|
set_setting("gateway_require_auth", "1")
|
|
|
|
|
|
class FakeEmbedClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"object": "list",
|
|
"model": body.get("model"),
|
|
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
|
|
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_embeddings_remaps_alias_to_configured_model(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = False
|
|
cfg["gateway_embed_enabled"] = True
|
|
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_embeddings(
|
|
{"model": "molodetz~embed", "input": "hello"},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "qwen/qwen3-embedding-8b"
|
|
|
|
|
|
def test_embeddings_success_records_ledger(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_embed_enabled"] = True
|
|
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
|
rt = svc.runtime()
|
|
before = rt.embed_calls
|
|
resp = run_async(
|
|
rt.handle_embeddings(
|
|
{"model": "molodetz~embed", "input": "hello"},
|
|
cfg,
|
|
("guest", "ledger_probe"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = json.loads(bytes(resp.body).decode())
|
|
assert payload["data"][0]["embedding"] == [0.1, 0.2]
|
|
assert rt.embed_calls == before + 1
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="ledger_probe")
|
|
assert row is not None
|
|
assert row["backend"] == "embed"
|
|
assert row["endpoint"] == "embeddings"
|
|
assert row["requested_model"] == "molodetz~embed"
|
|
assert row["model"] == "qwen/qwen3-embedding-8b"
|
|
assert row["success"] == 1
|
|
|
|
|
|
def test_embeddings_disabled_returns_503(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_embed_enabled"] = False
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_embeddings(
|
|
{"input": "hello"}, cfg, ("guest", "test"), "test", "default"
|
|
)
|
|
)
|
|
assert resp.status_code == 503
|
|
|
|
|
|
class FakeImageClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"created": 1,
|
|
"model": body.get("model"),
|
|
"data": [{"b64_json": "aGVsbG8="}],
|
|
"usage": {"cost": 0.05},
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_images_remaps_alias_to_configured_model(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = False
|
|
cfg["gateway_image_enabled"] = True
|
|
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_images(
|
|
{
|
|
"model": "molodetz-img-small",
|
|
"prompt": "award emblem",
|
|
"size": "512x512",
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "black-forest-labs/flux.2-pro"
|
|
|
|
|
|
def test_image_route_overrides_upstream(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
routing.provider_store.set(
|
|
routing.ProviderIn(
|
|
name="gwimg",
|
|
base_url="https://routed.example/v1/chat/completions",
|
|
api_key="img-key",
|
|
)
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="custom-img",
|
|
provider="gwimg",
|
|
target_model="vendor/flux-pro",
|
|
kind="image",
|
|
price_input_per_m=0.06,
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_images(
|
|
{
|
|
"model": "custom-img",
|
|
"prompt": "trophy",
|
|
"response_format": "b64_json",
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
url, body = rt._client.calls[-1]
|
|
assert str(url) == "https://routed.example/v1/images"
|
|
assert body["model"] == "vendor/flux-pro"
|
|
finally:
|
|
routing.model_store.remove("custom-img")
|
|
routing.provider_store.remove("gwimg")
|
|
|
|
|
|
def test_images_success_records_ledger(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_image_enabled"] = True
|
|
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
|
rt = svc.runtime()
|
|
before = rt.image_calls
|
|
resp = run_async(
|
|
rt.handle_images(
|
|
{"model": "molodetz-img-small", "prompt": "badge"},
|
|
cfg,
|
|
("guest", "img_ledger"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.status_code == 200
|
|
payload = json.loads(bytes(resp.body).decode())
|
|
assert payload["data"][0]["b64_json"] == "aGVsbG8="
|
|
assert rt.image_calls == before + 1
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="img_ledger")
|
|
assert row is not None
|
|
assert row["backend"] == "image"
|
|
assert row["endpoint"] == "images/generations"
|
|
assert row["requested_model"] == "molodetz-img-small"
|
|
assert row["model"] == "black-forest-labs/flux.2-pro"
|
|
assert row["success"] == 1
|
|
assert float(row["cost_usd"]) == 0.05
|
|
|
|
|
|
def test_images_disabled_returns_503(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_image_enabled"] = False
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_images(
|
|
{"prompt": "badge"}, cfg, ("guest", "test"), "test", "default"
|
|
)
|
|
)
|
|
assert resp.status_code == 503
|
|
|
|
|
|
def test_compute_cost_embed_branch():
|
|
from devplacepy.services.openai_gateway.usage import (
|
|
Pricing,
|
|
compute_cost,
|
|
normalize_usage,
|
|
)
|
|
|
|
pricing = Pricing(
|
|
chat_cache_hit_per_m=0.0,
|
|
chat_cache_miss_per_m=0.0,
|
|
chat_output_per_m=0.0,
|
|
vision_input_per_m=0.0,
|
|
vision_output_per_m=0.0,
|
|
embed_input_per_m=0.01,
|
|
)
|
|
usage = {"prompt_tokens": 1_000_000, "total_tokens": 1_000_000}
|
|
norm = normalize_usage(usage)
|
|
total, input_cost, output_cost, native = compute_cost(
|
|
usage, norm, pricing, "embed"
|
|
)
|
|
assert native is False
|
|
assert output_cost == 0.0
|
|
assert abs(total - 0.01) < 1e-9
|
|
assert abs(input_cost - 0.01) < 1e-9
|
|
|
|
|
|
def test_app_reference_stored_in_ledger_for_chat(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "ref_test"}]},
|
|
cfg,
|
|
("guest", "ref_probe"),
|
|
"test-ua",
|
|
"my-custom-app",
|
|
)
|
|
)
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="ref_probe")
|
|
assert row is not None
|
|
assert row["app_reference"] == "my-custom-app"
|
|
assert row["backend"] == "chat"
|
|
|
|
|
|
def test_app_reference_defaults_when_not_passed(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "default_test"}]},
|
|
cfg,
|
|
("guest", "default_probe"),
|
|
"test-ua",
|
|
"default",
|
|
)
|
|
)
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="default_probe")
|
|
assert row is not None
|
|
assert row["app_reference"] == "default"
|
|
|
|
|
|
def test_app_reference_stored_in_ledger_for_embeddings(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_embed_enabled"] = True
|
|
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_embeddings(
|
|
{"input": "hello"},
|
|
cfg,
|
|
("guest", "embed_ref"),
|
|
"test-ua",
|
|
"devplace-embed-test-v-1-0-0",
|
|
)
|
|
)
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="embed_ref")
|
|
assert row is not None
|
|
assert row["app_reference"] == "devplace-embed-test-v-1-0-0"
|
|
assert row["backend"] == "embed"
|
|
|
|
|
|
def test_app_reference_stored_in_ledger_for_images(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_image_enabled"] = True
|
|
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_images(
|
|
{"prompt": "ref badge"},
|
|
cfg,
|
|
("guest", "img_ref"),
|
|
"test-ua",
|
|
"devplace-image-test-v-1-0-0",
|
|
)
|
|
)
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="img_ref")
|
|
assert row is not None
|
|
assert row["app_reference"] == "devplace-image-test-v-1-0-0"
|
|
assert row["backend"] == "image"
|
|
|
|
|
|
def test_app_reference_column_exists(local_db):
|
|
db = get_table("gateway_usage_ledger").db
|
|
if "gateway_usage_ledger" not in db.tables:
|
|
return
|
|
rows = list(db.query("PRAGMA table_info('gateway_usage_ledger')"))
|
|
column_names = [r["name"] for r in rows]
|
|
assert "app_reference" in column_names
|
|
|
|
|
|
def test_chat_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = False
|
|
cfg["gateway_model"] = "deepseek-v4-flash"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"model": "nonexistent-model-v99", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "fallback_test_chat"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "deepseek-v4-flash"
|
|
|
|
|
|
class FakeFallbackClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
model = body.get("model")
|
|
if model == "primary-target":
|
|
return FakeResp_openai_gateway(status=500, payload={"error": "boom"})
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": model,
|
|
"choices": [{"message": {"content": "fallback ok"}}],
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
class FakeAlwaysFailClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
return FakeResp_openai_gateway(status=500, payload={"error": "boom"})
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
class FakeBadRequestClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
return FakeResp_openai_gateway(
|
|
status=400, payload={"error": {"message": "invalid request: bad param"}}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_chat_does_not_fall_back_on_an_unrecoverable_bad_request(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeBadRequestClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="fb-badreq-backup", target_model="backup-target")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="fb-badreq-primary",
|
|
target_model="primary-target",
|
|
fallback_model="fb-badreq-backup",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"model": "fb-badreq-primary", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "no_fallback_on_bad_request"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 400
|
|
assert len(rt._client.calls) == 1
|
|
finally:
|
|
routing.model_store.remove("fb-badreq-primary")
|
|
routing.model_store.remove("fb-badreq-backup")
|
|
|
|
|
|
def test_chat_falls_back_when_the_primary_model_fails(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeFallbackClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="fb-backup-route", target_model="backup-target")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="fb-primary-route",
|
|
target_model="primary-target",
|
|
fallback_model="fb-backup-route",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"model": "fb-primary-route", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "fallback_chat_success"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 200
|
|
assert rt._client.calls[0][1]["model"] == "primary-target"
|
|
assert rt._client.calls[-1][1]["model"] == "backup-target"
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="fallback_chat_success")
|
|
assert row is not None
|
|
assert row["requested_model"] == "fb-primary-route"
|
|
assert row["model"] == "backup-target"
|
|
assert row["success"] == 1
|
|
finally:
|
|
routing.model_store.remove("fb-primary-route")
|
|
routing.model_store.remove("fb-backup-route")
|
|
|
|
|
|
class FakeContextLengthClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
model = body.get("model")
|
|
if model == "ctx-primary-target":
|
|
return FakeResp_openai_gateway(
|
|
status=400,
|
|
payload={"error": {"message": "maximum context length exceeded"}},
|
|
)
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": model,
|
|
"choices": [{"message": {"content": "fallback ok"}}],
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_chat_falls_back_on_a_context_length_error(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeContextLengthClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="ctx-backup-route", target_model="ctx-backup-target")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="ctx-primary-route",
|
|
target_model="ctx-primary-target",
|
|
fallback_model="ctx-backup-route",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"model": "ctx-primary-route", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "context_length_fallback_success"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 200
|
|
assert rt._client.calls[0][1]["model"] == "ctx-primary-target"
|
|
assert rt._client.calls[-1][1]["model"] == "ctx-backup-target"
|
|
row = get_table("gateway_usage_ledger").find_one(
|
|
owner_id="context_length_fallback_success"
|
|
)
|
|
assert row["success"] == 1
|
|
assert row["fallback_used_route"] == "ctx-backup-route"
|
|
finally:
|
|
routing.model_store.remove("ctx-primary-route")
|
|
routing.model_store.remove("ctx-backup-route")
|
|
|
|
|
|
def test_chat_skips_a_too_small_primary_and_goes_straight_to_fallback(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeContextLengthClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="ctx-precheck-backup",
|
|
target_model="ctx-backup-target",
|
|
context_window=1_000_000,
|
|
)
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="ctx-precheck-primary",
|
|
target_model="ctx-primary-target",
|
|
context_window=50,
|
|
fallback_model="ctx-precheck-backup",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
long_message = "word " * 2000
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"model": "ctx-precheck-primary",
|
|
"messages": [{"role": "user", "content": long_message}],
|
|
},
|
|
cfg,
|
|
("guest", "context_precheck_skips_primary"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 200
|
|
# Only the fallback was ever called - the primary was known too small.
|
|
assert len(rt._client.calls) == 1
|
|
assert rt._client.calls[0][1]["model"] == "ctx-backup-target"
|
|
row = get_table("gateway_usage_ledger").find_one(
|
|
owner_id="context_precheck_skips_primary"
|
|
)
|
|
assert row["fallback_used_route"] == "ctx-precheck-backup"
|
|
finally:
|
|
routing.model_store.remove("ctx-precheck-primary")
|
|
routing.model_store.remove("ctx-precheck-backup")
|
|
|
|
|
|
def test_chat_falls_back_via_molodetz_when_the_client_sends_an_unrouted_model_name(
|
|
local_db, monkeypatch
|
|
):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
original = routing.model_store.get("molodetz")
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="fb-molodetz-backup", target_model="backup-target")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="molodetz",
|
|
target_model="primary-target",
|
|
fallback_model="fb-molodetz-backup",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
cfg["gateway_force_model"] = False
|
|
base_default_model = cfg["gateway_model"]
|
|
|
|
class FakeUnroutedPrimaryFailsClient:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
model = body.get("model")
|
|
if model == base_default_model:
|
|
return FakeResp_openai_gateway(
|
|
status=402, payload={"error": {"message": "Insufficient Balance"}}
|
|
)
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": model,
|
|
"choices": [{"message": {"content": "fallback ok"}}],
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeUnroutedPrimaryFailsClient)
|
|
rt = svc.runtime()
|
|
# The client sends a model name that matches no configured route at all
|
|
# (as a caller like dpc does, which is never told the exact "molodetz"
|
|
# routing alias) - the primary call falls through to the flat/global
|
|
# default model (no route overlay applies), but a failure there must
|
|
# still consult molodetz's own configured fallback, not silently skip
|
|
# it just because the client's literal string never matched anything.
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"model": "some-client-side-model-name",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
},
|
|
cfg,
|
|
("guest", "fallback_chat_unrouted_client_model"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 200
|
|
assert rt._client.calls[0][1]["model"] == base_default_model
|
|
assert rt._client.calls[-1][1]["model"] == "backup-target"
|
|
row = get_table("gateway_usage_ledger").find_one(
|
|
owner_id="fallback_chat_unrouted_client_model"
|
|
)
|
|
assert row is not None
|
|
assert row["requested_model"] == "some-client-side-model-name"
|
|
assert row["model"] == "backup-target"
|
|
assert row["success"] == 1
|
|
finally:
|
|
routing.model_store.remove("fb-molodetz-backup")
|
|
if original is not None:
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
**{
|
|
field: getattr(original, field)
|
|
for field in routing.ModelRouteIn.model_fields
|
|
}
|
|
)
|
|
)
|
|
else:
|
|
routing.model_store.remove("molodetz")
|
|
|
|
|
|
def test_chat_returns_the_fallback_failure_when_both_models_fail(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeAlwaysFailClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="fb-backup-route2", target_model="backup-target2")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="fb-primary-route2",
|
|
target_model="primary-target2",
|
|
fallback_model="fb-backup-route2",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"model": "fb-primary-route2", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "fallback_chat_both_fail"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 500
|
|
assert len(rt._client.calls) == 2
|
|
assert rt._client.calls[0][1]["model"] == "primary-target2"
|
|
assert rt._client.calls[1][1]["model"] == "backup-target2"
|
|
finally:
|
|
routing.model_store.remove("fb-primary-route2")
|
|
routing.model_store.remove("fb-backup-route2")
|
|
|
|
|
|
def test_chat_without_a_fallback_never_makes_a_second_call(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeAlwaysFailClient_openai_gateway)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="fb-no-fallback", target_model="no-fallback-target")
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"model": "fb-no-fallback", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "no_fallback_configured"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert len(rt._client.calls) == 1
|
|
finally:
|
|
routing.model_store.remove("fb-no-fallback")
|
|
|
|
|
|
def test_embeddings_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeEmbedClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = False
|
|
cfg["gateway_embed_enabled"] = True
|
|
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_embeddings(
|
|
{"model": "nonexistent-embed-model", "input": "hello"},
|
|
cfg,
|
|
("guest", "fallback_test_embed"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "qwen/qwen3-embedding-8b"
|
|
|
|
|
|
def test_images_unknown_model_falls_back_to_default(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeImageClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = False
|
|
cfg["gateway_image_enabled"] = True
|
|
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_images(
|
|
{"model": "nonexistent-image-model", "prompt": "test badge"},
|
|
cfg,
|
|
("guest", "fallback_test_img"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "black-forest-labs/flux.2-pro"
|
|
|
|
|
|
class FakeClientWithUsage_openai_gateway(FakeClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": body.get("model"),
|
|
"choices": [{"message": {"content": "hi there"}}],
|
|
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
|
|
}
|
|
)
|
|
|
|
|
|
def test_ollama_stream_does_not_send_stream_options(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_upstream_url"] = "http://127.0.0.1:11434/api/chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
cfg,
|
|
("guest", "ollama_stream_opts"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
upstream_payload = rt._client.calls[-1][1]
|
|
assert upstream_payload["stream"] is True
|
|
assert "stream_options" not in upstream_payload
|
|
assert upstream_payload["think"] is False
|
|
|
|
|
|
def test_ollama_explicit_dialect_overrides_url(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_upstream_url"] = "https://ai.example.com/v1/chat/completions"
|
|
cfg["gateway_thinking_dialect"] = "ollama"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "ollama_dialect_override"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
body = rt._client.calls[-1][1]
|
|
assert body["think"] is False
|
|
assert "thinking" not in body
|
|
|
|
|
|
def test_client_model_allowed_when_allow_client_model_on(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_allow_client_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"model": "llama3.2", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "client_model"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "llama3.2"
|
|
|
|
|
|
def test_molodetz_alias_still_remapped_with_allow_client_model(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
original = routing.model_store.get("molodetz")
|
|
routing.model_store.remove("molodetz")
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_allow_client_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"model": "molodetz", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "molodetz_alias"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
|
|
finally:
|
|
if original is not None:
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
**{
|
|
field: getattr(original, field)
|
|
for field in routing.ModelRouteIn.model_fields
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def test_stream_forwarded_to_upstream_with_forced_include_usage(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
"stream_options": {"include_usage": False},
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
upstream_payload = rt._client.calls[-1][1]
|
|
assert upstream_payload["stream"] is True
|
|
assert upstream_payload["stream_options"] == {"include_usage": True}
|
|
assert upstream_payload["thinking"] == {"type": "disabled"}
|
|
assert "think" not in upstream_payload
|
|
assert "reasoning" not in upstream_payload
|
|
|
|
|
|
def test_non_stream_has_no_stream_options_upstream(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
upstream_payload = rt._client.calls[-1][1]
|
|
assert "stream_options" not in upstream_payload
|
|
assert upstream_payload["stream"] is False
|
|
|
|
|
|
def test_thinking_disabled_by_default(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
body = rt._client.calls[-1][1]
|
|
assert body["thinking"] == {"type": "disabled"}
|
|
|
|
|
|
def test_client_think_true_enables_upstream_thinking(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"think": True,
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
body = rt._client.calls[-1][1]
|
|
assert body["thinking"] == {"type": "enabled"}
|
|
assert "think" not in body
|
|
|
|
|
|
def test_openrouter_upstream_gets_reasoning_none(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_upstream_url"] = "https://openrouter.ai/api/v1/chat/completions"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
body = rt._client.calls[-1][1]
|
|
assert body["reasoning"] == {"effort": "none"}
|
|
assert "thinking" not in body
|
|
|
|
|
|
def test_include_usage_emits_usage_chunk(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def drain():
|
|
out = []
|
|
async for chunk in resp.body_iterator:
|
|
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
|
return "".join(out)
|
|
|
|
body = run_async(drain())
|
|
assert '"usage"' in body
|
|
assert '"total_tokens": 10' in body
|
|
assert "[DONE]" in body
|
|
|
|
|
|
def test_no_usage_chunk_without_include_usage(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def drain():
|
|
out = []
|
|
async for chunk in resp.body_iterator:
|
|
out.append(chunk if isinstance(chunk, str) else chunk.decode())
|
|
return "".join(out)
|
|
|
|
body = run_async(drain())
|
|
assert '"usage"' not in body
|
|
assert "[DONE]" in body
|
|
|
|
|
|
def test_stream_headers_carry_no_cost_or_token_data(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
|
cfg,
|
|
("guest", "test"),
|
|
"test",
|
|
"my-app",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == cfg["gateway_model"]
|
|
assert resp.headers["X-Gateway-Backend"] == "chat"
|
|
assert resp.headers["X-App-Reference"] == "my-app"
|
|
assert "x-gateway-cost-usd" not in {k.lower() for k in resp.headers}
|
|
assert "x-gateway-total-tokens" not in {k.lower() for k in resp.headers}
|
|
|
|
|
|
def test_stream_records_ttft_and_inter_token_in_ledger(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientWithUsage_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
cfg,
|
|
("guest", "ttft_probe"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def drain():
|
|
async for _ in resp.body_iterator:
|
|
pass
|
|
|
|
run_async(drain())
|
|
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="ttft_probe")
|
|
assert row is not None
|
|
assert row["backend"] == "chat"
|
|
assert row["stream_requested"] == 1
|
|
assert row["success"] == 1
|
|
assert row["total_tokens"] == 10
|
|
assert row["ttft_ms"] is not None and row["ttft_ms"] >= 0.0
|
|
assert row["inter_token_ms"] is not None and row["inter_token_ms"] >= 0.0
|
|
|
|
|
|
class FakeClientReasoning_openai_gateway(FakeClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": body.get("model"),
|
|
"choices": [{"message": {"content": "x", "reasoning": "aaaaa"}}],
|
|
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
|
|
}
|
|
)
|
|
|
|
|
|
def test_ollama_reasoning_delta_is_counted_as_a_content_chunk(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClientReasoning_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
cfg,
|
|
("guest", "ollama_reasoning_probe"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def drain():
|
|
async for _ in resp.body_iterator:
|
|
pass
|
|
|
|
run_async(drain())
|
|
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="ollama_reasoning_probe")
|
|
assert row is not None
|
|
assert row["inter_token_ms"] is not None and row["inter_token_ms"] >= 0.0
|
|
|
|
|
|
def test_stream_client_disconnect_records_failure_and_closes_upstream(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
|
cfg,
|
|
("guest", "disconnect_probe"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
|
|
async def partial_then_close():
|
|
agen = resp.body_iterator
|
|
await agen.__anext__()
|
|
await agen.aclose()
|
|
|
|
run_async(partial_then_close())
|
|
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="disconnect_probe")
|
|
assert row is not None
|
|
assert row["success"] == 0
|
|
assert row["error_category"] == "client_disconnected"
|
|
|
|
|
|
def test_models_endpoint_publishes_molodetz(local_db):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
routing.seed_default_deepseek_routes()
|
|
try:
|
|
svc = GatewayService()
|
|
resp = svc._models_response()
|
|
payload = json.loads(resp.body.decode())
|
|
ids = {m["id"] for m in payload["data"]}
|
|
assert payload["object"] == "list"
|
|
assert {"molodetz", "molodetz-pro"} <= ids
|
|
for model in payload["data"]:
|
|
assert model["object"] == "model"
|
|
assert model["owned_by"] == "molodetz"
|
|
finally:
|
|
routing.model_store.remove("molodetz")
|
|
routing.model_store.remove("molodetz-pro")
|
|
|
|
|
|
# Upstream header collision policy: only X-Gateway-Model is ever trusted and
|
|
# forwarded from an upstream that happens to speak our own X-Gateway-* header
|
|
# convention (e.g. another DevPlace-style gateway). Every other figure
|
|
# (cost/tokens/latency/context/app-reference) must remain ours regardless of
|
|
# what an upstream claims. See usage.upstream_reported_model / CLAUDE.md.
|
|
|
|
|
|
class FakeClientUpstreamModelHeader_openai_gateway(FakeClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": body.get("model"),
|
|
"choices": [{"message": {"content": "hi there"}}],
|
|
},
|
|
extra_headers={"X-Gateway-Model": "upstream/served-model-x"},
|
|
)
|
|
|
|
|
|
class FakeClientHostileModelHeader_openai_gateway(FakeClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": body.get("model"),
|
|
"choices": [{"message": {"content": "hi there"}}],
|
|
},
|
|
extra_headers={"X-Gateway-Model": "evil\r\nX-Injected: true"},
|
|
)
|
|
|
|
|
|
class FakeEmbedClientUpstreamModelHeader_openai_gateway(FakeEmbedClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"object": "list",
|
|
"model": body.get("model"),
|
|
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
|
|
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
|
},
|
|
extra_headers={"X-Gateway-Model": "upstream/embed-served"},
|
|
)
|
|
|
|
|
|
class FakeImageClientUpstreamModelHeader_openai_gateway(FakeImageClient_openai_gateway):
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"created": 1,
|
|
"model": body.get("model"),
|
|
"data": [{"b64_json": "aGVsbG8="}],
|
|
"usage": {"cost": 0.05},
|
|
},
|
|
extra_headers={"X-Gateway-Model": "upstream/image-served"},
|
|
)
|
|
|
|
|
|
class FakePassthroughClientUpstreamModelHeader_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
return FakeResp_openai_gateway(
|
|
payload={"ok": True},
|
|
extra_headers={"X-Gateway-Model": "upstream/passthrough-served"},
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_upstream_x_gateway_model_header_is_forwarded_for_chat(local_db, monkeypatch):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeClientUpstreamModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "upstream_model_chat"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "upstream/served-model-x"
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="upstream_model_chat")
|
|
assert row is not None
|
|
assert row["model"] == "deepseek-chat"
|
|
|
|
|
|
def test_upstream_x_gateway_model_header_is_forwarded_for_streaming_chat(
|
|
local_db, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeClientUpstreamModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
|
|
cfg,
|
|
("guest", "upstream_model_stream"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "upstream/served-model-x"
|
|
assert resp.headers["X-Gateway-Backend"] == "chat"
|
|
|
|
async def drain():
|
|
async for _ in resp.body_iterator:
|
|
pass
|
|
|
|
run_async(drain())
|
|
|
|
|
|
def test_upstream_x_gateway_model_header_is_forwarded_for_embeddings(
|
|
local_db, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeEmbedClientUpstreamModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_embed_enabled"] = True
|
|
cfg["gateway_embed_model"] = "qwen/qwen3-embedding-8b"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_embeddings(
|
|
{"input": "hello"},
|
|
cfg,
|
|
("guest", "upstream_model_embed"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "upstream/embed-served"
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="upstream_model_embed")
|
|
assert row is not None
|
|
assert row["model"] == "qwen/qwen3-embedding-8b"
|
|
|
|
|
|
def test_upstream_x_gateway_model_header_is_forwarded_for_images(local_db, monkeypatch):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeImageClientUpstreamModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_image_enabled"] = True
|
|
cfg["gateway_image_model"] = "black-forest-labs/flux.2-pro"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_images(
|
|
{"prompt": "badge"},
|
|
cfg,
|
|
("guest", "upstream_model_img"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "upstream/image-served"
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="upstream_model_img")
|
|
assert row is not None
|
|
assert row["model"] == "black-forest-labs/flux.2-pro"
|
|
|
|
|
|
def test_upstream_x_gateway_model_header_is_forwarded_for_passthrough(
|
|
local_db, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakePassthroughClientUpstreamModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_passthrough(
|
|
"POST",
|
|
"responses",
|
|
"application/json",
|
|
b"{}",
|
|
cfg,
|
|
("guest", "upstream_model_passthrough"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "upstream/passthrough-served"
|
|
row = get_table("gateway_usage_ledger").find_one(
|
|
owner_id="upstream_model_passthrough"
|
|
)
|
|
assert row is not None
|
|
assert row["model"] == "deepseek-chat"
|
|
|
|
|
|
def test_no_upstream_model_header_keeps_our_own_model(local_db, monkeypatch):
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "no_upstream_header"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "deepseek-chat"
|
|
|
|
|
|
class FakeEmbeddedErrorClient_openai_gateway:
|
|
"""Mimics OpenRouter's documented behavior of answering 200 OK with the
|
|
failure embedded in the JSON body (openrouter.ai/docs/api-reference/errors)
|
|
instead of a non-2xx status."""
|
|
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
body = request.json_body or {}
|
|
model = body.get("model")
|
|
if model == "primary-target":
|
|
return FakeResp_openai_gateway(
|
|
status=200,
|
|
payload={"error": {"message": "no provider available", "code": 502}},
|
|
)
|
|
return FakeResp_openai_gateway(
|
|
payload={
|
|
"id": "x",
|
|
"model": model,
|
|
"choices": [{"message": {"content": "fallback ok"}}],
|
|
}
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_chat_falls_back_when_upstream_returns_200_with_an_embedded_error(
|
|
local_db, monkeypatch
|
|
):
|
|
from devplacepy.services.openai_gateway import routing
|
|
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeEmbeddedErrorClient_openai_gateway
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(source_model="eb-backup-route", target_model="backup-target")
|
|
)
|
|
routing.model_store.set(
|
|
routing.ModelRouteIn(
|
|
source_model="eb-primary-route",
|
|
target_model="primary-target",
|
|
fallback_model="eb-backup-route",
|
|
)
|
|
)
|
|
try:
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"model": "eb-primary-route", "messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "embedded_error_chat"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 200
|
|
assert rt._client.calls[0][1]["model"] == "primary-target"
|
|
assert rt._client.calls[-1][1]["model"] == "backup-target"
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="embedded_error_chat")
|
|
assert row is not None
|
|
assert row["requested_model"] == "eb-primary-route"
|
|
assert row["model"] == "backup-target"
|
|
assert row["success"] == 1
|
|
finally:
|
|
routing.model_store.remove("eb-primary-route")
|
|
routing.model_store.remove("eb-backup-route")
|
|
|
|
|
|
class FakeAlwaysEmbeddedErrorClient_openai_gateway:
|
|
def __init__(self, *a, **k):
|
|
self.calls = []
|
|
|
|
def build_request(self, method, url, headers=None, json=None, content=None):
|
|
return FakeRequest(method, url, json)
|
|
|
|
async def send(self, request, stream=False):
|
|
self.calls.append((request.url, request.json_body))
|
|
return FakeResp_openai_gateway(
|
|
status=200,
|
|
payload={"error": {"message": "no provider available", "code": 502}},
|
|
)
|
|
|
|
async def aclose(self):
|
|
pass
|
|
|
|
|
|
def test_chat_reports_502_when_upstream_returns_200_with_an_embedded_error_and_no_fallback(
|
|
local_db, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeAlwaysEmbeddedErrorClient_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_vision_enabled"] = False
|
|
cfg["gateway_max_retries"] = 0
|
|
rt = svc.runtime()
|
|
response = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "embedded_error_no_fallback"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert response.status_code == 502
|
|
row = get_table("gateway_usage_ledger").find_one(owner_id="embedded_error_no_fallback")
|
|
assert row is not None
|
|
assert row["success"] == 0
|
|
assert row["error_category"] == "upstream_error"
|
|
|
|
|
|
def test_ollama_dialect_sends_reasoning_effort_alongside_think(local_db, monkeypatch):
|
|
# Ollama's native /api/chat honors a boolean `think`, but its
|
|
# OpenAI-compatible /v1/chat/completions layer (what this gateway
|
|
# actually calls) does not - it maps reasoning_effort/reasoning
|
|
# instead (github.com/ollama/ollama issues #15288, #15293, #14820).
|
|
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient_openai_gateway)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_upstream_url"] = "http://127.0.0.1:11434/v1/chat/completions"
|
|
rt = svc.runtime()
|
|
run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "ollama_reasoning_effort"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
body = rt._client.calls[-1][1]
|
|
assert body["think"] is False
|
|
assert body["reasoning_effort"] == "none"
|
|
|
|
|
|
def test_hostile_upstream_model_header_is_ignored_end_to_end(local_db, monkeypatch):
|
|
monkeypatch.setattr(
|
|
gwmod.httpx, "AsyncClient", FakeClientHostileModelHeader_openai_gateway
|
|
)
|
|
svc = GatewayService()
|
|
cfg = svc.effective_config()
|
|
cfg["gateway_force_model"] = True
|
|
cfg["gateway_model"] = "deepseek-chat"
|
|
rt = svc.runtime()
|
|
resp = run_async(
|
|
rt.handle_chat(
|
|
{"messages": [{"role": "user", "content": "hi"}]},
|
|
cfg,
|
|
("guest", "hostile_header"),
|
|
"test",
|
|
"default",
|
|
)
|
|
)
|
|
assert resp.headers["X-Gateway-Model"] == "deepseek-chat"
|