feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send
- 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
This commit is contained in:
198
classes/services/ai_service.go
Normal file
198
classes/services/ai_service.go
Normal file
@@ -0,0 +1,198 @@
|
||||
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
|
||||
}
|
||||
37
classes/services/model_config.go
Normal file
37
classes/services/model_config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Package services is the AI service of the golem15.golem plugin: the
|
||||
// model configuration a call runs with, the admin-configured models and
|
||||
// AIService, which sends prompts through the provider adapters.
|
||||
package services
|
||||
|
||||
// ModelConfig is one AI model a call runs with (PHP's model config
|
||||
// array): the adapter (openai or anthropic), the API key, the base URL ("" means
|
||||
// the provider default), the model id and the model's default system
|
||||
// prompt. Trusted is set only for models an operator configured in the
|
||||
// admin: their calls may reach local-network endpoints (fetchguard
|
||||
// TrustedMode). Every other config, such as a user's or an organisation's
|
||||
// own key, runs guarded (fetchguard PublicOnlyMode). APIKey is a secret:
|
||||
// never log or serialize a ModelConfig.
|
||||
type ModelConfig struct {
|
||||
Name string `json:"-"`
|
||||
Adapter string `json:"-"`
|
||||
APIKey string `json:"-"`
|
||||
BaseURL string `json:"-"`
|
||||
Model string `json:"-"`
|
||||
SystemPrompt string `json:"-"`
|
||||
MaxCompletionTokens int `json:"-"`
|
||||
HasFilesEndpoint bool `json:"-"`
|
||||
AcceptsImages bool `json:"-"`
|
||||
GeneratesImages bool `json:"-"`
|
||||
Trusted bool `json:"-"`
|
||||
}
|
||||
|
||||
// DefaultModelID is the model a config without one uses (PHP's
|
||||
// `$config['model'] ?? 'gpt-4o'`).
|
||||
const DefaultModelID = "gpt-4o"
|
||||
|
||||
func (c *ModelConfig) modelID(def string) string {
|
||||
if c.Model == "" {
|
||||
return def
|
||||
}
|
||||
return c.Model
|
||||
}
|
||||
Reference in New Issue
Block a user