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
136 lines
6.4 KiB
Python
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()
|