Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,25 @@ sdk := llm.New(llm.WithProvider("my-gateway",

Requests and results are provider-neutral. Unknown message roles are rejected at the SDK boundary (never silently dropped or reinterpreted).

User messages may contain ordered text and inline image parts. `Content` remains
the backwards-compatible plain-text form; use `TextPart` and `ImagePart` when
an image is needed. Parts are user-role only and cannot be combined with
`Content` on the same message; empty text parts are rejected. Accepted image
types are png, jpeg, gif, and webp (the informal `image/jpg` alias is accepted
and normalized to `image/jpeg` on the wire). Per-image (`MaxImageBytes`, 10 MiB)
and per-request aggregate (`MaxRequestImageBytes`, 32 MiB) caps are enforced
before any network I/O. All validation failures are typed `*ConfigError`.

```go
req := &llm.ChatRequest{Messages: []llm.Message{{
Role: llm.RoleUser,
Parts: []llm.ContentPart{
llm.TextPart("Describe this image:"),
llm.ImagePart("image/png", pngBytes),
},
}}}
```

```go
type ChatRequest struct {
Model string // optional; ChatClient's model wins when both set
Expand Down
34 changes: 25 additions & 9 deletions anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package llm

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
neturl "net/url"
Expand All @@ -27,8 +28,9 @@ type anSysBlock struct {
}

type anBlock struct {
Type string `json:"type"` // "text" | "tool_use" | "tool_result"
Text string `json:"text,omitempty"`
Type string `json:"type"` // "text" | "tool_use" | "tool_result"
Text string `json:"text,omitempty"`
Source *anImageSource `json:"source,omitempty"`
// CacheControl is set on user text blocks when Message.Cache is true.
CacheControl *anCacheControl `json:"cache_control,omitempty"`
// thinking (replayed assistant turns; must be the FIRST block and
Expand All @@ -44,6 +46,12 @@ type anBlock struct {
Result string `json:"content,omitempty"`
}

type anImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}

type anMessage struct {
Role string `json:"role"` // "user" | "assistant"
Content []anBlock `json:"content"`
Expand Down Expand Up @@ -171,14 +179,22 @@ func buildAnthropicRequest(req *ChatRequest, model string, stream bool) ([]byte,
case RoleSystem:
out.System = append(out.System, anSysBlock{Type: "text", Text: m.Content})
case RoleUser:
blk := anBlock{Type: "text", Text: m.Content}
if m.Cache {
blk.CacheControl = &anCacheControl{Type: "ephemeral"}
blocks := make([]anBlock, 0, maxInt(1, len(m.Parts)))
if len(m.Parts) == 0 {
blocks = append(blocks, anBlock{Type: "text", Text: m.Content})
} else {
for _, p := range m.Parts {
if p.Type == ContentPartImage {
blocks = append(blocks, anBlock{Type: "image", Source: &anImageSource{Type: "base64", MediaType: wireMIME(p.MIMEType), Data: base64.StdEncoding.EncodeToString(p.Image)}})
} else {
blocks = append(blocks, anBlock{Type: "text", Text: p.Text})
}
}
}
out.Messages = append(out.Messages, anMessage{
Role: "user",
Content: []anBlock{blk},
})
if m.Cache && len(blocks) > 0 && blocks[0].Type == "text" {
blocks[0].CacheControl = &anCacheControl{Type: "ephemeral"}
}
out.Messages = append(out.Messages, anMessage{Role: "user", Content: blocks})
case RoleAssistant:
if m.ReasoningContent != "" && m.ThinkingSignature == "" {
return nil, &ConfigError{Msg: fmt.Sprintf("message %d: Anthropic thinking replay requires ThinkingSignature", i)}
Expand Down
3 changes: 3 additions & 0 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ func (pc *providerClient) buildChatRequest(req *ChatRequest, model string, strea
return nil, "", &ConfigError{Msg: fmt.Sprintf("message %d: unknown role %q", i, string(m.Role))}
}
}
if err := validateRequestContent(req.Messages); err != nil {
return nil, "", err
}
if model == "" {
model = req.Model
}
Expand Down
84 changes: 84 additions & 0 deletions content.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package llm

import (
"fmt"
"mime"
"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)
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 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 {
return &ConfigError{Msg: fmt.Sprintf("message %d: content parts require user 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)}
}
case ContentPartImage:
if p.Text != "" {
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: image part has text 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)}
}
default:
return &ConfigError{Msg: fmt.Sprintf("message %d part %d: unknown content part type %q", index, j, p.Type)}
}
}
return nil
}

var supportedImageMIME = map[string]bool{
"image/png": true, "image/jpeg": 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
}
23 changes: 19 additions & 4 deletions gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package llm

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
neturl "net/url"
Expand All @@ -27,10 +28,15 @@ type gmFnResp struct {

type gmPart struct {
Text string `json:"text,omitempty"`
InlineData *gmBlob `json:"inlineData,omitempty"`
Thought bool `json:"thought,omitempty"`
FunctionCall *gmFnCall `json:"functionCall,omitempty"`
FunctionResponse *gmFnResp `json:"functionResponse,omitempty"`
}
type gmBlob struct {
MIMEType string `json:"mimeType"`
Data string `json:"data"`
}

type gmContent struct {
Role string `json:"role,omitempty"` // "user" | "model"
Expand Down Expand Up @@ -134,10 +140,19 @@ func buildGeminiRequest(req *ChatRequest, model string, stream bool) ([]byte, er
case RoleSystem:
// folded into systemInstruction
case RoleUser:
out.Contents = append(out.Contents, gmContent{
Role: "user",
Parts: []gmPart{{Text: m.Content}},
})
parts := make([]gmPart, 0, maxInt(1, len(m.Parts)))
if len(m.Parts) == 0 {
parts = append(parts, gmPart{Text: m.Content})
} else {
for _, p := range m.Parts {
if p.Type == ContentPartImage {
parts = append(parts, gmPart{InlineData: &gmBlob{MIMEType: wireMIME(p.MIMEType), Data: base64.StdEncoding.EncodeToString(p.Image)}})
} else {
parts = append(parts, gmPart{Text: p.Text})
}
}
}
out.Contents = append(out.Contents, gmContent{Role: "user", Parts: parts})
case RoleAssistant:
for _, tc := range m.ToolCalls {
if tc.ID != "" {
Expand Down
36 changes: 34 additions & 2 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ import (
// Role enumerates canonical message roles.
type Role string

// ContentPartType identifies the payload carried by ContentPart.
type ContentPartType string

const (
ContentPartText ContentPartType = "text"
ContentPartImage ContentPartType = "image"
)

// ContentPart is one ordered text or inline-image segment in a message.
// Image data is raw bytes and is base64-encoded only by provider serializers.
// A part must contain exactly one supported payload: Text, or Image with a
// valid MIMEType. Inline images are bounded by MaxImageBytes.
type ContentPart struct {
Type ContentPartType
Text string
Image []byte
MIMEType string
}

const MaxImageBytes = 10 << 20

// TextPart creates an ordered text content part.
func TextPart(text string) ContentPart { return ContentPart{Type: ContentPartText, Text: text} }

// ImagePart creates an ordered inline-image content part.
func ImagePart(mimeType string, data []byte) ContentPart {
return ContentPart{Type: ContentPartImage, Image: data, MIMEType: mimeType}
}

const (
RoleSystem Role = "system"
RoleUser Role = "user"
Expand Down Expand Up @@ -55,8 +84,11 @@ type ToolCall struct {
// Anthropic re-serializes a signed thinking block as the first content
// block when ThinkingSignature is also set.
type Message struct {
Role Role
Content string
Role Role
Content string
// Parts optionally carries ordered text and inline-image content. When
// empty, Content retains the legacy plain-text representation.
Parts []ContentPart
ReasoningContent string
// ThinkingSignature authenticates ReasoningContent for providers that
// require thinking to be replayed verbatim (Anthropic signature, OpenAI
Expand Down
Loading
Loading