Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
208 lines
6.3 KiB
Go
208 lines
6.3 KiB
Go
package router
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// The Files API, as far as batches need it: a client uploads a file of
|
|
// requests, and reads back the files a batch writes.
|
|
|
|
const (
|
|
purposeBatch = "batch"
|
|
purposeBatchOutput = "batch_output"
|
|
)
|
|
|
|
func newID(prefix string) string {
|
|
var b [12]byte
|
|
_, _ = rand.Read(b[:])
|
|
return prefix + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// ownerOf is who a file or a batch belongs to: the key's project, so every
|
|
// key of it sees them, or the person for a personal key.
|
|
func ownerOf(p auth.Principal) string {
|
|
switch {
|
|
case p.ProjectID != "":
|
|
return "project:" + p.ProjectID
|
|
case p.UserID != "":
|
|
return "user:" + p.UserID
|
|
}
|
|
return "key:" + p.KeyID
|
|
}
|
|
|
|
// batchPrincipal is the caller, or a refusal when there is no store to keep
|
|
// files and batches in.
|
|
func (r *Router) batchPrincipal(w http.ResponseWriter, req *http.Request) (auth.Principal, bool) {
|
|
p, ok := auth.FromContext(req.Context())
|
|
if !ok || r.store == nil {
|
|
writeError(w, http.StatusNotImplemented, "invalid_request_error",
|
|
"files and batches need authentication and the store, which this gateway runs without")
|
|
return auth.Principal{}, false
|
|
}
|
|
// A batch's files are prompts and answers kept: not under a policy
|
|
// that keeps nothing.
|
|
if r.noStore(guard.Subject{KeyID: p.KeyID, UserID: p.UserID, ProjectID: p.ProjectID,
|
|
TeamID: p.TeamID, OrgID: p.OrgID, Path: req.URL.Path}) {
|
|
writeError(w, http.StatusForbidden, "invalid_request_error",
|
|
"the gateway's policy for this caller keeps nothing, and a batch is prompts and answers kept")
|
|
return auth.Principal{}, false
|
|
}
|
|
return p, true
|
|
}
|
|
|
|
func (r *Router) batchCfg() (maxBytes int64, maxLines, concurrency, retainDays int) {
|
|
b := r.cfg.Batch
|
|
maxBytes, maxLines, concurrency, retainDays = b.MaxFileBytes, b.MaxLines, b.Concurrency, b.RetainDays
|
|
if maxBytes <= 0 {
|
|
maxBytes = 100 << 20
|
|
}
|
|
if maxLines <= 0 {
|
|
maxLines = 50000
|
|
}
|
|
if concurrency <= 0 {
|
|
concurrency = 4
|
|
}
|
|
if retainDays <= 0 {
|
|
retainDays = 30
|
|
}
|
|
return
|
|
}
|
|
|
|
func fileJSON(f store.File) map[string]any {
|
|
out := map[string]any{"id": f.ID, "object": "file", "bytes": f.Bytes, "created_at": f.CreatedAt.Unix(),
|
|
"filename": f.Filename, "purpose": f.Purpose, "status": "processed"}
|
|
if !f.ExpiresAt.IsZero() {
|
|
out["expires_at"] = f.ExpiresAt.Unix()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *Router) handleUploadFile(w http.ResponseWriter, req *http.Request) {
|
|
p, ok := r.batchPrincipal(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
maxBytes, _, _, _ := r.batchCfg()
|
|
req.Body = http.MaxBytesReader(w, req.Body, maxBytes+1<<20)
|
|
if err := req.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error",
|
|
fmt.Sprintf("a multipart form with purpose and file, at most %d MB: %v", maxBytes>>20, err))
|
|
return
|
|
}
|
|
if purpose := req.FormValue("purpose"); purpose != purposeBatch {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error",
|
|
fmt.Sprintf("purpose %q: this gateway keeps files for batches only (purpose batch)", purpose))
|
|
return
|
|
}
|
|
file, header, err := req.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", "no file in the form: "+err.Error())
|
|
return
|
|
}
|
|
defer file.Close()
|
|
content, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
return
|
|
}
|
|
if int64(len(content)) > maxBytes {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request_error",
|
|
fmt.Sprintf("the file is larger than %d MB", maxBytes>>20))
|
|
return
|
|
}
|
|
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeBatch,
|
|
Filename: header.Filename, Bytes: int64(len(content)), CreatedAt: time.Now()}
|
|
if err := r.store.PutFile(req.Context(), f, content); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, fileJSON(f))
|
|
}
|
|
|
|
// ownedFile is a file the caller owns, or a 404 said.
|
|
func (r *Router) ownedFile(w http.ResponseWriter, req *http.Request, p auth.Principal) (store.File, bool) {
|
|
f, err := r.store.FileByID(req.Context(), req.PathValue("id"))
|
|
if errors.Is(err, store.ErrNotFound) || err == nil && f.Owner != ownerOf(p) {
|
|
writeErrorCode(w, http.StatusNotFound, "invalid_request_error", "file_not_found",
|
|
fmt.Sprintf("no file %q", req.PathValue("id")))
|
|
return store.File{}, false
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
|
return store.File{}, false
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
func (r *Router) handleListFiles(w http.ResponseWriter, req *http.Request) {
|
|
p, ok := r.batchPrincipal(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
fs, err := r.store.Files(req.Context(), ownerOf(p), req.URL.Query().Get("purpose"), 0)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
|
return
|
|
}
|
|
data := make([]map[string]any, 0, len(fs))
|
|
for _, f := range fs {
|
|
data = append(data, fileJSON(f))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
|
}
|
|
|
|
func (r *Router) handleGetFile(w http.ResponseWriter, req *http.Request) {
|
|
p, ok := r.batchPrincipal(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if f, ok := r.ownedFile(w, req, p); ok {
|
|
writeJSON(w, http.StatusOK, fileJSON(f))
|
|
}
|
|
}
|
|
|
|
func (r *Router) handleFileContent(w http.ResponseWriter, req *http.Request) {
|
|
p, ok := r.batchPrincipal(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
f, ok := r.ownedFile(w, req, p)
|
|
if !ok {
|
|
return
|
|
}
|
|
b, err := r.store.FileContent(req.Context(), f.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", f.Filename))
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
func (r *Router) handleDeleteFile(w http.ResponseWriter, req *http.Request) {
|
|
p, ok := r.batchPrincipal(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
f, ok := r.ownedFile(w, req, p)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := r.store.DeleteFile(req.Context(), f.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": f.ID, "object": "file", "deleted": true})
|
|
}
|