- 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
199 lines
6.2 KiB
Go
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
|
|
}
|