Files
sm-golem-plugin/classes/services/ai_service.go
Jakub Zych cd3f0f6c1c feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send
- Prompt, Payload and AIResponse value objects ported from Golem15.Golem
- OpenAIAdapter: chat completions mapping with PHP's response_format,
  max_completion_tokens and reasoning_effort rules
- AIService.Send through fetchguard (TrustedMode for operator models,
  PublicOnlyMode otherwise) with PHP's failure messages
- config defaults for the SSRF allowlist and the AI timeout, lang, README
2026-10-03 22:41:49 +02:00

199 lines
6.2 KiB
Go

package services
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-golem-plugin/classes/providers"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"gorm.io/gorm"
)
// ConfigTimeoutSeconds is the config key of the AI request timeout.
const ConfigTimeoutSeconds = "golem15.golem.ai.timeout_seconds"
// DefaultTimeout is the AI request timeout when the config sets none
// (PHP's streaming curl timeout).
const DefaultTimeout = 120 * time.Second
// PHP's failure messages.
const (
msgNoModel = "No AI model configured. Please add a model in Settings > AI."
msgNoAPIKey = "API key is not configured for the selected model."
msgConnectFailed = "Failed to connect to AI service."
msgInvalidPrefix = "Invalid response from AI service: "
jsonSyntaxError = "Syntax error"
jsonMalformedUTF8 = "Malformed UTF-8 characters, possibly incorrectly encoded"
)
// AIService sends prompts to AI providers (PHP AIService). A call with a
// nil config uses the admin's default model. Every request goes through a
// fetchguard.Client: TrustedMode for a Trusted config (an admin-configured
// model), PublicOnlyMode for every other config. A Client is safe for
// concurrent use.
type AIService struct {
db func() *gorm.DB
cfg *compass.Config
timeout time.Duration
mu sync.Mutex
clients map[bool]*fetchguard.Client
}
// NewAIService builds the service on db, which returns the database
// holding the admin's models (nil means none are configured), and cfg
// (the request timeout; nil means DefaultTimeout). db is called on every
// lookup, so a service built before the database is published still finds
// it.
func NewAIService(db func() *gorm.DB, cfg *compass.Config) *AIService {
timeout := DefaultTimeout
if cfg != nil {
if n := cfg.Int(ConfigTimeoutSeconds); n > 0 {
timeout = time.Duration(n) * time.Second
}
}
if db == nil {
db = func() *gorm.DB { return nil }
}
return &AIService{db: db, cfg: cfg, timeout: timeout, clients: map[bool]*fetchguard.Client{}}
}
// ForApp returns the application's AI service, publishing one on first
// use. Its database is the application's *gorm.DB, looked up per call.
func ForApp(app *backpack.App) *AIService {
if app == nil {
return NewAIService(nil, nil)
}
appMu.Lock()
defer appMu.Unlock()
if s, ok := app.Lookup[*AIService](); ok && s != nil {
return s
}
s := NewAIService(func() *gorm.DB {
db, _ := app.Lookup[*gorm.DB]()
return db
}, app.Config)
_ = app.Publish(s)
return s
}
var appMu sync.Mutex
// client returns the guarded (trusted false) or the trusted client.
func (s *AIService) client(trusted bool) (*fetchguard.Client, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.clients[trusted]; ok {
return c, nil
}
mode := fetchguard.PublicOnlyMode
if trusted {
mode = fetchguard.TrustedMode
}
c, err := fetchguard.NewClient(fetchguard.Policy{Mode: mode, Timeout: s.timeout}, s.cfg)
if err != nil {
return nil, err
}
s.clients[trusted] = c
return c, nil
}
// requestHeaders are the headers PHP's curl request carries: Content-Type
// first, the adapter's headers, curl's default Accept and no User-Agent
// (an empty value keeps net/http from sending its own).
func requestHeaders(contentType string, adapter http.Header) http.Header {
h := http.Header{}
if contentType != "" {
h.Set("Content-Type", contentType)
}
h.Set("Accept", "*/*")
h["User-Agent"] = []string{""}
for k, vs := range adapter {
h[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
}
return h
}
// Send is AIService::send: the prompt, with the model's system prompt when
// it has none, is posted to the config's chat endpoint and the answer
// parsed by the adapter, whatever its HTTP status. A nil config is the
// default model. The failures carry PHP's messages: no model, no API key,
// no connection, and an answer that is not JSON (Raw then holds
// {"raw": body}).
func (s *AIService) Send(ctx context.Context, prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.AIResponse {
if cfg == nil {
def, err := s.DefaultModel(ctx)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
cfg = def
}
if cfg == nil {
return valueobjects.Failure(msgNoModel, nil)
}
if cfg.APIKey == "" {
return valueobjects.Failure(msgNoAPIKey, nil)
}
prompt = applyModelSystemPrompt(prompt, cfg)
adapter := providers.ForName(cfg.Adapter)
payload := adapter.BuildChatPayload(prompt, cfg.modelID(DefaultModelID))
c, err := s.client(cfg.Trusted)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
res, err := c.PostJSON(ctx, adapter.ChatEndpoint(cfg.BaseURL), requestHeaders("", adapter.Headers(cfg.APIKey)), payload)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
data, msg := decodeObject(res.Body)
if msg != "" {
return valueobjects.Failure(msgInvalidPrefix+msg, map[string]any{"raw": string(res.Body)})
}
return adapter.ParseChatResponse(data)
}
// DefaultModel is the admin's default model, nil when none is configured.
func (s *AIService) DefaultModel(ctx context.Context) (*ModelConfig, error) {
return nil, nil
}
// applyModelSystemPrompt sets the model's system prompt on a prompt that
// has none.
func applyModelSystemPrompt(prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.Prompt {
if prompt.SystemPrompt() == "" && cfg.SystemPrompt != "" {
return prompt.WithSystem(cfg.SystemPrompt)
}
return prompt
}
// decodeObject is json_decode($body, true) for a chat answer. It returns
// PHP's json_last_error_msg() text when body is not JSON. A JSON array
// decodes to an empty object (PHP finds no content in it); a JSON scalar
// too.
func decodeObject(body []byte) (map[string]any, string) {
if !utf8.Valid(body) {
return nil, jsonMalformedUTF8
}
var v any
if err := json.Unmarshal(body, &v); err != nil {
return nil, jsonSyntaxError
}
if m, ok := v.(map[string]any); ok {
return m, ""
}
return map[string]any{}, ""
}
func failure(msg string, raw map[string]any, err error) valueobjects.AIResponse {
r := valueobjects.Failure(msg, raw)
r.Err = err
return r
}