Files
sm-golem-plugin/classes/valueobjects/prompt.go
Jakub Zych cd3f0f6c1c 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
2026-10-03 22:41:49 +02:00

303 lines
7.9 KiB
Go

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