- 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
530 lines
17 KiB
Go
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)
|
|
}
|