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:
2026-02-07 20:00:08 -05:00
parent 85a848d96e
commit a4cb4baab5
28 changed files with 3598 additions and 0 deletions

View 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
}
}

View 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
}

View 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)
}
}