Compare commits

..

5 Commits

Author SHA1 Message Date
1927f4d187 woops messed up restriction 2025-08-08 11:07:40 -04:00
5fa7c7e5c7 Restrict temperature override for unsupported models (o* and gpt-5*). 2025-08-08 10:37:09 -04:00
07a04d08a9 Add WithDescription method to Function struct
Extend the `Function` struct with a `WithDescription` method to allow setting descriptions fluently.
2025-07-31 01:51:28 -04:00
31766134ef bubble up the mime type 2025-07-21 23:14:55 -04:00
e0adc40661 fix junie's bad idea 2025-07-21 22:53:11 -04:00
6 changed files with 142 additions and 42 deletions

View File

@@ -1,20 +1,17 @@
package go_llm
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log"
"log/slog"
"net/http"
"gitea.stevedudenhoeffer.com/steve/go-llm/utils"
anth "github.com/liushuangls/go-anthropic/v2"
)
@@ -83,7 +80,6 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
}
if img.Base64 != "" {
// Anthropic models expect images to be < 5MiB in size
raw, err := base64.StdEncoding.DecodeString(img.Base64)
@@ -93,39 +89,16 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
// Check if image size exceeds 5MiB (5242880 bytes)
if len(raw) >= 5242880 {
// Decode the image
imgData, format, err := image.Decode(bytes.NewReader(raw))
compressed, mime, err := utils.CompressImage(img.Base64, 5*1024*1024)
// just replace the image with the compressed one
if err != nil {
log.Println("failed to decode image", err)
continue
}
var buf bytes.Buffer
switch format {
case "jpeg", "jpg":
err = jpeg.Encode(&buf, imgData, &jpeg.Options{Quality: 60})
case "png":
// For PNG, use a higher compression level
enc := &png.Encoder{
CompressionLevel: png.BestCompression,
}
err = enc.Encode(&buf, imgData)
case "gif":
err = gif.Encode(&buf, imgData, &gif.Options{
NumColors: 128,
})
default:
continue
}
if err != nil {
log.Println("failed to encode image", err)
continue
}
// Update the base64 string
img.Base64 = base64.StdEncoding.EncodeToString(buf.Bytes())
img.Base64 = compressed
img.ContentType = mime
}
m.Content = append(m.Content, anth.NewImageMessageContent(

View File

@@ -47,6 +47,11 @@ func (f Function) WithSyntheticFields(fieldsAndDescriptions map[string]string) F
return f
}
func (f Function) WithDescription(description string) Function {
f.Description = description
return f
}
func (f Function) Execute(ctx *Context, input string) (any, error) {
if !f.fn.IsValid() {
return "", fmt.Errorf("function %s is not implemented", f.Name)

5
go.mod
View File

@@ -6,6 +6,7 @@ require (
github.com/google/generative-ai-go v0.19.0
github.com/liushuangls/go-anthropic/v2 v2.15.0
github.com/openai/openai-go v0.1.0-beta.9
golang.org/x/image v0.29.0
google.golang.org/api v0.228.0
)
@@ -36,9 +37,9 @@ require (
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect

10
go.sum
View File

@@ -69,16 +69,18 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs=

View File

@@ -50,8 +50,13 @@ func (o openaiImpl) newRequestToOpenAIRequest(request Request) openai.ChatComple
}
if request.Temperature != nil {
// these are known models that do not support custom temperatures
// all the o* models
// gpt-5* models
if !strings.HasPrefix(o.model, "o") && !strings.HasPrefix(o.model, "gpt-5") {
res.Temperature = openai.Float(*request.Temperature)
}
}
return res
}

114
utils/compress_image.go Normal file
View File

@@ -0,0 +1,114 @@
package utils
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/gif"
"image/jpeg"
"net/http"
"golang.org/x/image/draw"
)
// CompressImage takes a base64encoded image (JPEG, PNG or GIF) and returns
// a base64encoded version that is at most maxLength in size, or an error.
func CompressImage(b64 string, maxLength int) (string, string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("base64 decode: %w", err)
}
mime := http.DetectContentType(raw)
if len(raw) <= maxLength {
return b64, mime, nil // small enough already
}
switch mime {
case "image/gif":
return compressGIF(raw, maxLength)
default: // jpeg, png, webp, etc. → treat as raster
return compressRaster(raw, maxLength)
}
}
// ---------- Raster path (jpeg / png / singleframe gif) ----------
func compressRaster(src []byte, maxLength int) (string, string, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("decode raster: %w", err)
}
quality := 95
for {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return "", "", fmt.Errorf("jpeg encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/jpeg", nil
}
if quality > 20 {
quality -= 5
continue
}
// downscale 80%
b := img.Bounds()
if b.Dx() < 100 || b.Dy() < 100 {
return "", "", fmt.Errorf("cannot compress below %.02fMiB without destroying image", float64(maxLength)/1048576.0)
}
dst := image.NewRGBA(image.Rect(0, 0, int(float64(b.Dx())*0.8), int(float64(b.Dy())*0.8)))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
img = dst
quality = 95 // restart ladder
}
}
// ---------- Animated GIF path ----------
func compressGIF(src []byte, maxLength int) (string, string, error) {
g, err := gif.DecodeAll(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("gif decode: %w", err)
}
for {
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
return "", "", fmt.Errorf("gif encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/gif", nil
}
// downscale every frame by 80%
w, h := g.Config.Width, g.Config.Height
if w < 100 || h < 100 {
return "", "", fmt.Errorf("cannot compress animated GIF below 5 MiB without excessive quality loss")
}
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
for i, frm := range g.Image {
// convert paletted frame → RGBA for scaling
rgba := image.NewRGBA(frm.Bounds())
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
// scaled destination
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
// quantize back to paletted using default encoder quantizer
paletted := image.NewPaletted(dst.Bounds(), nil)
draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), dst, dst.Bounds().Min)
g.Image[i] = paletted
}
g.Config.Width, g.Config.Height = nw, nh
// loop back and test size again …
}
}