From 20e9c323768c514f12b331d9e77b9b650f5c71e5 Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:26:19 +0100 Subject: [PATCH 1/5] feat(input): add convenience constructors for image inputs\n\n- Add URLImageSegment and BytesImageSegment\n- Write temp image files with automatic cleanup\n- Extend InputSegment with cleanup hook\n- Update normalizeInput to collect cleanups and expose a cleanup function --- input.go | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 4 deletions(-) diff --git a/input.go b/input.go index 3100120..3200eda 100644 --- a/input.go +++ b/input.go @@ -1,7 +1,13 @@ package godex import ( + "context" "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" "strings" ) @@ -15,6 +21,8 @@ type InputSegment struct { // LocalImagePath contains a filesystem path to an image that should be // forwarded to the CLI via --image. Leave empty for text segments. LocalImagePath string + + cleanup func() } // TextSegment creates a textual input segment. Multiple text segments are @@ -30,29 +38,134 @@ func LocalImageSegment(path string) InputSegment { return InputSegment{LocalImagePath: path} } +// URLImageSegment downloads an image from the provided URL into a temporary file and +// returns an input segment that references it. The file is cleaned up automatically +// when the run finishes. +func URLImageSegment(ctx context.Context, rawURL string) (InputSegment, error) { + if ctx == nil { + ctx = context.Background() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return InputSegment{}, fmt.Errorf("create image request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return InputSegment{}, fmt.Errorf("download image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return InputSegment{}, fmt.Errorf("download image: unexpected status %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + return InputSegment{}, fmt.Errorf("download image: missing Content-Type header") + } + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return InputSegment{}, fmt.Errorf("parse Content-Type %q: %w", contentType, err) + } + if !strings.HasPrefix(mediaType, "image/") { + return InputSegment{}, fmt.Errorf("download image: content-type %q is not an image", mediaType) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return InputSegment{}, fmt.Errorf("read image body: %w", err) + } + if len(data) == 0 { + return InputSegment{}, fmt.Errorf("download image: empty response body") + } + + ext := extensionForMediaType(mediaType) + if ext == "" { + detected := http.DetectContentType(data) + if strings.HasPrefix(detected, "image/") { + ext = extensionForMediaType(detected) + } + } + + return newTempImageSegment(data, ext) +} + +// BytesImageSegment writes the provided image bytes to a temporary file and returns +// a segment that references it. The file is cleaned up automatically when the run finishes. +func BytesImageSegment(name string, data []byte) (InputSegment, error) { + if len(data) == 0 { + return InputSegment{}, fmt.Errorf("image data is empty") + } + + ext := strings.ToLower(strings.TrimSpace(filepath.Ext(name))) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + mediaType := "" + if ext != "" { + mediaType = mime.TypeByExtension(ext) + } + detected := http.DetectContentType(data) + if mediaType == "" || !strings.HasPrefix(mediaType, "image/") { + mediaType = detected + } + + if !strings.HasPrefix(mediaType, "image/") { + return InputSegment{}, fmt.Errorf("bytes content-type %q is not an image", mediaType) + } + + if ext == "" { + ext = extensionForMediaType(mediaType) + } + + return newTempImageSegment(data, ext) +} + type normalizedInput struct { - prompt string - images []string + prompt string + images []string + cleanup func() } func normalizeInput(base string, segments []InputSegment) (normalizedInput, error) { + noCleanup := func() {} + if len(segments) == 0 { - return normalizedInput{prompt: base}, nil + return normalizedInput{prompt: base, cleanup: noCleanup}, nil } var ( promptParts []string images []string + cleanups []func() ) + cleanupAll := func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + cleanups[i]() + } + } + } + for i, segment := range segments { + if segment.cleanup != nil { + cleanups = append(cleanups, segment.cleanup) + } + hasText := segment.Text != "" hasImage := segment.LocalImagePath != "" switch { case hasText && hasImage: + cleanupAll() return normalizedInput{}, fmt.Errorf("input segment %d must specify either text or image, not both", i) case !hasText && !hasImage: + cleanupAll() return normalizedInput{}, fmt.Errorf("input segment %d must specify text or image", i) case hasText: promptParts = append(promptParts, segment.Text) @@ -66,5 +179,69 @@ func normalizeInput(base string, segments []InputSegment) (normalizedInput, erro prompt = strings.Join(promptParts, "\n\n") } - return normalizedInput{prompt: prompt, images: images}, nil + return normalizedInput{prompt: prompt, images: images, cleanup: cleanupAll}, nil +} + +func newTempImageSegment(data []byte, ext string) (InputSegment, error) { + path, cleanup, err := writeTempImageFile(ext, data) + if err != nil { + return InputSegment{}, err + } + return InputSegment{LocalImagePath: path, cleanup: cleanup}, nil +} + +func writeTempImageFile(ext string, data []byte) (string, func(), error) { + ext = strings.TrimSpace(ext) + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + pattern := "codex-image-*" + if ext != "" { + pattern += ext + } + + file, err := os.CreateTemp("", pattern) + if err != nil { + return "", nil, fmt.Errorf("create temp image: %w", err) + } + + path := file.Name() + cleanup := func() { + _ = os.Remove(path) + } + + if _, err := file.Write(data); err != nil { + _ = file.Close() + cleanup() + return "", nil, fmt.Errorf("write temp image: %w", err) + } + + if err := file.Close(); err != nil { + cleanup() + return "", nil, fmt.Errorf("close temp image: %w", err) + } + + return path, cleanup, nil +} + +func extensionForMediaType(mediaType string) string { + if mediaType == "" { + return "" + } + + exts, _ := mime.ExtensionsByType(mediaType) + if len(exts) == 0 { + return "" + } + + for _, preferred := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"} { + for _, candidate := range exts { + if candidate == preferred { + return candidate + } + } + } + + return exts[0] } From 2d3abc3125a49d4228f1474f47e4114984c7db3b Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:26:24 +0100 Subject: [PATCH 2/5] fix(thread): clean up temp image files in streamed runs\n\n- Call prepared.cleanup() on error and via defer to avoid leaking temp files\n- Rename schema cleanup var to avoid shadowing and ensure proper ordering --- thread.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/thread.go b/thread.go index 3c96092..2d2ffda 100644 --- a/thread.go +++ b/thread.go @@ -107,8 +107,9 @@ func (t *Thread) runStreamed(ctx context.Context, baseInput string, segments []I return RunStreamedResult{}, err } - schemaPath, cleanup, err := createOutputSchemaFile(turnOpts.OutputSchema) + schemaPath, schemaCleanup, err := createOutputSchemaFile(turnOpts.OutputSchema) if err != nil { + prepared.cleanup() return RunStreamedResult{}, err } @@ -121,7 +122,8 @@ func (t *Thread) runStreamed(ctx context.Context, baseInput string, segments []I go func() { defer close(events) defer stream.finish() - defer cleanup() + defer schemaCleanup() + defer prepared.cleanup() var threadErr error args := codexexec.Args{ Input: prepared.prompt, From faa00cbb4a0e661605dda68fff26453f7446885b Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:26:27 +0100 Subject: [PATCH 3/5] test(input): cover URLImageSegment and BytesImageSegment cleanup and behavior\n\n- Download and cleanup test using httptest server\n- Validate non-image content-type is rejected\n- Ensure bytes segment picks a .png extension and cleans up --- input_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/input_test.go b/input_test.go index 5eafa53..91f44a0 100644 --- a/input_test.go +++ b/input_test.go @@ -1,6 +1,15 @@ package godex -import "testing" +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) func TestNormalizeInputUsesBaseWhenNoSegments(t *testing.T) { prepared, err := normalizeInput("hello", nil) @@ -61,3 +70,91 @@ func TestNormalizeInputRejectsInvalidSegments(t *testing.T) { t.Fatal("expected error when both text and image are set") } } + +func TestURLImageSegmentDownloadsAndCleansUp(t *testing.T) { + imageData := decodeBase64(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av7l/wAAAABJRU5ErkJggg==") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageData) + })) + defer server.Close() + + segment, err := URLImageSegment(context.Background(), server.URL) + if err != nil { + t.Fatalf("URLImageSegment returned error: %v", err) + } + if segment.LocalImagePath == "" { + t.Fatal("expected LocalImagePath to be set") + } + + prepared, err := normalizeInput("", []InputSegment{segment}) + if err != nil { + t.Fatalf("normalizeInput returned error: %v", err) + } + if len(prepared.images) != 1 { + t.Fatalf("expected one image, got %v", prepared.images) + } + + path := prepared.images[0] + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected image file to exist: %v", err) + } + + prepared.cleanup() + + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected image file to be cleaned up, got %v", err) + } +} + +func TestURLImageSegmentRejectsNonImageContentType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("not an image")) + })) + defer server.Close() + + if _, err := URLImageSegment(context.Background(), server.URL); err == nil { + t.Fatal("expected error for non-image content type") + } +} + +func TestBytesImageSegmentCreatesFileWithExtension(t *testing.T) { + imageData := decodeBase64(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av7l/wAAAABJRU5ErkJggg==") + + segment, err := BytesImageSegment("example", imageData) + if err != nil { + t.Fatalf("BytesImageSegment returned error: %v", err) + } + if segment.LocalImagePath == "" { + t.Fatal("expected LocalImagePath to be set") + } + if !strings.HasSuffix(segment.LocalImagePath, ".png") { + t.Fatalf("expected .png extension, got %q", segment.LocalImagePath) + } + + prepared, err := normalizeInput("", []InputSegment{segment}) + if err != nil { + t.Fatalf("normalizeInput returned error: %v", err) + } + if len(prepared.images) != 1 { + t.Fatalf("expected one image, got %v", prepared.images) + } + + path := prepared.images[0] + prepared.cleanup() + + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected image file to be cleaned up, got %v", err) + } +} + +func decodeBase64(t *testing.T, s string) []byte { + t.Helper() + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + t.Fatalf("decode base64: %v", err) + } + return data +} From 61029bc146c8ffdb533be827bcc3d3f363b70df5 Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:26:30 +0100 Subject: [PATCH 4/5] docs(readme): document image segment helpers and broaden section title\n\n- Rename section to include remote and in-memory images\n- Add examples for URLImageSegment and BytesImageSegment with cleanup behavior explained --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 318a80e..278eadf 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ if err != nil { log.Printf("update: %+v", result) ``` -## Multi-part input and local images +## Multi-part input and images Mix text segments and local image paths by using `RunInputs` / `RunStreamedInputs` with `InputSegment` helpers. Text segments are joined with blank lines and each image path is @@ -132,6 +132,33 @@ if err != nil { fmt.Println("Assistant:", turn.FinalResponse) ``` +For remote assets or in-memory data, reach for the convenience constructors: + +```go +segment, err := godex.URLImageSegment(ctx, "https://example.com/image.png") +if err != nil { + log.Fatal(err) +} + +rawBytes := loadThumbnailBytes() // your own code that returns []byte + +bytesSegment, err := godex.BytesImageSegment("thumbnail", rawBytes) +if err != nil { + log.Fatal(err) +} + +turn, err := thread.RunInputs(ctx, []godex.InputSegment{ + godex.TextSegment("Describe both images."), + segment, + bytesSegment, +}, nil) +``` + +`URLImageSegment` downloads the image to a temp file, verifies that the server returned an +`image/*` content type, and schedules the file for cleanup once the run completes. Use +`BytesImageSegment` when you already have the image bytes; it writes them to a temporary file +with a suitable extension and cleans the file up automatically. + ## Examples - `examples/basic`: single-turn conversation (`go run ./examples/basic`) From da00e310e02f946232eed496a8910fb6719e5dcc Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:36:21 +0100 Subject: [PATCH 5/5] review --- input.go | 81 +++++++++++++++++++++++++++++++++++++++++++-------- input_test.go | 28 ++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/input.go b/input.go index 3200eda..1394f61 100644 --- a/input.go +++ b/input.go @@ -38,6 +38,11 @@ func LocalImageSegment(path string) InputSegment { return InputSegment{LocalImagePath: path} } +const ( + maxURLImageSizeBytes = 8 << 20 // 8 MiB safety limit for remote downloads + sniffBufferSize = 512 +) + // URLImageSegment downloads an image from the provided URL into a temporary file and // returns an input segment that references it. The file is cleaned up automatically // when the run finishes. @@ -74,23 +79,30 @@ func URLImageSegment(ctx context.Context, rawURL string) (InputSegment, error) { return InputSegment{}, fmt.Errorf("download image: content-type %q is not an image", mediaType) } - data, err := io.ReadAll(resp.Body) - if err != nil { - return InputSegment{}, fmt.Errorf("read image body: %w", err) - } - if len(data) == 0 { + ext := extensionForMediaType(mediaType) + limited := &io.LimitedReader{R: resp.Body, N: maxURLImageSizeBytes + 1} + sniff := make([]byte, sniffBufferSize) + n, err := io.ReadFull(limited, sniff) + switch { + case err == io.EOF && n == 0: return InputSegment{}, fmt.Errorf("download image: empty response body") + case err != nil && err != io.ErrUnexpectedEOF: + return InputSegment{}, fmt.Errorf("read image body: %w", err) } - ext := extensionForMediaType(mediaType) - if ext == "" { - detected := http.DetectContentType(data) + if ext == "" && n > 0 { + detected := http.DetectContentType(sniff[:n]) if strings.HasPrefix(detected, "image/") { ext = extensionForMediaType(detected) } } - return newTempImageSegment(data, ext) + path, cleanup, err := writeTempImageStream(ext, sniff[:n], limited, maxURLImageSizeBytes) + if err != nil { + return InputSegment{}, err + } + + return InputSegment{LocalImagePath: path, cleanup: cleanup}, nil } // BytesImageSegment writes the provided image bytes to a temporary file and returns @@ -183,14 +195,47 @@ func normalizeInput(base string, segments []InputSegment) (normalizedInput, erro } func newTempImageSegment(data []byte, ext string) (InputSegment, error) { - path, cleanup, err := writeTempImageFile(ext, data) + path, cleanup, err := writeTempImageBytes(ext, data) if err != nil { return InputSegment{}, err } return InputSegment{LocalImagePath: path, cleanup: cleanup}, nil } -func writeTempImageFile(ext string, data []byte) (string, func(), error) { +func writeTempImageBytes(ext string, data []byte) (string, func(), error) { + return writeTempImageFile(ext, func(f *os.File) (int64, error) { + n, err := f.Write(data) + return int64(n), err + }) +} + +func writeTempImageStream(ext string, head []byte, body io.Reader, maxSize int64) (string, func(), error) { + validator := func(total int64) error { + if total == 0 { + return fmt.Errorf("download image: empty response body") + } + if total > maxSize { + return fmt.Errorf("download image: exceeded %d byte size limit", maxSize) + } + return nil + } + + return writeTempImageFile(ext, func(f *os.File) (int64, error) { + var total int64 + if len(head) > 0 { + n, err := f.Write(head) + total += int64(n) + if err != nil { + return total, err + } + } + written, err := io.Copy(f, body) + total += written + return total, err + }, validator) +} + +func writeTempImageFile(ext string, writer func(*os.File) (int64, error), validators ...func(int64) error) (string, func(), error) { ext = strings.TrimSpace(ext) if ext != "" && !strings.HasPrefix(ext, ".") { ext = "." + ext @@ -211,12 +256,24 @@ func writeTempImageFile(ext string, data []byte) (string, func(), error) { _ = os.Remove(path) } - if _, err := file.Write(data); err != nil { + total, err := writer(file) + if err != nil { _ = file.Close() cleanup() return "", nil, fmt.Errorf("write temp image: %w", err) } + for _, validate := range validators { + if validate == nil { + continue + } + if err := validate(total); err != nil { + _ = file.Close() + cleanup() + return "", nil, err + } + } + if err := file.Close(); err != nil { cleanup() return "", nil, fmt.Errorf("close temp image: %w", err) diff --git a/input_test.go b/input_test.go index 91f44a0..43008c7 100644 --- a/input_test.go +++ b/input_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "errors" + "io" "net/http" "net/http/httptest" "os" @@ -120,6 +121,24 @@ func TestURLImageSegmentRejectsNonImageContentType(t *testing.T) { } } +func TestURLImageSegmentRejectsOversizedImage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + if _, err := io.CopyN(w, zeroReader{}, int64(maxURLImageSizeBytes)+1); err != nil && err != io.EOF { + t.Fatalf("failed to write large body: %v", err) + } + })) + defer server.Close() + + _, err := URLImageSegment(context.Background(), server.URL) + if err == nil { + t.Fatal("expected error for oversized image") + } + if !strings.Contains(err.Error(), "size limit") { + t.Fatalf("expected size limit error, got %v", err) + } +} + func TestBytesImageSegmentCreatesFileWithExtension(t *testing.T) { imageData := decodeBase64(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av7l/wAAAABJRU5ErkJggg==") @@ -158,3 +177,12 @@ func decodeBase64(t *testing.T, s string) []byte { } return data } + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +}