Files
2026-07-13 13:12:33 +08:00

374 lines
13 KiB
Python

"""Behavior tests for live model discovery (offline, stubbed transport)."""
from __future__ import annotations
import asyncio
import json
from typing import Any
import httpx
import pytest
from opensquilla.gateway import rpc_onboarding
from opensquilla.gateway.config import GatewayConfig, LlmProviderConfig
from opensquilla.gateway.rpc import RpcContext
from opensquilla.onboarding import probe as probe_module
from opensquilla.onboarding.probe import (
ProviderModelsDiscoverResult,
discover_provider_models,
discover_selectable_provider_models,
)
from opensquilla.provider.failures import ProviderFailureKind
def _patch_response(monkeypatch: Any, response_factory) -> list[httpx.Request]:
"""Route provider HTTP through a MockTransport, capturing requests."""
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return response_factory()
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return real_async_client(*args, **kwargs)
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
return seen
def _patch_transport_error(monkeypatch: Any, exc: Exception) -> None:
"""Route provider HTTP through a transport that always fails to connect."""
def handler(request: httpx.Request) -> httpx.Response:
raise exc
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
def patched_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return real_async_client(*args, **kwargs)
monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", patched_async_client)
def _models_response() -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "application/json"},
content=json.dumps(
{"data": [{"id": "test-model-a", "name": "Test Model A", "context_length": 64_000}]}
).encode(),
)
def _discover(**kwargs: Any):
return asyncio.run(discover_provider_models(**kwargs))
def _discover_selectable(**kwargs: Any):
return asyncio.run(discover_selectable_provider_models(**kwargs))
def test_selectable_discovery_fails_closed_before_credentials_or_provider_build(
monkeypatch: Any,
) -> None:
"""Unverified adapters stay free text and must not touch secrets/network."""
def _unexpected(*_args: Any, **_kwargs: Any) -> Any:
raise AssertionError("untrusted discovery must not resolve credentials or build")
monkeypatch.setattr(probe_module, "_resolve_probe_api_key", _unexpected)
monkeypatch.setattr(probe_module, "build_provider", _unexpected)
monkeypatch.setattr(probe_module, "discover_provider_models", _unexpected)
result = _discover_selectable(
provider_id="openai",
api_key="synthetic-secret",
base_url="https://api.openai.com/v1",
)
assert result == ProviderModelsDiscoverResult(ok=True, provider_id="openai")
@pytest.mark.parametrize("provider_id", ["openrouter", "tokenrhythm"])
def test_selectable_discovery_delegates_verified_official_hosts(
monkeypatch: Any, provider_id: str
) -> None:
calls: list[dict[str, Any]] = []
async def _fake_raw(**kwargs: Any) -> ProviderModelsDiscoverResult:
calls.append(kwargs)
return ProviderModelsDiscoverResult(
ok=True,
provider_id=kwargs["provider_id"],
source="live",
models=[{"id": "verified-model"}],
)
monkeypatch.setattr(probe_module, "discover_provider_models", _fake_raw)
result = _discover_selectable(provider_id=provider_id, api_key="synthetic-key")
assert result.source == "live"
assert result.models == [{"id": "verified-model"}]
assert calls == [
{
"provider_id": provider_id,
"api_key": "synthetic-key",
"api_key_env": "",
"base_url": "",
"proxy": "",
}
]
@pytest.mark.parametrize(
("provider_id", "base_url"),
[
("openrouter", "https://openrouter.ai.attacker.example/v1"),
("tokenrhythm", "https://tokenrhythm.studio.attacker.example/v1"),
],
)
def test_selectable_discovery_rejects_non_official_base_url_before_raw_discovery(
monkeypatch: Any, provider_id: str, base_url: str
) -> None:
async def _unexpected_raw(**_kwargs: Any) -> ProviderModelsDiscoverResult:
raise AssertionError("a custom host must not be treated as a verified catalog")
monkeypatch.setattr(probe_module, "discover_provider_models", _unexpected_raw)
result = _discover_selectable(
provider_id=provider_id,
api_key="synthetic-key",
base_url=base_url,
)
assert result == ProviderModelsDiscoverResult(ok=True, provider_id=provider_id)
@pytest.mark.parametrize(
("provider_id", "base_url"),
[
("openrouter", "http://openrouter.ai/api/v1"),
("tokenrhythm", "http://tokenrhythm.studio/v1"),
],
)
def test_selectable_discovery_rejects_plain_http_before_credentials_or_raw_discovery(
monkeypatch: Any, provider_id: str, base_url: str
) -> None:
def _unexpected(*_args: Any, **_kwargs: Any) -> Any:
raise AssertionError("plain HTTP must not resolve credentials or discover models")
monkeypatch.setattr(probe_module, "_resolve_probe_api_key", _unexpected)
monkeypatch.setattr(probe_module, "discover_provider_models", _unexpected)
result = _discover_selectable(
provider_id=provider_id,
api_key="synthetic-key",
base_url=base_url,
)
assert result == ProviderModelsDiscoverResult(ok=True, provider_id=provider_id)
def test_selectable_discovery_still_validates_unsupported_provider() -> None:
with pytest.raises(ValueError, match="no runtime support"):
_discover_selectable(provider_id="github_copilot")
def test_selectable_discovery_still_validates_unknown_provider() -> None:
with pytest.raises(ValueError, match="Unknown provider"):
_discover_selectable(provider_id="no-such-provider")
@pytest.mark.parametrize(
("provider_id", "base_url"),
[
("openrouter", "https://api.openrouter.ai/v1"),
("tokenrhythm", "https://api.tokenrhythm.studio/v1"),
],
)
def test_selectable_discovery_accepts_official_subdomains(
monkeypatch: Any, provider_id: str, base_url: str
) -> None:
calls: list[str] = []
async def _fake_raw(**kwargs: Any) -> ProviderModelsDiscoverResult:
calls.append(kwargs["base_url"])
return ProviderModelsDiscoverResult(ok=True, provider_id=provider_id)
monkeypatch.setattr(probe_module, "discover_provider_models", _fake_raw)
result = _discover_selectable(provider_id=provider_id, base_url=base_url)
assert result.ok is True
assert calls == [base_url]
def test_discover_reports_missing_key_without_network(monkeypatch: Any) -> None:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
result = _discover(provider_id="openai")
assert result.ok is False
assert result.failure_kind == ProviderFailureKind.AUTH_INVALID.value
assert "OPENAI_API_KEY" in result.detail
assert result.models == []
def test_discover_rejects_unknown_provider_as_validation_error() -> None:
with pytest.raises(ValueError, match="Unknown provider"):
_discover(provider_id="no-such-provider")
def test_discover_reports_build_failure_as_bad_request() -> None:
# vllm requires an explicit base_url; building without one is a
# configuration-shaped failure, not transport noise.
result = _discover(provider_id="vllm", base_url="")
assert result.ok is False
assert result.failure_kind == ProviderFailureKind.BAD_REQUEST.value
assert "base_url" in result.detail
def test_discover_lists_models_with_explicit_key(monkeypatch: Any) -> None:
seen = _patch_response(monkeypatch, _models_response)
result = _discover(provider_id="openai", api_key="sk-explicit")
assert result.ok is True
assert result.source == "live"
assert [m["id"] for m in result.models] == ["test-model-a"]
assert seen[0].headers["authorization"] == "Bearer sk-explicit"
def test_discover_resolves_key_from_provider_env(monkeypatch: Any) -> None:
# Mirrors the probe: an unset explicit key falls back to the provider's
# registry env key.
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
seen = _patch_response(monkeypatch, _models_response)
result = _discover(provider_id="openai")
assert result.ok is True
assert seen[0].headers["authorization"] == "Bearer sk-from-env"
def test_discover_classifies_rejected_key_as_auth_failure(monkeypatch: Any) -> None:
"""A 401 during listing is a wrong key, never ok=True/source='none'."""
_patch_response(
monkeypatch,
lambda: httpx.Response(
401,
headers={"content-type": "application/json"},
content=b'{"error": {"message": "Incorrect API key provided"}}',
),
)
result = _discover(provider_id="openai", api_key="sk-bad")
assert result.ok is False
assert result.failure_kind == ProviderFailureKind.AUTH_INVALID.value
assert result.source == "none"
assert result.models == []
def test_discover_classifies_connection_failure_as_transport_transient(
monkeypatch: Any,
) -> None:
_patch_transport_error(monkeypatch, httpx.ConnectError("connection refused"))
result = _discover(provider_id="openai", api_key="sk-test")
assert result.ok is False
assert result.failure_kind == ProviderFailureKind.TRANSPORT_TRANSIENT.value
assert result.models == []
def test_discover_empty_catalog_stays_ok_with_no_live_source(monkeypatch: Any) -> None:
# Distinguishable from the auth failure above: the provider answered
# successfully but lists nothing.
_patch_response(
monkeypatch,
lambda: httpx.Response(
200,
headers={"content-type": "application/json"},
content=b'{"data": []}',
),
)
result = _discover(provider_id="openai", api_key="sk-test")
assert result.ok is True
assert result.source == "none"
assert result.models == []
def test_discover_row_context_window_prefers_user_override(monkeypatch: Any) -> None:
"""A per-model ``[models.*]`` context_window override beats the live listing.
Discovery rows must show the window budgeting will actually use, so the
operator-declared value wins even when the provider reports its own.
"""
from opensquilla.provider.model_catalog import ModelCatalog, set_shared_catalog
catalog = ModelCatalog()
catalog.set_user_overrides({"openai/test-model-a": {"context_window": 32_000}})
set_shared_catalog(catalog)
try:
_patch_response(monkeypatch, _models_response)
result = _discover(provider_id="openai", api_key="sk-test")
finally:
set_shared_catalog(None)
assert result.ok is True
(row,) = result.models
# The live listing said 64_000; the user override is authoritative.
assert row["contextWindow"] == 32_000
async def test_discover_rpc_reuses_stored_credentials_when_blank(
tmp_path, monkeypatch: Any
) -> None:
"""Blank credentials on the RPC reuse the stored config's for the same
provider — upsert_llm_provider's "leave blank to keep current" semantics."""
seen = _patch_response(monkeypatch, _models_response)
cfg = GatewayConfig(
config_path=str(tmp_path / "opensquilla.toml"),
llm=LlmProviderConfig(provider="openrouter", model="m", api_key="sk-stored"),
)
ctx = RpcContext(conn_id="t", config=cfg)
payload = await rpc_onboarding._models_discover({"providerId": "openrouter"}, ctx)
assert payload["ok"] is True
assert seen[0].headers["authorization"] == "Bearer sk-stored"
async def test_discover_rpc_does_not_leak_stored_credentials_across_providers(
tmp_path, monkeypatch: Any
) -> None:
# The keep-current fallback is provider-bound: discovering a DIFFERENT
# provider with blank credentials must not send the stored key.
monkeypatch.delenv("TOKENRHYTHM_API_KEY", raising=False)
cfg = GatewayConfig(
config_path=str(tmp_path / "opensquilla.toml"),
llm=LlmProviderConfig(provider="openrouter", model="m", api_key="sk-stored"),
)
ctx = RpcContext(conn_id="t", config=cfg)
payload = await rpc_onboarding._models_discover({"providerId": "tokenrhythm"}, ctx)
assert payload["ok"] is False
assert payload["failureKind"] == ProviderFailureKind.AUTH_INVALID.value
async def test_discover_rpc_explicit_credentials_override_stored(
tmp_path, monkeypatch: Any
) -> None:
seen = _patch_response(monkeypatch, _models_response)
cfg = GatewayConfig(
config_path=str(tmp_path / "opensquilla.toml"),
llm=LlmProviderConfig(provider="openrouter", model="m", api_key="sk-stored"),
)
ctx = RpcContext(conn_id="t", config=cfg)
payload = await rpc_onboarding._models_discover(
{"providerId": "openrouter", "apiKey": "sk-candidate"}, ctx
)
assert payload["ok"] is True
assert seen[0].headers["authorization"] == "Bearer sk-candidate"