Repository navigation
Expand file tree
/
Copy pathcontent.go
More file actions
213 lines (196 loc) · 7.22 KB
/
Copy pathcontent.go
File metadata and controls
213 lines (196 loc) · 7.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
package llm
import (
"fmt"
"mime"
"net/url"
"strings"
)
// MaxRequestImageBytes bounds the aggregate inline image payload in one
// request, preventing many individually valid parts from causing an
// unexpectedly large allocation or HTTP body.
const MaxRequestImageBytes = 32 << 20
func validateRequestContent(messages []Message) error {
total := 0
for i, m := range messages {
if err := validateMessageContent(m, i); err != nil {
return err
}
for _, p := range m.Parts {
total += len(p.Image) + len(p.Data)
if total > MaxRequestImageBytes {
return &ConfigError{Msg: fmt.Sprintf("message %d: request inline images exceed %d bytes", i, MaxRequestImageBytes)}
}
}
}
return nil
}
func validateMessageContent(m Message, index int) error {
if m.IsError && m.Role != RoleTool {
return &ConfigError{Msg: fmt.Sprintf("message %d: IsError requires the tool role", index)}
}
if len(m.Parts) == 0 {
return nil
}
if m.Content != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d: Content and Parts cannot both be set", index)}
}
if m.Role != RoleUser && m.Role != RoleTool {
return &ConfigError{Msg: fmt.Sprintf("message %d: content parts require the user or tool role", index)}
}
for j, p := range m.Parts {
switch p.Type {
case ContentPartText:
if p.Text == "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: text part is empty", index, j)}
}
if p.Image != nil || p.MIMEType != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: text part has image fields", index, j)}
}
if p.URL != "" || p.Data != nil || p.Name != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: text part has non-text fields", index, j)}
}
case ContentPartImage:
if p.Text != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image part has text fields", index, j)}
}
if p.URL != "" || p.Data != nil || p.Name != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image part has non-image fields", index, j)}
}
media, params, err := mime.ParseMediaType(p.MIMEType)
if err != nil || len(params) != 0 || !supportedImageMIME[strings.ToLower(media)] || media != strings.ToLower(media) {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: MIMEType must be an image type", index, j)}
}
if len(p.Image) == 0 {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image data is empty", index, j)}
}
if len(p.Image) > MaxImageBytes {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image exceeds %d bytes", index, j, MaxImageBytes)}
}
case ContentPartDocument:
if err := validateDocumentPart(p, index, j); err != nil {
return err
}
case ContentPartImageURL:
if err := validateImageURLPart(p, index, j); err != nil {
return err
}
default:
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: unknown content part type %q", index, j, p.Type)}
}
}
return nil
}
// maxImageURLBytes is a sanity bound on a remote image URL.
const maxImageURLBytes = 8 << 10
var supportedDocumentMIME = map[string]bool{"application/pdf": true, "text/plain": true}
func validateDocumentPart(p ContentPart, index, j int) error {
media, params, err := mime.ParseMediaType(p.MIMEType)
if err != nil || len(params) != 0 || !supportedDocumentMIME[strings.ToLower(media)] {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: document MIMEType must be application/pdf or text/plain", index, j)}
}
if p.Text != "" || p.Image != nil || p.URL != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: document part has non-document fields", index, j)}
}
if len(p.Data) == 0 {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: document data is empty", index, j)}
}
if len(p.Data) > MaxDocumentBytes {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: document exceeds %d bytes", index, j, MaxDocumentBytes)}
}
return nil
}
func validateImageURLPart(p ContentPart, index, j int) error {
if p.Text != "" || p.Image != nil || p.Data != nil || p.Name != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image URL part has non-URL fields", index, j)}
}
u, err := url.Parse(p.URL)
if err != nil || len(p.URL) > maxImageURLBytes || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image URL must be a valid http(s) URL (at most %d bytes)", index, j, maxImageURLBytes)}
}
if p.MIMEType != "" {
media, params, err := mime.ParseMediaType(p.MIMEType)
if err != nil || len(params) != 0 || !supportedImageMIME[strings.ToLower(media)] {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: MIMEType must be an image type", index, j)}
}
}
return nil
}
var supportedImageMIME = map[string]bool{
"image/png": true, "image/jpeg": true, "image/jpg": true,
"image/gif": true, "image/webp": true,
}
// wireMIME maps an accepted MIME type to the form providers expect on the
// wire. The informal image/jpg alias is accepted at the API boundary (some
// callers derive MIME from file extensions) but must not reach providers:
// Anthropic and Gemini reject it while image/jpeg is universally valid.
func wireMIME(m string) string {
if m == "image/jpg" {
return "image/jpeg"
}
return m
}
// partsText returns a message's text: Content, or its text parts joined by
// newlines when it carries Parts.
func partsText(m Message) string {
if len(m.Parts) == 0 {
return m.Content
}
var texts []string
for _, p := range m.Parts {
if p.Type == ContentPartText {
texts = append(texts, p.Text)
}
}
return strings.Join(texts, "\n")
}
// attachmentParts returns a message's image, image-URL and document parts.
func attachmentParts(m Message) []ContentPart {
var out []ContentPart
for _, p := range m.Parts {
if p.Type != ContentPartText {
out = append(out, p)
}
}
return out
}
const defaultDocumentName = "document.pdf"
// canonMIME is the canonical wire form of a (case-insensitive) MIME type.
func canonMIME(m string) string { return wireMIME(strings.ToLower(m)) }
func documentName(p ContentPart) string {
if p.Name == "" {
return defaultDocumentName
}
return p.Name
}
// validateOpenAIFormatParts rejects documents the OpenAI-format wires cannot
// carry: documents in tool results, and any document that is not a PDF.
// responses selects the endpoint named in the tool-result error.
func validateOpenAIFormatParts(msgs []Message, responses bool) error {
for i, m := range msgs {
for j, p := range m.Parts {
if p.Type != ContentPartDocument {
continue
}
if m.Role == RoleTool {
if responses {
return &ConfigError{Msg: "tool results cannot carry documents on the Responses API"}
}
return &ConfigError{Msg: "tool results cannot carry documents on OpenAI-format providers"}
}
if strings.ToLower(p.MIMEType) != "application/pdf" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: OpenAI-format documents support application/pdf only", i, j)}
}
}
}
return nil
}
// imageLikeParts returns inline-image and image-URL parts.
func imageLikeParts(m Message) []ContentPart {
var out []ContentPart
for _, p := range m.Parts {
if p.Type == ContentPartImage || p.Type == ContentPartImageURL {
out = append(out, p)
}
}
return out
}