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:
100
README.md
Normal file
100
README.md
Normal 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 ./...
|
||||
```
|
||||
114
classes/providers/adapter.go
Normal file
114
classes/providers/adapter.go
Normal 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
107
classes/providers/openai.go
Normal 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
|
||||
}
|
||||
181
classes/providers/providers_test.go
Normal file
181
classes/providers/providers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
198
classes/services/ai_service.go
Normal file
198
classes/services/ai_service.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/providers"
|
||||
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConfigTimeoutSeconds is the config key of the AI request timeout.
|
||||
const ConfigTimeoutSeconds = "golem15.golem.ai.timeout_seconds"
|
||||
|
||||
// DefaultTimeout is the AI request timeout when the config sets none
|
||||
// (PHP's streaming curl timeout).
|
||||
const DefaultTimeout = 120 * time.Second
|
||||
|
||||
// PHP's failure messages.
|
||||
const (
|
||||
msgNoModel = "No AI model configured. Please add a model in Settings > AI."
|
||||
msgNoAPIKey = "API key is not configured for the selected model."
|
||||
msgConnectFailed = "Failed to connect to AI service."
|
||||
msgInvalidPrefix = "Invalid response from AI service: "
|
||||
jsonSyntaxError = "Syntax error"
|
||||
jsonMalformedUTF8 = "Malformed UTF-8 characters, possibly incorrectly encoded"
|
||||
)
|
||||
|
||||
// AIService sends prompts to AI providers (PHP AIService). A call with a
|
||||
// nil config uses the admin's default model. Every request goes through a
|
||||
// fetchguard.Client: TrustedMode for a Trusted config (an admin-configured
|
||||
// model), PublicOnlyMode for every other config. A Client is safe for
|
||||
// concurrent use.
|
||||
type AIService struct {
|
||||
db func() *gorm.DB
|
||||
cfg *compass.Config
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[bool]*fetchguard.Client
|
||||
}
|
||||
|
||||
// NewAIService builds the service on db, which returns the database
|
||||
// holding the admin's models (nil means none are configured), and cfg
|
||||
// (the request timeout; nil means DefaultTimeout). db is called on every
|
||||
// lookup, so a service built before the database is published still finds
|
||||
// it.
|
||||
func NewAIService(db func() *gorm.DB, cfg *compass.Config) *AIService {
|
||||
timeout := DefaultTimeout
|
||||
if cfg != nil {
|
||||
if n := cfg.Int(ConfigTimeoutSeconds); n > 0 {
|
||||
timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
if db == nil {
|
||||
db = func() *gorm.DB { return nil }
|
||||
}
|
||||
return &AIService{db: db, cfg: cfg, timeout: timeout, clients: map[bool]*fetchguard.Client{}}
|
||||
}
|
||||
|
||||
// ForApp returns the application's AI service, publishing one on first
|
||||
// use. Its database is the application's *gorm.DB, looked up per call.
|
||||
func ForApp(app *backpack.App) *AIService {
|
||||
if app == nil {
|
||||
return NewAIService(nil, nil)
|
||||
}
|
||||
appMu.Lock()
|
||||
defer appMu.Unlock()
|
||||
if s, ok := app.Lookup[*AIService](); ok && s != nil {
|
||||
return s
|
||||
}
|
||||
s := NewAIService(func() *gorm.DB {
|
||||
db, _ := app.Lookup[*gorm.DB]()
|
||||
return db
|
||||
}, app.Config)
|
||||
_ = app.Publish(s)
|
||||
return s
|
||||
}
|
||||
|
||||
var appMu sync.Mutex
|
||||
|
||||
// client returns the guarded (trusted false) or the trusted client.
|
||||
func (s *AIService) client(trusted bool) (*fetchguard.Client, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, ok := s.clients[trusted]; ok {
|
||||
return c, nil
|
||||
}
|
||||
mode := fetchguard.PublicOnlyMode
|
||||
if trusted {
|
||||
mode = fetchguard.TrustedMode
|
||||
}
|
||||
c, err := fetchguard.NewClient(fetchguard.Policy{Mode: mode, Timeout: s.timeout}, s.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.clients[trusted] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// requestHeaders are the headers PHP's curl request carries: Content-Type
|
||||
// first, the adapter's headers, curl's default Accept and no User-Agent
|
||||
// (an empty value keeps net/http from sending its own).
|
||||
func requestHeaders(contentType string, adapter http.Header) http.Header {
|
||||
h := http.Header{}
|
||||
if contentType != "" {
|
||||
h.Set("Content-Type", contentType)
|
||||
}
|
||||
h.Set("Accept", "*/*")
|
||||
h["User-Agent"] = []string{""}
|
||||
for k, vs := range adapter {
|
||||
h[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Send is AIService::send: the prompt, with the model's system prompt when
|
||||
// it has none, is posted to the config's chat endpoint and the answer
|
||||
// parsed by the adapter, whatever its HTTP status. A nil config is the
|
||||
// default model. The failures carry PHP's messages: no model, no API key,
|
||||
// no connection, and an answer that is not JSON (Raw then holds
|
||||
// {"raw": body}).
|
||||
func (s *AIService) Send(ctx context.Context, prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.AIResponse {
|
||||
if cfg == nil {
|
||||
def, err := s.DefaultModel(ctx)
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
cfg = def
|
||||
}
|
||||
if cfg == nil {
|
||||
return valueobjects.Failure(msgNoModel, nil)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return valueobjects.Failure(msgNoAPIKey, nil)
|
||||
}
|
||||
prompt = applyModelSystemPrompt(prompt, cfg)
|
||||
adapter := providers.ForName(cfg.Adapter)
|
||||
payload := adapter.BuildChatPayload(prompt, cfg.modelID(DefaultModelID))
|
||||
c, err := s.client(cfg.Trusted)
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
res, err := c.PostJSON(ctx, adapter.ChatEndpoint(cfg.BaseURL), requestHeaders("", adapter.Headers(cfg.APIKey)), payload)
|
||||
if err != nil {
|
||||
return failure(msgConnectFailed, nil, err)
|
||||
}
|
||||
data, msg := decodeObject(res.Body)
|
||||
if msg != "" {
|
||||
return valueobjects.Failure(msgInvalidPrefix+msg, map[string]any{"raw": string(res.Body)})
|
||||
}
|
||||
return adapter.ParseChatResponse(data)
|
||||
}
|
||||
|
||||
// DefaultModel is the admin's default model, nil when none is configured.
|
||||
func (s *AIService) DefaultModel(ctx context.Context) (*ModelConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// applyModelSystemPrompt sets the model's system prompt on a prompt that
|
||||
// has none.
|
||||
func applyModelSystemPrompt(prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.Prompt {
|
||||
if prompt.SystemPrompt() == "" && cfg.SystemPrompt != "" {
|
||||
return prompt.WithSystem(cfg.SystemPrompt)
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
// decodeObject is json_decode($body, true) for a chat answer. It returns
|
||||
// PHP's json_last_error_msg() text when body is not JSON. A JSON array
|
||||
// decodes to an empty object (PHP finds no content in it); a JSON scalar
|
||||
// too.
|
||||
func decodeObject(body []byte) (map[string]any, string) {
|
||||
if !utf8.Valid(body) {
|
||||
return nil, jsonMalformedUTF8
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
return nil, jsonSyntaxError
|
||||
}
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
return m, ""
|
||||
}
|
||||
return map[string]any{}, ""
|
||||
}
|
||||
|
||||
func failure(msg string, raw map[string]any, err error) valueobjects.AIResponse {
|
||||
r := valueobjects.Failure(msg, raw)
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
37
classes/services/model_config.go
Normal file
37
classes/services/model_config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Package services is the AI service of the golem15.golem plugin: the
|
||||
// model configuration a call runs with, the admin-configured models and
|
||||
// AIService, which sends prompts through the provider adapters.
|
||||
package services
|
||||
|
||||
// ModelConfig is one AI model a call runs with (PHP's model config
|
||||
// array): the adapter (openai or anthropic), the API key, the base URL ("" means
|
||||
// the provider default), the model id and the model's default system
|
||||
// prompt. Trusted is set only for models an operator configured in the
|
||||
// admin: their calls may reach local-network endpoints (fetchguard
|
||||
// TrustedMode). Every other config, such as a user's or an organisation's
|
||||
// own key, runs guarded (fetchguard PublicOnlyMode). APIKey is a secret:
|
||||
// never log or serialize a ModelConfig.
|
||||
type ModelConfig struct {
|
||||
Name string `json:"-"`
|
||||
Adapter string `json:"-"`
|
||||
APIKey string `json:"-"`
|
||||
BaseURL string `json:"-"`
|
||||
Model string `json:"-"`
|
||||
SystemPrompt string `json:"-"`
|
||||
MaxCompletionTokens int `json:"-"`
|
||||
HasFilesEndpoint bool `json:"-"`
|
||||
AcceptsImages bool `json:"-"`
|
||||
GeneratesImages bool `json:"-"`
|
||||
Trusted bool `json:"-"`
|
||||
}
|
||||
|
||||
// DefaultModelID is the model a config without one uses (PHP's
|
||||
// `$config['model'] ?? 'gpt-4o'`).
|
||||
const DefaultModelID = "gpt-4o"
|
||||
|
||||
func (c *ModelConfig) modelID(def string) string {
|
||||
if c.Model == "" {
|
||||
return def
|
||||
}
|
||||
return c.Model
|
||||
}
|
||||
302
classes/valueobjects/prompt.go
Normal file
302
classes/valueobjects/prompt.go
Normal 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
|
||||
}
|
||||
56
classes/valueobjects/response.go
Normal file
56
classes/valueobjects/response.go
Normal 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
10
config/config.yaml
Normal 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
38
go.mod
Normal 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
154
go.sum
Normal 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
65
golem_test.go
Normal 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
29
lang/en/lang.yaml
Normal 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
60
plugin.go
Normal 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{})
|
||||
}
|
||||
Reference in New Issue
Block a user