From 36d0239dc6c36d2094478cce671ab10ecd46f95c Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Sat, 2 May 2026 14:33:42 +0700 Subject: [PATCH] feat(openai): retrieve uploaded file metadata --- API.en.md | 7 ++- API.md | 7 ++- README.MD | 4 +- README.en.md | 4 +- .../deepseek/client/client_file_status.go | 10 +++- .../httpapi/openai/files/handler_files.go | 46 +++++++++++++++ internal/httpapi/openai/files_route_test.go | 59 +++++++++++++++++++ internal/httpapi/openai/test_bridge_test.go | 1 + internal/server/router.go | 2 + internal/server/router_routes_test.go | 2 + 10 files changed, 133 insertions(+), 9 deletions(-) diff --git a/API.en.md b/API.en.md index d4454b9..6cf2737 100644 --- a/API.en.md +++ b/API.en.md @@ -111,6 +111,7 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | GET | `/v1/responses/{response_id}` | Business | Query stored response (in-memory TTL) | | POST | `/v1/embeddings` | Business | OpenAI Embeddings API | | POST | `/v1/files` | Business | OpenAI Files upload (multipart/form-data) | +| GET | `/v1/files/{file_id}` | Business | Retrieve uploaded file status | | GET | `/anthropic/v1/models` | None | Claude model list | | POST | `/anthropic/v1/messages` | Business | Claude messages | | POST | `/anthropic/v1/messages/count_tokens` | Business | Claude token counting | @@ -167,7 +168,7 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit | | GET | `/admin/version` | Admin | Check current version and latest Release | -OpenAI `/v1/*` paths are canonical. For clients configured with the bare DS2API service URL, the same OpenAI handlers are also exposed through root shortcuts: `/models`, `/models/{id}`, `/chat/completions`, `/responses`, `/responses/{response_id}`, `/embeddings`, and `/files`. +OpenAI `/v1/*` paths are canonical. For clients configured with the bare DS2API service URL, the same OpenAI handlers are also exposed through root shortcuts: `/models`, `/models/{id}`, `/chat/completions`, `/responses`, `/responses/{response_id}`, `/embeddings`, `/files`, and `/files/{file_id}`. --- @@ -440,6 +441,10 @@ Constraints and behavior: - Total request size limit is **100 MiB** (over-limit returns `413`). - Success returns an OpenAI `file` object (`id/object/bytes/filename/purpose/status`, etc.) and includes `account_id` for source-account tracing. +### `GET /v1/files/{file_id}` + +Business auth required. Retrieves the current DeepSeek upload status for a file and returns an OpenAI `file` object. Returns `404` when no matching file is found. + --- ## Claude-Compatible API diff --git a/API.md b/API.md index 05c4edd..37b3036 100644 --- a/API.md +++ b/API.md @@ -111,6 +111,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | GET | `/v1/responses/{response_id}` | 业务 | 查询已生成 response(内存 TTL) | | POST | `/v1/embeddings` | 业务 | OpenAI Embeddings 接口 | | POST | `/v1/files` | 业务 | OpenAI Files 上传(multipart/form-data) | +| GET | `/v1/files/{file_id}` | 业务 | 查询已上传文件状态 | | GET | `/anthropic/v1/models` | 无 | Claude 模型列表 | | POST | `/anthropic/v1/messages` | 业务 | Claude 消息接口 | | POST | `/anthropic/v1/messages/count_tokens` | 业务 | Claude token 计数 | @@ -167,7 +168,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | PUT | `/admin/chat-history/settings` | Admin | 更新对话记录保留条数 | | GET | `/admin/version` | Admin | 查询当前版本与最新 Release | -OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端,同一套 OpenAI handler 也通过根路径快捷路由暴露:`/models`、`/models/{id}`、`/chat/completions`、`/responses`、`/responses/{response_id}`、`/embeddings`、`/files`。 +OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端,同一套 OpenAI handler 也通过根路径快捷路由暴露:`/models`、`/models/{id}`、`/chat/completions`、`/responses`、`/responses/{response_id}`、`/embeddings`、`/files`、`/files/{file_id}`。 --- @@ -443,6 +444,10 @@ data: [DONE] - 请求体总大小上限 **100 MiB**(超限返回 `413`)。 - 成功返回 OpenAI `file` 对象(`id/object/bytes/filename/purpose/status` 等字段),并附带 `account_id` 便于定位来源账号。 +### `GET /v1/files/{file_id}` + +需要业务鉴权。查询 DeepSeek 上传文件的当前状态,并返回 OpenAI `file` 对象;未找到匹配文件时返回 `404`。 + --- ## Claude 兼容接口 diff --git a/README.MD b/README.MD index 1b5d17b..7929f61 100644 --- a/README.MD +++ b/README.MD @@ -119,7 +119,7 @@ flowchart LR | 能力 | 说明 | | --- | --- | -| OpenAI 兼容 | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/responses/{response_id}`、`POST /v1/embeddings`、`POST /v1/files` | +| OpenAI 兼容 | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/responses/{response_id}`、`POST /v1/embeddings`、`POST /v1/files`、`GET /v1/files/{file_id}` | | Claude 兼容 | `GET /anthropic/v1/models`、`POST /anthropic/v1/messages`、`POST /anthropic/v1/messages/count_tokens`(及快捷路径 `/v1/messages`、`/messages`) | | Gemini 兼容 | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent`(及 `/v1/models/{model}:*` 路径) | | 统一 CORS 兼容 | `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*` 统一走同一套 CORS 策略;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同放行规则,尽量减少第三方预检请求头限制 | @@ -131,7 +131,7 @@ flowchart LR | WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式,支持查看服务器端对话记录) | | 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) | -OpenAI `/v1/*` 仍是推荐的规范路径;同时支持 `/models`、`/chat/completions`、`/responses`、`/embeddings`、`/files` 等根路径快捷路由,方便只配置 DS2API 根地址的第三方客户端。 +OpenAI `/v1/*` 仍是推荐的规范路径;同时支持 `/models`、`/chat/completions`、`/responses`、`/embeddings`、`/files`、`/files/{file_id}` 等根路径快捷路由,方便只配置 DS2API 根地址的第三方客户端。 ## 平台兼容矩阵 diff --git a/README.en.md b/README.en.md index 773d96f..583fd1a 100644 --- a/README.en.md +++ b/README.en.md @@ -116,7 +116,7 @@ For the full module-by-module architecture and directory responsibilities, see [ | Capability | Details | | --- | --- | -| OpenAI compatible | `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/responses/{response_id}`, `POST /v1/embeddings`, `POST /v1/files` | +| OpenAI compatible | `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/responses/{response_id}`, `POST /v1/embeddings`, `POST /v1/files`, `GET /v1/files/{file_id}` | | Claude compatible | `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens` (plus shortcut paths `/v1/messages`, `/messages`) | | Gemini compatible | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` (plus `/v1/models/{model}:*` paths) | | Unified CORS compatibility | `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*` share one CORS policy; on Vercel, the Node Runtime for `/v1/chat/completions` mirrors the same relaxed preflight behavior for third-party clients | @@ -128,7 +128,7 @@ For the full module-by-module architecture and directory responsibilities, see [ | WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) | | Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) | -OpenAI `/v1/*` routes remain canonical, and DS2API also accepts root shortcuts such as `/models`, `/chat/completions`, `/responses`, `/embeddings`, and `/files` for clients configured with the bare service URL. +OpenAI `/v1/*` routes remain canonical, and DS2API also accepts root shortcuts such as `/models`, `/chat/completions`, `/responses`, `/embeddings`, `/files`, and `/files/{file_id}` for clients configured with the bare service URL. ## Platform Compatibility Matrix diff --git a/internal/deepseek/client/client_file_status.go b/internal/deepseek/client/client_file_status.go index e9bfe28..07acf87 100644 --- a/internal/deepseek/client/client_file_status.go +++ b/internal/deepseek/client/client_file_status.go @@ -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 diff --git a/internal/httpapi/openai/files/handler_files.go b/internal/httpapi/openai/files/handler_files.go index 5365409..ad1a466 100644 --- a/internal/httpapi/openai/files/handler_files.go +++ b/internal/httpapi/openai/files/handler_files.go @@ -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 != "" { diff --git a/internal/httpapi/openai/files_route_test.go b/internal/httpapi/openai/files_route_test.go index f365dc3..680e547 100644 --- a/internal/httpapi/openai/files_route_test.go +++ b/internal/httpapi/openai/files_route_test.go @@ -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() diff --git a/internal/httpapi/openai/test_bridge_test.go b/internal/httpapi/openai/test_bridge_test.go index 7f04589..080667d 100644 --- a/internal/httpapi/openai/test_bridge_test.go +++ b/internal/httpapi/openai/test_bridge_test.go @@ -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) } diff --git a/internal/server/router.go b/internal/server/router.go index fa852ab..5d8fae2 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -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) diff --git a/internal/server/router_routes_test.go b/internal/server/router_routes_test.go index edb44e0..f6e0a0a 100644 --- a/internal/server/router_routes_test.go +++ b/internal/server/router_routes_test.go @@ -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",