From 1c53447b990a24c604cc96da7f28963a9d024088 Mon Sep 17 00:00:00 2001 From: "Thoth (goclaw)" Date: Sat, 27 Jun 2026 23:15:02 +0000 Subject: [PATCH] feat(gateway): add provider-backed llm rpc --- cmd/gateway.go | 14 +-- cmd/gateway_methods.go | 6 +- internal/gateway/methods/llm.go | 162 +++++++++++++++++++++++++++ internal/gateway/methods/llm_test.go | 88 +++++++++++++++ pkg/protocol/methods.go | 2 + ui/web/src/api/protocol.ts | 2 + 6 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 internal/gateway/methods/llm.go create mode 100644 internal/gateway/methods/llm_test.go diff --git a/cmd/gateway.go b/cmd/gateway.go index 2b80a17a..453ab1dc 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 { diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index f63282ef..28c5c976 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -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 diff --git a/internal/gateway/methods/llm.go b/internal/gateway/methods/llm.go new file mode 100644 index 00000000..521875d0 --- /dev/null +++ b/internal/gateway/methods/llm.go @@ -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, ¶ms); 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") +} diff --git a/internal/gateway/methods/llm_test.go b/internal/gateway/methods/llm_test.go new file mode 100644 index 00000000..61834e1b --- /dev/null +++ b/internal/gateway/methods/llm_test.go @@ -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") + } +} diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index a8d0e150..63ad5b0f 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -85,6 +85,8 @@ const ( MethodQuotaUsage = "quota.usage" + MethodLLMComplete = "llm.complete" + MethodSend = "send" ) diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 0b3b1caa..fb32db45 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -122,6 +122,8 @@ export const Methods = { QUOTA_USAGE: "quota.usage", + LLM_COMPLETE: "llm.complete", + SEND: "send", // Agent links (delegation)