commit 82b62f01659b631809080bd2e5aa3ae29ee21887 Author: zlei9 Date: Sun Mar 29 13:21:55 2026 +0800 Initial commit with translated description diff --git a/README.md b/README.md new file mode 100644 index 0000000..c58071b --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +โš ๏ธ Note: The FreeRide skill was recently removed from ClawHub without prior explanation. +Weโ€™ve opened an issue for clarification: https://github.com/openclaw/clawhub/issues/1262 + +# ๐ŸŽข FreeRide + +### Stop paying for AI. Start riding free. + +[![ClawHub Downloads](https://api.clawhub-badge.xyz/badge/free-ride/downloads.svg)](https://clawhub.ai/skills/free-ride) +[![ClawHub Current Installs](https://api.clawhub-badge.xyz/badge/free-ride/installs-current.svg)](https://clawhub.ai/skills/free-ride) +[![ClawHub Stars](https://api.clawhub-badge.xyz/badge/free-ride/stars.svg)](https://clawhub.ai/skills/free-ride) +[![ClawHub Version](https://api.clawhub-badge.xyz/badge/free-ride/version.svg)](https://clawhub.ai/skills/free-ride) + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![OpenClaw Compatible](https://img.shields.io/badge/OpenClaw-Compatible-blue.svg)](https://github.com/openclaw/openclaw) +--- + +**FreeRide** gives you unlimited free AI in [OpenClaw](https://github.com/openclaw/openclaw) by automatically managing OpenRouter's free models. + +``` +You: *hits rate limit* +FreeRide: "I got you." *switches to next best model* +You: *keeps coding* +``` + +## The Problem + +You're using OpenClaw. You love it. But: + +- ๐Ÿ’ธ API costs add up fast +- ๐Ÿšซ Free models have rate limits +- ๐Ÿ˜ค Manually switching models is annoying +- ๐Ÿคท You don't know which free model is actually good + +## The Solution + +One command. Free AI. Forever. + +```bash +freeride auto +``` + +That's it. FreeRide: + +1. **Finds** the 30+ free models on OpenRouter +2. **Ranks** them by quality (context length, capabilities, speed) +3. **Sets** the best one as your primary +4. **Configures** smart fallbacks for when you hit rate limits +5. **Preserves** your existing OpenClaw config + +## Installation + +```bash +npx clawhub@latest install free-ride +cd ~/.openclaw/workspace/skills/free-ride +pip install -e . +``` + +That's it. `freeride` and `freeride-watcher` are now available as global commands. + +## Quick Start + +### 1. Get a Free OpenRouter Key + +Go to [openrouter.ai/keys](https://openrouter.ai/keys) โ†’ Create account โ†’ Generate key + +No credit card. No trial. Actually free. + +### 2. Set Your Key + +```bash +export OPENROUTER_API_KEY="sk-or-v1-..." +``` + +Or add it to your OpenClaw config: + +```bash +openclaw config set env.OPENROUTER_API_KEY "sk-or-v1-..." +``` + +### 3. Run FreeRide + +```bash +freeride auto +``` + +### 4. Restart OpenClaw + +```bash +openclaw gateway restart +``` + +### 5. Verify It Works + +Message your agent on WhatsApp/Telegram/Discord or the dashboard: + +``` +You: /status +Agent: (shows the free model name + token count) +``` + +Done. You're now running on free AI with automatic fallbacks. + +## What You Get + +``` +Primary Model: openrouter/nvidia/nemotron-3-nano-30b-a3b:free (256K context) + +Fallbacks: + 1. openrouter/free โ† Smart router (auto-picks best available) + 2. qwen/qwen3-coder:free โ† Great for coding + 3. stepfun/step-3.5:free โ† Fast responses + 4. deepseek/deepseek:free โ† Strong reasoning + 5. mistral/mistral:free โ† Reliable fallback +``` + +When you hit a rate limit, OpenClaw automatically tries the next model. You keep working. No interruptions. + +## Commands + +| Command | What it does | +|---------|--------------| +| `freeride auto` | Auto-configure best model + fallbacks | +| `freeride list` | See all 30+ free models ranked | +| `freeride switch ` | Use a specific model | +| `freeride status` | Check your current setup | +| `freeride fallbacks` | Update fallbacks only | +| `freeride refresh` | Force refresh model cache | + +### Pro Tips + +```bash +# Already have a model you like? Just add fallbacks: +freeride auto -f + +# Want more fallbacks for maximum uptime? +freeride auto -c 10 + +# Coding? Switch to the best coding model: +freeride switch qwen3-coder + +# See what's available: +freeride list -n 30 + +# Always restart OpenClaw after changes: +openclaw gateway restart +``` + +## How It Ranks Models + +FreeRide scores each model (0-1) based on: + +| Factor | Weight | Why | +|--------|--------|-----| +| Context Length | 40% | Longer = handle bigger codebases | +| Capabilities | 30% | Vision, tools, structured output | +| Recency | 20% | Newer models = better performance | +| Provider Trust | 10% | Google, Meta, NVIDIA, etc. | + +The **smart fallback** `openrouter/free` is always first - it auto-selects based on what your request needs. + +## Testing with Your OpenClaw Agent + +After running `freeride auto` and `openclaw gateway restart`: + +```bash +# Check OpenClaw sees the models +openclaw models list + +# Validate config +openclaw doctor --fix + +# Open the dashboard and chat +openclaw dashboard +# Or message your agent on WhatsApp/Telegram/Discord +``` + +Useful agent commands to verify: + +| Command | What it tells you | +|---------|-------------------| +| `/status` | Current model + token usage | +| `/model` | Available models (your free models should be listed) | +| `/new` | Start fresh session with the new model | + +## Watcher (Auto-Rotation) + +FreeRide includes a watcher daemon that monitors for rate limits and automatically rotates models: + +```bash +# Run once (check + rotate if needed) +freeride-watcher + +# Run as daemon (continuous monitoring) +freeride-watcher --daemon + +# Force rotate to next model +freeride-watcher --rotate + +# Check watcher status +freeride-watcher --status + +# Clear rate limit cooldowns +freeride-watcher --clear-cooldowns +``` + +## FAQ + +**Is this actually free?** + +Yes. OpenRouter provides free tiers for many models. You just need an account (no credit card). + +**What about rate limits?** + +That's the whole point. FreeRide configures multiple fallbacks. When one model rate-limits you, OpenClaw automatically switches to the next. + +**Will it mess up my OpenClaw config?** + +No. FreeRide only touches `agents.defaults.model` and `agents.defaults.models`. Your gateway, channels, plugins, workspace, customInstructions - all preserved. + +**Which models are free?** + +Run `freeride list` to see current availability. It changes, which is why FreeRide exists. + +**Do I need to restart OpenClaw after changes?** + +Yes. Run `openclaw gateway restart` after any FreeRide command that changes your config. + +## The Math + +| Scenario | Monthly Cost | +|----------|--------------| +| GPT-4 API | $50-200+ | +| Claude API | $50-200+ | +| OpenClaw + FreeRide | **$0** | + +You're welcome. + +## Requirements + +- [OpenClaw](https://github.com/openclaw/openclaw) installed (Node โ‰ฅ22) +- Python 3.8+ +- Free OpenRouter account ([get key](https://openrouter.ai/keys)) + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ You โ”‚ โ”€โ”€โ†’ โ”‚ FreeRide โ”‚ โ”€โ”€โ†’ โ”‚ OpenRouter API โ”‚ +โ”‚ "freeride โ”‚ โ”‚ โ”‚ โ”‚ (30+ free โ”‚ +โ”‚ auto" โ”‚ โ”‚ โ€ข Fetch โ”‚ โ”‚ models) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ€ข Rank โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ€ข Configure โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ~/.openclaw/ โ”‚ + โ”‚ openclaw.jsonโ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + openclaw gateway restart + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ OpenClaw โ”‚ + โ”‚ (free AI!) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Contributing + +Found a bug? Want a feature? PRs welcome. + +```bash +cd ~/.openclaw/workspace/skills/free-ride + +# Test commands +freeride list +freeride status +freeride auto --help +``` + +## Related Projects + +- [OpenClaw](https://github.com/openclaw/openclaw) - The AI coding agent +- [OpenRouter](https://openrouter.ai) - The model router +- [ClawHub](https://github.com/clawhub) - Skill marketplace + +## License + +MIT - Do whatever you want. + +--- + +

+ Stop paying. Start riding. +
+
+ โญ Star us on GitHub + ยท + ๐Ÿ”‘ Get OpenRouter Key + ยท + ๐Ÿฆž Install OpenClaw +

diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..16f324d --- /dev/null +++ b/SKILL.md @@ -0,0 +1,91 @@ +--- +name: freeride +description: "ไธบOpenClaw็ฎก็†ๆฅ่‡ชOpenRouter็š„ๅ…่ดนAIๆจกๅž‹ใ€‚" +--- + +# FreeRide - Free AI for OpenClaw + +## What This Skill Does + +Configures OpenClaw to use **free** AI models from OpenRouter. Sets the best free model as primary, adds ranked fallbacks so rate limits don't interrupt the user, and preserves existing config. + +## Prerequisites + +Before running any FreeRide command, ensure: + +1. **OPENROUTER_API_KEY is set.** Check with `echo $OPENROUTER_API_KEY`. If empty, the user must get a free key at https://openrouter.ai/keys and set it: + ```bash + export OPENROUTER_API_KEY="sk-or-v1-..." + # Or persist it: + openclaw config set env.OPENROUTER_API_KEY "sk-or-v1-..." + ``` + +2. **The `freeride` CLI is installed.** Check with `which freeride`. If not found: + ```bash + cd ~/.openclaw/workspace/skills/free-ride + pip install -e . + ``` + +## Primary Workflow + +When the user wants free AI, run these steps in order: + +```bash +# Step 1: Configure best free model + fallbacks +freeride auto + +# Step 2: Restart gateway so OpenClaw picks up the changes +openclaw gateway restart +``` + +That's it. The user now has free AI with automatic fallback switching. + +Verify by telling the user to send `/status` to check the active model. + +## Commands Reference + +| Command | When to use it | +|---------|----------------| +| `freeride auto` | User wants free AI set up (most common) | +| `freeride auto -f` | User wants fallbacks but wants to keep their current primary model | +| `freeride auto -c 10` | User wants more fallbacks (default is 5) | +| `freeride list` | User wants to see available free models | +| `freeride list -n 30` | User wants to see all free models | +| `freeride switch ` | User wants a specific model (e.g. `freeride switch qwen3-coder`) | +| `freeride switch -f` | Add specific model as fallback only | +| `freeride status` | Check current FreeRide configuration | +| `freeride fallbacks` | Update only the fallback models | +| `freeride refresh` | Force refresh the cached model list | + +**After any command that changes config, always run `openclaw gateway restart`.** + +## What It Writes to Config + +FreeRide updates only these keys in `~/.openclaw/openclaw.json`: + +- `agents.defaults.model.primary` โ€” e.g. `openrouter/qwen/qwen3-coder:free` +- `agents.defaults.model.fallbacks` โ€” e.g. `["openrouter/free", "nvidia/nemotron:free", ...]` +- `agents.defaults.models` โ€” allowlist so `/model` command shows the free models + +Everything else (gateway, channels, plugins, env, customInstructions, named agents) is preserved. + +The first fallback is always `openrouter/free` โ€” OpenRouter's smart router that auto-picks the best available model based on the request. + +## Watcher (Optional) + +For auto-rotation when rate limited, the user can run: + +```bash +freeride-watcher --daemon # Continuous monitoring +freeride-watcher --rotate # Force rotate now +freeride-watcher --status # Check rotation history +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `freeride: command not found` | `cd ~/.openclaw/workspace/skills/free-ride && pip install -e .` | +| `OPENROUTER_API_KEY not set` | User needs a key from https://openrouter.ai/keys | +| Changes not taking effect | `openclaw gateway restart` then `/new` for fresh session | +| Agent shows 0 tokens | Check `freeride status` โ€” primary should be `openrouter//:free` | \ No newline at end of file diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..aae89e8 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7eepf540q01kxs5gzwnvsp5s80hhje", + "slug": "free-ride", + "version": "1.0.7", + "publishedAt": 1774618346215 +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6f8d32a --- /dev/null +++ b/main.py @@ -0,0 +1,767 @@ +#!/usr/bin/env python3 +""" +FreeRide - Free AI for OpenClaw +Automatically manage and switch between free AI models on OpenRouter +for unlimited free AI access. +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional + +try: + import requests +except ImportError: + print("Error: requests library required. Install with: pip install requests") + sys.exit(1) + + +# Constants +OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models" +OPENCLAW_CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json" +CACHE_FILE = Path.home() / ".openclaw" / ".freeride-cache.json" +CACHE_DURATION_HOURS = 6 + +# Free model ranking criteria (higher is better) +RANKING_WEIGHTS = { + "context_length": 0.4, # Prefer longer context + "capabilities": 0.3, # Prefer more capabilities + "recency": 0.2, # Prefer newer models + "provider_trust": 0.1 # Prefer trusted providers +} + +# Trusted providers (in order of preference) +TRUSTED_PROVIDERS = [ + "google", "meta-llama", "mistralai", "deepseek", + "nvidia", "qwen", "microsoft", "allenai", "arcee-ai" +] + + +def get_api_key() -> Optional[str]: + """Get OpenRouter API key from environment or OpenClaw config.""" + # Try environment first + api_key = os.environ.get("OPENROUTER_API_KEY") + if api_key: + return api_key + + # Try OpenClaw config + if OPENCLAW_CONFIG_PATH.exists(): + try: + config = json.loads(OPENCLAW_CONFIG_PATH.read_text()) + # Check env section + api_key = config.get("env", {}).get("OPENROUTER_API_KEY") + if api_key: + return api_key + except (json.JSONDecodeError, KeyError): + pass + + return None + + +def fetch_all_models(api_key: str) -> list: + """Fetch all models from OpenRouter API.""" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + try: + response = requests.get(OPENROUTER_API_URL, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + return data.get("data", []) + except requests.RequestException as e: + print(f"Error fetching models: {e}") + return [] + + +def filter_free_models(models: list) -> list: + """Filter models to only include free ones (pricing.prompt == 0).""" + free_models = [] + + for model in models: + model_id = model.get("id", "") + pricing = model.get("pricing", {}) + + # Check if model is free (prompt cost is 0 or None) + prompt_cost = pricing.get("prompt") + if prompt_cost is not None: + try: + if float(prompt_cost) == 0: + free_models.append(model) + except (ValueError, TypeError): + pass + + # Also include models with :free suffix + if ":free" in model_id and model not in free_models: + free_models.append(model) + + return free_models + + +def calculate_model_score(model: dict) -> float: + """Calculate a ranking score for a model based on multiple criteria.""" + score = 0.0 + + # Context length score (normalized to 0-1, max 1M tokens) + context_length = model.get("context_length", 0) + context_score = min(context_length / 1_000_000, 1.0) + score += context_score * RANKING_WEIGHTS["context_length"] + + # Capabilities score + capabilities = model.get("supported_parameters", []) + capability_count = len(capabilities) if capabilities else 0 + capability_score = min(capability_count / 10, 1.0) # Normalize to max 10 capabilities + score += capability_score * RANKING_WEIGHTS["capabilities"] + + # Recency score (based on creation date) + created = model.get("created", 0) + if created: + days_old = (time.time() - created) / 86400 + recency_score = max(0, 1 - (days_old / 365)) # Newer models score higher + score += recency_score * RANKING_WEIGHTS["recency"] + + # Provider trust score + model_id = model.get("id", "") + provider = model_id.split("/")[0] if "/" in model_id else "" + if provider in TRUSTED_PROVIDERS: + trust_index = TRUSTED_PROVIDERS.index(provider) + trust_score = 1 - (trust_index / len(TRUSTED_PROVIDERS)) + score += trust_score * RANKING_WEIGHTS["provider_trust"] + + return score + + +def rank_free_models(models: list) -> list: + """Rank free models by quality score.""" + scored_models = [] + for model in models: + score = calculate_model_score(model) + scored_models.append({**model, "_score": score}) + + # Sort by score descending + scored_models.sort(key=lambda x: x["_score"], reverse=True) + return scored_models + + +def get_cached_models() -> Optional[list]: + """Get cached model list if still valid.""" + if not CACHE_FILE.exists(): + return None + + try: + cache = json.loads(CACHE_FILE.read_text()) + cached_at = datetime.fromisoformat(cache.get("cached_at", "")) + if datetime.now() - cached_at < timedelta(hours=CACHE_DURATION_HOURS): + return cache.get("models", []) + except (json.JSONDecodeError, ValueError): + pass + + return None + + +def save_models_cache(models: list): + """Save models to cache file.""" + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + cache = { + "cached_at": datetime.now().isoformat(), + "models": models + } + CACHE_FILE.write_text(json.dumps(cache, indent=2)) + + +def get_free_models(api_key: str, force_refresh: bool = False) -> list: + """Get ranked free models (from cache or API).""" + if not force_refresh: + cached = get_cached_models() + if cached: + return cached + + all_models = fetch_all_models(api_key) + free_models = filter_free_models(all_models) + ranked_models = rank_free_models(free_models) + + save_models_cache(ranked_models) + return ranked_models + + +def load_openclaw_config() -> dict: + """Load OpenClaw configuration.""" + if not OPENCLAW_CONFIG_PATH.exists(): + return {} + + try: + return json.loads(OPENCLAW_CONFIG_PATH.read_text()) + except json.JSONDecodeError: + return {} + + +def save_openclaw_config(config: dict): + """Save OpenClaw configuration.""" + OPENCLAW_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + OPENCLAW_CONFIG_PATH.write_text(json.dumps(config, indent=2)) + + +def format_model_for_openclaw(model_id: str, with_provider_prefix: bool = True, append_free: bool = True) -> str: + """Format model ID for OpenClaw config. + + OpenClaw uses two formats: + - Primary model: "openrouter//:free" (with provider prefix) + - Fallbacks/models list: "/:free" (without prefix sometimes) + """ + base_id = model_id + + # Handle openrouter/free special case: "openrouter" is both the routing + # prefix OpenClaw adds AND the actual provider name in the API model ID. + # The API model ID is "openrouter/free" (no :free suffix โ€” it's a router, not a free-tier model). + # - with prefix: "openrouter/openrouter/free" (routing prefix + API ID) + # - without prefix: "openrouter/free" (just the API ID) + if model_id in ("openrouter/free", "openrouter/free:free"): + if with_provider_prefix: + return "openrouter/openrouter/free" + return "openrouter/free" + + # Remove existing openrouter/ routing prefix if present to get the base API ID + if base_id.startswith("openrouter/"): + base_id = base_id[len("openrouter/"):] + + # Ensure :free suffix + if append_free and ":free" not in base_id: + base_id = f"{base_id}:free" + + if with_provider_prefix: + return f"openrouter/{base_id}" + return base_id + + +def get_current_model(config: dict = None) -> Optional[str]: + """Get currently configured model in OpenClaw.""" + if config is None: + config = load_openclaw_config() + return config.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") + + +def get_current_fallbacks(config: dict = None) -> list: + """Get currently configured fallback models.""" + if config is None: + config = load_openclaw_config() + return config.get("agents", {}).get("defaults", {}).get("model", {}).get("fallbacks", []) + + +def ensure_config_structure(config: dict) -> dict: + """Ensure the config has the required nested structure without overwriting existing values.""" + if "agents" not in config: + config["agents"] = {} + if "defaults" not in config["agents"]: + config["agents"]["defaults"] = {} + if "model" not in config["agents"]["defaults"]: + config["agents"]["defaults"]["model"] = {} + if "models" not in config["agents"]["defaults"]: + config["agents"]["defaults"]["models"] = {} + return config + + +def setup_openrouter_auth(config: dict) -> dict: + """Set up OpenRouter auth profile if not exists.""" + if "auth" not in config: + config["auth"] = {} + if "profiles" not in config["auth"]: + config["auth"]["profiles"] = {} + + if "openrouter:default" not in config["auth"]["profiles"]: + config["auth"]["profiles"]["openrouter:default"] = { + "provider": "openrouter", + "mode": "api_key" + } + print("Added OpenRouter auth profile.") + + return config + + +def update_model_config( + model_id: str, + as_primary: bool = True, + add_fallbacks: bool = True, + fallback_count: int = 5, + setup_auth: bool = False, + append_free: bool = True +) -> bool: + """Update OpenClaw config with the specified model. + + Args: + model_id: The model ID to configure + as_primary: If True, set as primary model. If False, only add to fallbacks. + add_fallbacks: If True, also configure fallback models + fallback_count: Number of fallback models to add + setup_auth: If True, also set up OpenRouter auth profile + """ + config = load_openclaw_config() + config = ensure_config_structure(config) + + if setup_auth: + config = setup_openrouter_auth(config) + + formatted_primary = format_model_for_openclaw(model_id, with_provider_prefix=True, append_free=append_free) + formatted_for_list = format_model_for_openclaw(model_id, with_provider_prefix=False, append_free=append_free) + + if as_primary: + # Set as primary model + config["agents"]["defaults"]["model"]["primary"] = formatted_primary + # Add to models allowlist + config["agents"]["defaults"]["models"][formatted_for_list] = {} + + # Handle fallbacks + if add_fallbacks: + api_key = get_api_key() + if api_key: + free_models = get_free_models(api_key) + + # Get existing fallbacks + existing_fallbacks = config["agents"]["defaults"]["model"].get("fallbacks", []) + + # Build new fallbacks list + new_fallbacks = [] + + # Always add openrouter/free as first fallback (smart router) + # Skip if it's being set as primary + free_router = "openrouter/free" + free_router_primary = format_model_for_openclaw("openrouter/free", with_provider_prefix=True) + if formatted_primary != free_router_primary and formatted_for_list != free_router: + new_fallbacks.append(free_router) + config["agents"]["defaults"]["models"][free_router] = {} + + for m in free_models: + # Reserve one slot for openrouter/free + if len(new_fallbacks) >= fallback_count: + break + + m_formatted = format_model_for_openclaw(m["id"], with_provider_prefix=False) + m_formatted_primary = format_model_for_openclaw(m["id"], with_provider_prefix=True) + + # Skip openrouter/free (already added as first) + if "openrouter/free" in m["id"]: + continue + + # Skip if it's the new primary + if as_primary and (m_formatted == formatted_for_list or m_formatted_primary == formatted_primary): + continue + + # Skip if it's the current primary (when adding to fallbacks only) + current_primary = config["agents"]["defaults"]["model"].get("primary", "") + if not as_primary and m_formatted_primary == current_primary: + continue + + new_fallbacks.append(m_formatted) + config["agents"]["defaults"]["models"][m_formatted] = {} + + # If not setting as primary, prepend new model to fallbacks (after openrouter/free) + if not as_primary: + if formatted_for_list not in new_fallbacks: + # Insert after openrouter/free if present + insert_pos = 1 if free_router in new_fallbacks else 0 + new_fallbacks.insert(insert_pos, formatted_for_list) + config["agents"]["defaults"]["models"][formatted_for_list] = {} + + config["agents"]["defaults"]["model"]["fallbacks"] = new_fallbacks + + save_openclaw_config(config) + return True + + +# ============== Command Handlers ============== + +def cmd_list(args): + """List available free models ranked by quality.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + print("Set it via: export OPENROUTER_API_KEY='sk-or-...'") + print("Or get a free key at: https://openrouter.ai/keys") + sys.exit(1) + + print("Fetching free models from OpenRouter...") + models = get_free_models(api_key, force_refresh=args.refresh) + + if not models: + print("No free models available.") + return + + current = get_current_model() + fallbacks = get_current_fallbacks() + limit = args.limit if args.limit else 15 + + print(f"\nTop {min(limit, len(models))} Free AI Models (ranked by quality):\n") + print(f"{'#':<3} {'Model ID':<50} {'Context':<12} {'Score':<8} {'Status'}") + print("-" * 90) + + for i, model in enumerate(models[:limit], 1): + model_id = model.get("id", "unknown") + context = model.get("context_length", 0) + score = model.get("_score", 0) + + # Format context length + if context >= 1_000_000: + context_str = f"{context // 1_000_000}M tokens" + elif context >= 1_000: + context_str = f"{context // 1_000}K tokens" + else: + context_str = f"{context} tokens" + + # Check status + formatted = format_model_for_openclaw(model_id, with_provider_prefix=True) + formatted_fallback = format_model_for_openclaw(model_id, with_provider_prefix=False) + + if current and formatted == current: + status = "[PRIMARY]" + elif formatted_fallback in fallbacks or formatted in fallbacks: + status = "[FALLBACK]" + else: + status = "" + + print(f"{i:<3} {model_id:<50} {context_str:<12} {score:.3f} {status}") + + if len(models) > limit: + print(f"\n... and {len(models) - limit} more. Use --limit to see more.") + + print(f"\nTotal free models available: {len(models)}") + print("\nCommands:") + print(" freeride switch Set as primary model") + print(" freeride switch -f Add to fallbacks only (keep current primary)") + print(" freeride auto Auto-select best model") + + +def cmd_switch(args): + """Switch to a specific free model.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + model_id = args.model + as_fallback = args.fallback_only + + # Validate model exists and is free + models = get_free_models(api_key) + model_ids = [m["id"] for m in models] + + # Check for exact match or partial match + matched_model = None + if model_id in model_ids: + matched_model = model_id + else: + # Try partial match + for m_id in model_ids: + if model_id.lower() in m_id.lower(): + matched_model = m_id + break + + if not matched_model: + print(f"Error: Model '{model_id}' not found in free models list.") + print("Use 'freeride list' to see available models.") + sys.exit(1) + + if as_fallback: + print(f"Adding to fallbacks: {matched_model}") + else: + print(f"Setting as primary: {matched_model}") + + if update_model_config( + matched_model, + as_primary=not as_fallback, + add_fallbacks=not args.no_fallbacks, + setup_auth=args.setup_auth, + append_free=False + ): + config = load_openclaw_config() + + if as_fallback: + print("Success! Added to fallbacks.") + print(f"Primary model (unchanged): {get_current_model(config)}") + else: + print("Success! OpenClaw config updated.") + print(f"Primary model: {get_current_model(config)}") + + fallbacks = get_current_fallbacks(config) + if fallbacks: + print(f"Fallback models ({len(fallbacks)}):") + for fb in fallbacks[:5]: + print(f" - {fb}") + if len(fallbacks) > 5: + print(f" ... and {len(fallbacks) - 5} more") + + print("\nRestart OpenClaw for changes to take effect.") + else: + print("Error: Failed to update OpenClaw config.") + sys.exit(1) + + +def cmd_auto(args): + """Automatically select the best free model.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + config = load_openclaw_config() + current_primary = get_current_model(config) + + print("Finding best free model...") + models = get_free_models(api_key, force_refresh=True) + + if not models: + print("Error: No free models available.") + sys.exit(1) + + # Find best SPECIFIC model (skip openrouter/free router) + # openrouter/free is a router, not a specific model - use it as fallback only + best_model = None + for m in models: + if "openrouter/free" not in m["id"]: + best_model = m + break + + if not best_model: + # Fallback to first model if all are routers (unlikely) + best_model = models[0] + + model_id = best_model["id"] + context = best_model.get("context_length", 0) + score = best_model.get("_score", 0) + + # Determine if we should change primary or just add fallbacks + as_fallback = args.fallback_only + + if not as_fallback: + if current_primary: + print(f"\nReplacing current primary: {current_primary}") + print(f"\nBest free model: {model_id}") + print(f"Context length: {context:,} tokens") + print(f"Quality score: {score:.3f}") + else: + print(f"\nKeeping current primary, adding fallbacks only.") + print(f"Best available: {model_id} ({context:,} tokens, score: {score:.3f})") + + if update_model_config( + model_id, + as_primary=not as_fallback, + add_fallbacks=True, + fallback_count=args.fallback_count, + setup_auth=args.setup_auth + ): + config = load_openclaw_config() + + if as_fallback: + print("\nFallbacks configured!") + print(f"Primary (unchanged): {get_current_model(config)}") + print("First fallback: openrouter/free (smart router - auto-selects best available)") + else: + print("\nOpenClaw config updated!") + print(f"Primary: {get_current_model(config)}") + + fallbacks = get_current_fallbacks(config) + if fallbacks: + print(f"Fallbacks ({len(fallbacks)}):") + for fb in fallbacks: + print(f" - {fb}") + + print("\nRestart OpenClaw for changes to take effect.") + else: + print("Error: Failed to update config.") + sys.exit(1) + + +def cmd_status(args): + """Show current configuration status.""" + api_key = get_api_key() + config = load_openclaw_config() + current = get_current_model(config) + fallbacks = get_current_fallbacks(config) + + print("FreeRide Status") + print("=" * 50) + + # API Key status + if api_key: + masked = api_key[:8] + "..." + api_key[-4:] if len(api_key) > 12 else "***" + print(f"OpenRouter API Key: {masked}") + else: + print("OpenRouter API Key: NOT SET") + print(" Set with: export OPENROUTER_API_KEY='sk-or-...'") + + # Auth profile status + auth_profiles = config.get("auth", {}).get("profiles", {}) + if "openrouter:default" in auth_profiles: + print("OpenRouter Auth Profile: Configured") + else: + print("OpenRouter Auth Profile: Not set (use --setup-auth to add)") + + # Current model + print(f"\nPrimary Model: {current or 'Not configured'}") + + # Fallbacks + if fallbacks: + print(f"Fallback Models ({len(fallbacks)}):") + for fb in fallbacks: + print(f" - {fb}") + else: + print("Fallback Models: None configured") + + # Cache status + if CACHE_FILE.exists(): + try: + cache = json.loads(CACHE_FILE.read_text()) + cached_at = datetime.fromisoformat(cache.get("cached_at", "")) + models_count = len(cache.get("models", [])) + age = datetime.now() - cached_at + hours = age.seconds // 3600 + mins = (age.seconds % 3600) // 60 + print(f"\nModel Cache: {models_count} models (updated {hours}h {mins}m ago)") + except: + print("\nModel Cache: Invalid") + else: + print("\nModel Cache: Not created yet") + + # OpenClaw config path + print(f"\nOpenClaw Config: {OPENCLAW_CONFIG_PATH}") + print(f" Exists: {'Yes' if OPENCLAW_CONFIG_PATH.exists() else 'No'}") + + +def cmd_refresh(args): + """Force refresh the model cache.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + print("Refreshing free models cache...") + models = get_free_models(api_key, force_refresh=True) + print(f"Cached {len(models)} free models.") + print(f"Cache expires in {CACHE_DURATION_HOURS} hours.") + + +def cmd_fallbacks(args): + """Configure fallback models for rate limit handling.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + config = load_openclaw_config() + current = get_current_model(config) + + if not current: + print("Warning: No primary model configured.") + print("Fallbacks will still be added.") + + print(f"Current primary: {current or 'None'}") + print(f"Setting up {args.count} fallback models...") + + models = get_free_models(api_key) + config = ensure_config_structure(config) + + # Get fallbacks excluding current model + fallbacks = [] + + # Always add openrouter/free as first fallback (smart router) + free_router = "openrouter/free" + free_router_primary = format_model_for_openclaw("openrouter/free", with_provider_prefix=True) + if not current or current != free_router_primary: + fallbacks.append(free_router) + config["agents"]["defaults"]["models"][free_router] = {} + + for m in models: + formatted = format_model_for_openclaw(m["id"], with_provider_prefix=False) + formatted_primary = format_model_for_openclaw(m["id"], with_provider_prefix=True) + + if current and (formatted_primary == current): + continue + # Skip openrouter/free (already added as first) + if "openrouter/free" in m["id"]: + continue + if len(fallbacks) >= args.count: + break + + fallbacks.append(formatted) + config["agents"]["defaults"]["models"][formatted] = {} + + config["agents"]["defaults"]["model"]["fallbacks"] = fallbacks + save_openclaw_config(config) + + print(f"\nConfigured {len(fallbacks)} fallback models:") + for i, fb in enumerate(fallbacks, 1): + print(f" {i}. {fb}") + + print("\nWhen rate limited, OpenClaw will automatically try these models.") + print("Restart OpenClaw for changes to take effect.") + + +def main(): + parser = argparse.ArgumentParser( + prog="freeride", + description="FreeRide - Free AI for OpenClaw. Manage free models from OpenRouter." + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # list command + list_parser = subparsers.add_parser("list", help="List available free models") + list_parser.add_argument("--limit", "-n", type=int, default=15, + help="Number of models to show (default: 15)") + list_parser.add_argument("--refresh", "-r", action="store_true", + help="Force refresh from API (ignore cache)") + + # switch command + switch_parser = subparsers.add_parser("switch", help="Switch to a specific model") + switch_parser.add_argument("model", help="Model ID to switch to") + switch_parser.add_argument("--fallback-only", "-f", action="store_true", + help="Add to fallbacks only, don't change primary") + switch_parser.add_argument("--no-fallbacks", action="store_true", + help="Don't configure fallback models") + switch_parser.add_argument("--setup-auth", action="store_true", + help="Also set up OpenRouter auth profile") + + # auto command + auto_parser = subparsers.add_parser("auto", help="Auto-select best free model") + auto_parser.add_argument("--fallback-count", "-c", type=int, default=5, + help="Number of fallback models (default: 5)") + auto_parser.add_argument("--fallback-only", "-f", action="store_true", + help="Add to fallbacks only, don't change primary") + auto_parser.add_argument("--setup-auth", action="store_true", + help="Also set up OpenRouter auth profile") + + # status command + subparsers.add_parser("status", help="Show current configuration") + + # refresh command + subparsers.add_parser("refresh", help="Refresh model cache") + + # fallbacks command + fallbacks_parser = subparsers.add_parser("fallbacks", help="Configure fallback models") + fallbacks_parser.add_argument("--count", "-c", type=int, default=5, + help="Number of fallback models (default: 5)") + + args = parser.parse_args() + + if args.command == "list": + cmd_list(args) + elif args.command == "switch": + cmd_switch(args) + elif args.command == "auto": + cmd_auto(args) + elif args.command == "status": + cmd_status(args) + elif args.command == "refresh": + cmd_refresh(args) + elif args.command == "fallbacks": + cmd_fallbacks(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..37912b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.31.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..79ca5d2 --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup, find_packages + +setup( + name="freeride", + version="1.0.0", + description="Free AI for OpenClaw - Automatic free model management via OpenRouter", + author="Shaishav Pidadi", + url="https://github.com/Shaivpidadi/FreeRide", + py_modules=["main", "watcher"], + install_requires=[ + "requests>=2.31.0", + ], + entry_points={ + "console_scripts": [ + "freeride=main:main", + "freeride-watcher=watcher:main", + ], + }, + python_requires=">=3.8", + license="MIT", + classifiers=[ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + ], +) \ No newline at end of file diff --git a/skill.json b/skill.json new file mode 100644 index 0000000..d7989cd --- /dev/null +++ b/skill.json @@ -0,0 +1,41 @@ +{ + "name": "freeride", + "displayName": "FreeRide - Free AI for OpenClaw", + "version": "1.0.1", + "description": "Unlimited free AI access for OpenClaw via OpenRouter's free models with automatic fallback switching", + "author": "Shaishav Pidadi", + "repository": "https://github.com/Shaivpidadi/FreeRide", + "license": "MIT", + "commands": [ + "list", + "switch", + "auto", + "status", + "refresh", + "fallbacks" + ], + "binaries": { + "freeride": "main:main", + "freeride-watcher": "watcher:main" + }, + "dependencies": { + "requests": ">=2.31.0" + }, + "config": { + "OPENROUTER_API_KEY": { + "description": "Your OpenRouter API key (get free at openrouter.ai/keys)", + "required": true, + "env": true + } + }, + "openclaw": { + "compatible": true, + "minVersion": "1.0.0", + "configPath": "~/.openclaw/openclaw.json", + "configKeys": [ + "agents.defaults.model", + "agents.defaults.models" + ] + }, + "install": "npx clawhub@latest install freeride && cd ~/.openclaw/workspace/skills/free-ride && pip install -e ." +} \ No newline at end of file diff --git a/watcher.py b/watcher.py new file mode 100644 index 0000000..14464a5 --- /dev/null +++ b/watcher.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +""" +FreeRide Watcher +Monitors for rate limits and automatically rotates models. +Can run as a daemon or be called periodically via cron. +""" + +import json +import os +import sys +import time +import signal +from pathlib import Path +from datetime import datetime, timedelta +from typing import Optional + +try: + import requests +except ImportError: + print("Error: requests library required") + sys.exit(1) + + +# Import from main module +from main import ( + get_api_key, + get_free_models, + load_openclaw_config, + save_openclaw_config, + ensure_config_structure, + format_model_for_openclaw, + OPENCLAW_CONFIG_PATH +) + + +# Constants +STATE_FILE = Path.home() / ".openclaw" / ".freeride-watcher-state.json" +RATE_LIMIT_COOLDOWN_MINUTES = 30 +CHECK_INTERVAL_SECONDS = 60 +OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" + + +def load_state() -> dict: + """Load watcher state.""" + if STATE_FILE.exists(): + try: + return json.loads(STATE_FILE.read_text()) + except json.JSONDecodeError: + pass + return {"rate_limited_models": {}, "rotation_count": 0} + + +def save_state(state: dict): + """Save watcher state.""" + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(state, indent=2)) + + +def is_model_rate_limited(state: dict, model_id: str) -> bool: + """Check if a model is currently in rate-limit cooldown.""" + rate_limited = state.get("rate_limited_models", {}) + if model_id not in rate_limited: + return False + + limited_at = datetime.fromisoformat(rate_limited[model_id]) + cooldown_end = limited_at + timedelta(minutes=RATE_LIMIT_COOLDOWN_MINUTES) + return datetime.now() < cooldown_end + + +def mark_rate_limited(state: dict, model_id: str): + """Mark a model as rate limited.""" + if "rate_limited_models" not in state: + state["rate_limited_models"] = {} + state["rate_limited_models"][model_id] = datetime.now().isoformat() + save_state(state) + + +def test_model(api_key: str, model_id: str) -> tuple[bool, Optional[str]]: + """ + Test if a model is available by making a minimal API call. + Returns (success, error_type). + """ + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/Shaivpidadi/FreeRide", + "X-Title": "FreeRide Health Check" + } + + payload = { + "model": model_id, + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 5, + "stream": False + } + + try: + response = requests.post( + OPENROUTER_CHAT_URL, + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + return True, None + elif response.status_code == 429: + return False, "rate_limit" + elif response.status_code == 503: + return False, "unavailable" + else: + return False, f"error_{response.status_code}" + + except requests.Timeout: + return False, "timeout" + except requests.RequestException as e: + return False, "request_error" + + +def get_next_available_model(api_key: str, state: dict, exclude_model: str = None) -> Optional[str]: + """Get the next best model that isn't rate limited.""" + models = get_free_models(api_key) + + for model in models: + model_id = model["id"] + + # Skip the openrouter/free router - we want specific models + if "openrouter/free" in model_id: + continue + + # Skip if same as excluded model + if exclude_model and model_id == exclude_model: + continue + + # Skip if in cooldown + if is_model_rate_limited(state, model_id): + continue + + # Test if actually available + success, error = test_model(api_key, model_id) + if success: + return model_id + + # Mark as rate limited if that's the error + if error == "rate_limit": + mark_rate_limited(state, model_id) + + return None + + +def rotate_to_next_model(api_key: str, state: dict, reason: str = "manual"): + """Rotate to the next available model.""" + config = load_openclaw_config() + config = ensure_config_structure(config) + current = config.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") + + # Extract base model ID from OpenClaw format + current_base = None + if current: + # openrouter/provider/model:free -> provider/model:free + if current.startswith("openrouter/"): + current_base = current[len("openrouter/"):] + else: + current_base = current + + print(f"[{datetime.now().isoformat()}] Rotating from: {current_base or 'none'}") + print(f" Reason: {reason}") + + next_model = get_next_available_model(api_key, state, current_base) + + if not next_model: + print(" Error: No available models found!") + return False + + print(f" New model: {next_model}") + + # Update config - primary uses provider prefix, fallbacks don't + formatted_primary = format_model_for_openclaw(next_model, with_provider_prefix=True) + config["agents"]["defaults"]["model"]["primary"] = formatted_primary + + # Add to models allowlist + formatted_for_list = format_model_for_openclaw(next_model, with_provider_prefix=False) + config["agents"]["defaults"]["models"][formatted_for_list] = {} + + # Rebuild fallbacks from remaining models (using correct format: no provider prefix) + models = get_free_models(api_key) + fallbacks = [] + + # Always add openrouter/free as first fallback + free_router = "openrouter/free" + fallbacks.append(free_router) + config["agents"]["defaults"]["models"][free_router] = {} + + for m in models: + if m["id"] == next_model or "openrouter/free" in m["id"]: + continue + if is_model_rate_limited(state, m["id"]): + continue + + fb_formatted = format_model_for_openclaw(m["id"], with_provider_prefix=False) + fallbacks.append(fb_formatted) + config["agents"]["defaults"]["models"][fb_formatted] = {} + + if len(fallbacks) >= 5: + break + + config["agents"]["defaults"]["model"]["fallbacks"] = fallbacks + + save_openclaw_config(config) + + # Update state + state["rotation_count"] = state.get("rotation_count", 0) + 1 + state["last_rotation"] = datetime.now().isoformat() + state["last_rotation_reason"] = reason + save_state(state) + + print(f" Success! Rotated to {next_model}") + print(f" Total rotations this session: {state['rotation_count']}") + + return True + + +def check_and_rotate(api_key: str, state: dict) -> bool: + """Check current model and rotate if needed.""" + config = load_openclaw_config() + current = config.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") + + if not current: + print("No primary model configured. Running initial setup...") + return rotate_to_next_model(api_key, state, "initial_setup") + + # Extract base model ID + if current.startswith("openrouter/"): + current_base = current[len("openrouter/"):] + else: + current_base = current + + # Check if current model is rate limited + if is_model_rate_limited(state, current_base): + return rotate_to_next_model(api_key, state, "cooldown_active") + + # Test current model + print(f"[{datetime.now().isoformat()}] Testing: {current_base}") + success, error = test_model(api_key, current_base) + + if success: + print(f" Status: OK") + return False # No rotation needed + else: + print(f" Status: {error}") + if error == "rate_limit": + mark_rate_limited(state, current_base) + return rotate_to_next_model(api_key, state, error) + + +def cleanup_old_rate_limits(state: dict): + """Remove rate limit entries that have expired.""" + rate_limited = state.get("rate_limited_models", {}) + current_time = datetime.now() + expired = [] + + for model_id, limited_at_str in rate_limited.items(): + try: + limited_at = datetime.fromisoformat(limited_at_str) + if current_time - limited_at > timedelta(minutes=RATE_LIMIT_COOLDOWN_MINUTES): + expired.append(model_id) + except (ValueError, TypeError): + expired.append(model_id) + + for model_id in expired: + del rate_limited[model_id] + print(f" Cleared cooldown: {model_id}") + + if expired: + save_state(state) + + +def run_once(): + """Run a single check and rotate cycle.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + state = load_state() + cleanup_old_rate_limits(state) + check_and_rotate(api_key, state) + + +def run_daemon(): + """Run as a continuous daemon.""" + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + + print(f"FreeRide Watcher started") + print(f"Check interval: {CHECK_INTERVAL_SECONDS}s") + print(f"Rate limit cooldown: {RATE_LIMIT_COOLDOWN_MINUTES}m") + print("-" * 50) + + # Handle graceful shutdown + running = True + def signal_handler(signum, frame): + nonlocal running + print("\nShutting down watcher...") + running = False + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + state = load_state() + + while running: + try: + cleanup_old_rate_limits(state) + check_and_rotate(api_key, state) + except Exception as e: + print(f"Error during check: {e}") + + # Sleep in small increments to allow graceful shutdown + for _ in range(CHECK_INTERVAL_SECONDS): + if not running: + break + time.sleep(1) + + print("Watcher stopped.") + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + prog="freeride-watcher", + description="FreeRide Watcher - Monitor and auto-rotate free AI models" + ) + parser.add_argument("--daemon", "-d", action="store_true", + help="Run as continuous daemon") + parser.add_argument("--rotate", "-r", action="store_true", + help="Force rotate to next model") + parser.add_argument("--status", "-s", action="store_true", + help="Show watcher status") + parser.add_argument("--clear-cooldowns", action="store_true", + help="Clear all rate limit cooldowns") + + args = parser.parse_args() + + if args.status: + state = load_state() + print("FreeRide Watcher Status") + print("=" * 40) + print(f"Total rotations: {state.get('rotation_count', 0)}") + print(f"Last rotation: {state.get('last_rotation', 'Never')}") + print(f"Last reason: {state.get('last_rotation_reason', 'N/A')}") + print(f"\nModels in cooldown:") + for model, limited_at in state.get("rate_limited_models", {}).items(): + print(f" - {model} (since {limited_at})") + if not state.get("rate_limited_models"): + print(" None") + + elif args.clear_cooldowns: + state = load_state() + state["rate_limited_models"] = {} + save_state(state) + print("Cleared all rate limit cooldowns.") + + elif args.rotate: + api_key = get_api_key() + if not api_key: + print("Error: OPENROUTER_API_KEY not set") + sys.exit(1) + state = load_state() + rotate_to_next_model(api_key, state, "manual_rotation") + + elif args.daemon: + run_daemon() + + else: + run_once() + + +if __name__ == "__main__": + main() \ No newline at end of file