Initial commit with translated description
This commit is contained in:
383
watcher.py
Normal file
383
watcher.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user