From d2366b478539b621c1a2a080a6412c4ec426d7f5 Mon Sep 17 00:00:00 2001 From: Bernardo Date: Tue, 10 Mar 2026 14:54:12 +0100 Subject: [PATCH] feat: implement OpenRouter Free default and paid model authorization check --- pkg/agent/loop.go | 58 ++++++++++++++++++++++++++++++++++++++++-- pkg/config/defaults.go | 9 ++++++- pkg/session/manager.go | 36 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 58f53bef8..d8e034437 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -664,8 +664,29 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SendResponse: false, } - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. + // Check for pending authorization response + if isYesResponse(msg.Content) { + if pending, _ := agent.Sessions.GetMetadata(opts.SessionKey, "pending_paid_auth"); pending == true { + agent.Sessions.SetMetadata(opts.SessionKey, "paid_model_authorized", true) + agent.Sessions.SetMetadata(opts.SessionKey, "pending_paid_auth", false) + + // Retrieve the stashed message to resume + if stashed, ok := agent.Sessions.GetMetadata(opts.SessionKey, "stashed_user_message"); ok { + opts.UserMessage = stashed.(string) + // Clear stash + agent.Sessions.SetMetadata(opts.SessionKey, "stashed_user_message", nil) + + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: "✅ Authorized! Processing your request with the advanced model now...", + }) + } else { + return "✅ Authorized! DeepSeek and other paid models are now enabled for this session. What would you like me to do?", nil + } + } + } + if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { return response, nil } @@ -926,6 +947,20 @@ func (al *AgentLoop) runLLMIteration( // tool chain doesn't switch models mid-way through. activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages) + // Check for paid model authorization + if isPaidModel(activeModel) { + authorized, _ := agent.Sessions.GetMetadata(opts.SessionKey, "paid_model_authorized") + if authorized != true { + // Stash the message and ask for permission + agent.Sessions.SetMetadata(opts.SessionKey, "pending_paid_auth", true) + agent.Sessions.SetMetadata(opts.SessionKey, "stashed_user_message", opts.UserMessage) + agent.Sessions.Save(opts.SessionKey) + + authMsg := fmt.Sprintf("🛡️ **Authorization Required**\n\nI've detected this task requires an advanced model (%s) which may incur costs. \n\nReply with **'yes'** or **'y'** to authorize this session, or I can continue with the free model if you prefer.", activeModel) + return authMsg, 0, nil + } + } + for iteration < agent.MaxIterations { iteration++ @@ -1865,3 +1900,22 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } + +func isPaidModel(model string) bool { + m := strings.ToLower(model) + // Explicitly free models + if strings.Contains(m, "/free") || strings.HasPrefix(m, "ollama/") || strings.HasPrefix(m, "vllm/") { + return false + } + // Models on OpenRouter that are free or we want to treat as free + if strings.HasPrefix(m, "openrouter/free") { + return false + } + // Everything else (DeepSeek, Claude, GPT-4, etc.) is considered paid + return true +} + +func isYesResponse(content string) bool { + c := strings.ToLower(strings.TrimSpace(content)) + return c == "yes" || c == "y" || c == "ok" || c == "confirm" || c == "proceed" +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 6d578a47a..9a87ece56 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -29,7 +29,8 @@ func DefaultConfig() *Config { Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", - Model: "", + ModelName: "openrouter-free", + Model: "openrouter/free", MaxTokens: 32768, Temperature: nil, // nil means use provider default MaxToolIterations: 10, @@ -248,6 +249,12 @@ func DefaultConfig() *Config { }, // OpenRouter (100+ models) - https://openrouter.ai/keys + { + ModelName: "openrouter-free", + Model: "openrouter/free", + APIBase: "https://openrouter.ai/api/v1", + APIKey: "", + }, { ModelName: "openrouter-auto", Model: "openrouter/auto", diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad2..baf0cb9e7 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -15,6 +15,7 @@ type Session struct { Key string `json:"key"` Messages []providers.Message `json:"messages"` Summary string `json:"summary,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } @@ -145,6 +146,41 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { session.Updated = time.Now() } +func (sm *SessionManager) SetMetadata(key string, metaKey string, value any) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, ok := sm.sessions[key] + if !ok { + session = &Session{ + Key: key, + Messages: []providers.Message{}, + Metadata: make(map[string]any), + Created: time.Now(), + } + sm.sessions[key] = session + } + + if session.Metadata == nil { + session.Metadata = make(map[string]any) + } + session.Metadata[metaKey] = value + session.Updated = time.Now() +} + +func (sm *SessionManager) GetMetadata(key string, metaKey string) (any, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + session, ok := sm.sessions[key] + if !ok || session.Metadata == nil { + return nil, false + } + + val, ok := session.Metadata[metaKey] + return val, ok +} + // sanitizeFilename converts a session key into a cross-platform safe filename. // Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the // volume separator on Windows, so filepath.Base would misinterpret the key.