435 lines
12 KiB
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
|
|
}
|