diff --git a/k3s/deployment.yaml b/k3s/deployment.yaml index db35a0458..4fed370a1 100644 --- a/k3s/deployment.yaml +++ b/k3s/deployment.yaml @@ -57,6 +57,11 @@ spec: secretKeyRef: name: picoclaw-secrets key: telegram-token + - name: OPENROUTER_API_KEY + valueFrom: + secretKeyRef: + name: picoclaw-secrets + key: OPENROUTER_API_KEY volumeMounts: - name: picoclaw-data mountPath: /home/picoclaw/.picoclaw diff --git a/k3s/secrets.yaml b/k3s/secrets.yaml index 217cc0d95..f6ad1754c 100644 --- a/k3s/secrets.yaml +++ b/k3s/secrets.yaml @@ -9,3 +9,4 @@ stringData: telegram-token: "YOUR_TELEGRAM_TOKEN_HERE" nvidia-api-key: "YOUR_NVIDIA_API_KEY_HERE" azure-api-key: "YOUR_AZURE_API_KEY_HERE" + OPENROUTER_API_KEY: "YOUR_OPENROUTER_API_KEY_HERE" diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7106a6024..bc65ca08d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -78,6 +78,7 @@ type AgentLoop struct { activeRequests sync.WaitGroup reloadFunc func() error + configPath string } // processOptions configures how a message is processed @@ -124,6 +125,7 @@ const ( func NewAgentLoop( cfg *config.Config, + configPath string, msgBus *bus.MessageBus, provider providers.LLMProvider, ) *AgentLoop { @@ -151,14 +153,15 @@ func NewAgentLoop( eventBus := NewEventBus() al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - eventBus: eventBus, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), + bus: msgBus, + cfg: cfg, + configPath: configPath, + registry: registry, + state: stateManager, + eventBus: eventBus, + fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), } al.agentCacheTTL = 24 * time.Hour @@ -336,6 +339,10 @@ func registerSharedTools( // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") + if skills_enabled { + agent.Tools.Register(tools.NewFreeRideTool(al.GetConfigPath(), al.GetReloadFunc())) + } + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") if skills_enabled && (find_skills_enable || install_skills_enable) { @@ -1209,6 +1216,16 @@ func (al *AgentLoop) SetReloadFunc(fn func() error) { al.reloadFunc = fn } +// GetReloadFunc returns the current reload callback. +func (al *AgentLoop) GetReloadFunc() func() error { + return al.reloadFunc +} + +// GetConfigPath returns the path to the configuration file. +func (al *AgentLoop) GetConfigPath() string { + return al.configPath +} + var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index ef1532806..9bc65e77b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -182,7 +182,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider) fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() diff --git a/pkg/tools/freeride.go b/pkg/tools/freeride.go new file mode 100644 index 000000000..38d19f5a9 --- /dev/null +++ b/pkg/tools/freeride.go @@ -0,0 +1,308 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// 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 + reloadFunc func() error +} + +func NewFreeRideTool(configPath string, reloadFunc func() error) *FreeRideTool { + return &FreeRideTool{ + configPath: configPath, + 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"}, + "description": "The command to run: 'auto' (configures models), 'list' (shows free models), 'status' (checks current setup)", + }, + "limit": map[string]any{ + "type": "integer", + "description": "For 'list', how many models to show. For 'auto', how many fallbacks to configure.", + "default": 5, + }, + }, + "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) + } + + switch cmd { + case "list": + return t.handleList(ctx, limit) + case "auto": + return t.handleAuto(ctx, limit) + case "status": + return t.handleStatus() + default: + return ErrorResult(fmt.Sprintf("unknown command: %s", cmd)) + } +} + +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 { + if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" { + 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 SilentResult(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 + var addedModels []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: "openrouter/" + m.ID, + Enabled: true, + } + mc.SetAPIKey("env://OPENROUTER_API_KEY") + cfgObj.ModelList = append(cfgObj.ModelList, mc) + addedModels = append(addedModels, modelName) + } + } + + // 2. Set fallbacks for the default agent + if len(addedModels) > 0 { + // Update AgentDefaults fallbacks + cfgObj.Agents.Defaults.ModelFallbacks = append(cfgObj.Agents.Defaults.ModelFallbacks, addedModels...) + // 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(addedModels), strings.Join(addedModels, ", ")) + 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 SilentResult(msg) + } + + return SilentResult("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 SilentResult(sb.String()) +} + +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 && strings.HasPrefix(m.Model, "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 +}