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:
Jakub Zych
2026-10-03 22:41:49 +02:00
commit cd3f0f6c1c
14 changed files with 1451 additions and 0 deletions

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# golem15.golem
The shared AI plugin for SummerCMS applications: provider adapters for the Anthropic Messages API and the OpenAI-compatible chat completions API, prompts and AI responses, and an AI service that sends them through the framework's guarded outbound client.
```go
import _ "git.golem15.com/golem15/sm-golem-plugin"
```
Module `git.golem15.com/golem15/sm-golem-plugin`, package `golem`, plugin ID `golem15.golem`.
## Overview
The plugin is a compiled SummerCMS plugin ported from the WinterCMS `Golem15.Golem` plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. Another plugin that sends AI requests lists `golem15.golem` in its `Requires()` and imports the `classes/...` packages.
The adapters are hand-written JSON mappings with no vendor SDK. Every request goes through a `fetchguard.Client`: a config marked `Trusted` (a model an operator configured) runs in `fetchguard.TrustedMode`, every other config (a key a user or an organisation supplied) in `fetchguard.PublicOnlyMode`, which refuses private and reserved addresses at dial time.
The plugin's config keys, lang namespace and plugin ID are a contract shared by every application that mounts it: change them only in a backwards-compatible way.
## Features
- **Prompts.** `valueobjects.Prompt` is an immutable request: a system prompt, user and assistant messages, messages with base64 images (`AddUserMessageWithImage`, `AddUserMessageWithImages`) or an uploaded file id (`AddUserMessageWithFileID`), a model override and extra options in insertion order. `ToAPIPayload` builds the OpenAI chat completions body as a `valueobjects.Payload`, a JSON object that keeps its key order.
- **Responses.** `valueobjects.AIResponse` carries the content, token usage and the provider's decoded answer (`Raw`, which keeps `finish_reason` and `stop_reason`) or the failure message. `Err` holds the Go error behind a failure for logs and tests and is never shown to a user.
- **OpenAI-compatible adapter.** `providers.OpenAIAdapter` posts to `<base>/chat/completions` with `Authorization: Bearer`, drops `response_format`, maps `max_tokens` to `max_completion_tokens` and adds `reasoning_effort: low` for o-series and gpt-5 models. It also serves compatible providers through their base URL. `providers.ForName` picks the adapter by name.
- **AI service.** `services.AIService.Send` applies the model's system prompt when the prompt has none and posts the prompt to the config's chat endpoint. It returns the WinterCMS plugin's failure messages: no model configured, no API key, no connection, and an answer that is not JSON. `services.ForApp` returns the application's shared service.
## Usage
Mount the plugin in an application as a git submodule at `plugins/golem15/golem`:
```sh
git submodule add git@git.golem15.com:golem15/sm-golem-plugin.git plugins/golem15/golem
```
Add it to the application's `go.work` and `go.mod`:
```text
use ./plugins/golem15/golem
require git.golem15.com/golem15/sm-golem-plugin v0.0.0
replace git.golem15.com/golem15/sm-golem-plugin => ./plugins/golem15/golem
```
List it in `summer.yaml` before the plugins that require it and run `summer build`:
```yaml
module: git.golem15.com/golem15/acme
binary: acme
plugins:
- id: golem15.golem
module: git.golem15.com/golem15/sm-golem-plugin
- id: acme.blog
module: git.golem15.com/golem15/acme/plugins/acme/blog
```
The plugin's own `go.mod` replaces the framework with `../../../../summercms.go`, so it expects the framework checkout next to the application checkout.
A plugin sends a prompt with a user's own key:
```go
svc := services.ForApp(app)
prompt := valueobjects.NewPrompt().
WithSystem("You catalog physical items.").
AddUserMessage("List the items.")
res := svc.Send(ctx, prompt, &services.ModelConfig{
Adapter: "openai",
APIKey: key,
Model: "gpt-4o-mini",
})
if !res.Success {
return errors.New(res.Error)
}
```
## API reference
- `golem`: `Plugin`, the `golem15.golem` plugin.
- `classes/valueobjects`: `Prompt`, `NewPrompt`, `Message`, `ContentPart`, `ImageURL`, `FileRef`, `Image`, `Payload`, `NewPayload`, `AIResponse`, `Success`, `Failure`.
- `classes/providers`: `Adapter`, `OpenAIAdapter`, `ForName`, `OpenAIBaseURL`, `AnthropicBaseURL`.
- `classes/services`: `ModelConfig`, `DefaultModelID`, `AIService`, `NewAIService`, `ForApp`, `ConfigTimeoutSeconds`, `DefaultTimeout`.
## Configuration
Defaults live in `config/config.yaml` and are merged under `golem15.golem.*`.
| Key | Default | Meaning |
|-----|---------|---------|
| `golem15.golem.ssrf.allowed_hosts` | `oaidalleapiprodscus.blob.core.windows.net`, `.openai.com` | Hosts an untrusted base URL or an AI-returned image URL may use. A leading dot matches any subdomain. |
| `golem15.golem.ai.timeout_seconds` | `120` | Timeout of every AI provider request. |
## Dependencies
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `compass`, `fetchguard`, `pact`, `party`).
- GORM, as pinned in `go.mod`.
## Testing
```sh
go test ./...
```

View File

@@ -0,0 +1,114 @@
// Package providers maps AI requests and answers to each provider's wire
// format: the OpenAI-compatible chat completions API and the Anthropic
// Messages API. The adapters only build and parse JSON; the AI service in
// classes/services sends the requests through the framework's guarded
// outbound client.
package providers
import (
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// Adapter is one provider's mapping (PHP AIProviderAdapter).
type Adapter interface {
// Headers are the provider's auth and version headers for apiKey.
Headers(apiKey string) http.Header
// ChatEndpoint is the chat URL under baseURL ("" means the
// provider's default base URL).
ChatEndpoint(baseURL string) string
// FilesEndpoint is the file upload URL under baseURL.
FilesEndpoint(baseURL string) string
// ImageEndpoint is the image generation URL, "" when the provider
// has none.
ImageEndpoint(baseURL string) string
// BuildChatPayload is the chat request body for prompt, with modelID
// unless the prompt overrides the model.
BuildChatPayload(prompt valueobjects.Prompt, modelID string) *valueobjects.Payload
// FileUploadFields are the plain multipart fields of a file upload.
FileUploadFields(purpose string) []fetchguard.FormField
// FileUploadHeaders are the headers of a file upload.
FileUploadHeaders(apiKey string) http.Header
// ParseChatResponse maps a decoded chat answer to an AIResponse.
ParseChatResponse(data map[string]any) valueobjects.AIResponse
// ParseStreamChunk reads one server-sent-events line: the content
// delta it carries ("" when none) and whether the stream ended.
ParseStreamChunk(line string) (delta string, done bool)
}
// Default base URLs, used when a config leaves base_url empty.
const (
OpenAIBaseURL = "https://api.openai.com/v1"
AnthropicBaseURL = "https://api.anthropic.com/v1"
)
// ForName is ProviderFactory::make: the Anthropic adapter for "anthropic",
// the OpenAI-compatible adapter for anything else.
func ForName(adapter string) Adapter {
return OpenAIAdapter{}
}
// endpoint is rtrim(base, '/') . path, with def standing in for an empty
// base.
func endpoint(base, def, path string) string {
if base == "" {
base = def
}
return strings.TrimRight(base, "/") + path
}
// dig walks nested JSON objects and arrays (PHP $a['x'][0]['y'] ?? null).
func dig(v any, path ...any) any {
for _, p := range path {
switch key := p.(type) {
case string:
m, ok := v.(map[string]any)
if !ok {
return nil
}
v = m[key]
case int:
s, ok := v.([]any)
if !ok || key < 0 || key >= len(s) {
return nil
}
v = s[key]
default:
return nil
}
}
return v
}
// errorMessage is PHP's `$data['error']['message'] ?? 'Unknown API
// error'` for a response that carries an error key, and false otherwise.
func errorMessage(data map[string]any) (string, bool) {
e, ok := data["error"]
if !ok || e == nil {
return "", false
}
if msg := dig(e, "message"); msg != nil {
return phpString(msg), true
}
return "Unknown API error", true
}
// phpString is a PHP string cast of a decoded JSON scalar.
func phpString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "1"
}
return ""
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
}
return ""
}

107
classes/providers/openai.go Normal file
View File

@@ -0,0 +1,107 @@
package providers
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// OpenAIAdapter is the OpenAI-compatible chat completions mapping (PHP
// OpenAIAdapter). It also serves compatible providers (Groq, Together,
// local model servers) through their base URL.
type OpenAIAdapter struct{}
var _ Adapter = OpenAIAdapter{}
// reasoningModel matches the o-series and gpt-5 model ids, which spend
// completion tokens on hidden reasoning.
var reasoningModel = regexp.MustCompile(`(?i)^(o\d|gpt-5)`)
// Headers is Authorization: Bearer <key>.
func (OpenAIAdapter) Headers(apiKey string) http.Header {
return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}}
}
// ChatEndpoint is <base>/chat/completions.
func (OpenAIAdapter) ChatEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/chat/completions")
}
// FilesEndpoint is <base>/files.
func (OpenAIAdapter) FilesEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/files")
}
// ImageEndpoint is <base>/images/generations.
func (OpenAIAdapter) ImageEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/images/generations")
}
// BuildChatPayload is the prompt's chat completions body with three
// adjustments PHP makes: response_format is dropped (recognition asks for
// JSON in the prompt instead), a max_tokens option becomes
// max_completion_tokens (an explicit max_completion_tokens wins), and an
// o-series or gpt-5 model gets reasoning_effort low unless the prompt set
// one.
func (OpenAIAdapter) BuildChatPayload(prompt valueobjects.Prompt, modelID string) *valueobjects.Payload {
payload := prompt.ToAPIPayload(modelID)
payload.Delete("response_format")
if payload.IsSet("max_tokens") || payload.IsSet("max_completion_tokens") {
v, _ := payload.Get("max_completion_tokens")
if v == nil {
v, _ = payload.Get("max_tokens")
}
payload.Set("max_completion_tokens", v)
payload.Delete("max_tokens")
}
if reasoningModel.MatchString(modelID) && !payload.IsSet("reasoning_effort") {
payload.Set("reasoning_effort", "low")
}
return payload
}
// FileUploadFields is the purpose field.
func (OpenAIAdapter) FileUploadFields(purpose string) []fetchguard.FormField {
return []fetchguard.FormField{{Name: "purpose", Value: purpose}}
}
// FileUploadHeaders is Authorization: Bearer <key>.
func (OpenAIAdapter) FileUploadHeaders(apiKey string) http.Header {
return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}}
}
// ParseChatResponse reads choices[0].message.content; an error object is a
// failure carrying the provider's message and the whole body.
func (OpenAIAdapter) ParseChatResponse(data map[string]any) valueobjects.AIResponse {
if msg, ok := errorMessage(data); ok {
return valueobjects.Failure(msg, data)
}
content := dig(data, "choices", 0, "message", "content")
if content == nil {
return valueobjects.Failure("No content in AI response.", data)
}
usage, _ := data["usage"].(map[string]any)
return valueobjects.Success(phpString(content), usage, data)
}
// ParseStreamChunk reads a `data: {...}` line's choices[0].delta.content;
// `data: [DONE]` ends the stream.
func (OpenAIAdapter) ParseStreamChunk(line string) (string, bool) {
payload, ok := strings.CutPrefix(line, "data: ")
if !ok {
return "", false
}
if payload == "[DONE]" {
return "", true
}
var v map[string]any
if json.Unmarshal([]byte(payload), &v) != nil || len(v) == 0 {
return "", false
}
delta, _ := dig(v, "choices", 0, "delta", "content").(string)
return delta, false
}

View File

@@ -0,0 +1,181 @@
package providers
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
)
func payloadMap(t *testing.T, p *valueobjects.Payload) map[string]any {
t.Helper()
raw, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// TestOpenAIAdapterPayload ports OpenAIAdapterTest.php.
func TestOpenAIAdapterPayload(t *testing.T) {
a := OpenAIAdapter{}
t.Run("strips response_format and migrates max_tokens", func(t *testing.T) {
prompt := valueobjects.NewPrompt().
WithSystem("You catalog physical items.").
AddUserMessage("List the items.").
WithOptions(map[string]any{"response_format": map[string]any{"type": "json_object"}, "max_tokens": 800}, "response_format", "max_tokens")
p := a.BuildChatPayload(prompt, "gpt-4o")
if p.Has("response_format") {
t.Fatal("response_format must be stripped")
}
if p.Has("max_tokens") {
t.Fatal("legacy max_tokens must be removed")
}
if v, _ := p.Get("max_completion_tokens"); v != 800 {
t.Fatalf("max_completion_tokens = %v, want 800", v)
}
m := payloadMap(t, p)
msgs := m["messages"].([]any)
if first := msgs[0].(map[string]any); first["role"] != "system" || first["content"] != "You catalog physical items." {
t.Fatalf("system message first: %v", first)
}
if strings.Join(p.Keys(), ",") != "model,messages,max_completion_tokens" {
t.Fatalf("keys = %v", p.Keys())
}
})
t.Run("explicit max_completion_tokens wins", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.").
WithOptions(map[string]any{"max_tokens": 800, "max_completion_tokens": 1200}, "max_tokens", "max_completion_tokens")
p := a.BuildChatPayload(prompt, "gpt-4o")
if p.Has("max_tokens") {
t.Fatal("max_tokens leaked")
}
if v, _ := p.Get("max_completion_tokens"); v != 1200 {
t.Fatalf("max_completion_tokens = %v, want 1200", v)
}
})
t.Run("reasoning models get reasoning_effort low", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.")
for _, model := range []string{"gpt-5.5", "gpt-5", "o1", "o3-mini", "o4-mini", "O3"} {
if v, _ := a.BuildChatPayload(prompt, model).Get("reasoning_effort"); v != "low" {
t.Fatalf("%s: reasoning_effort = %v", model, v)
}
}
})
t.Run("non-reasoning models get none and a caller value wins", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("List the items.")
for _, model := range []string{"gpt-4o", "gpt-4.1", "gpt-4o-mini"} {
if a.BuildChatPayload(prompt, model).Has("reasoning_effort") {
t.Fatalf("%s: reasoning_effort must not be set", model)
}
}
explicit := prompt.WithOptions(map[string]any{"reasoning_effort": "high"})
if v, _ := a.BuildChatPayload(explicit, "gpt-5.5").Get("reasoning_effort"); v != "high" {
t.Fatalf("caller reasoning_effort overridden: %v", v)
}
})
t.Run("native image_url block passes through", func(t *testing.T) {
b64 := base64.StdEncoding.EncodeToString([]byte("fake-image-bytes"))
prompt := valueobjects.NewPrompt().AddUserMessageWithImage("What is in this photo?", b64, "image/jpeg")
m := payloadMap(t, a.BuildChatPayload(prompt, "gpt-4o"))
parts := m["messages"].([]any)[0].(map[string]any)["content"].([]any)
img := parts[1].(map[string]any)
if img["type"] != "image_url" {
t.Fatalf("block = %v", img)
}
iu := img["image_url"].(map[string]any)
if iu["url"] != "data:image/jpeg;base64,"+b64 || iu["detail"] != "high" {
t.Fatalf("image_url = %v", iu)
}
if text := parts[0].(map[string]any); text["type"] != "text" || text["text"] != "What is in this photo?" {
t.Fatalf("text block = %v", text)
}
})
t.Run("prompt model override", func(t *testing.T) {
prompt := valueobjects.NewPrompt().AddUserMessage("hi").WithModel("gpt-4.1")
if v, _ := a.BuildChatPayload(prompt, "gpt-4o").Get("model"); v != "gpt-4.1" {
t.Fatalf("model = %v", v)
}
})
t.Run("parse surfaces the API error", func(t *testing.T) {
r := a.ParseChatResponse(map[string]any{"error": map[string]any{"message": "invalid API key"}})
if r.Success || r.Error != "invalid API key" {
t.Fatalf("response = %+v", r)
}
r = a.ParseChatResponse(map[string]any{"error": map[string]any{"code": "x"}})
if r.Error != "Unknown API error" {
t.Fatalf("error without message = %q", r.Error)
}
})
t.Run("parse content and usage", func(t *testing.T) {
var data map[string]any
_ = json.Unmarshal([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`), &data)
r := a.ParseChatResponse(data)
if !r.Success || r.Content != "pong" || r.TotalTokens() != 4 {
t.Fatalf("response = %+v", r)
}
if dig(r.Raw, "choices", 0, "finish_reason") != "stop" {
t.Fatal("raw keeps finish_reason")
}
r = a.ParseChatResponse(map[string]any{"choices": []any{}})
if r.Success || r.Error != "No content in AI response." {
t.Fatalf("no content = %+v", r)
}
})
t.Run("endpoints and headers", func(t *testing.T) {
if got := a.ChatEndpoint(""); got != "https://api.openai.com/v1/chat/completions" {
t.Fatal(got)
}
if got := a.ChatEndpoint("https://llm.example.test/v1/"); got != "https://llm.example.test/v1/chat/completions" {
t.Fatal(got)
}
if got := a.FilesEndpoint(""); got != "https://api.openai.com/v1/files" {
t.Fatal(got)
}
if got := a.ImageEndpoint(""); got != "https://api.openai.com/v1/images/generations" {
t.Fatal(got)
}
if got := a.Headers("sk-test").Get("Authorization"); got != "Bearer sk-test" {
t.Fatal(got)
}
if f := a.FileUploadFields("assistants"); len(f) != 1 || f[0].Name != "purpose" || f[0].Value != "assistants" {
t.Fatalf("fields = %v", f)
}
})
t.Run("stream chunks", func(t *testing.T) {
cases := []struct {
line string
delta string
done bool
}{
{"", "", false},
{": keep-alive", "", false},
{`data: {"choices":[{"delta":{"content":"Hel"}}]}`, "Hel", false},
{`data: {"choices":[{"delta":{}}]}`, "", false},
{"data: not json", "", false},
{"data: [DONE]", "", true},
}
for _, c := range cases {
d, done := a.ParseStreamChunk(c.line)
if d != c.delta || done != c.done {
t.Fatalf("%q: (%q,%v)", c.line, d, done)
}
}
})
}

View 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
}

View 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
}

View File

@@ -0,0 +1,302 @@
// Package valueobjects holds the AI request and response values: Prompt,
// its messages and content parts, the ordered request Payload, and
// AIResponse.
package valueobjects
import (
"bytes"
"encoding/json"
"maps"
"slices"
)
// ContentPart is one block of a multimodal message: a text block, an
// image_url block (a data: URL or a remote URL, with detail) or a file
// block referencing an uploaded file id. The JSON shape is OpenAI's chat
// completions shape; the Anthropic adapter rewrites image and file blocks.
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
File *FileRef `json:"file,omitempty"`
}
// ImageURL is an image_url block's target.
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
// FileRef is a file block's uploaded file id.
type FileRef struct {
FileID string `json:"file_id"`
}
// Message is one conversation message. Its content is Text when Parts is
// nil, else the Parts list.
type Message struct {
Role string
Text string
Parts []ContentPart
}
// Content is the message content as JSON encodes it: a string or the list
// of parts.
func (m Message) Content() any {
if m.Parts != nil {
return m.Parts
}
return m.Text
}
// MarshalJSON writes {"role":…,"content":…}.
func (m Message) MarshalJSON() ([]byte, error) {
p := NewPayload()
p.Set("role", m.Role)
p.Set("content", m.Content())
return p.MarshalJSON()
}
// Prompt is an immutable AI request: an optional system prompt, the
// messages, an optional model override and extra request options
// (temperature, max_tokens and the like, in insertion order). Every method
// returns a changed copy.
type Prompt struct {
system string
messages []Message
model *string
options *Payload
}
// NewPrompt returns an empty prompt.
func NewPrompt() Prompt { return Prompt{} }
func (p Prompt) clone() Prompt {
c := p
c.messages = slices.Clone(p.messages)
if p.options != nil {
c.options = p.options.Clone()
}
return c
}
// WithSystem sets the system prompt.
func (p Prompt) WithSystem(system string) Prompt {
c := p.clone()
c.system = system
return c
}
// AddUserMessage appends a user message.
func (p Prompt) AddUserMessage(content string) Prompt {
return p.AddMessage("user", content)
}
// AddAssistantMessage appends an assistant message.
func (p Prompt) AddAssistantMessage(content string) Prompt {
return p.AddMessage("assistant", content)
}
// AddMessage appends a message with role.
func (p Prompt) AddMessage(role, content string) Prompt {
c := p.clone()
c.messages = append(c.messages, Message{Role: role, Text: content})
return c
}
// Image is one image of AddUserMessageWithImages: base64 data and its MIME
// type (image/jpeg when empty).
type Image struct {
Base64 string
MIMEType string
}
// AddUserMessageWithImage appends a user message carrying text and one
// base64 image as a data: URL with detail high. mimeType defaults to
// image/jpeg.
func (p Prompt) AddUserMessageWithImage(text, base64Image, mimeType string) Prompt {
return p.AddUserMessageWithImages(text, []Image{{Base64: base64Image, MIMEType: mimeType}})
}
// AddUserMessageWithImages appends a user message carrying text and every
// image, in order.
func (p Prompt) AddUserMessageWithImages(text string, images []Image) Prompt {
parts := []ContentPart{{Type: "text", Text: text}}
for _, img := range images {
mime := img.MIMEType
if mime == "" {
mime = "image/jpeg"
}
parts = append(parts, ContentPart{
Type: "image_url",
ImageURL: &ImageURL{URL: "data:" + mime + ";base64," + img.Base64, Detail: "high"},
})
}
c := p.clone()
c.messages = append(c.messages, Message{Role: "user", Parts: parts})
return c
}
// AddUserMessageWithFileID appends a user message carrying text and a
// reference to an uploaded file.
func (p Prompt) AddUserMessageWithFileID(text, fileID string) Prompt {
c := p.clone()
c.messages = append(c.messages, Message{Role: "user", Parts: []ContentPart{
{Type: "text", Text: text},
{Type: "file", File: &FileRef{FileID: fileID}},
}})
return c
}
// WithModel overrides the model id of the config the prompt is sent with.
func (p Prompt) WithModel(model string) Prompt {
c := p.clone()
c.model = &model
return c
}
// WithOptions merges options into the prompt's options (PHP array_merge
// on string keys: a later value replaces an earlier one in place). keys
// fixes the order of new keys; keys missing from it follow sorted.
func (p Prompt) WithOptions(options map[string]any, keys ...string) Prompt {
c := p.clone()
if c.options == nil {
c.options = NewPayload()
}
seen := map[string]bool{}
for _, k := range keys {
if v, ok := options[k]; ok && !seen[k] {
c.options.Set(k, v)
seen[k] = true
}
}
for _, k := range slices.Sorted(maps.Keys(options)) {
if !seen[k] {
c.options.Set(k, options[k])
}
}
return c
}
// SystemPrompt is the system prompt, "" when none.
func (p Prompt) SystemPrompt() string { return p.system }
// Messages is a copy of the messages.
func (p Prompt) Messages() []Message { return slices.Clone(p.messages) }
// Model is the model override, or nil.
func (p Prompt) Model() *string { return p.model }
// Options is a copy of the options in insertion order.
func (p Prompt) Options() *Payload {
if p.options == nil {
return NewPayload()
}
return p.options.Clone()
}
// ToAPIPayload is Prompt::toApiPayload, the OpenAI chat completions body:
// model (the override or defaultModel), messages with the system prompt
// first, then every option.
func (p Prompt) ToAPIPayload(defaultModel string) *Payload {
messages := make([]Message, 0, len(p.messages)+1)
if p.system != "" {
messages = append(messages, Message{Role: "system", Text: p.system})
}
messages = append(messages, p.messages...)
out := NewPayload()
model := defaultModel
if p.model != nil {
model = *p.model
}
out.Set("model", model)
out.Set("messages", messages)
for _, k := range p.Options().Keys() {
v, _ := p.options.Get(k)
out.Set(k, v)
}
return out
}
// Payload is a JSON object that keeps its keys in insertion order, as a
// PHP array does. A Set on an existing key keeps its position.
type Payload struct {
keys []string
vals map[string]any
}
// NewPayload returns an empty payload.
func NewPayload() *Payload { return &Payload{vals: map[string]any{}} }
// Set stores v under k.
func (p *Payload) Set(k string, v any) {
if _, ok := p.vals[k]; !ok {
p.keys = append(p.keys, k)
}
p.vals[k] = v
}
// Get returns k's value.
func (p *Payload) Get(k string) (any, bool) {
v, ok := p.vals[k]
return v, ok
}
// Has reports whether k is set (PHP array_key_exists).
func (p *Payload) Has(k string) bool {
_, ok := p.vals[k]
return ok
}
// IsSet is PHP isset: k is present and not nil.
func (p *Payload) IsSet(k string) bool {
v, ok := p.vals[k]
return ok && v != nil
}
// Delete removes k.
func (p *Payload) Delete(k string) {
if _, ok := p.vals[k]; !ok {
return
}
delete(p.vals, k)
p.keys = slices.DeleteFunc(p.keys, func(s string) bool { return s == k })
}
// Keys is the keys in order.
func (p *Payload) Keys() []string { return slices.Clone(p.keys) }
// Len is the number of keys.
func (p *Payload) Len() int { return len(p.keys) }
// Clone is a shallow copy.
func (p *Payload) Clone() *Payload {
return &Payload{keys: slices.Clone(p.keys), vals: maps.Clone(p.vals)}
}
// Map is a copy of the values as a plain map.
func (p *Payload) Map() map[string]any { return maps.Clone(p.vals) }
// MarshalJSON writes the object in key order.
func (p *Payload) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range p.keys {
if i > 0 {
buf.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return nil, err
}
buf.Write(kb)
buf.WriteByte(':')
vb, err := json.Marshal(p.vals[k])
if err != nil {
return nil, err
}
buf.Write(vb)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,56 @@
package valueobjects
// AIResponse is the outcome of an AI call. A success carries Content,
// Usage and the provider's decoded answer in Raw; a failure carries Error,
// PHP's message for the caller, and sometimes Raw (the provider's error
// body, or {"raw": body} when the answer was not JSON). Err is the Go
// error behind a failure, for logs and tests; it is never shown to a user
// and may be nil.
type AIResponse struct {
Success bool
Content string
Error string
Usage map[string]any
Raw map[string]any
Err error
}
// Success builds a successful response.
func Success(content string, usage, raw map[string]any) AIResponse {
if usage == nil {
usage = map[string]any{}
}
if raw == nil {
raw = map[string]any{}
}
return AIResponse{Success: true, Content: content, Usage: usage, Raw: raw}
}
// Failure builds a failed response.
func Failure(message string, raw map[string]any) AIResponse {
if raw == nil {
raw = map[string]any{}
}
return AIResponse{Error: message, Usage: map[string]any{}, Raw: raw}
}
// TotalTokens is usage.total_tokens, 0 when absent.
func (r AIResponse) TotalTokens() int { return usageInt(r.Usage, "total_tokens") }
// PromptTokens is usage.prompt_tokens, 0 when absent.
func (r AIResponse) PromptTokens() int { return usageInt(r.Usage, "prompt_tokens") }
// CompletionTokens is usage.completion_tokens, 0 when absent.
func (r AIResponse) CompletionTokens() int { return usageInt(r.Usage, "completion_tokens") }
func usageInt(m map[string]any, k string) int {
switch n := m[k].(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}

10
config/config.yaml Normal file
View File

@@ -0,0 +1,10 @@
ssrf:
# Hosts an untrusted AI base URL (and an AI-returned image URL) may use.
# A leading dot matches any subdomain. GOLEM15_SSRF_ALLOWED_HOSTS
# (a comma list) replaces the list when it is set and not empty.
allowed_hosts:
- oaidalleapiprodscus.blob.core.windows.net
- .openai.com
ai:
# Request timeout of every AI provider call, in seconds.
timeout_seconds: 120

38
go.mod Normal file
View File

@@ -0,0 +1,38 @@
module git.golem15.com/golem15/sm-golem-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.0.0
gorm.io/gorm v1.31.2
)
require (
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-gormigrate/gormigrate/v2 v2.1.7 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect
github.com/knadh/koanf/providers/file v1.2.1 // indirect
github.com/knadh/koanf/v2 v2.3.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/wneessen/go-mail v0.8.1 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
replace git.golem15.com/golem15/summercms => ../../../../summercms.go

154
go.sum Normal file
View File

@@ -0,0 +1,154 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU=
github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A=
github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0=
github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

65
golem_test.go Normal file
View File

@@ -0,0 +1,65 @@
package golem
import (
"context"
"os"
"path/filepath"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
func testConfig(t testing.TB) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: golem-test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
return cfg
}
// TestGolemPluginBoot activates the plugin alone (no golem15.user: it
// requires no other plugin) and checks its ID, its config defaults and its
// lang namespace.
func TestGolemPluginBoot(t *testing.T) {
cfg := testConfig(t)
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"golem15.golem"})
if err != nil {
t.Fatalf("activate golem15.golem alone: %v", err)
}
if len(plugins) != 1 || plugins[0].ID() != "golem15.golem" {
t.Fatalf("activated plugins = %v", plugins)
}
if got := (&Plugin{}).Requires(); len(got) != 0 {
t.Fatalf("Requires() = %v, want none", got)
}
if got := cfg.Int("golem15.golem.ai.timeout_seconds"); got != 120 {
t.Fatalf("golem15.golem.ai.timeout_seconds = %d, want 120", got)
}
hosts, ok := cfg.Lookup("golem15.golem.ssrf.allowed_hosts")
if !ok {
t.Fatal("golem15.golem.ssrf.allowed_hosts is not set")
}
list, _ := hosts.([]any)
if len(list) != 2 || list[0] != "oaidalleapiprodscus.blob.core.windows.net" || list[1] != ".openai.com" {
t.Fatalf("allowed_hosts = %#v", hosts)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("no translator published")
}
if got := tr.Get(context.Background(), "golem15.golem::lang.plugin.name", nil); got != "Golem" {
t.Fatalf("golem15.golem::lang.plugin.name = %q", got)
}
if got := tr.Get(context.Background(), "golem15.golem::lang.permissions.access_settings", nil); got != "Manage AI settings" {
t.Fatalf("access_settings label = %q", got)
}
}

29
lang/en/lang.yaml Normal file
View File

@@ -0,0 +1,29 @@
plugin:
name: Golem
description: Clean, extendable AI integration
permissions:
access_settings: Manage AI settings
settings:
menu_label: AI
menu_description: Configure AI models and settings
adapter: Adapter
adapter_comment: OpenAI-compatible works for most providers (Perplexity, Groq, Together, etc.)
is_enabled: Enabled
has_files_endpoint: Has files endpoint
max_completion_tokens: Max Completion Tokens
max_completion_tokens_comment: "Maximum tokens in AI response (default: 4096)"
models: AI Models
add_model: Add Model
model_name: Name
api_key: API Key
api_key_comment: Write-only. Leave it unchanged to keep the stored key.
base_url: Base URL
base_url_comment: OpenAI-compatible endpoint
model_id: Model ID
model_id_comment: e.g., gpt-4o, gpt-4o-mini, claude-3-5-sonnet
system_prompt: System Prompt
system_prompt_comment: Default system prompt for this model. Used when no explicit system prompt is provided by the caller.
is_default: Default Model
generates_images: Generates Images
accepts_images: Accepts Images (Vision)
sort_order: Order

60
plugin.go Normal file
View File

@@ -0,0 +1,60 @@
// Package golem is the golem15.golem plugin: the shared AI layer of
// SummerCMS applications. It ports the WinterCMS Golem15.Golem plugin's AI
// service (provider adapters for the Anthropic Messages API and the
// OpenAI-compatible chat completions API, prompts, image generation, file
// uploads, streaming) and its SSRF guard.
package golem
import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
var langFS embed.FS
// Plugin is the golem15.golem plugin.
type Plugin struct {
app *backpack.App
}
// ID is the plugin ID, also its config and lang namespace.
func (p *Plugin) ID() string { return "golem15.golem" }
// Requires is empty: the plugin depends on no other plugin.
func (p *Plugin) Requires() []string { return nil }
// Register keeps the application.
func (p *Plugin) Register(app *backpack.App) error {
p.app = app
return nil
}
// Boot keeps the application.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
return nil
}
// ConfigFS ships config/config.yaml, merged under golem15.golem.*.
func (p *Plugin) ConfigFS() fs.FS { return configFS }
// LangFS ships the golem15.golem lang namespace.
func (p *Plugin) LangFS() fs.FS { return langFS }
func init() {
party.Register(&Plugin{})
}