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:
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