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":""} 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 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) }