feat: adapt FreeRide skill for PicoClaw
This commit is contained in:
parent
6ac2a46680
commit
cd41d44085
5 changed files with 340 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -153,6 +155,7 @@ func NewAgentLoop(
|
|||
al := &AgentLoop{
|
||||
bus: msgBus,
|
||||
cfg: cfg,
|
||||
configPath: configPath,
|
||||
registry: registry,
|
||||
state: stateManager,
|
||||
eventBus: eventBus,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
308
pkg/tools/freeride.go
Normal file
308
pkg/tools/freeride.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue