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 }