first commit
This commit is contained in:
454
plugins/maxim/attachments.go
Normal file
454
plugins/maxim/attachments.go
Normal file
@@ -0,0 +1,454 @@
|
||||
// Package maxim provides attachment extraction from Bifrost requests for Maxim logging.
|
||||
package maxim
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
"github.com/maximhq/maxim-go/logging"
|
||||
)
|
||||
|
||||
// ExtractAttachmentsFromRequest extracts image_url, file, and input_audio blocks from
|
||||
// Chat and Responses API messages, and input_images from image generation requests,
|
||||
// converting them to maxim-go attachment types.
|
||||
// Returns a slice of *logging.UrlAttachment or *logging.FileDataAttachment for use with
|
||||
// Logger.GenerationAddAttachment.
|
||||
func ExtractAttachmentsFromRequest(req *schemas.BifrostRequest) []interface{} {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch req.RequestType {
|
||||
case schemas.ChatCompletionRequest, schemas.ChatCompletionStreamRequest:
|
||||
return extractFromChatRequest(req.ChatRequest)
|
||||
case schemas.ResponsesRequest, schemas.ResponsesStreamRequest:
|
||||
return extractFromResponsesRequest(req.ResponsesRequest)
|
||||
case schemas.ImageGenerationRequest, schemas.ImageGenerationStreamRequest:
|
||||
return extractFromImageGenerationRequest(req.ImageGenerationRequest)
|
||||
case schemas.ImageEditRequest, schemas.ImageEditStreamRequest:
|
||||
return extractFromImageEditRequest(req.ImageEditRequest)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractFromImageGenerationRequest(igr *schemas.BifrostImageGenerationRequest) []interface{} {
|
||||
if igr == nil || igr.Params == nil || len(igr.Params.InputImages) == 0 {
|
||||
return nil
|
||||
}
|
||||
var attachments []interface{}
|
||||
for _, img := range igr.Params.InputImages {
|
||||
if att := inputImageStringToAttachment(img); att != nil {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
func extractFromImageEditRequest(ier *schemas.BifrostImageEditRequest) []interface{} {
|
||||
if ier == nil || ier.Input == nil || len(ier.Input.Images) == 0 {
|
||||
return nil
|
||||
}
|
||||
var attachments []interface{}
|
||||
for i, img := range ier.Input.Images {
|
||||
if att := imageInputToAttachment(img, i); att != nil {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
// imageInputToAttachment converts a raw ImageInput (binary image bytes) to a maxim FileDataAttachment.
|
||||
// idx is appended to the filename when greater than zero (for multi-image edit requests).
|
||||
func imageInputToAttachment(img schemas.ImageInput, idx int) interface{} {
|
||||
if len(img.Image) == 0 {
|
||||
return nil
|
||||
}
|
||||
mime := http.DetectContentType(img.Image)
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
mime = "image/png"
|
||||
}
|
||||
ext := extFromMime(mime)
|
||||
var name string
|
||||
if idx == 0 {
|
||||
name = "input_image." + ext
|
||||
} else {
|
||||
digits := []byte{byte('0' + idx%10)}
|
||||
if idx >= 10 {
|
||||
digits = []byte{byte('0' + idx/10), byte('0' + idx%10)}
|
||||
}
|
||||
name = "input_image_" + string(digits) + "." + ext
|
||||
}
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: img.Image,
|
||||
}
|
||||
}
|
||||
|
||||
// inputImageStringToAttachment maps ImageGenerationParameters.InputImages entries (URL,
|
||||
// data URL, or raw base64) to maxim attachment types.
|
||||
func inputImageStringToAttachment(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, "data:") || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||||
return urlToAttachment(s, "image", "")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return urlToAttachment(s, "image", "")
|
||||
}
|
||||
mime := http.DetectContentType(decoded)
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
mime = "image/png"
|
||||
}
|
||||
name := "input_image." + extFromMime(mime)
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: decoded,
|
||||
}
|
||||
}
|
||||
|
||||
func extractFromChatRequest(cr *schemas.BifrostChatRequest) []interface{} {
|
||||
if cr == nil || cr.Input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var attachments []interface{}
|
||||
for _, msg := range cr.Input {
|
||||
if msg.Content == nil || msg.Content.ContentBlocks == nil {
|
||||
continue
|
||||
}
|
||||
for _, block := range msg.Content.ContentBlocks {
|
||||
if att := chatBlockToAttachment(block); att != nil {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
func extractFromResponsesRequest(rr *schemas.BifrostResponsesRequest) []interface{} {
|
||||
if rr == nil || rr.Input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var attachments []interface{}
|
||||
for _, msg := range rr.Input {
|
||||
if msg.Content == nil || msg.Content.ContentBlocks == nil {
|
||||
continue
|
||||
}
|
||||
for _, block := range msg.Content.ContentBlocks {
|
||||
if att := responsesBlockToAttachment(block); att != nil {
|
||||
attachments = append(attachments, att)
|
||||
}
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
func chatBlockToAttachment(block schemas.ChatContentBlock) interface{} {
|
||||
switch block.Type {
|
||||
case schemas.ChatContentBlockTypeImage:
|
||||
if block.ImageURLStruct != nil && block.ImageURLStruct.URL != "" {
|
||||
return urlToAttachment(block.ImageURLStruct.URL, "image", "")
|
||||
}
|
||||
case schemas.ChatContentBlockTypeFile:
|
||||
if block.File != nil {
|
||||
return chatFileToAttachment(block.File)
|
||||
}
|
||||
case schemas.ChatContentBlockTypeInputAudio:
|
||||
if block.InputAudio != nil && block.InputAudio.Data != "" {
|
||||
return audioDataToAttachment(block.InputAudio.Data, block.InputAudio.Format)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsesBlockToAttachment(block schemas.ResponsesMessageContentBlock) interface{} {
|
||||
switch block.Type {
|
||||
case schemas.ResponsesInputMessageContentBlockTypeImage:
|
||||
if block.ImageURL != nil && *block.ImageURL != "" {
|
||||
return urlToAttachment(*block.ImageURL, "image", "")
|
||||
}
|
||||
case schemas.ResponsesInputMessageContentBlockTypeFile:
|
||||
return responsesFileToAttachment(&block)
|
||||
case schemas.ResponsesInputMessageContentBlockTypeAudio:
|
||||
if block.Audio != nil && block.Audio.Data != "" {
|
||||
format := block.Audio.Format
|
||||
if format == "" {
|
||||
format = "mp3"
|
||||
}
|
||||
return audioDataToAttachment(block.Audio.Data, &format)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsesFileToAttachment(block *schemas.ResponsesMessageContentBlock) interface{} {
|
||||
if block.FileURL != nil && *block.FileURL != "" {
|
||||
name := "attachment"
|
||||
if block.Filename != nil && *block.Filename != "" {
|
||||
name = *block.Filename
|
||||
}
|
||||
mime := ""
|
||||
if block.FileType != nil {
|
||||
mime = *block.FileType
|
||||
}
|
||||
urlStr := *block.FileURL
|
||||
return &logging.UrlAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
Metadata: map[string]string{"url": urlStr},
|
||||
},
|
||||
Type: logging.AttachmentTypeURL,
|
||||
URL: urlStr,
|
||||
}
|
||||
}
|
||||
if block.FileData != nil && *block.FileData != "" {
|
||||
data, err := base64.StdEncoding.DecodeString(*block.FileData)
|
||||
if err != nil {
|
||||
log.Printf("%s failed to decode file_data base64: %v", PluginLoggerPrefix, err)
|
||||
return nil
|
||||
}
|
||||
name := "attachment"
|
||||
if block.Filename != nil && *block.Filename != "" {
|
||||
name = *block.Filename
|
||||
}
|
||||
mime := "application/octet-stream"
|
||||
if block.FileType != nil && *block.FileType != "" {
|
||||
mime = *block.FileType
|
||||
}
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatFileToAttachment(f *schemas.ChatInputFile) interface{} {
|
||||
if f.FileURL != nil && *f.FileURL != "" {
|
||||
name := "attachment"
|
||||
if f.Filename != nil && *f.Filename != "" {
|
||||
name = *f.Filename
|
||||
}
|
||||
mime := ""
|
||||
if f.FileType != nil {
|
||||
mime = *f.FileType
|
||||
}
|
||||
urlStr := *f.FileURL
|
||||
return &logging.UrlAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
Metadata: map[string]string{"url": urlStr},
|
||||
},
|
||||
Type: logging.AttachmentTypeURL,
|
||||
URL: urlStr,
|
||||
}
|
||||
}
|
||||
if f.FileData != nil && *f.FileData != "" {
|
||||
data, err := base64.StdEncoding.DecodeString(*f.FileData)
|
||||
if err != nil {
|
||||
log.Printf("%s failed to decode file_data base64: %v", PluginLoggerPrefix, err)
|
||||
return nil
|
||||
}
|
||||
name := "attachment"
|
||||
if f.Filename != nil && *f.Filename != "" {
|
||||
name = *f.Filename
|
||||
}
|
||||
mime := "application/octet-stream"
|
||||
if f.FileType != nil && *f.FileType != "" {
|
||||
mime = *f.FileType
|
||||
}
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func audioDataToAttachment(data string, format *string) interface{} {
|
||||
// Data can be base64 or a data URL (data:audio/wav;base64,...)
|
||||
var b64 string
|
||||
if strings.HasPrefix(data, "data:") {
|
||||
idx := strings.Index(data, ";base64,")
|
||||
if idx == -1 {
|
||||
log.Printf("%s invalid audio data URL format", PluginLoggerPrefix)
|
||||
return nil
|
||||
}
|
||||
b64 = data[idx+8:]
|
||||
} else {
|
||||
b64 = data
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
log.Printf("%s failed to decode audio base64: %v", PluginLoggerPrefix, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
mime := "audio/mpeg"
|
||||
if format != nil {
|
||||
switch strings.ToLower(*format) {
|
||||
case "wav":
|
||||
mime = "audio/wav"
|
||||
case "mp3":
|
||||
mime = "audio/mpeg"
|
||||
default:
|
||||
mime = "audio/" + *format
|
||||
}
|
||||
}
|
||||
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: "audio." + extFromMime(mime),
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: decoded,
|
||||
}
|
||||
}
|
||||
|
||||
// urlToAttachment builds a UrlAttachment (e.g. chat vision image_url). For images, MIME is:
|
||||
// outputFormat when set → URL rsct query (e.g. Azure) → image/png.
|
||||
func urlToAttachment(urlStr string, kind string, outputFormat string) interface{} {
|
||||
if strings.HasPrefix(urlStr, "data:") {
|
||||
return dataURLToAttachment(urlStr, kind)
|
||||
}
|
||||
// HTTP/HTTPS URL
|
||||
name := "attachment"
|
||||
mime := ""
|
||||
u, errParse := url.Parse(urlStr)
|
||||
if errParse == nil {
|
||||
if p := path.Base(u.Path); p != "" && p != "." {
|
||||
name = p
|
||||
}
|
||||
}
|
||||
if kind == "image" {
|
||||
mime = imageOutputFormatToMime(outputFormat)
|
||||
if mime == "" && errParse == nil {
|
||||
mime = imageOutputFormatToMime(strings.TrimPrefix(strings.ToLower(path.Ext(u.Path)), "."))
|
||||
}
|
||||
if mime == "" && errParse == nil {
|
||||
if q := u.Query().Get("rsct"); q != "" {
|
||||
mime = q
|
||||
}
|
||||
}
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
}
|
||||
return &logging.UrlAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
Metadata: map[string]string{"url": urlStr},
|
||||
},
|
||||
Type: logging.AttachmentTypeURL,
|
||||
URL: urlStr,
|
||||
}
|
||||
}
|
||||
|
||||
// imageOutputFormatToMime maps provider output_format (e.g. png, jpeg, webp) to a MIME type.
|
||||
func imageOutputFormatToMime(format string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "jpeg", "jpg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func dataURLToAttachment(dataURL string, kind string) interface{} {
|
||||
// Format: data:image/png;base64,iVBORw0...
|
||||
idx := strings.Index(dataURL, ";base64,")
|
||||
if idx == -1 {
|
||||
log.Printf("%s invalid data URL format", PluginLoggerPrefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
header := dataURL[5:idx] // "image/png" or "image/png;charset=..."
|
||||
mime := strings.Split(header, ";")[0]
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
|
||||
b64 := dataURL[idx+8:]
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
log.Printf("%s failed to decode data URL base64: %v", PluginLoggerPrefix, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
name := kind + "." + extFromMime(mime)
|
||||
return &logging.FileDataAttachment{
|
||||
BaseAttachmentProps: logging.BaseAttachmentProps{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
MimeType: mime,
|
||||
},
|
||||
Type: logging.AttachmentTypeFileData,
|
||||
Data: decoded,
|
||||
}
|
||||
}
|
||||
|
||||
func extFromMime(mime string) string {
|
||||
switch mime {
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "audio/wav":
|
||||
return "wav"
|
||||
case "audio/mpeg":
|
||||
return "mp3"
|
||||
case "application/pdf":
|
||||
return "pdf"
|
||||
default:
|
||||
return "bin"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user