mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(gateway): add provider-backed llm rpc
This commit is contained in:
1 parent
d460c90403
commit
1c53447b99
6 files changed
+265
-9
No files matched your search
+7
-7
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,8 @@ const (
|
||||
|
||||
MethodQuotaUsage = "quota.usage"
|
||||
|
||||
MethodLLMComplete = "llm.complete"
|
||||
|
||||
MethodSend = "send"
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ export const Methods = {
|
||||
|
||||
QUOTA_USAGE: "quota.usage",
|
||||
|
||||
LLM_COMPLETE: "llm.complete",
|
||||
|
||||
SEND: "send",
|
||||
|
||||
// Agent links (delegation)
|
||||
|
||||
Reference in new issue
Block a user