mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-29 06:56:57 +00:00
feat: unified image and audio endpoints with gemini image translation
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package traefikllmgateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// geminiImageAspectRatioBySize maps OpenAI's images.generations "size"
|
||||
// values to the Imagen aspectRatio parameter this translator understands,
|
||||
// per spec §3's mapping table. A size string absent from this map is one
|
||||
// Imagen has no known aspect ratio for — geminiImageAspectRatio rejects it
|
||||
// as a *translateError rather than guessing.
|
||||
var geminiImageAspectRatioBySize = map[string]string{
|
||||
"1024x1024": "1:1",
|
||||
"1792x1024": "16:9",
|
||||
"1024x1792": "9:16",
|
||||
"512x512": "1:1",
|
||||
}
|
||||
|
||||
// geminiImageAspectRatio resolves req's OpenAI "size" field to an Imagen
|
||||
// aspectRatio parameter: an absent or empty size defaults to "1:1" (spec
|
||||
// §3), and any other value must be one of geminiImageAspectRatioBySize's
|
||||
// keys. An unrecognized size is a *translateError, not a silent fallback
|
||||
// — the client asked for a specific size the gateway cannot honor, so
|
||||
// letting Imagen apply its own default instead would silently ignore the
|
||||
// request rather than fail loudly.
|
||||
func geminiImageAspectRatio(size string) (string, error) {
|
||||
if size == "" {
|
||||
return "1:1", nil
|
||||
}
|
||||
ratio, ok := geminiImageAspectRatioBySize[size]
|
||||
if !ok {
|
||||
return "", &translateError{msg: fmt.Sprintf("size %q is not supported for gemini image generation", size)}
|
||||
}
|
||||
return ratio, nil
|
||||
}
|
||||
|
||||
// geminiImagesRequestFromOpenAI maps an OpenAI images.generations request
|
||||
// body ({"model","prompt","n","size","response_format",...}) to Imagen's
|
||||
// :predict request body ({"instances":[{"prompt"}],"parameters":
|
||||
// {"sampleCount","aspectRatio"}}), per spec §3. response_format:"url" is
|
||||
// rejected outright — the gateway stores nothing, so it cannot honor a
|
||||
// client asking for a hosted URL back; the response this translator
|
||||
// always produces is b64_json (translate_gemini_images.go's
|
||||
// openAIImagesResponseFromGemini). A truthy "quality" or "style" field is
|
||||
// rejected the same way chat's translators reject an unsupported field
|
||||
// (isTruthy, translate_anthropic.go): Imagen has no equivalent for
|
||||
// either. n defaults to 1 when absent or non-positive, matching OpenAI's
|
||||
// own default. The request's "model" field is deliberately never read
|
||||
// here — the adapter reads it separately to build the :predict URL, the
|
||||
// same convention chatCompletion/embeddings already follow.
|
||||
func geminiImagesRequestFromOpenAI(req map[string]any) (map[string]any, error) {
|
||||
if rf, ok := req["response_format"].(string); ok && rf == "url" {
|
||||
return nil, &translateError{msg: `response_format "url" is not supported for gemini image generation (the gateway stores nothing to host a URL from)`}
|
||||
}
|
||||
if v, ok := req["quality"]; ok && isTruthy(v) {
|
||||
return nil, geminiUnsupportedFieldError("quality")
|
||||
}
|
||||
if v, ok := req["style"]; ok && isTruthy(v) {
|
||||
return nil, geminiUnsupportedFieldError("style")
|
||||
}
|
||||
|
||||
prompt, _ := req["prompt"].(string)
|
||||
|
||||
n := int64(1)
|
||||
if v, ok := req["n"]; ok {
|
||||
if parsed, ok2 := toInt64(v); ok2 && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
|
||||
size, _ := req["size"].(string)
|
||||
aspectRatio, err := geminiImageAspectRatio(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"instances": []any{map[string]any{"prompt": prompt}},
|
||||
"parameters": map[string]any{
|
||||
"sampleCount": n,
|
||||
"aspectRatio": aspectRatio,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// geminiImagePrediction is one entry of Imagen's :predict response
|
||||
// "predictions" array this translator reads.
|
||||
type geminiImagePrediction struct {
|
||||
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||
}
|
||||
|
||||
// geminiImagePredictResponse is the subset of Imagen's :predict response
|
||||
// body this translator reads.
|
||||
type geminiImagePredictResponse struct {
|
||||
Predictions []geminiImagePrediction `json:"predictions"`
|
||||
}
|
||||
|
||||
// openAIImagesResponseFromGemini maps an Imagen :predict response body to
|
||||
// an OpenAI images.generations response ({"created","data":[{"b64_json"}]}),
|
||||
// per spec §3's response mapping. created is the caller-supplied response
|
||||
// timestamp — Imagen's response carries none. An empty predictions array
|
||||
// decodes to an empty (non-nil) data array, not an error: Imagen
|
||||
// answering with zero images is a valid, if unusual, response to forward
|
||||
// as-is.
|
||||
func openAIImagesResponseFromGemini(body []byte, created int64) (map[string]any, error) {
|
||||
var resp geminiImagePredictResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode gemini images response: %w", errUpstream, err)
|
||||
}
|
||||
|
||||
data := make([]any, len(resp.Predictions))
|
||||
for i, p := range resp.Predictions {
|
||||
data[i] = map[string]any{"b64_json": p.BytesBase64Encoded}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"created": created,
|
||||
"data": data,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user