diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index d5f51cce..a117b7d0 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1602,7 +1602,7 @@ class AgentManager: "ANTHROPIC_BASE_URL": proxy_url, # Pin subagent ids; CLI default 'claude-haiku-4-5-20251001' # gets rejected by Pro's surface as "No credentials for provider: anthropic". - # (Free-trial down-routes all of these to Haiku server-side.) + # (Free-trial clamps to its allowed Claude set + weights credits server-side.) "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 09d99c64..b2d89e1c 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -143,6 +143,23 @@ async def update_settings(body: AppSettings): for k in SERVER_OWNED_FIELDS: setattr(body, k, getattr(old, k, None)) + # If the user connects their own model while the free trial is armed, hand + # the wheel back to their provider. Without this, connection_mode (server- + # owned, so the loop above just restored it to "free-trial") would keep them + # pinned to the forced Haiku lane even though they pasted a real key. + if getattr(old, "connection_mode", "own_key") == "free-trial": + from backend.apps.subscription.free_trial import _has_own_model + if _has_own_model(body): + body.connection_mode = "own_key" + body.free_trial_token = None + body.free_trial_remaining = None + try: + import asyncio as _aio + from backend.apps.nine_router import sync_pro_routing as _spr + _aio.create_task(_spr(body)) # drop the now-stale free-trial 9router node + except Exception: + pass + secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", "openswarm_bearer_token", "free_trial_token", "installation_id"} diff --git a/backend/apps/subscription/free_trial.py b/backend/apps/subscription/free_trial.py index b3273611..622c0945 100644 --- a/backend/apps/subscription/free_trial.py +++ b/backend/apps/subscription/free_trial.py @@ -74,7 +74,7 @@ def _fingerprint(settings_obj) -> str | None: def _has_own_model(s) -> bool: - """True if the user already has any real model path; never shadow it.""" + """True if the user already has any real model path in settings; never shadow it.""" if any(getattr(s, k, None) for k in ( "anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -90,6 +90,23 @@ def _has_own_model(s) -> bool: return False +async def _has_connected_subscription() -> bool: + """True if 9Router holds a live Claude/ChatGPT/Gemini subscription. Those + connections live in 9Router, not settings, so the sync check above misses + them; this catches a sub connected while the trial was armed.""" + try: + from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers + if not _9r_running(): + return False + conns = await _9r_providers() + return any( + c.get("isActive") and c.get("provider") in ("claude", "codex", "gemini-cli") + for c in conns + ) + except Exception: + return False + + def _proxy_base(settings_obj) -> str: return (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/") @@ -117,9 +134,14 @@ async def arm_free_trial(settings_obj) -> dict: free-trial mode. Guarded: never arms over a real key/subscription.""" if not _enabled(): return {"armed": False, "reason": "disabled"} - if getattr(settings_obj, "connection_mode", "own_key") not in ("own_key", "free-trial"): + mode = getattr(settings_obj, "connection_mode", "own_key") + if mode not in ("own_key", "free-trial"): return {"armed": False, "reason": "other_mode"} - if _has_own_model(settings_obj): + if _has_own_model(settings_obj) or await _has_connected_subscription(): + # A real model exists now (key, custom provider, or a 9Router sub). If we + # were on the free lane, hand the wheel back instead of re-arming. + if mode == "free-trial": + await clear_free_trial(settings_obj) return {"armed": False, "reason": "has_model"} fp = _fingerprint(settings_obj)