223 lines
8.5 KiB
Python
223 lines
8.5 KiB
Python
"""Explicit llm.base_url must win over provider-derived env overrides (#484).
|
|
|
|
The resolver distinguishes an operator-chosen base_url from a derived one by
|
|
value-vs-baseline: a stored value equal to the pydantic field default, the
|
|
provider spec default, or the current derived env value (``OPENAI_BASE_URL``
|
|
et al.) is *derived*, everything else is *explicit*. Explicit values beat the
|
|
env var; derived values keep today's env-first behavior so a fleet-wide
|
|
``*_BASE_URL`` still applies to configs that never chose an endpoint.
|
|
|
|
Everything here is offline and synthetic; env vars are scoped via monkeypatch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.gateway.llm_runtime import resolve_llm_runtime_config
|
|
from opensquilla.gateway.rpc_config import _handle_config_set
|
|
from opensquilla.onboarding.config_store import persist_config
|
|
from opensquilla.onboarding.mutations import upsert_llm_provider
|
|
|
|
_ENV_URL = "https://corp-endpoint.example/v1"
|
|
_USER_URL = "https://user-proxy.example/v1"
|
|
_OPENAI_SPEC_DEFAULT = "https://api.openai.com/v1"
|
|
_FIELD_DEFAULT = "https://openrouter.ai/api/v1"
|
|
|
|
|
|
class _CapturingSelector:
|
|
def __init__(self) -> None:
|
|
self.synced = None
|
|
|
|
def sync_primary(self, cfg) -> None:
|
|
self.synced = cfg
|
|
|
|
|
|
def _admin_ctx(config, selector=None):
|
|
return SimpleNamespace(config=config, provider_selector=selector)
|
|
|
|
|
|
def test_explicit_config_base_url_beats_provider_env(monkeypatch) -> None:
|
|
"""#484 core: a user-chosen base_url must survive env-present resolution."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
cfg = GatewayConfig(
|
|
llm={
|
|
"provider": "openai",
|
|
"model": "gpt-5.5",
|
|
"api_key": "sk-explicit",
|
|
"base_url": _USER_URL,
|
|
}
|
|
)
|
|
|
|
runtime = resolve_llm_runtime_config(cfg)
|
|
|
|
assert runtime.base_url == _USER_URL
|
|
assert runtime.base_url_from_env is False
|
|
# The live model must keep serving the explicit URL too (no env clobber).
|
|
assert cfg.llm.base_url == _USER_URL
|
|
|
|
|
|
def test_saved_base_url_survives_restart_with_env_set(tmp_path, monkeypatch) -> None:
|
|
"""#484 end-to-end: save via the provider mutation, persist, reboot, resolve."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
config_path = tmp_path / "config.toml"
|
|
cfg = GatewayConfig(config_path=str(config_path))
|
|
resolve_llm_runtime_config(cfg) # boot-time resolution on the default config
|
|
|
|
res = upsert_llm_provider(
|
|
cfg,
|
|
provider_id="openai_responses",
|
|
model="gpt-5.5",
|
|
api_key="sk-user",
|
|
base_url=_USER_URL,
|
|
)
|
|
persist_config(res.config, path=config_path)
|
|
|
|
assert tomllib.loads(config_path.read_text())["llm"]["base_url"] == _USER_URL
|
|
|
|
rebooted = GatewayConfig.load_from_toml(config_path)
|
|
runtime = resolve_llm_runtime_config(rebooted)
|
|
|
|
assert runtime.base_url == _USER_URL
|
|
assert rebooted.llm.base_url == _USER_URL
|
|
assert runtime.base_url_from_env is False
|
|
|
|
|
|
def test_minimal_toml_resolves_spec_default_not_field_default(tmp_path, monkeypatch) -> None:
|
|
"""A config that never chose an endpoint must get the PROVIDER's default.
|
|
|
|
The pydantic field default is the openrouter URL; before value-vs-baseline
|
|
resolution it leaked into every minimal non-openrouter config.
|
|
"""
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text('[llm]\nprovider = "openai"\nmodel = "gpt-5.5"\napi_key = "sk-x"\n')
|
|
|
|
runtime = resolve_llm_runtime_config(GatewayConfig.load_from_toml(config_path))
|
|
|
|
assert runtime.base_url == _OPENAI_SPEC_DEFAULT
|
|
assert runtime.base_url_from_env is False
|
|
|
|
|
|
def test_spec_default_stored_value_yields_to_env(monkeypatch) -> None:
|
|
"""A stored value equal to the spec default is derived, not operator-chosen.
|
|
|
|
The Web UI seeds the endpoint field with the spec default and submits it
|
|
verbatim, so this shape is common on disk; a fleet-wide env override must
|
|
keep applying to it.
|
|
"""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
cfg = GatewayConfig(
|
|
llm={
|
|
"provider": "openai",
|
|
"model": "gpt-5.5",
|
|
"api_key": "sk-x",
|
|
"base_url": _OPENAI_SPEC_DEFAULT,
|
|
}
|
|
)
|
|
|
|
runtime = resolve_llm_runtime_config(cfg)
|
|
|
|
assert runtime.base_url == _ENV_URL
|
|
assert runtime.base_url_from_env is True
|
|
|
|
|
|
def test_field_default_stored_value_yields_to_env(tmp_path, monkeypatch) -> None:
|
|
"""Minimal TOML (field default in the loaded model) keeps env-first behavior."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text('[llm]\nprovider = "openai"\nmodel = "gpt-5.5"\napi_key = "sk-x"\n')
|
|
|
|
runtime = resolve_llm_runtime_config(GatewayConfig.load_from_toml(config_path))
|
|
|
|
assert runtime.base_url == _ENV_URL
|
|
assert runtime.base_url_from_env is True
|
|
|
|
|
|
def test_stored_env_value_is_env_derived_and_idempotent(monkeypatch) -> None:
|
|
"""A stored value equal to the current env value counts as env-derived.
|
|
|
|
This is what keeps repeated in-process resolves stable: the first resolve
|
|
materializes the env URL into the model, and the second must classify it
|
|
exactly the same way (from_env stays True, no explicit promotion).
|
|
"""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
cfg = GatewayConfig(
|
|
llm={"provider": "openai", "model": "gpt-5.5", "api_key": "sk-x", "base_url": _ENV_URL}
|
|
)
|
|
|
|
first = resolve_llm_runtime_config(cfg)
|
|
second = resolve_llm_runtime_config(cfg)
|
|
|
|
assert first.base_url == _ENV_URL
|
|
assert first.base_url_from_env is True
|
|
assert second.base_url == _ENV_URL
|
|
assert second.base_url_from_env is True
|
|
|
|
|
|
def test_base_url_from_env_reports_false_without_env(monkeypatch) -> None:
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
explicit = GatewayConfig(
|
|
llm={"provider": "openai", "model": "gpt-5.5", "api_key": "sk-x", "base_url": _USER_URL}
|
|
)
|
|
derived = GatewayConfig(llm={"provider": "openai", "model": "gpt-5.5", "api_key": "sk-x"})
|
|
|
|
assert resolve_llm_runtime_config(explicit).base_url_from_env is False
|
|
assert resolve_llm_runtime_config(derived).base_url_from_env is False
|
|
|
|
|
|
def test_unknown_provider_preserves_explicit_base_url(monkeypatch) -> None:
|
|
"""Degraded boot (unregistered provider) must not lose the stored endpoint."""
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
cfg = GatewayConfig(
|
|
llm={"provider": "not-a-provider", "model": "m", "api_key": "k", "base_url": _USER_URL}
|
|
)
|
|
|
|
runtime = resolve_llm_runtime_config(cfg)
|
|
|
|
assert runtime.base_url == _USER_URL
|
|
assert runtime.base_url_from_env is False
|
|
|
|
|
|
async def test_config_set_persists_user_base_url_with_env_set(tmp_path, monkeypatch) -> None:
|
|
"""#484 via config.set: the user's URL must reach disk and the selector."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
config_path = tmp_path / "config.toml"
|
|
cfg = GatewayConfig(
|
|
config_path=str(config_path),
|
|
llm={"provider": "openai", "model": "gpt-5.5", "api_key": "sk-x"},
|
|
)
|
|
resolve_llm_runtime_config(cfg) # boot
|
|
selector = _CapturingSelector()
|
|
ctx = _admin_ctx(cfg, selector)
|
|
|
|
await _handle_config_set({"path": "llm.base_url", "value": _USER_URL}, ctx)
|
|
|
|
assert tomllib.loads(config_path.read_text())["llm"]["base_url"] == _USER_URL
|
|
assert _ENV_URL not in config_path.read_text()
|
|
assert ctx.config.llm.base_url == _USER_URL
|
|
assert selector.synced.base_url == _USER_URL
|
|
|
|
rebooted = GatewayConfig.load_from_toml(config_path)
|
|
assert resolve_llm_runtime_config(rebooted).base_url == _USER_URL
|
|
|
|
|
|
async def test_config_set_unrelated_change_does_not_bake_env_url(tmp_path, monkeypatch) -> None:
|
|
"""An unrelated config.set on an env-resolved config must not write the env URL."""
|
|
monkeypatch.setenv("OPENAI_BASE_URL", _ENV_URL)
|
|
config_path = tmp_path / "config.toml"
|
|
config_path.write_text('[llm]\nprovider = "openai"\nmodel = "gpt-5.5"\napi_key = "sk-x"\n')
|
|
cfg = GatewayConfig.load_from_toml(config_path)
|
|
cfg.config_path = str(config_path)
|
|
resolve_llm_runtime_config(cfg) # boot materializes the env URL + records it
|
|
assert cfg.llm.base_url == _ENV_URL
|
|
ctx = _admin_ctx(cfg, _CapturingSelector())
|
|
|
|
await _handle_config_set({"path": "port", "value": 18795}, ctx)
|
|
|
|
text = config_path.read_text()
|
|
assert _ENV_URL not in text
|
|
assert tomllib.loads(text)["port"] == 18795
|