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
572 lines
19 KiB
Python
572 lines
19 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from devplacepy.services.openai_gateway import routing as r
|
|
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
|
from tests.conftest import run_async
|
|
|
|
|
|
class _FakeModelsResponse:
|
|
def __init__(self, status_code, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeModelsClient:
|
|
def __init__(self, status_code, payload=None, error=None):
|
|
self.status_code = status_code
|
|
self.payload = payload
|
|
self.error = error
|
|
self.requested_url = None
|
|
self.headers = None
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc_info):
|
|
return False
|
|
|
|
async def get(self, url):
|
|
self.requested_url = url
|
|
if self.error is not None:
|
|
raise self.error
|
|
return _FakeModelsResponse(self.status_code, self.payload)
|
|
|
|
|
|
def _patch_stealth_client(monkeypatch, fake_client):
|
|
def _factory(**kwargs):
|
|
fake_client.headers = kwargs.get("headers")
|
|
return fake_client
|
|
|
|
monkeypatch.setattr("devplacepy.stealth.stealth_async_client", _factory)
|
|
|
|
|
|
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
|
|
assert r.image_overlay("ghost-image-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_resolve_fallback_returns_none_without_a_configured_fallback(local_db):
|
|
r.model_store.set(r.ModelRouteIn(source_model="fb-primary", target_model="v/a"))
|
|
try:
|
|
assert r.resolve_fallback("fb-primary", "chat") is None
|
|
finally:
|
|
_cleanup([], ["fb-primary"])
|
|
|
|
|
|
def test_resolve_fallback_returns_none_for_an_unknown_source(local_db):
|
|
assert r.resolve_fallback("ghost-fb-source", "chat") is None
|
|
|
|
|
|
def test_resolve_fallback_rejects_self_reference(local_db):
|
|
with pytest.raises(ValidationError):
|
|
r.ModelRouteIn(
|
|
source_model="fb-self", target_model="v/a", fallback_model="fb-self"
|
|
)
|
|
|
|
|
|
def test_resolve_fallback_returns_the_configured_route(local_db):
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-backup", target_model="v/backup", kind="chat"
|
|
)
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-primary",
|
|
target_model="v/primary",
|
|
kind="chat",
|
|
fallback_model="fb-backup",
|
|
)
|
|
)
|
|
try:
|
|
fallback = r.resolve_fallback("fb-primary", "chat")
|
|
assert fallback is not None
|
|
assert fallback.source_model == "fb-backup"
|
|
assert fallback.target_model == "v/backup"
|
|
finally:
|
|
_cleanup([], ["fb-primary", "fb-backup"])
|
|
|
|
|
|
def test_resolve_fallback_ignores_an_inactive_fallback_route(local_db):
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-backup-off",
|
|
target_model="v/backup",
|
|
kind="chat",
|
|
is_active=False,
|
|
)
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-primary-2",
|
|
target_model="v/primary",
|
|
kind="chat",
|
|
fallback_model="fb-backup-off",
|
|
)
|
|
)
|
|
try:
|
|
assert r.resolve_fallback("fb-primary-2", "chat") is None
|
|
finally:
|
|
_cleanup([], ["fb-primary-2", "fb-backup-off"])
|
|
|
|
|
|
def test_resolve_fallback_ignores_a_fallback_of_a_different_kind(local_db):
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-backup-embed", target_model="v/e", kind="embed"
|
|
)
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="fb-primary-3",
|
|
target_model="v/primary",
|
|
kind="chat",
|
|
fallback_model="fb-backup-embed",
|
|
)
|
|
)
|
|
try:
|
|
assert r.resolve_fallback("fb-primary-3", "chat") is None
|
|
finally:
|
|
_cleanup([], ["fb-primary-3", "fb-backup-embed"])
|
|
|
|
|
|
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_image_route_and_kind_isolation(local_db):
|
|
r.provider_store.set(
|
|
r.ProviderIn(
|
|
name="utimg",
|
|
base_url="https://img.example/v1/chat/completions",
|
|
api_key="ik",
|
|
)
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="ut-image",
|
|
provider="utimg",
|
|
target_model="vendor/flux",
|
|
kind="image",
|
|
price_input_per_m=0.05,
|
|
)
|
|
)
|
|
try:
|
|
overlay = r.image_overlay("ut-image", {})
|
|
assert overlay["gateway_image_model"] == "vendor/flux"
|
|
assert overlay["gateway_image_url"] == "https://img.example/v1/images"
|
|
assert overlay["gateway_image_key"] == "ik"
|
|
assert overlay["gateway_image_price_per_call"] == 0.05
|
|
assert r.chat_overlay("ut-image", {}) is None
|
|
assert r.embed_overlay("ut-image", {}) is None
|
|
pricing = pricing_from_cfg(overlay)
|
|
assert pricing.image_per_call == 0.05
|
|
finally:
|
|
_cleanup(["utimg"], ["ut-image"])
|
|
|
|
|
|
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"])
|
|
|
|
|
|
def test_provider_with_a_client_profile_gets_extra_headers(local_db):
|
|
r.provider_store.set(
|
|
r.ProviderIn(
|
|
name="zen",
|
|
base_url="https://opencode.ai/zen/v1/chat/completions",
|
|
api_key="public",
|
|
client_profile="opencode",
|
|
)
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(source_model="ut-zen", provider="zen", target_model="kimi-k3")
|
|
)
|
|
try:
|
|
overlay = r.chat_overlay("ut-zen", {})
|
|
headers = overlay["gateway_extra_request_headers"]
|
|
assert headers["x-opencode-client"]
|
|
assert "User-Agent" in headers
|
|
finally:
|
|
_cleanup(["zen"], ["ut-zen"])
|
|
|
|
|
|
def test_provider_without_a_client_profile_gets_no_extra_headers(local_db):
|
|
r.provider_store.set(
|
|
r.ProviderIn(name="plainprov", base_url="https://up.example/v1/chat/completions")
|
|
)
|
|
r.model_store.set(
|
|
r.ModelRouteIn(source_model="ut-plain", provider="plainprov", target_model="vendor/x")
|
|
)
|
|
try:
|
|
overlay = r.chat_overlay("ut-plain", {})
|
|
assert "gateway_extra_request_headers" not in overlay
|
|
finally:
|
|
_cleanup(["plainprov"], ["ut-plain"])
|
|
|
|
|
|
def test_client_profile_rejects_unknown_value():
|
|
with pytest.raises(ValidationError):
|
|
r.ProviderIn(name="badprofile", client_profile="not-a-real-profile")
|
|
|
|
|
|
def test_tier2_and_off_peak_fields_propagate_through_overlay(local_db):
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="ut-tiered",
|
|
target_model="vendor/tiered",
|
|
kind="chat",
|
|
price_cache_hit_per_m=0.0028,
|
|
price_cache_miss_per_m=0.14,
|
|
price_output_per_m=0.28,
|
|
context_tier_threshold_tokens=128_000,
|
|
price_output_per_m_tier2=0.56,
|
|
off_peak_start_minute=990,
|
|
off_peak_end_minute=30,
|
|
off_peak_discount_pct=25.0,
|
|
)
|
|
)
|
|
try:
|
|
overlay = r.chat_overlay("ut-tiered", {})
|
|
assert overlay["gateway_context_tier_threshold_tokens"] == 128_000
|
|
assert overlay["gateway_price_output_per_m_tier2"] == 0.56
|
|
assert overlay["gateway_price_cache_hit_per_m_tier2"] is None
|
|
assert overlay["gateway_off_peak_start_minute"] == 990
|
|
assert overlay["gateway_off_peak_end_minute"] == 30
|
|
assert overlay["gateway_off_peak_discount_pct"] == 25.0
|
|
pricing = pricing_from_cfg(overlay)
|
|
assert pricing.context_tier_threshold_tokens == 128_000
|
|
assert pricing.chat_output_per_m_tier2 == 0.56
|
|
assert pricing.chat_cache_hit_per_m_tier2 is None
|
|
assert pricing.off_peak_start_minute == 990
|
|
finally:
|
|
_cleanup([], ["ut-tiered"])
|
|
|
|
|
|
def test_unrouted_request_never_gains_tier_or_off_peak_keys(local_db):
|
|
assert r.chat_overlay("ghost-model-untiered", {}) is None
|
|
pricing = pricing_from_cfg({})
|
|
assert pricing.context_tier_threshold_tokens == 0
|
|
assert pricing.off_peak_start_minute is None
|
|
assert pricing.off_peak_end_minute is None
|
|
assert pricing.chat_output_per_m_tier2 is None
|
|
|
|
|
|
def test_off_peak_window_requires_both_start_and_end():
|
|
with pytest.raises(ValidationError):
|
|
r.ModelRouteIn(
|
|
source_model="ut-bad-window",
|
|
target_model="vendor/x",
|
|
off_peak_start_minute=60,
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
r.ModelRouteIn(
|
|
source_model="ut-bad-window",
|
|
target_model="vendor/x",
|
|
off_peak_end_minute=120,
|
|
)
|
|
|
|
|
|
def test_seed_default_image_routes_is_idempotent(local_db):
|
|
r.model_store.remove(r.IMAGE_SOURCE_MODEL)
|
|
r.seed_default_image_routes()
|
|
route = r.model_store.get(r.IMAGE_SOURCE_MODEL)
|
|
assert route is not None
|
|
assert route.kind == "image"
|
|
assert route.target_model == r.FLUX_TARGET_MODEL
|
|
r.seed_default_image_routes()
|
|
assert r.model_store.get(r.IMAGE_SOURCE_MODEL).target_model == r.FLUX_TARGET_MODEL
|
|
|
|
|
|
def test_seed_default_deepseek_routes_is_idempotent_and_preserves_customization(
|
|
local_db,
|
|
):
|
|
r.seed_default_deepseek_routes()
|
|
flash = r.model_store.get("deepseek-v4-flash")
|
|
pro = r.model_store.get("deepseek-v4-pro")
|
|
assert flash is not None and pro is not None
|
|
assert flash.price_cache_hit_per_m == 0.0028
|
|
assert flash.price_cache_miss_per_m == 0.14
|
|
assert flash.price_output_per_m == 0.28
|
|
assert flash.context_window == 1_048_576
|
|
assert pro.price_cache_hit_per_m == 0.003625
|
|
assert pro.price_cache_miss_per_m == 0.435
|
|
assert pro.price_output_per_m == 0.87
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="deepseek-v4-pro",
|
|
target_model="deepseek-v4-pro",
|
|
price_output_per_m=1.23,
|
|
)
|
|
)
|
|
try:
|
|
r.seed_default_deepseek_routes()
|
|
assert r.model_store.get("deepseek-v4-pro").price_output_per_m == 1.23
|
|
finally:
|
|
r.model_store.set(
|
|
r.ModelRouteIn(
|
|
source_model="deepseek-v4-pro",
|
|
target_model="deepseek-v4-pro",
|
|
price_cache_hit_per_m=0.003625,
|
|
price_cache_miss_per_m=0.435,
|
|
price_output_per_m=0.87,
|
|
context_window=1_048_576,
|
|
)
|
|
)
|
|
|
|
|
|
def test_seed_publishes_molodetz_aliases(local_db):
|
|
r.seed_default_deepseek_routes()
|
|
try:
|
|
molodetz = r.model_store.resolve("molodetz", "chat")
|
|
molodetz_pro = r.model_store.resolve("molodetz-pro", "chat")
|
|
assert molodetz is not None
|
|
assert molodetz.target_model == "deepseek-v4-flash"
|
|
assert molodetz_pro is not None
|
|
assert molodetz_pro.target_model == "deepseek-v4-pro"
|
|
sources = {row["source_model"] for row in r.model_store.list()}
|
|
assert {"molodetz", "molodetz-pro"} <= sources
|
|
finally:
|
|
r.model_store.remove("molodetz")
|
|
r.model_store.remove("molodetz-pro")
|
|
|
|
|
|
def test_models_url_from_base_swaps_chat_completions():
|
|
assert (
|
|
r._models_url_from_base("https://x.example/v1/chat/completions")
|
|
== "https://x.example/v1/models"
|
|
)
|
|
|
|
|
|
def test_models_url_from_base_appends_when_no_chat_completions_suffix():
|
|
assert r._models_url_from_base("https://x.example/v1") == "https://x.example/v1/models"
|
|
assert r._models_url_from_base("https://x.example/v1/") == "https://x.example/v1/models"
|
|
|
|
|
|
def test_models_url_from_base_blank_is_blank():
|
|
assert r._models_url_from_base("") == ""
|
|
assert r._models_url_from_base(" ") == ""
|
|
|
|
|
|
def test_fetch_provider_models_returns_ids_on_success(local_db, monkeypatch):
|
|
r.provider_store.set(
|
|
r.ProviderIn(
|
|
name="probeprov",
|
|
base_url="https://x.example/v1/chat/completions",
|
|
api_key="sk-probe",
|
|
)
|
|
)
|
|
fake_client = _FakeModelsClient(200, {"data": [{"id": "vendor/a"}, {"id": "vendor/b"}]})
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
try:
|
|
models = run_async(r.fetch_provider_models("probeprov"))
|
|
assert models == ["vendor/a", "vendor/b"]
|
|
assert fake_client.requested_url == "https://x.example/v1/models"
|
|
assert fake_client.headers == {"authorization": "Bearer sk-probe"}
|
|
finally:
|
|
r.provider_store.remove("probeprov")
|
|
|
|
|
|
def test_fetch_provider_models_uses_default_provider_when_blank(local_db, monkeypatch):
|
|
monkeypatch.setattr(
|
|
r,
|
|
"_default_provider_credentials",
|
|
lambda: ("https://default.example/v1/chat/completions", "sk-default"),
|
|
)
|
|
fake_client = _FakeModelsClient(200, {"data": [{"id": "default/model"}]})
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
models = run_async(r.fetch_provider_models(""))
|
|
assert models == ["default/model"]
|
|
assert fake_client.requested_url == "https://default.example/v1/models"
|
|
|
|
|
|
def test_fetch_provider_models_none_for_unknown_provider(local_db):
|
|
assert run_async(r.fetch_provider_models("no-such-provider")) is None
|
|
|
|
|
|
def test_fetch_provider_models_none_on_non_200(local_db, monkeypatch):
|
|
r.provider_store.set(
|
|
r.ProviderIn(name="probeprov404", base_url="https://x.example/v1/chat/completions")
|
|
)
|
|
fake_client = _FakeModelsClient(404, {})
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
try:
|
|
assert run_async(r.fetch_provider_models("probeprov404")) is None
|
|
finally:
|
|
r.provider_store.remove("probeprov404")
|
|
|
|
|
|
def test_fetch_provider_models_none_on_malformed_payload(local_db, monkeypatch):
|
|
r.provider_store.set(
|
|
r.ProviderIn(name="probeprovbad", base_url="https://x.example/v1/chat/completions")
|
|
)
|
|
fake_client = _FakeModelsClient(200, {"not_data": []})
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
try:
|
|
assert run_async(r.fetch_provider_models("probeprovbad")) is None
|
|
finally:
|
|
r.provider_store.remove("probeprovbad")
|
|
|
|
|
|
def test_fetch_provider_models_none_on_empty_list(local_db, monkeypatch):
|
|
r.provider_store.set(
|
|
r.ProviderIn(name="probeprovempty", base_url="https://x.example/v1/chat/completions")
|
|
)
|
|
fake_client = _FakeModelsClient(200, {"data": []})
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
try:
|
|
assert run_async(r.fetch_provider_models("probeprovempty")) is None
|
|
finally:
|
|
r.provider_store.remove("probeprovempty")
|
|
|
|
|
|
def test_fetch_provider_models_none_on_network_error(local_db, monkeypatch):
|
|
r.provider_store.set(
|
|
r.ProviderIn(name="probeprovdown", base_url="https://x.example/v1/chat/completions")
|
|
)
|
|
fake_client = _FakeModelsClient(200, error=RuntimeError("connection refused"))
|
|
_patch_stealth_client(monkeypatch, fake_client)
|
|
try:
|
|
assert run_async(r.fetch_provider_models("probeprovdown")) is None
|
|
finally:
|
|
r.provider_store.remove("probeprovdown")
|
|
|
|
|
|
def test_fetch_provider_models_none_when_provider_has_no_base_url(local_db):
|
|
r.provider_store.set(r.ProviderIn(name="probeprovnourl", base_url=""))
|
|
try:
|
|
assert run_async(r.fetch_provider_models("probeprovnourl")) is None
|
|
finally:
|
|
r.provider_store.remove("probeprovnourl")
|