- 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
303 lines
7.9 KiB
Go
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
|
|
}
|