1package provider
2
3import (
4 "bytes"
5 "image"
6 "image/jpeg"
7 "image/png"
8 "strings"
9
10 xdraw "golang.org/x/image/draw"
11
12 // Register decoders for the formats we want to accept on input.
13 _ "image/gif"
14 _ "image/jpeg"
15 _ "image/png"
16)
17
18// anthMaxImageSide is Anthropic's per-image dimension cap when a
19// request contains more than one image. Single-image requests are
20// allowed up to 8000 px, but zot conversations routinely include
21// multiple images (a screenshot here, a tool result image there)
22// so we always normalise down to the stricter limit. 2000 px on the
23// longest side is well below their cap and still readable for the
24// kinds of screenshots / charts the model usually consumes.
25const anthMaxImageSide = 2000
26
27// anthSniffImageMIME returns the media type implied by an image's
28// leading magic bytes, independent of any declared type or stdlib
29// decoder registration. Returns "" when the signature is unrecognized,
30// leaving the caller's declared MIME untouched.
31func anthSniffImageMIME(data []byte) string {
32 switch {
33 case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
34 return "image/png"
35 case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
36 return "image/jpeg"
37 case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
38 return "image/gif"
39 case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
40 return "image/webp"
41 }
42 return ""
43}
44
45// anthShrinkImageBytesIfTooBig returns data unchanged when the image
46// already fits within Anthropic's per-image dimension cap. When it
47// doesn't, the image is decoded, resampled with Catmull-Rom (a good
48// balance of quality and speed for downscaling), and re-encoded in
49// the same format. The MIME type may be rewritten when re-encoding
50// requires a format change (e.g. an image originally tagged image/gif
51// is shipped as image/png after resize, since stdlib only encodes
52// gif at the package's own narrow API which is awkward for a single
53// frame).
54//
55// Errors during decode/encode return the original bytes untouched so
56// the caller's existing flow continues to work; Anthropic will then
57// emit its own clearer error than we could synthesise. We log no
58// failures here because this runs on every outbound request and is
59// best-effort.
60func anthShrinkImageBytesIfTooBig(data []byte, mime string) ([]byte, string) {
61 if len(data) == 0 {
62 return data, mime
63 }
64 // Reconcile the declared media type with the actual bytes before
65 // anything else. Callers can mislabel images (a .png that is really
66 // JPEG, an extension that hardcodes a type), and so can already-
67 // persisted session transcripts created before this fix existed.
68 // Anthropic rejects the whole request when the declared type
69 // disagrees with the bytes it sniffs, which would make such a
70 // session impossible to continue. Sniffing the magic bytes here
71 // (independent of stdlib decoder registration, and resilient to a
72 // DecodeConfig failure below) makes the mismatch impossible to ship
73 // and lets a previously-broken session resume cleanly.
74 if real := anthSniffImageMIME(data); real != "" {
75 mime = real
76 }
77 cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
78 if err != nil {
79 return data, mime
80 }
81 if cfg.Width <= anthMaxImageSide && cfg.Height <= anthMaxImageSide {
82 return data, mime
83 }
84 // Compute target dimensions preserving aspect ratio, longest side
85 // clamped to anthMaxImageSide.
86 tw, th := cfg.Width, cfg.Height
87 if tw >= th {
88 th = th * anthMaxImageSide / tw
89 tw = anthMaxImageSide
90 } else {
91 tw = tw * anthMaxImageSide / th
92 th = anthMaxImageSide
93 }
94 if tw < 1 {
95 tw = 1
96 }
97 if th < 1 {
98 th = 1
99 }
100
101 src, _, err := image.Decode(bytes.NewReader(data))
102 if err != nil {
103 return data, mime
104 }
105 dst := image.NewRGBA(image.Rect(0, 0, tw, th))
106 xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
107
108 var buf bytes.Buffer
109 switch strings.ToLower(format) {
110 case "jpeg":
111 if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
112 return data, mime
113 }
114 return buf.Bytes(), "image/jpeg"
115 case "png", "gif":
116 // gifs are re-encoded as png so the call site stays simple
117 // (avoiding a single-frame Encode helper). Anthropic accepts
118 // image/png happily; the visual result is the same for the
119 // model's vision pipeline.
120 if err := png.Encode(&buf, dst); err != nil {
121 return data, mime
122 }
123 nextMime := mime
124 if strings.ToLower(format) == "gif" {
125 nextMime = "image/png"
126 }
127 return buf.Bytes(), nextMime
128 default:
129 return data, mime
130 }
131}