mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-11 03:37:40 +08:00
Merge pull request #404 from NgoQuocViet2001/ai/openai-file-retrieve
feat(openai): retrieve uploaded file metadata
This commit is contained in:
@@ -22,6 +22,9 @@ const (
|
||||
|
||||
var fileReadySleep = time.Sleep
|
||||
|
||||
// ErrUploadFileNotFound indicates that DeepSeek returned no matching uploaded file.
|
||||
var ErrUploadFileNotFound = errors.New("uploaded file not found")
|
||||
|
||||
func (c *Client) waitForUploadedFile(ctx context.Context, a *auth.RequestAuth, result *UploadFileResult) error {
|
||||
if result == nil || strings.TrimSpace(result.ID) == "" {
|
||||
return nil
|
||||
@@ -42,7 +45,7 @@ func (c *Client) waitForUploadedFile(ctx context.Context, a *auth.RequestAuth, r
|
||||
return fmt.Errorf("waiting for file %s to become ready: %w", result.ID, err)
|
||||
}
|
||||
|
||||
fetched, err := c.fetchUploadedFile(pollCtx, a, result.ID)
|
||||
fetched, err := c.FetchUploadedFile(pollCtx, a, result.ID)
|
||||
if err == nil && fetched != nil {
|
||||
mergeUploadFileResults(result, fetched)
|
||||
if isReadyUploadFileStatus(result.Status) {
|
||||
@@ -65,7 +68,8 @@ func (c *Client) waitForUploadedFile(ctx context.Context, a *auth.RequestAuth, r
|
||||
return fmt.Errorf("file %s did not become ready: %w", result.ID, lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) fetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fileID string) (*UploadFileResult, error) {
|
||||
// FetchUploadedFile returns metadata for an uploaded DeepSeek file by ID.
|
||||
func (c *Client) FetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fileID string) (*UploadFileResult, error) {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return nil, errors.New("file id is required")
|
||||
@@ -92,7 +96,7 @@ func (c *Client) fetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fil
|
||||
|
||||
result := extractFetchedUploadFileResult(resp, fileID)
|
||||
if result == nil || strings.TrimSpace(result.ID) == "" {
|
||||
return nil, errors.New("fetch files succeeded without matching file data")
|
||||
return nil, ErrUploadFileNotFound
|
||||
}
|
||||
result.Raw = resp
|
||||
return result, nil
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/chathistory"
|
||||
"ds2api/internal/config"
|
||||
@@ -22,6 +26,10 @@ type Handler struct {
|
||||
ChatHistory *chathistory.Store
|
||||
}
|
||||
|
||||
type fileFetcher interface {
|
||||
FetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fileID string) (*dsclient.UploadFileResult, error)
|
||||
}
|
||||
|
||||
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := h.Auth.Determine(r)
|
||||
if err != nil {
|
||||
@@ -85,6 +93,44 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
shared.WriteJSON(w, http.StatusOK, buildOpenAIFileObject(result))
|
||||
}
|
||||
|
||||
func (h *Handler) RetrieveFile(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := h.Auth.Determine(r)
|
||||
if err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
detail := err.Error()
|
||||
if err == auth.ErrNoAccount {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
shared.WriteOpenAIError(w, status, detail)
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
|
||||
fileID := strings.TrimSpace(chi.URLParam(r, "file_id"))
|
||||
if fileID == "" {
|
||||
shared.WriteOpenAIError(w, http.StatusBadRequest, "file_id is required")
|
||||
return
|
||||
}
|
||||
fetcher, ok := h.DS.(fileFetcher)
|
||||
if !ok {
|
||||
shared.WriteOpenAIError(w, http.StatusNotImplemented, "file retrieval is not available")
|
||||
return
|
||||
}
|
||||
result, err := fetcher.FetchUploadedFile(r.Context(), a, fileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, dsclient.ErrUploadFileNotFound) {
|
||||
shared.WriteOpenAIError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
shared.WriteOpenAIError(w, http.StatusInternalServerError, "Failed to retrieve file.")
|
||||
return
|
||||
}
|
||||
if result != nil && result.AccountID == "" {
|
||||
result.AccountID = a.AccountID
|
||||
}
|
||||
shared.WriteJSON(w, http.StatusOK, buildOpenAIFileObject(result))
|
||||
}
|
||||
|
||||
func resolveUploadModelType(store shared.ConfigReader, r *http.Request) string {
|
||||
for _, candidate := range []string{r.FormValue("model_type"), r.Header.Get("X-Model-Type")} {
|
||||
if modelType := normalizeUploadModelType(candidate); modelType != "" {
|
||||
|
||||
@@ -43,6 +43,7 @@ func (managedFilesAuthStub) Release(_ *auth.RequestAuth) {}
|
||||
type filesRouteDSStub struct {
|
||||
lastReq dsclient.UploadFileRequest
|
||||
upload *dsclient.UploadFileResult
|
||||
fetched *dsclient.UploadFileResult
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -65,6 +66,16 @@ func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, re
|
||||
return &dsclient.UploadFileResult{ID: "file-123", Filename: req.Filename, Bytes: int64(len(req.Data)), Purpose: req.Purpose, Status: "uploaded"}, nil
|
||||
}
|
||||
|
||||
func (m *filesRouteDSStub) FetchUploadedFile(_ context.Context, _ *auth.RequestAuth, fileID string) (*dsclient.UploadFileResult, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
if m.fetched != nil {
|
||||
return m.fetched, nil
|
||||
}
|
||||
return &dsclient.UploadFileResult{ID: fileID, Filename: "notes.txt", Bytes: 11, Purpose: "assistants", Status: "processed"}, nil
|
||||
}
|
||||
|
||||
func (m *filesRouteDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
@@ -169,6 +180,54 @@ func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRouteRetrieveSuccess(t *testing.T) {
|
||||
ds := &filesRouteDSStub{fetched: &dsclient.UploadFileResult{
|
||||
ID: "file-123",
|
||||
Filename: "notes.txt",
|
||||
Bytes: 11,
|
||||
Purpose: "assistants",
|
||||
Status: "processed",
|
||||
}}
|
||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, DS: ds}
|
||||
r := chi.NewRouter()
|
||||
registerOpenAITestRoutes(r, h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/files/file-123", nil)
|
||||
req.Header.Set("Authorization", "Bearer direct-token")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if out["id"] != "file-123" || out["filename"] != "notes.txt" || out["status"] != "processed" {
|
||||
t.Fatalf("unexpected file object: %#v", out)
|
||||
}
|
||||
if out["account_id"] != "acct-123" {
|
||||
t.Fatalf("expected account_id acct-123, got %#v", out["account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRouteRetrieveNotFound(t *testing.T) {
|
||||
ds := &filesRouteDSStub{err: dsclient.ErrUploadFileNotFound}
|
||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
||||
r := chi.NewRouter()
|
||||
registerOpenAITestRoutes(r, h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/files/missing-file", nil)
|
||||
req.Header.Set("Authorization", "Bearer direct-token")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRouteRejectsNonMultipart(t *testing.T) {
|
||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}}
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -104,6 +104,7 @@ func registerOpenAITestRoutes(r chi.Router, h *openAITestSurface) {
|
||||
r.Post("/v1/responses", h.responsesHandler().Responses)
|
||||
r.Get("/v1/responses/{response_id}", h.responsesHandler().GetResponseByID)
|
||||
r.Post("/v1/files", h.filesHandler().UploadFile)
|
||||
r.Get("/v1/files/{file_id}", h.filesHandler().RetrieveFile)
|
||||
r.Post("/v1/embeddings", h.embeddingsHandler().Embeddings)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ func NewApp() (*App, error) {
|
||||
r.Post("/v1/responses", responsesHandler.Responses)
|
||||
r.Get("/v1/responses/{response_id}", responsesHandler.GetResponseByID)
|
||||
r.Post("/v1/files", filesHandler.UploadFile)
|
||||
r.Get("/v1/files/{file_id}", filesHandler.RetrieveFile)
|
||||
r.Post("/v1/embeddings", embeddingsHandler.Embeddings)
|
||||
// Root OpenAI aliases support clients configured with the bare DS2API service URL.
|
||||
r.Get("/models", modelsHandler.ListModels)
|
||||
@@ -107,6 +108,7 @@ func NewApp() (*App, error) {
|
||||
r.Post("/responses", responsesHandler.Responses)
|
||||
r.Get("/responses/{response_id}", responsesHandler.GetResponseByID)
|
||||
r.Post("/files", filesHandler.UploadFile)
|
||||
r.Get("/files/{file_id}", filesHandler.RetrieveFile)
|
||||
r.Post("/embeddings", embeddingsHandler.Embeddings)
|
||||
claude.RegisterRoutes(r, claudeHandler)
|
||||
gemini.RegisterRoutes(r, geminiHandler)
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestAPIRoutesRemainRegistered(t *testing.T) {
|
||||
"POST /v1/responses",
|
||||
"GET /v1/responses/{response_id}",
|
||||
"POST /v1/files",
|
||||
"GET /v1/files/{file_id}",
|
||||
"POST /v1/embeddings",
|
||||
"GET /models",
|
||||
"GET /models/{model_id}",
|
||||
@@ -43,6 +44,7 @@ func TestAPIRoutesRemainRegistered(t *testing.T) {
|
||||
"POST /responses",
|
||||
"GET /responses/{response_id}",
|
||||
"POST /files",
|
||||
"GET /files/{file_id}",
|
||||
"POST /embeddings",
|
||||
"GET /anthropic/v1/models",
|
||||
"POST /anthropic/v1/messages",
|
||||
|
||||
Reference in New Issue
Block a user