Files
sm-golem-plugin/classes/services/ai_service.go
Jakub Zych c93e2d9142 feat: AI models admin, settings import, Anthropic adapter and the rest of AIService
- golem15_golem_models (encrypted write-only api_key) with its cabana list
  and form under golem15.golem.access_settings, and the Settings lookups
  (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File,
  ByName), every one Trusted
- golem:import-settings copies the WinterCMS golem_settings repeater once
- AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader,
  SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask
- PromptFactory; README documents the config keys, the environment
  variable, the command and the admin screen
2026-10-03 23:09:02 +02:00

530 lines
17 KiB
Go

package services
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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 DefaultModel(ctx, s.db())
}
// 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
}
// More of PHP's failure messages.
const (
msgInternal = "Internal server error"
msgNoFileModel = "No file model configured."
msgFilesConnect = "Failed to connect to files API at: "
msgFilesInvalid = "Invalid response from files API: "
msgNoFileID = "No file ID in response."
msgNoImageModel = "No image generation model configured."
msgNoImageModelLong = `No image generation model configured. Enable a model with "generates_images" in Settings > AI.`
msgNoImageEndpoint = "The configured adapter does not support image generation."
msgNoImageKey = "API key is not configured for the image model."
msgImageConnect = "Failed to connect to image generation service."
msgImageInvalid = "Invalid response: "
msgNoImageURL = "No image URL in response."
msgNoVisionModel = "No vision model configured."
msgUnknownAPIError = "Unknown API error"
defaultImageModelID = "dall-e-3"
defaultImageSize = "1792x1024"
defaultImageQuality = "standard"
defaultUploadPurpose = "assistants"
)
// safeMessage is SafeExceptionResponse::safeExceptionMessage: the error's
// own text with app.debug on, PHP's generic text otherwise.
func (s *AIService) safeMessage(err error) string {
if s.cfg != nil && s.cfg.Bool("app.debug") {
return err.Error()
}
return msgInternal
}
// SendStream is AIService::sendStream: the prompt is posted with stream
// true and Accept: text/event-stream, and onChunk receives, in order, one
// {"content":"<delta>"} JSON string per content delta the adapter reads
// from the server-sent events, until the provider ends the stream. A nil
// config is the default model. It fails with PHP's message when no model
// or no key is configured, with the transport error when the connection
// fails, and with "HTTP <status> from AI service" on a status of 400 or
// more.
func (s *AIService) SendStream(ctx context.Context, prompt valueobjects.Prompt, onChunk func(chunk string), cfg *ModelConfig) error {
if cfg == nil {
def, err := s.DefaultModel(ctx)
if err != nil {
return err
}
cfg = def
}
if cfg == nil {
return errors.New(msgNoModel)
}
if cfg.APIKey == "" {
return errors.New(msgNoAPIKey)
}
prompt = applyModelSystemPrompt(prompt, cfg)
adapter := providers.ForName(cfg.Adapter)
payload := adapter.BuildChatPayload(prompt, cfg.modelID(DefaultModelID))
payload.Set("stream", true)
raw, err := json.Marshal(payload)
if err != nil {
return err
}
c, err := s.client(cfg.Trusted)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, adapter.ChatEndpoint(cfg.BaseURL), bytes.NewReader(raw))
if err != nil {
return err
}
req.Header = requestHeaders("application/json", adapter.Headers(cfg.APIKey))
req.Header.Set("Accept", "text/event-stream")
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
r := bufio.NewReader(resp.Body)
for {
line, err := r.ReadString('\n')
if err != nil {
// A last line without a newline is never processed, as in PHP.
if !errors.Is(err, io.EOF) {
return err
}
break
}
delta, done := adapter.ParseStreamChunk(strings.TrimRight(strings.TrimSuffix(line, "\n"), "\r"))
if done {
break
}
if delta != "" && onChunk != nil {
chunk, _ := json.Marshal(map[string]string{"content": delta})
onChunk(string(chunk))
}
}
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP %d from AI service", resp.StatusCode)
}
return nil
}
// SendFile is AIService::sendFile: body uploaded as fileName with
// contentType for the assistants purpose (see SendFileReader).
func (s *AIService) SendFile(ctx context.Context, body io.Reader, fileName, contentType, modelName string) valueobjects.AIResponse {
return s.SendFileReader(ctx, body, defaultUploadPurpose, modelName, contentType, fileName)
}
// SendFilePath is AIService::sendFilePath for a file on disk: mimeType and
// fileName default to the sniffed type and the base name. A missing file
// is PHP's caught exception: "Internal server error" outside app.debug.
func (s *AIService) SendFilePath(ctx context.Context, path, purpose, modelName, mimeType, fileName string) valueobjects.AIResponse {
cfg, fail := s.fileModel(ctx, modelName)
if fail != nil {
return *fail
}
data, err := os.ReadFile(path)
if err != nil {
return failure(s.safeMessage(fmt.Errorf("File not found: %s", path)), nil, err)
}
if mimeType == "" {
mimeType = http.DetectContentType(data)
if mimeType == "" {
mimeType = "application/octet-stream"
}
}
if fileName == "" {
fileName = filepath.Base(path)
}
return s.upload(ctx, cfg, bytes.NewReader(data), purpose, mimeType, fileName)
}
// SendFileReader uploads body to the files endpoint of the model named
// modelName (which must have a files endpoint), or of the first model
// with one when modelName is empty, and answers the uploaded file's id as
// Content. The upload is multipart: the adapter's fields (OpenAI's
// purpose), then the file part "file".
func (s *AIService) SendFileReader(ctx context.Context, body io.Reader, purpose, modelName, mimeType, fileName string) valueobjects.AIResponse {
cfg, fail := s.fileModel(ctx, modelName)
if fail != nil {
return *fail
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
return s.upload(ctx, cfg, body, purpose, mimeType, fileName)
}
func (s *AIService) fileModel(ctx context.Context, modelName string) (*ModelConfig, *valueobjects.AIResponse) {
var cfg *ModelConfig
var err error
if modelName != "" {
cfg, err = ModelByName(ctx, s.db(), modelName)
if err == nil && (cfg == nil || !cfg.HasFilesEndpoint) {
r := valueobjects.Failure(fmt.Sprintf("Model '%s' does not support file uploads.", modelName), nil)
return nil, &r
}
} else {
cfg, err = FileModel(ctx, s.db())
}
if err != nil {
r := failure(s.safeMessage(err), nil, err)
return nil, &r
}
if cfg == nil {
r := valueobjects.Failure(msgNoFileModel, nil)
return nil, &r
}
return cfg, nil
}
func (s *AIService) upload(ctx context.Context, cfg *ModelConfig, body io.Reader, purpose, mimeType, fileName string) valueobjects.AIResponse {
adapter := providers.ForName(cfg.Adapter)
endpoint := adapter.FilesEndpoint(cfg.BaseURL)
c, err := s.client(cfg.Trusted)
if err != nil {
return failure(msgFilesConnect+endpoint, nil, err)
}
res, err := c.PostMultipart(ctx, endpoint, requestHeaders("", adapter.FileUploadHeaders(cfg.APIKey)),
adapter.FileUploadFields(purpose),
[]fetchguard.FormFile{{Field: "file", Filename: fileName, ContentType: mimeType, Body: body}})
if err != nil {
return failure(msgFilesConnect+endpoint, nil, err)
}
data, msg := decodeObject(res.Body)
if msg != "" {
return valueobjects.Failure(msgFilesInvalid+msg, map[string]any{"raw": string(res.Body)})
}
if e, ok := data["error"]; ok && e != nil {
text := msgUnknownAPIError
if m, ok := e.(map[string]any); ok {
if v, ok := m["message"].(string); ok {
text = v
}
}
return valueobjects.Failure(text, data)
}
id, _ := data["id"].(string)
if id == "" {
return valueobjects.Failure(msgNoFileID, data)
}
return valueobjects.Success(id, nil, data)
}
// SendWithModel is AIService::sendWithModel: Send with the enabled model
// named name, or PHP's not-found failure.
func (s *AIService) SendWithModel(ctx context.Context, prompt valueobjects.Prompt, name string) valueobjects.AIResponse {
cfg, err := ModelByName(ctx, s.db(), name)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
if cfg == nil {
return valueobjects.Failure(fmt.Sprintf("Model '%s' not found in settings.", name), nil)
}
return s.Send(ctx, prompt, cfg)
}
// SendToImageModel is AIService::sendToImageModel: Send with the first
// model that generates images.
func (s *AIService) SendToImageModel(ctx context.Context, prompt valueobjects.Prompt) valueobjects.AIResponse {
cfg, err := ImageModel(ctx, s.db())
if err != nil {
return failure(msgConnectFailed, nil, err)
}
if cfg == nil {
return valueobjects.Failure(msgNoImageModel, nil)
}
return s.Send(ctx, prompt, cfg)
}
// SendToVisionModel is AIService::sendToVisionModel: Send with the first
// model that accepts images.
func (s *AIService) SendToVisionModel(ctx context.Context, prompt valueobjects.Prompt) valueobjects.AIResponse {
cfg, err := s.VisionModel(ctx)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
if cfg == nil {
return valueobjects.Failure(msgNoVisionModel, nil)
}
return s.Send(ctx, prompt, cfg)
}
// VisionModel is the admin's vision model, nil when none is configured.
func (s *AIService) VisionModel(ctx context.Context) (*ModelConfig, error) {
return VisionModel(ctx, s.db())
}
// GenerateImage is AIService::generateImage: the image model's images
// endpoint is asked for one image (size defaults to 1792x1024, quality to
// standard, the model id to dall-e-3) and Content is the returned URL,
// with Raw {"url", "revised_prompt"}. The URL is the provider's: run it
// through security.AssertSafeURL before fetching it, as the WinterCMS
// plugin does.
func (s *AIService) GenerateImage(ctx context.Context, prompt, size, quality string) valueobjects.AIResponse {
cfg, err := ImageModel(ctx, s.db())
if err != nil {
return failure(msgImageConnect, nil, err)
}
if cfg == nil {
return valueobjects.Failure(msgNoImageModelLong, nil)
}
adapter := providers.ForName(cfg.Adapter)
endpoint := adapter.ImageEndpoint(cfg.BaseURL)
if endpoint == "" {
return valueobjects.Failure(msgNoImageEndpoint, nil)
}
if cfg.APIKey == "" {
return valueobjects.Failure(msgNoImageKey, nil)
}
if size == "" {
size = defaultImageSize
}
if quality == "" {
quality = defaultImageQuality
}
payload := valueobjects.NewPayload()
payload.Set("model", cfg.modelID(defaultImageModelID))
payload.Set("prompt", prompt)
payload.Set("n", 1)
payload.Set("size", size)
payload.Set("quality", quality)
c, err := s.client(cfg.Trusted)
if err != nil {
return failure(msgImageConnect, nil, err)
}
res, err := c.PostJSON(ctx, endpoint, requestHeaders("", adapter.Headers(cfg.APIKey)), payload)
if err != nil {
return failure(msgImageConnect, nil, err)
}
data, msg := decodeObject(res.Body)
if msg != "" {
return valueobjects.Failure(msgImageInvalid+msg, nil)
}
if e, ok := data["error"]; ok && e != nil {
text := msgUnknownAPIError
if m, ok := e.(map[string]any); ok {
if v, ok := m["message"].(string); ok {
text = v
}
}
return valueobjects.Failure(text, nil)
}
var first map[string]any
if list, ok := data["data"].([]any); ok && len(list) > 0 {
first, _ = list[0].(map[string]any)
}
url, _ := first["url"].(string)
if url == "" {
return valueobjects.Failure(msgNoImageURL, nil)
}
return valueobjects.Success(url, nil, map[string]any{"url": url, "revised_prompt": first["revised_prompt"]})
}
// Ask is AIService::ask: one user message, with an optional system
// prompt, sent with the default model.
func (s *AIService) Ask(ctx context.Context, message, system string) valueobjects.AIResponse {
p := valueobjects.NewPrompt()
if system != "" {
p = p.WithSystem(system)
}
return s.Send(ctx, p.AddUserMessage(message), nil)
}