feat(gateway): add provider-backed llm rpc

This commit is contained in:
Thoth (goclaw) committed 2026-06-27 23:15:02 +00:00
1 parent d460c90403
commit 1c53447b99
6 files changed
+265 -9

No files matched your search

+7 -7
View File
@@ -449,12 +449,12 @@ func runGateway() {
oauthRefresher = r
}
mcpOAuthH = httpapi.NewMCPOAuthHandler(httpapi.MCPOAuthHandlerDeps{
MCPStore: pgStores.MCP,
OAuthStore: pgStores.MCPOAuthTokens,
Discoverer: mcpoauth.NewDiscoverer(safeHTTPClient),
FlowMgr: mcpoauth.NewFlowManager(safeHTTPClient),
Refresher: oauthRefresher,
EventBus: msgBus,
MCPStore: pgStores.MCP,
OAuthStore: pgStores.MCPOAuthTokens,
Discoverer: mcpoauth.NewDiscoverer(safeHTTPClient),
FlowMgr: mcpoauth.NewFlowManager(safeHTTPClient),
Refresher: oauthRefresher,
EventBus: msgBus,
PublicURL: cfg.Gateway.PublicURL,
Port: cfg.Gateway.Port,
TenantStore: pgStores.Tenants,
@@ -511,7 +511,7 @@ func runGateway() {
// Register all RPC methods
server.SetLogTee(logTee)
server.SetRuntimeLogsHandler(httpapi.NewRuntimeLogsHandler(logTee))
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc)
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc, providerRegistry)
// Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history).
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
+4 -2
View File
@@ -10,12 +10,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
router := server.Router()
// Phase 1: Core methods
@@ -65,6 +66,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
// Phase 2: Usage (queries SessionStore for real token data)
methods.NewUsageMethods(sessStore).Register(router)
methods.NewLLMMethods(providerReg, cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel).Register(router)
// Phase 2: Exec approval (always registered — returns empty when manager is nil)
methods.NewExecApprovalMethods(execApprovalMgr, msgBus).Register(router)
@@ -77,7 +79,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
slog.Info("registered all RPC methods",
"phase1", []string{"chat", "agents", "sessions", "config"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "llm", "exec_approval", "send"},
)
return pairingMethods, heartbeatMethods, chatMethods, cfgPerms
+162
View File
@@ -0,0 +1,162 @@
package methods
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// LLMMethods exposes small provider-backed completion helpers for trusted
// operational scripts. It bypasses the agent loop so scripts can use the
// gateway's configured provider registry without writing provider-specific API
// code or storing provider keys in cron payloads.
type LLMMethods struct {
providers *providers.Registry
cfg llmDefaults
}
type llmDefaults struct {
Provider string
Model string
}
func NewLLMMethods(providerReg *providers.Registry, defaultProvider, defaultModel string) *LLMMethods {
return &LLMMethods{
providers: providerReg,
cfg: llmDefaults{
Provider: defaultProvider,
Model: defaultModel,
},
}
}
func (m *LLMMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodLLMComplete, m.handleComplete)
}
type llmCompleteParams struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Messages []providers.Message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
}
func (m *LLMMethods) handleComplete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !permissions.HasMinRole(client.Role(), permissions.RoleOperator) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, protocol.MethodLLMComplete)))
return
}
if m.providers == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "no providers configured"))
return
}
var params llmCompleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if len(params.Messages) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgsRequired)))
return
}
for i, msg := range params.Messages {
role := strings.TrimSpace(msg.Role)
if role == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, fmt.Sprintf("messages[%d].role is required", i)))
return
}
if strings.TrimSpace(msg.Content) == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, fmt.Sprintf("messages[%d].content is required", i)))
return
}
}
providerName := strings.TrimSpace(params.Provider)
if providerName == "" {
providerName = strings.TrimSpace(m.cfg.Provider)
}
prov, model, err := m.resolveProvider(ctx, providerName, strings.TrimSpace(params.Model))
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
}
options := map[string]any{}
if params.MaxTokens > 0 {
options[providers.OptMaxTokens] = params.MaxTokens
}
if params.Temperature != nil {
options[providers.OptTemperature] = *params.Temperature
}
resp, err := prov.Chat(ctx, providers.ChatRequest{
Messages: params.Messages,
Model: model,
Options: options,
})
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
result := map[string]any{
"provider": prov.Name(),
"model": model,
"content": resp.Content,
}
if resp.Usage != nil {
result["usage"] = resp.Usage
}
client.SendResponse(protocol.NewOKResponse(req.ID, result))
}
func (m *LLMMethods) resolveProvider(ctx context.Context, providerName, model string) (providers.Provider, string, error) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = providers.MasterTenantID
}
try := func(name string) (providers.Provider, string, bool) {
if name == "" {
return nil, "", false
}
p, err := m.providers.GetForTenant(tenantID, name)
if err != nil || p == nil {
return nil, "", false
}
selectedModel := model
if selectedModel == "" {
selectedModel = strings.TrimSpace(m.cfg.Model)
}
if selectedModel == "" {
selectedModel = p.DefaultModel()
}
return p, selectedModel, true
}
if p, selectedModel, ok := try(providerName); ok {
return p, selectedModel, nil
}
if providerName != "" {
return nil, "", fmt.Errorf("provider not found: %s", providerName)
}
for _, name := range m.providers.ListForTenant(tenantID) {
if p, selectedModel, ok := try(name); ok {
return p, selectedModel, nil
}
}
return nil, "", fmt.Errorf("no providers configured")
}
+88
View File
@@ -0,0 +1,88 @@
package methods
import (
"context"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
type stubLLMProvider struct {
name string
model string
req providers.ChatRequest
}
func (p *stubLLMProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.req = req
return &providers.ChatResponse{Content: `{"ok":true}`, Usage: &providers.Usage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3}}, nil
}
func (p *stubLLMProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return nil, nil
}
func (p *stubLLMProvider) DefaultModel() string { return p.model }
func (p *stubLLMProvider) Name() string { return p.name }
func TestLLMCompleteUsesProviderRegistry(t *testing.T) {
tid := uuid.New()
prov := &stubLLMProvider{name: "local", model: "default-model"}
reg := providers.NewRegistry(store.TenantIDFromContext)
reg.RegisterForTenant(tid, prov)
m := NewLLMMethods(reg, "local", "")
client, out := gateway.NewCapturingTestClient(permissions.RoleOperator, tid, "user-1", 1)
params := map[string]any{
"messages": []map[string]string{
{"role": "system", "content": "summarize"},
{"role": "user", "content": "hello"},
},
"maxTokens": 123,
}
raw, _ := json.Marshal(params)
ctx := store.WithTenantID(t.Context(), tid)
m.handleComplete(ctx, client, &protocol.RequestFrame{ID: "r1", Params: raw})
var frame protocol.ResponseFrame
if err := json.Unmarshal(<-out, &frame); err != nil {
t.Fatalf("decode response: %v", err)
}
if frame.Error != nil {
t.Fatalf("unexpected error: %+v", frame.Error)
}
result, ok := frame.Payload.(map[string]any)
if !ok {
t.Fatalf("payload type = %T", frame.Payload)
}
if result["content"] != `{"ok":true}` {
t.Fatalf("content = %v", result["content"])
}
if prov.req.Model != "default-model" {
t.Fatalf("model = %q", prov.req.Model)
}
if got := prov.req.Options[providers.OptMaxTokens]; got != float64(123) && got != 123 {
t.Fatalf("max tokens option = %#v", got)
}
}
func TestLLMCompleteRequiresOperator(t *testing.T) {
reg := providers.NewRegistry(store.TenantIDFromContext)
m := NewLLMMethods(reg, "", "")
client, out := gateway.NewCapturingTestClient(permissions.RoleViewer, uuid.New(), "user-1", 1)
raw := json.RawMessage(`{"messages":[{"role":"user","content":"hi"}]}`)
m.handleComplete(t.Context(), client, &protocol.RequestFrame{ID: "r1", Params: raw})
var frame protocol.ResponseFrame
if err := json.Unmarshal(<-out, &frame); err != nil {
t.Fatalf("decode response: %v", err)
}
if frame.Error == nil {
t.Fatal("expected authorization error")
}
}
+2
View File
@@ -85,6 +85,8 @@ const (
MethodQuotaUsage = "quota.usage"
MethodLLMComplete = "llm.complete"
MethodSend = "send"
)
+2
View File
@@ -122,6 +122,8 @@ export const Methods = {
QUOTA_USAGE: "quota.usage",
LLM_COMPLETE: "llm.complete",
SEND: "send",
// Agent links (delegation)