llm: transcode WebP images for llama-server (#17755)

llama-server does not currently support WebP image payloads. Detect WebP media before forwarding, and transcode it to PNG. Pass all other media through unchanged.

Replace an existing vision integration image with a lossless WebP version so we now have coverage of JPG/PNG/WebP formats.

Fixes #17753
This commit is contained in:
Daniel Hiltgen
2026-08-14 13:21:11 -07:00
committed by GitHub
parent 0f25c31bd5
commit f427fa0753
3 changed files with 332 additions and 508 deletions
+38 -8
View File
@@ -22,6 +22,7 @@ import (
"encoding/json"
"errors"
"fmt"
"image/png"
"io"
"log/slog"
"math/rand"
@@ -39,6 +40,7 @@ import (
"sync/atomic"
"time"
"golang.org/x/image/webp"
"golang.org/x/sync/semaphore"
"github.com/ollama/ollama/api"
@@ -1604,7 +1606,11 @@ func (s *llamaServerRunner) Completion(ctx context.Context, req CompletionReques
for _, media := range req.Media {
marker := fmt.Sprintf("[img-%d]", media.ID)
promptStr = strings.Replace(promptStr, marker, s.llamaServerMediaMarker(), 1)
mediaData = append(mediaData, base64.StdEncoding.EncodeToString(media.Data))
data, err := llamaServerMediaBytes(media.Data)
if err != nil {
return err
}
mediaData = append(mediaData, base64.StdEncoding.EncodeToString(data))
}
lsReq.Prompt = llamaServerMultimodalPrompt{
PromptString: promptStr,
@@ -2227,34 +2233,58 @@ func llamaServerChatMessage(msg Message) (map[string]any, error) {
})
}
for _, media := range msg.Media {
parts = append(parts, llamaServerChatMediaPart(media))
part, err := llamaServerChatMediaPart(media)
if err != nil {
return nil, err
}
parts = append(parts, part)
}
converted["content"] = parts
return converted, nil
}
func llamaServerChatMediaPart(media MediaData) map[string]any {
encoded := base64.StdEncoding.EncodeToString(media.Data)
func llamaServerChatMediaPart(media MediaData) (map[string]any, error) {
if format, ok := AudioFormat(media.Data); ok {
return map[string]any{
"type": "input_audio",
"input_audio": map[string]any{
"data": encoded,
"data": base64.StdEncoding.EncodeToString(media.Data),
"format": format,
},
}
}, nil
}
mime := http.DetectContentType(media.Data)
data, err := llamaServerMediaBytes(media.Data)
if err != nil {
return nil, err
}
mime := http.DetectContentType(data)
if !strings.HasPrefix(mime, "image/") {
mime = "image/jpeg"
}
return map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + encoded,
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data),
},
}, nil
}
func llamaServerMediaBytes(data []byte) ([]byte, error) {
if http.DetectContentType(data) != "image/webp" {
return data, nil
}
img, err := webp.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode WebP image: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode WebP image as PNG: %w", err)
}
return buf.Bytes(), nil
}
func llamaServerChatToolCalls(tcs []api.ToolCall) ([]llamaServerChatToolCall, error) {
+14 -4
View File
@@ -3639,27 +3639,37 @@ func TestLlamaServerChatMessageConvertsToolCalls(t *testing.T) {
func TestLlamaServerChatMessageConvertsMediaParts(t *testing.T) {
png := []byte("\x89PNG\r\n\x1a\n")
webp, err := base64.StdEncoding.DecodeString("UklGRhwAAABXRUJQVlA4TA8AAAAvAAAAAAcQ/Y/+ByKi/wEA")
if err != nil {
t.Fatal(err)
}
wav := []byte("RIFF\x00\x00\x00\x00WAVE")
mp3 := []byte("ID3\x04\x00\x00")
msg, err := llamaServerChatMessage(Message{
Role: "user",
Content: "describe these",
Media: []MediaData{NewMediaData(0, png), NewMediaData(1, wav), NewMediaData(2, mp3)},
Media: []MediaData{NewMediaData(0, png), NewMediaData(1, webp), NewMediaData(2, wav), NewMediaData(3, mp3)},
})
if err != nil {
t.Fatal(err)
}
parts, ok := msg["content"].([]map[string]any)
if !ok || len(parts) != 4 {
t.Fatalf("expected four content parts, got %#v", msg["content"])
if !ok || len(parts) != 5 {
t.Fatalf("expected five content parts, got %#v", msg["content"])
}
if parts[1]["type"] != "image_url" {
t.Fatalf("expected image_url for PNG, got %#v", parts[1])
}
if imageURL := parts[1]["image_url"].(map[string]any)["url"]; imageURL != "data:image/png;base64,"+base64.StdEncoding.EncodeToString(png) {
t.Fatalf("expected PNG to pass through unchanged, got %#v", imageURL)
}
if imageURL := parts[2]["image_url"].(map[string]any)["url"].(string); !strings.HasPrefix(imageURL, "data:image/png;base64,") {
t.Fatalf("expected WebP to be converted to PNG, got %q", imageURL)
}
for i, want := range []string{"wav", "mp3"} {
part := parts[i+2]
part := parts[i+3]
if part["type"] != "input_audio" {
t.Fatalf("expected input_audio for %s, got %#v", want, part)
}