- Go API server with PostgreSQL + Redis - AI floor replacement (OpenRouter Gemini) - Product database (10 brands, 3539 products) - Recommendation engine, calculator, articles - Redis async queue + worker pool - Hot product caching, brand view tracking - JWT auth, favorites, projects - Docker deployment ready Co-Authored-By: Claude <noreply@anthropic.com>
467 lines
13 KiB
Go
467 lines
13 KiB
Go
package openrouter
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/image/draw"
|
|
_ "golang.org/x/image/webp"
|
|
)
|
|
|
|
const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview"
|
|
|
|
const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged."
|
|
|
|
var supportedAspectRatios = []struct {
|
|
value string
|
|
width int
|
|
height int
|
|
}{
|
|
{value: "1:1", width: 1, height: 1},
|
|
{value: "2:3", width: 2, height: 3},
|
|
{value: "3:2", width: 3, height: 2},
|
|
{value: "3:4", width: 3, height: 4},
|
|
{value: "4:3", width: 4, height: 3},
|
|
{value: "4:5", width: 4, height: 5},
|
|
{value: "5:4", width: 5, height: 4},
|
|
{value: "9:16", width: 9, height: 16},
|
|
{value: "16:9", width: 16, height: 9},
|
|
{value: "21:9", width: 21, height: 9},
|
|
}
|
|
|
|
type ImageGenerationRequest struct {
|
|
Prompt string
|
|
OutputPath string
|
|
Model string
|
|
Modalities []string
|
|
Provider *Provider
|
|
InputImagePath string
|
|
InputMimeType string
|
|
MaskImagePath string
|
|
MaskMimeType string
|
|
AspectRatio string
|
|
ImageSize string
|
|
TopP *float64
|
|
TargetWidth int
|
|
TargetHeight int
|
|
MatchSizeImagePath string
|
|
}
|
|
|
|
type ImageGenerationResult struct {
|
|
Path string
|
|
MimeType string
|
|
Model string
|
|
TextContent string
|
|
Width int
|
|
Height int
|
|
}
|
|
|
|
type ImageRecognitionRequest struct {
|
|
Prompt string
|
|
OutputPath string
|
|
Model string
|
|
Modalities []string
|
|
Provider *Provider
|
|
InputImagePath string
|
|
InputMimeType string
|
|
AspectRatio string
|
|
ImageSize string
|
|
TopP *float64
|
|
TargetWidth int
|
|
TargetHeight int
|
|
MatchSizeImagePath string
|
|
ResizeOutput bool
|
|
}
|
|
|
|
type ImageRecognitionResult = ImageGenerationResult
|
|
|
|
// RecognizeImage calls an OpenRouter image-capable chat model and saves the
|
|
// first returned image as a PNG file while returning the text content for JSON parsing.
|
|
func (c *Client) RecognizeImage(ctx context.Context, req ImageRecognitionRequest) (*ImageRecognitionResult, error) {
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
return nil, stderrors.New("openrouter: recognition prompt is required")
|
|
}
|
|
if strings.TrimSpace(req.InputImagePath) == "" {
|
|
return nil, stderrors.New("openrouter: recognition input image path is required")
|
|
}
|
|
if strings.TrimSpace(req.OutputPath) == "" {
|
|
return nil, stderrors.New("openrouter: recognition output path is required")
|
|
}
|
|
|
|
model := req.Model
|
|
if model == "" {
|
|
model = Gemini3ProImagePreviewModel
|
|
}
|
|
targetWidth, targetHeight := 0, 0
|
|
if req.ResizeOutput || req.AspectRatio == "" {
|
|
if req.MatchSizeImagePath == "" {
|
|
req.MatchSizeImagePath = req.InputImagePath
|
|
}
|
|
var err error
|
|
targetWidth, targetHeight, err = resolveTargetSize(ImageGenerationRequest{
|
|
TargetWidth: req.TargetWidth,
|
|
TargetHeight: req.TargetHeight,
|
|
MatchSizeImagePath: req.MatchSizeImagePath,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
content, err := buildImageRecognitionContent(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
chatReq := ChatRequest{
|
|
Model: model,
|
|
Modalities: imageOutputModalities(model, req.Modalities),
|
|
Provider: req.Provider,
|
|
TopP: req.TopP,
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: content,
|
|
},
|
|
},
|
|
}
|
|
if req.AspectRatio == "" && targetWidth > 0 && targetHeight > 0 {
|
|
req.AspectRatio = aspectRatioString(targetWidth, targetHeight)
|
|
}
|
|
if req.AspectRatio != "" || req.ImageSize != "" {
|
|
chatReq.ImageConfig = &ImageConfig{
|
|
AspectRatio: req.AspectRatio,
|
|
ImageSize: normalizeImageSize(req.ImageSize),
|
|
}
|
|
}
|
|
|
|
resp, err := c.chatCompletion(ctx, chatReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outputWidth, outputHeight := 0, 0
|
|
if req.ResizeOutput {
|
|
outputWidth, outputHeight = targetWidth, targetHeight
|
|
}
|
|
return saveFirstResponseImage(resp.Choices, req.OutputPath, model, outputWidth, outputHeight)
|
|
}
|
|
|
|
// GenerateImageToFile calls an OpenRouter image-capable chat model and saves the
|
|
// first returned image as a PNG file.
|
|
func (c *Client) GenerateImageToFile(ctx context.Context, req ImageGenerationRequest) (*ImageGenerationResult, error) {
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
return nil, stderrors.New("openrouter: image prompt is required")
|
|
}
|
|
if strings.TrimSpace(req.OutputPath) == "" {
|
|
return nil, stderrors.New("openrouter: image output path is required")
|
|
}
|
|
|
|
model := req.Model
|
|
if model == "" {
|
|
model = Gemini3ProImagePreviewModel
|
|
}
|
|
if req.MatchSizeImagePath == "" {
|
|
req.MatchSizeImagePath = req.InputImagePath
|
|
}
|
|
|
|
targetWidth, targetHeight, err := resolveTargetSize(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
content, err := buildImageGenerationContent(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
chatReq := ChatRequest{
|
|
Model: model,
|
|
Modalities: imageOutputModalities(model, req.Modalities),
|
|
Provider: req.Provider,
|
|
TopP: req.TopP,
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: content,
|
|
},
|
|
},
|
|
}
|
|
if req.AspectRatio == "" && targetWidth > 0 && targetHeight > 0 {
|
|
req.AspectRatio = aspectRatioString(targetWidth, targetHeight)
|
|
}
|
|
if req.AspectRatio != "" || req.ImageSize != "" {
|
|
chatReq.ImageConfig = &ImageConfig{
|
|
AspectRatio: req.AspectRatio,
|
|
ImageSize: normalizeImageSize(req.ImageSize),
|
|
}
|
|
}
|
|
|
|
resp, err := c.chatCompletion(ctx, chatReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return saveFirstResponseImage(resp.Choices, req.OutputPath, model, targetWidth, targetHeight)
|
|
}
|
|
|
|
func saveFirstResponseImage(choices []Choice, outputPath string, model string, targetWidth, targetHeight int) (*ImageGenerationResult, error) {
|
|
outputPath = normalizePNGOutputPath(outputPath)
|
|
if len(choices) == 0 {
|
|
return nil, stderrors.New("openrouter: image response has no choices")
|
|
}
|
|
|
|
message := choices[0].Message
|
|
if len(message.Images) == 0 {
|
|
return nil, fmt.Errorf("openrouter: image response has no images: %s", message.Content)
|
|
}
|
|
|
|
_, data, err := decodeDataURL(message.Images[0].ImageURL.URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, width, height, err := encodePNGImageData(data, targetWidth, targetHeight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("openrouter: create output directory: %w", err)
|
|
}
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
return nil, fmt.Errorf("openrouter: write image file: %w", err)
|
|
}
|
|
|
|
return &ImageGenerationResult{
|
|
Path: outputPath,
|
|
MimeType: "image/png",
|
|
Model: model,
|
|
TextContent: message.Content,
|
|
Width: width,
|
|
Height: height,
|
|
}, nil
|
|
}
|
|
|
|
func resolveTargetSize(req ImageGenerationRequest) (int, int, error) {
|
|
if req.TargetWidth < 0 || req.TargetHeight < 0 {
|
|
return 0, 0, stderrors.New("openrouter: target image size must be positive")
|
|
}
|
|
if req.TargetWidth > 0 || req.TargetHeight > 0 {
|
|
if req.TargetWidth == 0 || req.TargetHeight == 0 {
|
|
return 0, 0, stderrors.New("openrouter: target width and height must be set together")
|
|
}
|
|
return req.TargetWidth, req.TargetHeight, nil
|
|
}
|
|
if req.MatchSizeImagePath == "" {
|
|
return 0, 0, nil
|
|
}
|
|
|
|
file, err := os.Open(req.MatchSizeImagePath)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("openrouter: open match size image: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
config, _, err := image.DecodeConfig(file)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("openrouter: decode match size image config: %w", err)
|
|
}
|
|
return config.Width, config.Height, nil
|
|
}
|
|
|
|
func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, error) {
|
|
if req.MaskImagePath != "" && req.InputImagePath == "" {
|
|
return nil, stderrors.New("openrouter: mask image requires input image")
|
|
}
|
|
|
|
content := make([]ContentPart, 0, 4)
|
|
if req.InputImagePath != "" {
|
|
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content = append(content, ContentPart{
|
|
Type: "image_url",
|
|
ImageURL: &ImageURL{URL: dataURI},
|
|
})
|
|
}
|
|
if req.MaskImagePath != "" {
|
|
dataURI, err := imageFileDataURI(req.MaskImagePath, req.MaskMimeType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content = append(content, ContentPart{Type: "text", Text: maskImageInstruction})
|
|
content = append(content, ContentPart{
|
|
Type: "image_url",
|
|
ImageURL: &ImageURL{URL: dataURI},
|
|
})
|
|
}
|
|
content = append(content, ContentPart{Type: "text", Text: req.Prompt})
|
|
return content, nil
|
|
}
|
|
|
|
func buildImageRecognitionContent(req ImageRecognitionRequest) ([]ContentPart, error) {
|
|
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []ContentPart{
|
|
{
|
|
Type: "image_url",
|
|
ImageURL: &ImageURL{URL: dataURI},
|
|
},
|
|
{Type: "text", Text: req.Prompt},
|
|
}, nil
|
|
}
|
|
|
|
func imageFileDataURI(path string, mimeType string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("openrouter: read input image: %w", err)
|
|
}
|
|
if mimeType == "" {
|
|
mimeType = http.DetectContentType(data)
|
|
}
|
|
return fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(data)), nil
|
|
}
|
|
|
|
func normalizePNGOutputPath(outputPath string) string {
|
|
ext := filepath.Ext(outputPath)
|
|
if strings.EqualFold(ext, ".png") {
|
|
return strings.TrimSuffix(outputPath, ext) + ".png"
|
|
}
|
|
if ext == "" {
|
|
return outputPath + ".png"
|
|
}
|
|
return strings.TrimSuffix(outputPath, ext) + ".png"
|
|
}
|
|
|
|
func normalizeImageSize(imageSize string) string {
|
|
imageSize = strings.TrimSpace(imageSize)
|
|
if strings.HasSuffix(strings.ToLower(imageSize), "k") {
|
|
return strings.TrimSuffix(imageSize, imageSize[len(imageSize)-1:]) + "K"
|
|
}
|
|
return imageSize
|
|
}
|
|
|
|
func imageOutputModalities(model string, override []string) []string {
|
|
if len(override) > 0 {
|
|
return normalizeModalities(override)
|
|
}
|
|
if isImageOnlyModel(model) {
|
|
return []string{"image"}
|
|
}
|
|
return []string{"image", "text"}
|
|
}
|
|
|
|
func normalizeModalities(modalities []string) []string {
|
|
normalized := make([]string, 0, len(modalities))
|
|
seen := make(map[string]struct{}, len(modalities))
|
|
for _, modality := range modalities {
|
|
modality = strings.ToLower(strings.TrimSpace(modality))
|
|
if modality == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[modality]; ok {
|
|
continue
|
|
}
|
|
seen[modality] = struct{}{}
|
|
normalized = append(normalized, modality)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func isImageOnlyModel(model string) bool {
|
|
model = strings.ToLower(strings.TrimSpace(model))
|
|
imageOnlyPrefixes := []string{
|
|
"black-forest-labs/",
|
|
"sourceful/",
|
|
"bytedance-seed/",
|
|
}
|
|
for _, prefix := range imageOnlyPrefixes {
|
|
if strings.HasPrefix(model, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
imageOnlyModels := []string{
|
|
"x-ai/grok-imagine-image-quality",
|
|
}
|
|
for _, candidate := range imageOnlyModels {
|
|
if model == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func encodePNGImageData(data []byte, targetWidth, targetHeight int) ([]byte, int, int, error) {
|
|
src, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, 0, 0, fmt.Errorf("openrouter: decode generated image: %w", err)
|
|
}
|
|
|
|
dst := src
|
|
width := src.Bounds().Dx()
|
|
height := src.Bounds().Dy()
|
|
if targetWidth > 0 && targetHeight > 0 {
|
|
resized := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
|
draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil)
|
|
dst = resized
|
|
width = targetWidth
|
|
height = targetHeight
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, dst); err != nil {
|
|
return nil, 0, 0, fmt.Errorf("openrouter: encode png image: %w", err)
|
|
}
|
|
return buf.Bytes(), width, height, nil
|
|
}
|
|
|
|
func aspectRatioString(width, height int) string {
|
|
if width <= 0 || height <= 0 {
|
|
return ""
|
|
}
|
|
|
|
sourceRatio := math.Log(float64(width) / float64(height))
|
|
best := supportedAspectRatios[0]
|
|
bestDistance := math.Abs(sourceRatio - math.Log(float64(best.width)/float64(best.height)))
|
|
for _, candidate := range supportedAspectRatios[1:] {
|
|
distance := math.Abs(sourceRatio - math.Log(float64(candidate.width)/float64(candidate.height)))
|
|
if distance < bestDistance {
|
|
best = candidate
|
|
bestDistance = distance
|
|
}
|
|
}
|
|
return best.value
|
|
}
|
|
|
|
func decodeDataURL(dataURL string) (string, []byte, error) {
|
|
header, payload, ok := strings.Cut(dataURL, ",")
|
|
if !ok {
|
|
return "", nil, stderrors.New("openrouter: invalid image data url")
|
|
}
|
|
if !strings.HasPrefix(header, "data:") || !strings.Contains(header, ";base64") {
|
|
return "", nil, stderrors.New("openrouter: unsupported image data url")
|
|
}
|
|
|
|
mimeType := strings.TrimPrefix(strings.Split(header, ";")[0], "data:")
|
|
data, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("openrouter: decode image data: %w", err)
|
|
}
|
|
|
|
return mimeType, data, nil
|
|
}
|