picoclaw/pkg/tools/freeride.go

435 lines
12 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// FreeRideTool adapts the FreeRide logic (from clawhub/free-ride) for PicoClaw.
// It manages OpenRouter's free models and configures them as fallbacks.
type FreeRideTool struct {
configPath string
cooldownPath string
reloadFunc func() error
}
func NewFreeRideTool(configPath, cooldownPath string, reloadFunc func() error) *FreeRideTool {
return &FreeRideTool{
configPath: configPath,
cooldownPath: cooldownPath,
reloadFunc: reloadFunc,
}
}
func (t *FreeRideTool) Name() string {
return "freeride"
}
func (t *FreeRideTool) Description() string {
return "FreeRide gives you unlimited free AI in PicoClaw by automatically managing OpenRouter's free models. " +
"Use 'auto' to configure best model + fallbacks, or 'list' to see available free models."
}
func (t *FreeRideTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"enum": []string{"auto", "list", "status", "settimeout", "clear"},
"description": "The command to run: 'auto' (configures models), 'list' (shows free models), 'status' (checks current setup), 'settimeout' (sets request timeout), 'clear' (resets model cooldowns)",
},
"limit": map[string]any{
"type": "integer",
"description": "For 'list', how many models to show. For 'auto', how many fallbacks to configure.",
"default": 5,
},
"timeout": map[string]any{
"type": "integer",
"description": "For 'settimeout', the request timeout in seconds (default 300)",
"default": 300,
},
},
"required": []string{"command"},
}
}
type openRouterModel struct {
ID string `json:"id"`
Name string `json:"name"`
ContextLength int `json:"context_length"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
} `json:"pricing"`
SupportedParameters []string `json:"supported_parameters"`
Created int64 `json:"created"`
}
func (t *FreeRideTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
cmd, _ := args["command"].(string)
limit := 5
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
timeout := 300
switch v := args["timeout"].(type) {
case float64:
timeout = int(v)
case int:
timeout = v
}
switch cmd {
case "list":
return t.handleList(ctx, limit)
case "auto":
return t.handleAuto(ctx, limit)
case "status":
return t.handleStatus()
case "settimeout":
return t.handleSetTimeout(timeout)
case "clear":
return t.handleClear()
default:
return ErrorResult(fmt.Sprintf("unknown command: %s", cmd))
}
}
func (t *FreeRideTool) handleClear() *ToolResult {
if t.cooldownPath == "" {
return ErrorResult("cooldown path not configured")
}
// Double-check: does the file exist?
if _, err := os.Stat(t.cooldownPath); os.IsNotExist(err) {
return UserResult("Cooldowns are already empty (no file found).")
}
if err := os.Remove(t.cooldownPath); err != nil {
return ErrorResult(fmt.Sprintf("failed to delete cooldown file: %v", err))
}
msg := "Success! Cooldown state cleared from disk.\n"
msg += "Re-loading configuration to reset in-memory state..."
if t.reloadFunc != nil {
if err := t.reloadFunc(); err != nil {
return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err))
}
}
return UserResult(msg)
}
func (t *FreeRideTool) fetchFreeModels(ctx context.Context) ([]openRouterModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
}
var wrapper struct {
Data []openRouterModel `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
return nil, err
}
var freeModels []openRouterModel
for _, m := range wrapper.Data {
// Only consider free models
if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" {
// CRITICAL: PeakClaw requires tool support for its steering logic.
// Filter out models that don't explicitly support function calling.
hasTools := false
for _, p := range m.SupportedParameters {
if p == "tools" {
hasTools = true
break
}
}
if !hasTools {
continue
}
// Blacklist known tool-blind models with inaccurate metadata
lowerID := strings.ToLower(m.ID)
if strings.Contains(lowerID, "lyria") || strings.Contains(lowerID, "liquid") {
continue
}
freeModels = append(freeModels, m)
}
}
// Rank models
sort.Slice(freeModels, func(i, j int) bool {
return scoreModel(freeModels[i]) > scoreModel(freeModels[j])
})
return freeModels, nil
}
func scoreModel(m openRouterModel) float64 {
score := 0.0
// Context length (40%) - normalize against 128k
ctxScore := float64(m.ContextLength) / 128000.0
if ctxScore > 1.0 {
ctxScore = 1.0
}
score += ctxScore * 0.4
// Capabilities (30%) - tools, vision, prompt caching, etc.
capabilityScore := 0.0
for _, p := range m.SupportedParameters {
if p == "tools" {
capabilityScore += 0.5
}
if p == "response_format" {
capabilityScore += 0.5
}
}
if capabilityScore > 1.0 {
capabilityScore = 1.0
}
score += capabilityScore * 0.3
// Recency (20%) - newer is better
// Normalize against 2 years ago
twoYearsAgo := time.Now().AddDate(-2, 0, 0).Unix()
now := time.Now().Unix()
if m.Created > twoYearsAgo {
recencyScore := float64(m.Created-twoYearsAgo) / float64(now-twoYearsAgo)
score += recencyScore * 0.2
}
// Provider Trust (10%) - hardcoded list of trusted names
trustNames := []string{
"google",
"meta",
"nvidia",
"mistral",
"anthropic",
"openai",
"microsoft",
"qwen",
"deepseek",
}
for _, name := range trustNames {
if strings.Contains(strings.ToLower(m.ID), name) {
score += 0.1
break
}
}
return score
}
func (t *FreeRideTool) handleList(ctx context.Context, limit int) *ToolResult {
models, err := t.fetchFreeModels(ctx)
if err != nil {
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
}
if len(models) == 0 {
return SilentResult("No free models found on OpenRouter.")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d free models on OpenRouter (ranked by quality):\n\n", len(models)))
for i, m := range models {
if i >= limit {
break
}
sb.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, m.Name, m.ID))
sb.WriteString(fmt.Sprintf(" Context: %d tokens | Score: %.2f\n", m.ContextLength, scoreModel(m)))
sb.WriteString(fmt.Sprintf(" Parameters: %s\n\n", strings.Join(m.SupportedParameters, ", ")))
}
return UserResult(sb.String())
}
func (t *FreeRideTool) handleAuto(ctx context.Context, limit int) *ToolResult {
models, err := t.fetchFreeModels(ctx)
if err != nil {
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
}
if len(models) == 0 {
return ErrorResult("No free models found on OpenRouter.")
}
cfgObj, err := config.LoadConfig(t.configPath)
if err != nil {
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
}
// 1. Add models to ModelList if not present
// 2. Collect all valid free models for fallbacks (new AND existing)
var fallbackModels []string
for i, m := range models {
if i >= limit {
break
}
modelName := strings.ReplaceAll(m.ID, "/", "-")
if !modelExists(cfgObj, modelName) {
mc := &config.ModelConfig{
ModelName: modelName,
Model: m.ID,
Protocol: "openrouter",
Enabled: true,
}
mc.SetAPIKey("env://OPENROUTER_API_KEY")
cfgObj.ModelList = append(cfgObj.ModelList, mc)
}
fallbackModels = append(fallbackModels, modelName)
}
// 2. Set fallbacks for the default agent
if len(fallbackModels) > 0 {
// Update AgentDefaults fallbacks
cfgObj.Agents.Defaults.ModelFallbacks = append(cfgObj.Agents.Defaults.ModelFallbacks, fallbackModels...)
// Deduplicate fallbacks
cfgObj.Agents.Defaults.ModelFallbacks = uniqueStrings(cfgObj.Agents.Defaults.ModelFallbacks)
if err := config.SaveConfig(t.configPath, cfgObj); err != nil {
return ErrorResult(fmt.Errorf("failed to save config: %w", err).Error())
}
msg := fmt.Sprintf(
"Success! Added %d free models as fallbacks: %s.\n",
len(fallbackModels),
strings.Join(fallbackModels, ", "),
)
msg += "Re-loading configuration to apply changes..."
if t.reloadFunc != nil {
if err := t.reloadFunc(); err != nil {
return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err))
}
}
return UserResult(msg)
}
return UserResult(
"No new free models to add. Your configuration is up to date.",
)
}
func (t *FreeRideTool) handleStatus() *ToolResult {
cfgObj, err := config.LoadConfig(t.configPath)
if err != nil {
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
}
var sb strings.Builder
sb.WriteString("FreeRide Status:\n")
sb.WriteString(fmt.Sprintf("- Primary Model: %s\n", cfgObj.Agents.Defaults.GetModelName()))
sb.WriteString(fmt.Sprintf("- Fallback Models: %s\n", strings.Join(cfgObj.Agents.Defaults.ModelFallbacks, ", ")))
// Check for OpenRouter models in fallbacks
openRouterCount := 0
for _, fb := range cfgObj.Agents.Defaults.ModelFallbacks {
if strings.Contains(strings.ToLower(fb), "openrouter") || isKnownOpenRouterAlias(cfgObj, fb) {
openRouterCount++
}
}
sb.WriteString(fmt.Sprintf("- Managed Free Models: %d\n", openRouterCount))
return UserResult(sb.String())
}
func (t *FreeRideTool) handleSetTimeout(timeoutSeconds int) *ToolResult {
if timeoutSeconds < 30 {
return ErrorResult("timeout must be at least 30 seconds")
}
cfgObj, err := config.LoadConfig(t.configPath)
if err != nil {
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
}
updated := 0
for _, mc := range cfgObj.ModelList {
// Only update OpenRouter models (free models)
protocol := strings.ToLower(mc.Protocol)
if protocol == "openrouter" {
mc.RequestTimeout = timeoutSeconds
updated++
}
}
if updated == 0 {
return ErrorResult("no OpenRouter models found in config. Run 'freeride auto' first.")
}
if err := config.SaveConfig(t.configPath, cfgObj); err != nil {
return ErrorResult(fmt.Errorf("failed to save config: %w", err).Error())
}
msg := fmt.Sprintf("Set request timeout to %d seconds for %d OpenRouter models.\n", timeoutSeconds, updated)
msg += "Re-loading configuration to apply changes..."
if t.reloadFunc != nil {
if err := t.reloadFunc(); err != nil {
return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err))
}
}
return UserResult(msg)
}
func modelExists(cfg *config.Config, modelName string) bool {
for _, m := range cfg.ModelList {
if m.ModelName == modelName {
return true
}
}
return false
}
func isKnownOpenRouterAlias(cfg *config.Config, modelName string) bool {
for _, m := range cfg.ModelList {
if m.ModelName == modelName {
if strings.HasPrefix(m.Model, "openrouter/") {
return true
}
if strings.ToLower(m.Protocol) == "openrouter" {
return true
}
}
}
return false
}
func uniqueStrings(input []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range input {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}