Add go-llm v2: redesigned API for simpler LLM abstraction
v2 is a new Go module (v2/) with a dramatically simpler API: - Unified Message type (no more Input marker interface) - Define[T] for ergonomic tool creation with standard context.Context - Chat session with automatic tool-call loop (agent loop) - Streaming via pull-based StreamReader - MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer) - Middleware support (logging, retry, timeout, usage tracking) - Decoupled JSON Schema (map[string]any, no provider coupling) - Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP - Providers: OpenAI, Anthropic, Google (all with streaming) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
105
v2/internal/imageutil/compress.go
Normal file
105
v2/internal/imageutil/compress.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package imageutil provides image compression utilities.
|
||||
package imageutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png" // register PNG decoder
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// CompressImage takes a base-64-encoded image (JPEG, PNG or GIF) and returns
|
||||
// a base-64-encoded version that is at most maxLength bytes, along with the MIME type.
|
||||
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
|
||||
}
|
||||
|
||||
switch mime {
|
||||
case "image/gif":
|
||||
return compressGIF(raw, maxLength)
|
||||
default:
|
||||
return compressRaster(raw, maxLength)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
w, h := g.Config.Width, g.Config.Height
|
||||
if w < 100 || h < 100 {
|
||||
return "", "", fmt.Errorf("cannot compress animated GIF below %.02fMiB", float64(maxLength)/1048576.0)
|
||||
}
|
||||
|
||||
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
|
||||
for i, frm := range g.Image {
|
||||
rgba := image.NewRGBA(frm.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
188
v2/internal/schema/schema.go
Normal file
188
v2/internal/schema/schema.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Package schema provides JSON Schema generation from Go structs.
|
||||
// It produces standard JSON Schema as map[string]any, with no provider-specific types.
|
||||
package schema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FromStruct generates a JSON Schema object from a Go struct.
|
||||
// Struct tags used:
|
||||
// - `json:"name"` — sets the property name (standard Go JSON convention)
|
||||
// - `description:"..."` — sets the property description
|
||||
// - `enum:"a,b,c"` — restricts string values to the given set
|
||||
//
|
||||
// Pointer fields are treated as optional; non-pointer fields are required.
|
||||
// Anonymous (embedded) struct fields are flattened into the parent.
|
||||
func FromStruct(v any) map[string]any {
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic("schema.FromStruct expects a struct or pointer to struct")
|
||||
}
|
||||
return objectSchema(t)
|
||||
}
|
||||
|
||||
func objectSchema(t reflect.Type) map[string]any {
|
||||
properties := map[string]any{}
|
||||
var required []string
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Flatten anonymous (embedded) structs
|
||||
if field.Anonymous {
|
||||
ft := field.Type
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
embedded := objectSchema(ft)
|
||||
if props, ok := embedded["properties"].(map[string]any); ok {
|
||||
for k, v := range props {
|
||||
properties[k] = v
|
||||
}
|
||||
}
|
||||
if req, ok := embedded["required"].([]string); ok {
|
||||
required = append(required, req...)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := fieldName(field)
|
||||
isRequired := true
|
||||
ft := field.Type
|
||||
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
isRequired = false
|
||||
}
|
||||
|
||||
prop := fieldSchema(field, ft)
|
||||
properties[name] = prop
|
||||
|
||||
if isRequired {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
result["required"] = required
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldSchema(field reflect.StructField, ft reflect.Type) map[string]any {
|
||||
prop := map[string]any{}
|
||||
|
||||
// Check for enum tag first
|
||||
if enumTag, ok := field.Tag.Lookup("enum"); ok {
|
||||
vals := parseEnum(enumTag)
|
||||
prop["type"] = "string"
|
||||
prop["enum"] = vals
|
||||
if desc, ok := field.Tag.Lookup("description"); ok {
|
||||
prop["description"] = desc
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
switch ft.Kind() {
|
||||
case reflect.String:
|
||||
prop["type"] = "string"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
prop["type"] = "integer"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
prop["type"] = "number"
|
||||
case reflect.Bool:
|
||||
prop["type"] = "boolean"
|
||||
case reflect.Struct:
|
||||
return objectSchema(ft)
|
||||
case reflect.Slice:
|
||||
prop["type"] = "array"
|
||||
elemType := ft.Elem()
|
||||
if elemType.Kind() == reflect.Ptr {
|
||||
elemType = elemType.Elem()
|
||||
}
|
||||
prop["items"] = typeSchema(elemType)
|
||||
case reflect.Map:
|
||||
prop["type"] = "object"
|
||||
if ft.Key().Kind() == reflect.String {
|
||||
valType := ft.Elem()
|
||||
if valType.Kind() == reflect.Ptr {
|
||||
valType = valType.Elem()
|
||||
}
|
||||
prop["additionalProperties"] = typeSchema(valType)
|
||||
}
|
||||
default:
|
||||
prop["type"] = "string" // fallback
|
||||
}
|
||||
|
||||
if desc, ok := field.Tag.Lookup("description"); ok {
|
||||
prop["description"] = desc
|
||||
}
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
func typeSchema(t reflect.Type) map[string]any {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return map[string]any{"type": "string"}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return map[string]any{"type": "integer"}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return map[string]any{"type": "number"}
|
||||
case reflect.Bool:
|
||||
return map[string]any{"type": "boolean"}
|
||||
case reflect.Struct:
|
||||
return objectSchema(t)
|
||||
case reflect.Slice:
|
||||
elemType := t.Elem()
|
||||
if elemType.Kind() == reflect.Ptr {
|
||||
elemType = elemType.Elem()
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"items": typeSchema(elemType),
|
||||
}
|
||||
default:
|
||||
return map[string]any{"type": "string"}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldName(f reflect.StructField) string {
|
||||
if tag, ok := f.Tag.Lookup("json"); ok {
|
||||
parts := strings.SplitN(tag, ",", 2)
|
||||
if parts[0] != "" && parts[0] != "-" {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
return f.Name
|
||||
}
|
||||
|
||||
func parseEnum(tag string) []string {
|
||||
parts := strings.Split(tag, ",")
|
||||
var vals []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
vals = append(vals, p)
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
181
v2/internal/schema/schema_test.go
Normal file
181
v2/internal/schema/schema_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type SimpleParams struct {
|
||||
Name string `json:"name" description:"The name"`
|
||||
Age int `json:"age" description:"The age"`
|
||||
}
|
||||
|
||||
type OptionalParams struct {
|
||||
Required string `json:"required" description:"A required field"`
|
||||
Optional *string `json:"optional,omitempty" description:"An optional field"`
|
||||
}
|
||||
|
||||
type EnumParams struct {
|
||||
Color string `json:"color" description:"The color" enum:"red,green,blue"`
|
||||
}
|
||||
|
||||
type NestedParams struct {
|
||||
Inner SimpleParams `json:"inner" description:"Nested object"`
|
||||
}
|
||||
|
||||
type ArrayParams struct {
|
||||
Items []string `json:"items" description:"A list of items"`
|
||||
}
|
||||
|
||||
type EmbeddedBase struct {
|
||||
ID string `json:"id" description:"The ID"`
|
||||
}
|
||||
|
||||
type EmbeddedParams struct {
|
||||
EmbeddedBase
|
||||
Name string `json:"name" description:"The name"`
|
||||
}
|
||||
|
||||
func TestFromStruct_Simple(t *testing.T) {
|
||||
s := FromStruct(SimpleParams{})
|
||||
|
||||
if s["type"] != "object" {
|
||||
t.Errorf("expected type=object, got %v", s["type"])
|
||||
}
|
||||
|
||||
props, ok := s["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected properties to be map[string]any")
|
||||
}
|
||||
|
||||
if len(props) != 2 {
|
||||
t.Errorf("expected 2 properties, got %d", len(props))
|
||||
}
|
||||
|
||||
nameSchema, ok := props["name"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected name property to be map[string]any")
|
||||
}
|
||||
if nameSchema["type"] != "string" {
|
||||
t.Errorf("expected name type=string, got %v", nameSchema["type"])
|
||||
}
|
||||
if nameSchema["description"] != "The name" {
|
||||
t.Errorf("expected name description='The name', got %v", nameSchema["description"])
|
||||
}
|
||||
|
||||
ageSchema, ok := props["age"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected age property to be map[string]any")
|
||||
}
|
||||
if ageSchema["type"] != "integer" {
|
||||
t.Errorf("expected age type=integer, got %v", ageSchema["type"])
|
||||
}
|
||||
|
||||
required, ok := s["required"].([]string)
|
||||
if !ok {
|
||||
t.Fatal("expected required to be []string")
|
||||
}
|
||||
if len(required) != 2 {
|
||||
t.Errorf("expected 2 required fields, got %d", len(required))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_Optional(t *testing.T) {
|
||||
s := FromStruct(OptionalParams{})
|
||||
|
||||
required, ok := s["required"].([]string)
|
||||
if !ok {
|
||||
t.Fatal("expected required to be []string")
|
||||
}
|
||||
|
||||
// Only "required" field should be required, not "optional"
|
||||
if len(required) != 1 {
|
||||
t.Errorf("expected 1 required field, got %d: %v", len(required), required)
|
||||
}
|
||||
if required[0] != "required" {
|
||||
t.Errorf("expected required field 'required', got %v", required[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_Enum(t *testing.T) {
|
||||
s := FromStruct(EnumParams{})
|
||||
|
||||
props := s["properties"].(map[string]any)
|
||||
colorSchema := props["color"].(map[string]any)
|
||||
|
||||
if colorSchema["type"] != "string" {
|
||||
t.Errorf("expected enum type=string, got %v", colorSchema["type"])
|
||||
}
|
||||
|
||||
enums, ok := colorSchema["enum"].([]string)
|
||||
if !ok {
|
||||
t.Fatal("expected enum to be []string")
|
||||
}
|
||||
if len(enums) != 3 {
|
||||
t.Errorf("expected 3 enum values, got %d", len(enums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_Nested(t *testing.T) {
|
||||
s := FromStruct(NestedParams{})
|
||||
|
||||
props := s["properties"].(map[string]any)
|
||||
innerSchema := props["inner"].(map[string]any)
|
||||
|
||||
if innerSchema["type"] != "object" {
|
||||
t.Errorf("expected nested type=object, got %v", innerSchema["type"])
|
||||
}
|
||||
|
||||
innerProps := innerSchema["properties"].(map[string]any)
|
||||
if len(innerProps) != 2 {
|
||||
t.Errorf("expected 2 inner properties, got %d", len(innerProps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_Array(t *testing.T) {
|
||||
s := FromStruct(ArrayParams{})
|
||||
|
||||
props := s["properties"].(map[string]any)
|
||||
itemsSchema := props["items"].(map[string]any)
|
||||
|
||||
if itemsSchema["type"] != "array" {
|
||||
t.Errorf("expected array type=array, got %v", itemsSchema["type"])
|
||||
}
|
||||
|
||||
items := itemsSchema["items"].(map[string]any)
|
||||
if items["type"] != "string" {
|
||||
t.Errorf("expected items type=string, got %v", items["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_Embedded(t *testing.T) {
|
||||
s := FromStruct(EmbeddedParams{})
|
||||
|
||||
props := s["properties"].(map[string]any)
|
||||
|
||||
// Should have both ID from embedded and Name
|
||||
if len(props) != 2 {
|
||||
t.Errorf("expected 2 properties (flattened), got %d", len(props))
|
||||
}
|
||||
|
||||
if _, ok := props["id"]; !ok {
|
||||
t.Error("expected 'id' property from embedded struct")
|
||||
}
|
||||
if _, ok := props["name"]; !ok {
|
||||
t.Error("expected 'name' property")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromStruct_ValidJSON(t *testing.T) {
|
||||
s := FromStruct(SimpleParams{})
|
||||
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("schema should be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("schema should round-trip through JSON: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user