commit cd3f0f6c1c12bbb7c791f9463d103315e8a1ea36 Author: Jakub Zych Date: Sat Oct 3 22:41:49 2026 +0200 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9aa677d --- /dev/null +++ b/README.md @@ -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 `/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 ./... +``` diff --git a/classes/providers/adapter.go b/classes/providers/adapter.go new file mode 100644 index 0000000..6afe353 --- /dev/null +++ b/classes/providers/adapter.go @@ -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 "" +} diff --git a/classes/providers/openai.go b/classes/providers/openai.go new file mode 100644 index 0000000..c16948a --- /dev/null +++ b/classes/providers/openai.go @@ -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 . +func (OpenAIAdapter) Headers(apiKey string) http.Header { + return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}} +} + +// ChatEndpoint is /chat/completions. +func (OpenAIAdapter) ChatEndpoint(baseURL string) string { + return endpoint(baseURL, OpenAIBaseURL, "/chat/completions") +} + +// FilesEndpoint is /files. +func (OpenAIAdapter) FilesEndpoint(baseURL string) string { + return endpoint(baseURL, OpenAIBaseURL, "/files") +} + +// ImageEndpoint is /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 . +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 +} diff --git a/classes/providers/providers_test.go b/classes/providers/providers_test.go new file mode 100644 index 0000000..0ead97e --- /dev/null +++ b/classes/providers/providers_test.go @@ -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) + } + } + }) +} diff --git a/classes/services/ai_service.go b/classes/services/ai_service.go new file mode 100644 index 0000000..b38dc4f --- /dev/null +++ b/classes/services/ai_service.go @@ -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 +} diff --git a/classes/services/model_config.go b/classes/services/model_config.go new file mode 100644 index 0000000..5255223 --- /dev/null +++ b/classes/services/model_config.go @@ -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 +} diff --git a/classes/valueobjects/prompt.go b/classes/valueobjects/prompt.go new file mode 100644 index 0000000..68adc13 --- /dev/null +++ b/classes/valueobjects/prompt.go @@ -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 +} diff --git a/classes/valueobjects/response.go b/classes/valueobjects/response.go new file mode 100644 index 0000000..8c2a608 --- /dev/null +++ b/classes/valueobjects/response.go @@ -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 +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..5027a69 --- /dev/null +++ b/config/config.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d653f74 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4776d15 --- /dev/null +++ b/go.sum @@ -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= diff --git a/golem_test.go b/golem_test.go new file mode 100644 index 0000000..ab19d71 --- /dev/null +++ b/golem_test.go @@ -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) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml new file mode 100644 index 0000000..b516ecf --- /dev/null +++ b/lang/en/lang.yaml @@ -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 diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..57c64f1 --- /dev/null +++ b/plugin.go @@ -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{}) +}