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