Files
openswarm/backend/tests/test_free_trial.py
T

179 lines
7.0 KiB
Python

"""Free-trial dispatch injection: the pure-logic pieces that decide routing."""
import backend # noqa: F401 (path sanity asserted below)
import pytest
from backend.apps.settings.models import AppSettings
from backend.apps.settings.credentials import proxy_auth
from backend.apps.agents.core.error_classify import (
is_free_trial_exhausted,
is_transient_capacity_error,
)
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
from backend.apps.subscription import free_trial as ft
from backend.apps.subscription.free_trial import has_own_model, arm_free_trial, clear_free_trial
def test_proxy_auth_for_each_mode():
assert proxy_auth(AppSettings()) == (None, None)
pro = AppSettings(
connection_mode="openswarm-pro",
openswarm_bearer_token="bear",
openswarm_proxy_url="https://api.openswarm.com",
)
assert proxy_auth(pro) == ("bear", "https://api.openswarm.com")
free = AppSettings(
connection_mode="free-trial",
free_trial_token="ftk",
openswarm_proxy_url="https://api.openswarm.com",
)
# Free-trial carries the /free segment so the same SDK lands on the metered route.
assert proxy_auth(free) == ("ftk", "https://api.openswarm.com/free")
def test_free_trial_resolves_to_a_bare_anthropic_id():
s = AppSettings(connection_mode="free-trial", free_trial_token="ftk")
mid = resolve_model_id_for_sdk("sonnet", s)
# The bug this fixes: without the free-trial branch this returns a cc/-prefixed id that 401s when no Claude subscription is connected.
assert "cc/" not in mid
assert mid.startswith("claude-")
def test_exhaustion_is_classified_and_not_retried():
assert is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
assert is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
assert not is_free_trial_exhausted(Exception("overloaded, try again"))
# Must NOT look transient, or the agent loop would retry a spent trial forever.
assert not is_transient_capacity_error(Exception("free_trial_exhausted"))
def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
system_event_tail = (
'{"subtype":"api_retry","data":{"error_status":429,'
'"error":"rate_limit","max_retries":10}}'
)
assert is_transient_capacity_error(
Exception("Command failed with exit code 1"),
extra_text=system_event_tail,
)
def testhas_own_model_never_shadows_a_real_provider():
assert not has_own_model(AppSettings(connection_mode="free-trial", free_trial_token="x"))
assert not has_own_model(AppSettings())
assert has_own_model(AppSettings(anthropic_api_key="sk-ant-x"))
assert has_own_model(
AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="b")
)
@pytest.mark.asyncio
async def test_arm_waits_for_9router_before_shadowing_a_background_started_sub(monkeypatch):
"""The regression: 9Router starts in the background, so at first-boot mint time
a real Claude sub is invisible. arm() must bring 9Router up (so the sub becomes
visible) BEFORE deciding, instead of arming the free trial over it."""
saved: list = []
monkeypatch.setattr(ft, "save_settings_async", _record(saved))
monkeypatch.setattr(ft, "p_sync_routing", _noop)
started = {"called": False}
async def fake_ensure_running():
started["called"] = True # 9Router comes up here; the sub is now visible
# The sub is only reachable AFTER ensure_running ran (mirrors the real race).
async def sub_visible_after_start():
return started["called"]
import backend.apps.nine_router as nr
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
monkeypatch.setattr(ft, "p_has_connected_subscription", sub_visible_after_start)
s = AppSettings() # no key, own_key mode: a subscription-only user
out = await arm_free_trial(s)
assert started["called"], "arm must start 9Router before trusting the sub check"
assert out["armed"] is False and out["reason"] == "has_model"
assert s.connection_mode == "own_key"
assert s.default_model != "haiku"
@pytest.mark.asyncio
async def test_arm_tolerates_provider_load_lag(monkeypatch):
"""9Router's /api/providers can lag is_running on a cold start. arm must re-check
a few times so a sub that loads a beat late is still caught, not shadowed."""
monkeypatch.setattr(ft, "save_settings_async", _noop)
monkeypatch.setattr(ft, "p_sync_routing", _noop)
async def fake_ensure_running():
return None
calls = {"n": 0}
async def lagging_sub():
calls["n"] += 1
return calls["n"] >= 3 # empty for the first two probes, then the sub appears
import backend.apps.nine_router as nr
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
monkeypatch.setattr(ft, "p_has_connected_subscription", lagging_sub)
s = AppSettings()
res = await ft.arm_free_trial(s)
assert res["reason"] == "has_model", res
assert s.default_model != "haiku"
assert calls["n"] >= 3, "should have re-checked past the lagging-empty probes"
@pytest.mark.asyncio
async def test_arm_with_no_sub_is_bounded_and_falls_through_to_arm(monkeypatch):
"""The 'don't poll for something that doesn't exist' guarantee: a genuinely
sub-less user must exhaust the re-checks quickly and PROCEED to arm, never hang."""
async def fake_ensure_running():
return None
async def never_sub():
return False
import time
import backend.apps.nine_router as nr
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
monkeypatch.setattr(ft, "p_has_connected_subscription", never_sub)
# Short-circuit before the cloud mint so the test stays offline + deterministic; reaching this branch proves arm did NOT falsely conclude has_model.
monkeypatch.setattr(ft, "p_fingerprint", lambda _s: None)
s = AppSettings()
t = time.monotonic()
res = await ft.arm_free_trial(s)
elapsed = time.monotonic() - t
assert res["reason"] == "no_fingerprint", res # got past the sub guard to the arm path
assert elapsed < 3.0, f"re-check budget not bounded: {elapsed:.2f}s"
@pytest.mark.asyncio
async def test_clear_reverts_forced_haiku_so_it_doesnt_outlive_the_trial(monkeypatch):
monkeypatch.setattr(ft, "save_settings_async", _noop)
monkeypatch.setattr(ft, "p_sync_routing", _noop)
s = AppSettings(connection_mode="free-trial", free_trial_token="ftk", default_model="haiku")
await clear_free_trial(s)
assert s.connection_mode == "own_key"
assert s.default_model == "sonnet" # forced free-run pick handed back, not left on Haiku
assert s.free_trial_token is None
# A user who deliberately picked haiku OUTSIDE free-trial mode is left alone.
s2 = AppSettings(connection_mode="own_key", default_model="haiku")
await clear_free_trial(s2)
assert s2.default_model == "haiku"
async def _noop(*_a, **_k):
return None
def _record(bucket):
async def _inner(obj, *_a, **_k):
bucket.append(obj)
return _inner