feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send

- Prompt, Payload and AIResponse value objects ported from Golem15.Golem
- OpenAIAdapter: chat completions mapping with PHP's response_format,
  max_completion_tokens and reasoning_effort rules
- AIService.Send through fetchguard (TrustedMode for operator models,
  PublicOnlyMode otherwise) with PHP's failure messages
- config defaults for the SSRF allowlist and the AI timeout, lang, README
This commit is contained in:
Jakub Zych
2026-10-03 22:41:49 +02:00
commit cd3f0f6c1c
14 changed files with 1451 additions and 0 deletions

View File

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

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

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

View File

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

View File

@@ -0,0 +1,198 @@
package services
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-golem-plugin/classes/providers"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"gorm.io/gorm"
)
// ConfigTimeoutSeconds is the config key of the AI request timeout.
const ConfigTimeoutSeconds = "golem15.golem.ai.timeout_seconds"
// DefaultTimeout is the AI request timeout when the config sets none
// (PHP's streaming curl timeout).
const DefaultTimeout = 120 * time.Second
// PHP's failure messages.
const (
msgNoModel = "No AI model configured. Please add a model in Settings > AI."
msgNoAPIKey = "API key is not configured for the selected model."
msgConnectFailed = "Failed to connect to AI service."
msgInvalidPrefix = "Invalid response from AI service: "
jsonSyntaxError = "Syntax error"
jsonMalformedUTF8 = "Malformed UTF-8 characters, possibly incorrectly encoded"
)
// AIService sends prompts to AI providers (PHP AIService). A call with a
// nil config uses the admin's default model. Every request goes through a
// fetchguard.Client: TrustedMode for a Trusted config (an admin-configured
// model), PublicOnlyMode for every other config. A Client is safe for
// concurrent use.
type AIService struct {
db func() *gorm.DB
cfg *compass.Config
timeout time.Duration
mu sync.Mutex
clients map[bool]*fetchguard.Client
}
// NewAIService builds the service on db, which returns the database
// holding the admin's models (nil means none are configured), and cfg
// (the request timeout; nil means DefaultTimeout). db is called on every
// lookup, so a service built before the database is published still finds
// it.
func NewAIService(db func() *gorm.DB, cfg *compass.Config) *AIService {
timeout := DefaultTimeout
if cfg != nil {
if n := cfg.Int(ConfigTimeoutSeconds); n > 0 {
timeout = time.Duration(n) * time.Second
}
}
if db == nil {
db = func() *gorm.DB { return nil }
}
return &AIService{db: db, cfg: cfg, timeout: timeout, clients: map[bool]*fetchguard.Client{}}
}
// ForApp returns the application's AI service, publishing one on first
// use. Its database is the application's *gorm.DB, looked up per call.
func ForApp(app *backpack.App) *AIService {
if app == nil {
return NewAIService(nil, nil)
}
appMu.Lock()
defer appMu.Unlock()
if s, ok := app.Lookup[*AIService](); ok && s != nil {
return s
}
s := NewAIService(func() *gorm.DB {
db, _ := app.Lookup[*gorm.DB]()
return db
}, app.Config)
_ = app.Publish(s)
return s
}
var appMu sync.Mutex
// client returns the guarded (trusted false) or the trusted client.
func (s *AIService) client(trusted bool) (*fetchguard.Client, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.clients[trusted]; ok {
return c, nil
}
mode := fetchguard.PublicOnlyMode
if trusted {
mode = fetchguard.TrustedMode
}
c, err := fetchguard.NewClient(fetchguard.Policy{Mode: mode, Timeout: s.timeout}, s.cfg)
if err != nil {
return nil, err
}
s.clients[trusted] = c
return c, nil
}
// requestHeaders are the headers PHP's curl request carries: Content-Type
// first, the adapter's headers, curl's default Accept and no User-Agent
// (an empty value keeps net/http from sending its own).
func requestHeaders(contentType string, adapter http.Header) http.Header {
h := http.Header{}
if contentType != "" {
h.Set("Content-Type", contentType)
}
h.Set("Accept", "*/*")
h["User-Agent"] = []string{""}
for k, vs := range adapter {
h[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
}
return h
}
// Send is AIService::send: the prompt, with the model's system prompt when
// it has none, is posted to the config's chat endpoint and the answer
// parsed by the adapter, whatever its HTTP status. A nil config is the
// default model. The failures carry PHP's messages: no model, no API key,
// no connection, and an answer that is not JSON (Raw then holds
// {"raw": body}).
func (s *AIService) Send(ctx context.Context, prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.AIResponse {
if cfg == nil {
def, err := s.DefaultModel(ctx)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
cfg = def
}
if cfg == nil {
return valueobjects.Failure(msgNoModel, nil)
}
if cfg.APIKey == "" {
return valueobjects.Failure(msgNoAPIKey, nil)
}
prompt = applyModelSystemPrompt(prompt, cfg)
adapter := providers.ForName(cfg.Adapter)
payload := adapter.BuildChatPayload(prompt, cfg.modelID(DefaultModelID))
c, err := s.client(cfg.Trusted)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
res, err := c.PostJSON(ctx, adapter.ChatEndpoint(cfg.BaseURL), requestHeaders("", adapter.Headers(cfg.APIKey)), payload)
if err != nil {
return failure(msgConnectFailed, nil, err)
}
data, msg := decodeObject(res.Body)
if msg != "" {
return valueobjects.Failure(msgInvalidPrefix+msg, map[string]any{"raw": string(res.Body)})
}
return adapter.ParseChatResponse(data)
}
// DefaultModel is the admin's default model, nil when none is configured.
func (s *AIService) DefaultModel(ctx context.Context) (*ModelConfig, error) {
return nil, nil
}
// applyModelSystemPrompt sets the model's system prompt on a prompt that
// has none.
func applyModelSystemPrompt(prompt valueobjects.Prompt, cfg *ModelConfig) valueobjects.Prompt {
if prompt.SystemPrompt() == "" && cfg.SystemPrompt != "" {
return prompt.WithSystem(cfg.SystemPrompt)
}
return prompt
}
// decodeObject is json_decode($body, true) for a chat answer. It returns
// PHP's json_last_error_msg() text when body is not JSON. A JSON array
// decodes to an empty object (PHP finds no content in it); a JSON scalar
// too.
func decodeObject(body []byte) (map[string]any, string) {
if !utf8.Valid(body) {
return nil, jsonMalformedUTF8
}
var v any
if err := json.Unmarshal(body, &v); err != nil {
return nil, jsonSyntaxError
}
if m, ok := v.(map[string]any); ok {
return m, ""
}
return map[string]any{}, ""
}
func failure(msg string, raw map[string]any, err error) valueobjects.AIResponse {
r := valueobjects.Failure(msg, raw)
r.Err = err
return r
}

View File

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

View File

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

View File

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