Files
hermes-agent/tests/hermes_cli/test_freemodel2api_provider.py
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

136 lines
6.4 KiB
Python

"""FreeModel2API is a first-class provider plugin (``plugins/model-providers/freemodel2api/``):
its credentials live in their own env slots, its model catalog is whatever the saved key may use,
and nothing about it routes through a ``providers:`` / ``custom_providers:`` entry.
"""
import hermes_cli.models as models
import hermes_cli.models_pricing as mp
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
from hermes_cli.config import invalidate_env_cache
from hermes_cli.models_catalog_static import CANONICAL_PROVIDERS
from hermes_cli.provider_catalog import provider_catalog_by_slug
from hermes_cli.providers import get_provider, normalize_provider
def test_freemodel2api_is_registered_as_an_independent_provider():
desc = provider_catalog_by_slug()["freemodel2api"]
assert desc.label == "FreeModel2API"
assert desc.api_key_env_vars == ("FREEMODEL2API_API_KEY",)
assert desc.base_url_env_var == "FREEMODEL2API_BASE_URL"
assert desc.tab == "keys"
# The desktop picker only reaches rows in CANONICAL_PROVIDERS; a plugin profile lands there
# automatically, which is what makes this provider selectable at all.
assert "freemodel2api" in {e.slug for e in CANONICAL_PROVIDERS}
assert normalize_provider("freemodel2api") == "freemodel2api"
def test_freemodel2api_is_the_default_route_before_credentials_are_configured():
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_requested_provider
model = load_config()["model"]
assert model["provider"] == "freemodel2api"
assert model["default"] == ""
assert resolve_requested_provider() == "freemodel2api"
def test_freemodel2api_has_live_catalog_and_pricing_hooks():
# A live probe is the only catalog source: the profile ships fallback_models=(), so a miss
# would leave the row with no models and drop it from the picker.
assert models._PROVIDER_CATALOG_FETCHERS["freemodel2api"] is models._api_key_provider_live
assert mp._PRICING_FETCHERS["freemodel2api"] is mp._fetch_freemodel2api_pricing
assert mp._STATIC_PRICING_SCOPES["freemodel2api"] is mp._freemodel2api_pricing_scope
def test_credentials_resolve_from_the_provider_env_slots(monkeypatch):
"""No base URL is shipped (per-install), so an unconfigured provider must resolve to nothing
rather than to a wrong default host — the picker then simply omits the row."""
assert resolve_api_key_provider_credentials("freemodel2api")["base_url"] == ""
assert PROVIDER_REGISTRY["freemodel2api"].base_url_env_var == "FREEMODEL2API_BASE_URL"
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-env")
monkeypatch.setenv("FREEMODEL2API_BASE_URL", "http://h:3020/v1")
creds = resolve_api_key_provider_credentials("freemodel2api")
assert creds["api_key"] == "sk-env"
assert creds["base_url"] == "http://h:3020/v1"
def test_default_runtime_uses_saved_api_key_without_account_login(tmp_path, monkeypatch):
"""The account session only manages keys; inference needs no FreeModel2API login once the
profile has a key, endpoint and concrete model."""
home = tmp_path / "hermes"
home.mkdir()
(home / "config.yaml").write_text("model:\n default: gpt-5.4\n", encoding="utf-8")
(home / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-direct\n"
"FREEMODEL2API_BASE_URL=https://fm2.example/v1\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("FREEMODEL2API_API_KEY", raising=False)
monkeypatch.delenv("FREEMODEL2API_BASE_URL", raising=False)
invalidate_env_cache()
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider()
assert runtime["provider"] == "freemodel2api"
assert runtime["requested_provider"] == "freemodel2api"
assert runtime["api_key"] == "sk-direct"
assert runtime["base_url"] == "https://fm2.example/v1"
def test_picker_hint_exposes_base_url_env_only_for_per_install_providers():
"""The onboarding key form renders a second (required) base-URL field only for providers that
ship no default endpoint. freemodel2api qualifies; an override-slot provider like deepseek
(default URL exists) must NOT, or every ordinary provider would grow a mandatory URL field."""
from hermes_cli.inventory import _provider_auth_hint
auth_type, key_env, base_url_env = _provider_auth_hint("freemodel2api")
assert (auth_type, key_env, base_url_env) == (
"api_key", "FREEMODEL2API_API_KEY", "FREEMODEL2API_BASE_URL")
assert _provider_auth_hint("deepseek")[2] == ""
def test_credentials_and_endpoint_follow_profile_scope_a_b_a(tmp_path, monkeypatch):
"""A multiplexed Desktop backend must never reuse the launch profile's FreeModel2API route."""
from agent import secret_scope
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
home_a = tmp_path / "a"
home_b = tmp_path / "b"
home_a.mkdir()
home_b.mkdir()
(home_a / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-a\nFREEMODEL2API_BASE_URL=https://a.example/v1\n",
encoding="utf-8",
)
(home_b / ".env").write_text(
"FREEMODEL2API_API_KEY=sk-b\nFREEMODEL2API_BASE_URL=https://b.example/v1\n",
encoding="utf-8",
)
monkeypatch.setenv("FREEMODEL2API_API_KEY", "sk-launch")
monkeypatch.setenv("FREEMODEL2API_BASE_URL", "https://launch.example/v1")
def resolve(home):
home_token = set_hermes_home_override(str(home))
scope_token = secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(home))
try:
invalidate_env_cache()
creds = resolve_api_key_provider_credentials("freemodel2api")
provider = get_provider("freemodel2api", allow_network=False)
return creds["api_key"], creds["base_url"], provider.base_url if provider else ""
finally:
secret_scope.reset_secret_scope(scope_token)
reset_hermes_home_override(home_token)
secret_scope.set_multiplex_active(True)
try:
assert resolve(home_a) == ("sk-a", "https://a.example/v1", "https://a.example/v1")
assert resolve(home_b) == ("sk-b", "https://b.example/v1", "https://b.example/v1")
assert resolve(home_a) == ("sk-a", "https://a.example/v1", "https://a.example/v1")
finally:
secret_scope.set_multiplex_active(False)
invalidate_env_cache()