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

484 lines
18 KiB
Python

"""Wiring of ``CredentialPool`` into profile credential resolution.
``[llm_profiles.<id>].api_key_env_pool`` holds env-var NAMES only (never key
values); the runtime rotates over the resolved keys with per-session pinning,
429 cooldown, long parking on insufficient credits, and process-permanent
parking on invalid auth. A profile without a pool must take exactly the
pre-pool single-key path.
All env vars and key values here are synthetic test dummies.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from opensquilla.engine.runtime import _report_credential_pool_failure
from opensquilla.engine.selector_override import resolve_tier_provider_config
from opensquilla.gateway.config import (
GatewayConfig,
LlmProviderConfig,
LlmProviderProfile,
is_sensitive_config_key,
)
from opensquilla.gateway.llm_runtime import (
INSUFFICIENT_CREDITS_COOLDOWN_SECONDS,
RATE_LIMITED_COOLDOWN_SECONDS,
ProfileCredentialPools,
masked_key_id,
profile_credential_pools,
reset_profile_credential_pools,
)
from opensquilla.provider.credentials import Credential, NoCredentialsAvailable
from opensquilla.provider.failures import ProviderFailureKind
from opensquilla.provider.types import ErrorEvent
_SECRET_A = "sk-test-000-aaaaaaaaaaaaaaaa"
_SECRET_B = "sk-test-000-bbbbbbbbbbbbbbbb"
_ENV_A = "OPENSQUILLA_TEST_POOL_KEY_A"
_ENV_B = "OPENSQUILLA_TEST_POOL_KEY_B"
_ENV_UNSET = "OPENSQUILLA_TEST_POOL_KEY_UNSET"
@pytest.fixture(autouse=True)
def _fresh_pools():
reset_profile_credential_pools()
yield
reset_profile_credential_pools()
@pytest.fixture()
def pool_env(monkeypatch):
monkeypatch.setenv(_ENV_A, _SECRET_A)
monkeypatch.setenv(_ENV_B, _SECRET_B)
monkeypatch.delenv(_ENV_UNSET, raising=False)
def _config(profile: LlmProviderProfile) -> GatewayConfig:
cfg = GatewayConfig()
cfg.squilla_router.cross_provider_tiers = True
cfg.llm_profiles = {"openai": profile}
return cfg
def _resolve(cfg: GatewayConfig, session_key: str, metadata: dict | None = None):
return resolve_tier_provider_config(
cfg,
"openai",
"gpt-5.4-nano",
session_key=session_key,
turn_metadata=metadata,
)
class _FakeClock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
class _LogRecorder:
"""Deterministic structlog stand-in.
Global structlog config is shared test state (other suites install
level-filtering wrapper classes that drop info/debug before
``capture_logs`` processors run), so telemetry assertions patch the
module loggers with this recorder instead.
"""
def __init__(self) -> None:
self.events: list[dict] = []
def _record(self, level: str, event: str, **kw) -> None:
self.events.append({"event": event, "log_level": level, **kw})
def debug(self, event: str, **kw) -> None:
self._record("debug", event, **kw)
def info(self, event: str, **kw) -> None:
self._record("info", event, **kw)
def warning(self, event: str, **kw) -> None:
self._record("warning", event, **kw)
@pytest.fixture()
def log_recorder(monkeypatch):
recorder = _LogRecorder()
monkeypatch.setattr("opensquilla.gateway.llm_runtime.log", recorder)
monkeypatch.setattr("opensquilla.engine.selector_override.log", recorder)
return recorder
# ---------------------------------------------------------------------------
# Credential repr must never leak the secret
# ---------------------------------------------------------------------------
def test_credential_repr_omits_secret() -> None:
cred = Credential(cred_id=_ENV_A, secret=_SECRET_A)
rendered = repr(cred)
assert _SECRET_A not in rendered
assert _SECRET_A not in str(cred)
assert _ENV_A in rendered # the non-secret id stays identifiable
assert f"cred={cred}" == f"cred={rendered}" # f-string interpolation is safe
def test_pooled_credential_repr_omits_secret(pool_env) -> None:
pooled = profile_credential_pools().acquire_for_session("openai", [_ENV_A], "s")
assert pooled is not None
assert _SECRET_A not in repr(pooled)
assert pooled.env_name == _ENV_A
assert pooled.key_id == masked_key_id(_SECRET_A)
assert _SECRET_A not in pooled.key_id
# ---------------------------------------------------------------------------
# Empty pool == today's single-key path
# ---------------------------------------------------------------------------
def test_empty_pool_matches_single_key_resolution(monkeypatch) -> None:
monkeypatch.setenv(_ENV_A, _SECRET_A)
def _explode(*args, **kwargs): # pragma: no cover - failure marker
raise AssertionError("pool manager must not be consulted without a pool")
monkeypatch.setattr(
"opensquilla.gateway.llm_runtime.profile_credential_pools",
_explode,
)
cfg = _config(LlmProviderProfile(api_key_env=_ENV_A))
resolved = _resolve(cfg, "s1")
legacy = resolve_tier_provider_config(cfg, "openai", "gpt-5.4-nano")
assert resolved is not None and legacy is not None
assert resolved == legacy # same dataclass, field-for-field
assert resolved.api_key == _SECRET_A
assert resolved.provider == "openai"
assert resolved.base_url == "https://api.openai.com/v1"
assert resolved.replay_provider_state is False
def test_explicit_api_key_beats_pool(pool_env) -> None:
cfg = _config(
LlmProviderProfile(api_key="sk-test-000-explicit", api_key_env_pool=[_ENV_A, _ENV_B])
)
metadata: dict = {}
resolved = _resolve(cfg, "s1", metadata)
assert resolved is not None
assert resolved.api_key == "sk-test-000-explicit"
assert "credential_pool" not in metadata
# ---------------------------------------------------------------------------
# Rotation + session pinning
# ---------------------------------------------------------------------------
def test_pool_rotates_across_sessions(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
first = _resolve(cfg, "session-1")
second = _resolve(cfg, "session-2")
assert first is not None and second is not None
assert {first.api_key, second.api_key} == {_SECRET_A, _SECRET_B}
def test_session_pinning_reuses_key_across_turns(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
resolutions = [_resolve(cfg, "sticky-session") for _ in range(5)]
assert all(r is not None for r in resolutions)
keys = {r.api_key for r in resolutions if r is not None}
assert len(keys) == 1 # same session -> same key -> warm prompt cache
def test_pool_stamps_non_secret_metadata(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
metadata: dict = {}
resolved = _resolve(cfg, "s1", metadata)
assert resolved is not None
stamp = metadata["credential_pool"]
assert stamp["provider"] == "openai"
assert stamp["session_key"] == "s1"
assert stamp["env_name"] in {_ENV_A, _ENV_B}
assert _SECRET_A not in str(stamp) and _SECRET_B not in str(stamp)
# ---------------------------------------------------------------------------
# Failure handling: 429 cooldown, credits, auth
# ---------------------------------------------------------------------------
def test_429_cooldown_rotates_then_reinstates(monkeypatch) -> None:
clock = _FakeClock()
pools = ProfileCredentialPools(clock=clock)
monkeypatch.setenv(_ENV_A, _SECRET_A)
monkeypatch.setenv(_ENV_B, _SECRET_B)
first = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert first is not None
pools.report_failure("openai", "s1", ProviderFailureKind.RATE_LIMITED)
rotated = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert rotated is not None
assert rotated.env_name != first.env_name
# Cooldown expires -> the parked key is eligible again.
clock.now += RATE_LIMITED_COOLDOWN_SECONDS + 1
pools.report_failure("openai", "s1", ProviderFailureKind.RATE_LIMITED)
third = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert third is not None
assert third.env_name == first.env_name
def test_retry_after_hint_overrides_default_cooldown(monkeypatch) -> None:
clock = _FakeClock()
pools = ProfileCredentialPools(clock=clock)
monkeypatch.setenv(_ENV_A, _SECRET_A)
acquired = pools.acquire_for_session("openai", [_ENV_A], "s1")
assert acquired is not None
pools.report_failure(
"openai",
"s1",
ProviderFailureKind.RATE_LIMITED,
retry_after_seconds=5.0,
)
with pytest.raises(NoCredentialsAvailable):
pools.acquire_for_session("openai", [_ENV_A], "s1")
clock.now += 6.0
assert pools.acquire_for_session("openai", [_ENV_A], "s1") is not None
def test_insufficient_credits_rotates_immediately_and_parks_long(monkeypatch) -> None:
clock = _FakeClock()
pools = ProfileCredentialPools(clock=clock)
monkeypatch.setenv(_ENV_A, _SECRET_A)
monkeypatch.setenv(_ENV_B, _SECRET_B)
first = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert first is not None
pools.report_failure("openai", "s1", ProviderFailureKind.INSUFFICIENT_CREDITS)
rotated = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert rotated is not None and rotated.env_name != first.env_name
# Far longer than a 429 window, still parked; eligible after the credits
# cooldown (accounts may be topped up mid-process, so not permanent).
clock.now += RATE_LIMITED_COOLDOWN_SECONDS + 1
pools.report_failure("openai", "s1", ProviderFailureKind.RATE_LIMITED)
with pytest.raises(NoCredentialsAvailable):
pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
clock.now += INSUFFICIENT_CREDITS_COOLDOWN_SECONDS
assert pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1") is not None
def test_auth_invalid_marks_key_bad_for_process(monkeypatch) -> None:
clock = _FakeClock()
pools = ProfileCredentialPools(clock=clock)
monkeypatch.setenv(_ENV_A, _SECRET_A)
monkeypatch.setenv(_ENV_B, _SECRET_B)
first = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], "s1")
assert first is not None
pools.report_failure("openai", "s1", ProviderFailureKind.AUTH_INVALID)
clock.now += 1e9 # no cooldown ever reinstates an invalid key
for session in ("s1", "s2", "s3"):
acquired = pools.acquire_for_session("openai", [_ENV_A, _ENV_B], session)
assert acquired is not None
assert acquired.env_name != first.env_name
def test_all_parked_surfaces_as_unresolved_credentials(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
assert _resolve(cfg, "s1") is not None
assert _resolve(cfg, "s2") is not None
pools = profile_credential_pools()
pools.report_failure("openai", "s1", ProviderFailureKind.AUTH_INVALID)
pools.report_failure("openai", "s2", ProviderFailureKind.RATE_LIMITED)
# NoCredentialsAvailable is absorbed into the existing unresolved-
# credentials contract: None back, caller keeps the active provider.
assert _resolve(cfg, "s3") is None
def test_unattributable_failure_is_noop(pool_env) -> None:
pools = profile_credential_pools()
# Never-pinned session / never-served provider: nothing to park.
pools.report_failure("openai", "ghost-session", ProviderFailureKind.RATE_LIMITED)
pools.report_failure("acme-llm", "s1", ProviderFailureKind.RATE_LIMITED)
# ---------------------------------------------------------------------------
# Unset env names
# ---------------------------------------------------------------------------
def test_unset_env_names_skipped_with_warning(pool_env, log_recorder) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_UNSET, _ENV_A]))
resolved = _resolve(cfg, "s1")
assert resolved is not None
assert resolved.api_key == _SECRET_A
skipped = [e for e in log_recorder.events if e["event"] == "credential_pool.env_unset"]
assert skipped and skipped[0]["env_name"] == _ENV_UNSET
assert skipped[0]["log_level"] == "warning"
def test_pool_with_no_resolvable_names_degrades_to_single_key(
monkeypatch, log_recorder
) -> None:
monkeypatch.delenv(_ENV_UNSET, raising=False)
monkeypatch.setenv(_ENV_A, _SECRET_A)
cfg = _config(
LlmProviderProfile(api_key_env=_ENV_A, api_key_env_pool=[_ENV_UNSET])
)
resolved = _resolve(cfg, "s1")
assert resolved is not None
assert resolved.api_key == _SECRET_A # singular api_key_env picked it up
assert any(
e["event"] == "credential_pool.no_resolvable_keys" for e in log_recorder.events
)
# ---------------------------------------------------------------------------
# Telemetry: env-var name + masked id only, never the secret
# ---------------------------------------------------------------------------
def test_no_secret_values_in_rotation_or_cooldown_logs(pool_env, log_recorder) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
assert _resolve(cfg, "s1") is not None
assert _resolve(cfg, "s1") is not None # pin reuse (debug event)
assert _resolve(cfg, "s2") is not None
pools = profile_credential_pools()
pools.report_failure("openai", "s1", ProviderFailureKind.RATE_LIMITED)
pools.report_failure("openai", "s2", ProviderFailureKind.AUTH_INVALID)
captured = log_recorder.events
everything = repr(captured)
assert _SECRET_A not in everything
assert _SECRET_B not in everything
rotations = [e for e in captured if e["event"] == "credential_pool.rotation"]
assert rotations
for event in rotations:
assert set(event) >= {"provider", "session_key", "env_name", "key_id", "pool_size"}
assert event["key_id"] in {masked_key_id(_SECRET_A), masked_key_id(_SECRET_B)}
cooldowns = [e for e in captured if e["event"] == "credential_pool.cooldown"]
assert len(cooldowns) == 2
for event in cooldowns:
assert set(event) >= {
"provider",
"session_key",
"env_name",
"key_id",
"failure_kind",
"cooldown_seconds",
"permanent",
}
assert {e["failure_kind"] for e in cooldowns} == {"rate_limited", "auth_invalid"}
# ---------------------------------------------------------------------------
# Provider-failure hook (engine/runtime.py)
# ---------------------------------------------------------------------------
def test_stream_error_hook_parks_pinned_key(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
metadata: dict = {}
first = _resolve(cfg, "s1", metadata)
assert first is not None
metadata["routed_provider_applied"] = "openai" # tier config was applied
_report_credential_pool_failure(
"openai",
metadata,
ErrorEvent(message="rate limit exceeded", code="429"),
)
rotated = _resolve(cfg, "s1", metadata)
assert rotated is not None
assert rotated.api_key != first.api_key
def test_stream_error_hook_ignores_unapplied_tier(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
metadata: dict = {}
first = _resolve(cfg, "s1", metadata)
assert first is not None
# Tier config resolved but never applied (e.g. explicit override won):
# the failing provider is not the pool's, so nothing may be parked.
_report_credential_pool_failure(
"openai",
metadata,
ErrorEvent(message="rate limit exceeded", code="429"),
)
unchanged = _resolve(cfg, "s1", metadata)
assert unchanged is not None
assert unchanged.api_key == first.api_key
def test_stream_error_hook_ignores_non_reportable_kinds(pool_env) -> None:
cfg = _config(LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B]))
metadata: dict = {}
first = _resolve(cfg, "s1", metadata)
assert first is not None
metadata["routed_provider_applied"] = "openai"
_report_credential_pool_failure(
"openai",
metadata,
ErrorEvent(message="internal server error", code="500"),
)
unchanged = _resolve(cfg, "s1", metadata)
assert unchanged is not None
assert unchanged.api_key == first.api_key
# ---------------------------------------------------------------------------
# Config contract: profiles-only, additive, downgrade-tolerated
# ---------------------------------------------------------------------------
def test_profile_accepts_api_key_env_pool() -> None:
profile = LlmProviderProfile(api_key_env_pool=[_ENV_A, _ENV_B])
assert profile.api_key_env_pool == [_ENV_A, _ENV_B]
cfg = GatewayConfig.model_validate(
{"llm_profiles": {"openai": {"api_key_env_pool": [_ENV_A]}}}
)
assert cfg.llm_profiles["openai"].api_key_env_pool == [_ENV_A]
dumped = cfg.to_toml_dict()
assert dumped["llm_profiles"]["openai"]["api_key_env_pool"] == [_ENV_A]
def test_top_level_llm_does_not_gain_pool_field() -> None:
assert "api_key_env_pool" not in LlmProviderConfig.model_fields
# [llm] stays extra="forbid": an rc1 config never sees the field there,
# and a stamped default would brick downgrade (decision D5).
with pytest.raises(ValidationError):
LlmProviderConfig(api_key_env_pool=[_ENV_A])
def test_pool_field_is_not_treated_as_secret() -> None:
# Env-var NAMES must stay readable in the public config surface, exactly
# like api_key_env and the other *_env reference fields.
assert is_sensitive_config_key("api_key_env_pool") is False
assert is_sensitive_config_key("api_key") is True
class _Rc1LlmProviderProfile(BaseSettings):
"""Shape of the 0.5.0rc1 profile model: no pool field, extra ignored."""
model_config = SettingsConfigDict(extra="ignore")
api_key: str = ""
api_key_env: str = ""
base_url: str = ""
proxy: str = ""
def test_rc1_rollback_tolerates_pool_field_on_load() -> None:
"""Downgrade contract: rc1 loads a pool-bearing profile, then strips it.
The current cycle may persist ``api_key_env_pool`` inside
``[llm_profiles.<id>]``. rc1's profile model is ``extra="ignore"``, so
the field is tolerated on load and silently dropped by rc1's first
persist — accepted, release-noted lossiness (the pool must be re-added
on upgrade).
"""
profile = _Rc1LlmProviderProfile.model_validate(
{"api_key_env": _ENV_A, "api_key_env_pool": [_ENV_A, _ENV_B]}
)
assert profile.api_key_env == _ENV_A
assert "api_key_env_pool" not in profile.model_dump()