| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "mime/multipart" |
| 10 | "net/http" |
| 11 | "net/textproto" |
| 12 | "path" |
| 13 | "strings" |
| 14 | "time" |
| 15 | "unicode/utf8" |
| 16 | ) |
| 17 | |
| 18 | const ( |
| 19 | anthropicFilesBeta = "files-api-2025-04-14" |
| 20 | maxUploadFilename = 512 |
| 21 | ) |
| 22 | |
| 23 | // FileUpload is a Files API image upload (purpose=user_data). |
| 24 | type FileUpload struct { |
| 25 | BaseURL string |
| 26 | APIKey string |
| 27 | AuthHeader bool |
| 28 | Protocol string // "anthropic" uses the Anthropic-compatible Files API |
| 29 | Filename string |
| 30 | Data []byte |
| 31 | Client *http.Client |
| 32 | } |
| 33 | |
| 34 | type openaiFileObject struct { |
| 35 | ID string `json:"id"` |
| 36 | } |
| 37 | |
| 38 | type anthropicFileObject struct { |
| 39 | ID string `json:"id"` |
| 40 | } |
| 41 | |
| 42 | // UploadUserDataFile uploads an image and returns its file_id (file-api-…). |
| 43 | func UploadUserDataFile(ctx context.Context, u FileUpload) (string, error) { |
| 44 | if len(u.Data) == 0 || len(u.Data) > MaxFileAPIImageBytes { |
| 45 | return "", fmt.Errorf("files api image must be between 1 byte and 64 MiB") |
| 46 | } |
| 47 | if strings.TrimSpace(u.APIKey) == "" { |
| 48 | return "", fmt.Errorf("files api: missing api key") |
| 49 | } |
| 50 | filename := sanitizeUploadFilename(u.Filename) |
| 51 | endpoint, err := filesEndpoint(u.BaseURL, u.Protocol) |
| 52 | if err != nil { |
| 53 | return "", err |
| 54 | } |
| 55 | body := &bytes.Buffer{} |
| 56 | writer := multipart.NewWriter(body) |
| 57 | if err := writer.WriteField("purpose", "user_data"); err != nil { |
| 58 | return "", err |
| 59 | } |
| 60 | hdr := make(textproto.MIMEHeader) |
| 61 | hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filename))) |
| 62 | hdr.Set("Content-Type", "application/octet-stream") |
| 63 | part, err := writer.CreatePart(hdr) |
| 64 | if err != nil { |
| 65 | return "", err |
| 66 | } |
| 67 | if _, err := part.Write(u.Data); err != nil { |
| 68 | return "", err |
| 69 | } |
| 70 | if err := writer.Close(); err != nil { |
| 71 | return "", err |
| 72 | } |
| 73 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) |
| 74 | if err != nil { |
| 75 | return "", err |
| 76 | } |
| 77 | req.Header.Set("Content-Type", writer.FormDataContentType()) |
| 78 | anthropic := strings.EqualFold(strings.TrimSpace(u.Protocol), "anthropic") |
| 79 | if anthropic { |
| 80 | if u.AuthHeader { |
| 81 | req.Header.Set("Authorization", "Bearer "+u.APIKey) |
| 82 | } else { |
| 83 | req.Header.Set("x-api-key", u.APIKey) |
| 84 | } |
| 85 | req.Header.Set("anthropic-beta", anthropicFilesBeta) |
| 86 | } else { |
| 87 | req.Header.Set("Authorization", "Bearer "+u.APIKey) |
| 88 | } |
| 89 | client := u.Client |
| 90 | if client == nil { |
| 91 | client = &http.Client{Timeout: 2 * time.Minute} |
| 92 | } |
| 93 | resp, err := client.Do(req) |
| 94 | if err != nil { |
| 95 | return "", fmt.Errorf("files api: %w", err) |
| 96 | } |
| 97 | defer resp.Body.Close() |
| 98 | raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| 99 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| 100 | return "", fmt.Errorf("files api: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) |
| 101 | } |
| 102 | id := parseUploadedFileID(raw, anthropic) |
| 103 | if !IsImageFileID(id) { |
| 104 | return "", fmt.Errorf("files api: missing file_id") |
| 105 | } |
| 106 | return id, nil |
| 107 | } |
| 108 | |
| 109 | func parseUploadedFileID(raw []byte, anthropic bool) string { |
| 110 | if anthropic { |
| 111 | var obj anthropicFileObject |
| 112 | if json.Unmarshal(raw, &obj) == nil { |
| 113 | return strings.TrimSpace(obj.ID) |
| 114 | } |
| 115 | } |
| 116 | var obj openaiFileObject |
| 117 | if json.Unmarshal(raw, &obj) == nil { |
| 118 | return strings.TrimSpace(obj.ID) |
| 119 | } |
| 120 | return "" |
| 121 | } |
| 122 | |
| 123 | func filesEndpoint(baseURL, protocol string) (string, error) { |
| 124 | base := strings.TrimRight(strings.TrimSpace(baseURL), "/") |
| 125 | if base == "" { |
| 126 | return "", fmt.Errorf("files api: empty base url") |
| 127 | } |
| 128 | base = strings.TrimSuffix(base, "/v1") |
| 129 | if strings.EqualFold(strings.TrimSpace(protocol), "anthropic") { |
| 130 | return base + "/v1/files", nil |
| 131 | } |
| 132 | return base + "/files", nil |
| 133 | } |
| 134 | |
| 135 | func sanitizeUploadFilename(name string) string { |
| 136 | name = path.Base(strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")) |
| 137 | if name == "" || name == "." || name == "/" { |
| 138 | name = "image.png" |
| 139 | } |
| 140 | if utf8.RuneCountInString(name) > maxUploadFilename { |
| 141 | runes := []rune(name) |
| 142 | name = string(runes[:maxUploadFilename]) |
| 143 | } |
| 144 | return name |
| 145 | } |
| 146 | |
| 147 | func escapeQuotes(s string) string { |
| 148 | return strings.ReplaceAll(s, `"`, `\"`) |
| 149 | } |
| 150 |