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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user