feat: implement OpenAI-compatible file upload and reference handling for DeepSeek API

This commit is contained in:
CJACK
2026-04-12 23:30:22 +08:00
parent 0a23c77ff7
commit caafdedb00
31 changed files with 1882 additions and 330 deletions

View File

@@ -34,11 +34,13 @@ func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request)
type openAIProxyCaptureStub struct {
seenModel string
seenReq map[string]any
}
func (s *openAIProxyCaptureStub) ChatCompletions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
s.seenReq = req
if m, ok := req["model"].(string); ok {
s.seenModel = m
}
@@ -84,3 +86,33 @@ func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) {
t.Fatalf("expected mapped proxy model deepseek-reasoner, got %q", got)
}
}
func TestClaudeProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{OpenAI: openAI}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJDRA=="}}]}],"stream":false}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
messages, _ := openAI.seenReq["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("expected one translated message, got %#v", openAI.seenReq)
}
msg, _ := messages[0].(map[string]any)
content, _ := msg["content"].([]any)
if len(content) != 2 {
t.Fatalf("expected translated content blocks, got %#v", msg)
}
imageBlock, _ := content[1].(map[string]any)
if strings.TrimSpace(asString(imageBlock["type"])) != "image_url" {
t.Fatalf("expected image_url block, got %#v", imageBlock)
}
imageURL, _ := imageBlock["image_url"].(map[string]any)
if !strings.HasPrefix(strings.TrimSpace(asString(imageURL["url"])), "data:image/png;base64,") {
t.Fatalf("expected translated data url, got %#v", imageBlock)
}
}

View File

@@ -82,11 +82,17 @@ func (s geminiOpenAIErrorStub) ChatCompletions(w http.ResponseWriter, _ *http.Re
}
type geminiOpenAISuccessStub struct {
stream bool
body string
stream bool
body string
seenReq map[string]any
}
func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
func (s *geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, r *http.Request) {
if r != nil {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
s.seenReq = req
}
if s.stream {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
@@ -144,7 +150,7 @@ func TestGeminiRoutesRegistered(t *testing.T) {
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAISuccessStub{
OpenAI: &geminiOpenAISuccessStub{
body: `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`,
},
}
@@ -184,7 +190,7 @@ func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
}
func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
h := &Handler{Store: testGeminiConfig{}, OpenAI: geminiOpenAISuccessStub{}}
h := &Handler{Store: testGeminiConfig{}, OpenAI: &geminiOpenAISuccessStub{}}
r := chi.NewRouter()
RegisterRoutes(r, h)
@@ -217,7 +223,7 @@ func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
func TestStreamGenerateContentEmitsSSE(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAISuccessStub{stream: true},
OpenAI: &geminiOpenAISuccessStub{stream: true},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
@@ -251,6 +257,39 @@ func TestStreamGenerateContentEmitsSSE(t *testing.T) {
}
}
func TestGeminiProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) {
openAI := &geminiOpenAISuccessStub{}
h := &Handler{Store: testGeminiConfig{}, OpenAI: openAI}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := `{"contents":[{"role":"user","parts":[{"text":"hello"},{"inlineData":{"mimeType":"image/png","data":"QUJDRA=="}}]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body))
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())
}
messages, _ := openAI.seenReq["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("expected one translated message, got %#v", openAI.seenReq)
}
msg, _ := messages[0].(map[string]any)
content, _ := msg["content"].([]any)
if len(content) != 2 {
t.Fatalf("expected translated content blocks, got %#v", msg)
}
imageBlock, _ := content[1].(map[string]any)
if strings.TrimSpace(asString(imageBlock["type"])) != "image_url" {
t.Fatalf("expected image_url block, got %#v", imageBlock)
}
imageURL, _ := imageBlock["image_url"].(map[string]any)
if !strings.HasPrefix(strings.TrimSpace(asString(imageURL["url"])), "data:image/png;base64,") {
t.Fatalf("expected translated data url, got %#v", imageBlock)
}
}
func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},

View File

@@ -18,6 +18,7 @@ type AuthResolver interface {
type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
UploadFile(ctx context.Context, a *auth.RequestAuth, req deepseek.UploadFileRequest, maxAttempts int) (*deepseek.UploadFileResult, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error

View File

@@ -0,0 +1,375 @@
package openai
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"mime"
"net/http"
"net/url"
"path/filepath"
"strings"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
)
type inlineFileUploadError struct {
status int
message string
err error
}
func (e *inlineFileUploadError) Error() string {
if e == nil {
return ""
}
if strings.TrimSpace(e.message) != "" {
return e.message
}
if e.err != nil {
return e.err.Error()
}
return "inline file processing failed"
}
type inlineUploadState struct {
ctx context.Context
handler *Handler
auth *auth.RequestAuth
uploadedByID map[string]string
}
type inlineDecodedFile struct {
Data []byte
ContentType string
Filename string
ReplacementType string
}
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
if h == nil || h.DS == nil || len(req) == 0 {
return nil
}
state := &inlineUploadState{
ctx: ctx,
handler: h,
auth: a,
uploadedByID: map[string]string{},
}
for _, key := range []string{"messages", "input", "attachments"} {
if raw, ok := req[key]; ok {
updated, err := state.walk(raw)
if err != nil {
return err
}
req[key] = updated
}
}
if refIDs := collectOpenAIRefFileIDs(req); len(refIDs) > 0 {
req["ref_file_ids"] = stringsToAnySlice(refIDs)
}
return nil
}
func writeOpenAIInlineFileError(w http.ResponseWriter, err error) {
inlineErr, ok := err.(*inlineFileUploadError)
if !ok || inlineErr == nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to process file input.")
return
}
status := inlineErr.status
if status == 0 {
status = http.StatusInternalServerError
}
message := strings.TrimSpace(inlineErr.message)
if message == "" {
message = "Failed to process file input."
}
writeOpenAIError(w, status, message)
}
func (s *inlineUploadState) walk(raw any) (any, error) {
switch x := raw.(type) {
case []any:
out := make([]any, len(x))
for i, item := range x {
updated, err := s.walk(item)
if err != nil {
return nil, err
}
out[i] = updated
}
return out, nil
case map[string]any:
if replacement, replaced, err := s.tryUploadBlock(x); replaced || err != nil {
return replacement, err
}
for _, key := range []string{"messages", "input", "attachments", "content", "files", "items", "data", "source", "file", "image_url"} {
if nested, ok := x[key]; ok {
updated, err := s.walk(nested)
if err != nil {
return nil, err
}
x[key] = updated
}
}
return x, nil
default:
return raw, nil
}
}
func (s *inlineUploadState) tryUploadBlock(block map[string]any) (map[string]any, bool, error) {
decoded, ok, err := decodeOpenAIInlineFileBlock(block)
if err != nil {
return nil, true, &inlineFileUploadError{status: http.StatusBadRequest, message: err.Error(), err: err}
}
if !ok {
return nil, false, nil
}
fileID, err := s.uploadInlineFile(decoded)
if err != nil {
return nil, true, &inlineFileUploadError{status: http.StatusInternalServerError, message: "Failed to upload inline file.", err: err}
}
replacement := map[string]any{
"type": decoded.ReplacementType,
"file_id": fileID,
}
if decoded.Filename != "" {
replacement["filename"] = decoded.Filename
}
if decoded.ContentType != "" {
replacement["mime_type"] = decoded.ContentType
}
return replacement, true, nil
}
func (s *inlineUploadState) uploadInlineFile(file inlineDecodedFile) (string, error) {
sum := sha256.Sum256(append(append([]byte(file.ContentType+"\x00"+file.Filename+"\x00"), file.Data...)))
cacheKey := fmt.Sprintf("%x", sum[:])
if fileID, ok := s.uploadedByID[cacheKey]; ok && strings.TrimSpace(fileID) != "" {
return fileID, nil
}
contentType := strings.TrimSpace(file.ContentType)
if contentType == "" {
contentType = http.DetectContentType(file.Data)
}
result, err := s.handler.DS.UploadFile(s.ctx, s.auth, deepseek.UploadFileRequest{
Filename: file.Filename,
ContentType: contentType,
Data: file.Data,
}, 3)
if err != nil {
return "", err
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return "", fmt.Errorf("upload succeeded without file id")
}
s.uploadedByID[cacheKey] = fileID
return fileID, nil
}
func decodeOpenAIInlineFileBlock(block map[string]any) (inlineDecodedFile, bool, error) {
if block == nil {
return inlineDecodedFile{}, false, nil
}
if strings.TrimSpace(asString(block["file_id"])) != "" {
return inlineDecodedFile{}, false, nil
}
if nested, ok := block["file"].(map[string]any); ok {
decoded, matched, err := decodeOpenAIInlineFileBlock(nested)
if err != nil || !matched {
return decoded, matched, err
}
if decoded.Filename == "" {
decoded.Filename = pickInlineFilename(block, decoded.ContentType, defaultInlinePrefix(decoded.ReplacementType))
}
return decoded, true, nil
}
blockType := strings.ToLower(strings.TrimSpace(asString(block["type"])))
if raw, matched := extractInlineImageDataURL(block); matched {
data, contentType, err := decodeInlinePayload(raw, contentTypeFromMap(block))
if err != nil {
return inlineDecodedFile{}, true, fmt.Errorf("invalid image input")
}
return inlineDecodedFile{
Data: data,
ContentType: contentType,
Filename: pickInlineFilename(block, contentType, "image"),
ReplacementType: "input_image",
}, true, nil
}
if raw, matched := extractInlineFilePayload(block, blockType); matched {
data, contentType, err := decodeInlinePayload(raw, contentTypeFromMap(block))
if err != nil {
return inlineDecodedFile{}, true, fmt.Errorf("invalid file input")
}
return inlineDecodedFile{
Data: data,
ContentType: contentType,
Filename: pickInlineFilename(block, contentType, defaultInlinePrefix(blockType)),
ReplacementType: "input_file",
}, true, nil
}
return inlineDecodedFile{}, false, nil
}
func extractInlineImageDataURL(block map[string]any) (string, bool) {
imageURL := block["image_url"]
switch x := imageURL.(type) {
case string:
if isDataURL(x) {
return strings.TrimSpace(x), true
}
case map[string]any:
if raw := strings.TrimSpace(asString(x["url"])); isDataURL(raw) {
return raw, true
}
}
if raw := strings.TrimSpace(asString(block["url"])); isDataURL(raw) {
return raw, true
}
return "", false
}
func extractInlineFilePayload(block map[string]any, blockType string) (string, bool) {
for _, value := range []any{block["file_data"], block["base64"], block["data"]} {
if raw := strings.TrimSpace(asString(value)); raw != "" {
if strings.Contains(blockType, "file") || block["file_data"] != nil || block["filename"] != nil || block["file_name"] != nil || block["name"] != nil {
return raw, true
}
}
}
return "", false
}
func decodeInlinePayload(raw string, explicitContentType string) ([]byte, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, "", fmt.Errorf("empty payload")
}
if isDataURL(raw) {
return decodeDataURL(raw, explicitContentType)
}
decoded, err := decodeBase64Flexible(raw)
if err != nil {
return nil, "", err
}
contentType := strings.TrimSpace(explicitContentType)
if contentType == "" && len(decoded) > 0 {
contentType = http.DetectContentType(decoded)
}
return decoded, contentType, nil
}
func decodeDataURL(raw string, explicitContentType string) ([]byte, string, error) {
raw = strings.TrimSpace(raw)
if !isDataURL(raw) {
return nil, "", fmt.Errorf("unsupported data url")
}
header, payload, ok := strings.Cut(raw, ",")
if !ok {
return nil, "", fmt.Errorf("invalid data url")
}
meta := strings.TrimSpace(strings.TrimPrefix(header, "data:"))
contentType := strings.TrimSpace(explicitContentType)
if contentType == "" {
contentType = "application/octet-stream"
if meta != "" {
parts := strings.Split(meta, ";")
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
contentType = strings.TrimSpace(parts[0])
}
}
}
if strings.Contains(strings.ToLower(meta), ";base64") {
decoded, err := decodeBase64Flexible(payload)
if err != nil {
return nil, "", err
}
return decoded, contentType, nil
}
decoded, err := url.QueryUnescape(payload)
if err != nil {
return nil, "", err
}
return []byte(decoded), contentType, nil
}
func decodeBase64Flexible(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
decoded, err := enc.DecodeString(raw)
if err == nil {
return decoded, nil
}
}
return nil, fmt.Errorf("invalid base64 payload")
}
func contentTypeFromMap(block map[string]any) string {
for _, value := range []any{block["mime_type"], block["mimeType"], block["content_type"], block["contentType"], block["media_type"], block["mediaType"]} {
if contentType := strings.TrimSpace(asString(value)); contentType != "" {
return contentType
}
}
if imageURL, ok := block["image_url"].(map[string]any); ok {
for _, value := range []any{imageURL["mime_type"], imageURL["mimeType"], imageURL["content_type"], imageURL["contentType"]} {
if contentType := strings.TrimSpace(asString(value)); contentType != "" {
return contentType
}
}
}
return ""
}
func pickInlineFilename(block map[string]any, contentType string, prefix string) string {
for _, value := range []any{block["filename"], block["file_name"], block["name"]} {
if name := strings.TrimSpace(asString(value)); name != "" {
return filepath.Base(name)
}
}
if prefix == "" {
prefix = "upload"
}
ext := ".bin"
if parsedType := strings.TrimSpace(contentType); parsedType != "" {
if comma := strings.Index(parsedType, ";"); comma >= 0 {
parsedType = strings.TrimSpace(parsedType[:comma])
}
if exts, err := mime.ExtensionsByType(parsedType); err == nil && len(exts) > 0 && strings.TrimSpace(exts[0]) != "" {
ext = exts[0]
}
}
return prefix + ext
}
func defaultInlinePrefix(blockType string) string {
blockType = strings.ToLower(strings.TrimSpace(blockType))
if strings.Contains(blockType, "image") {
return "image"
}
return "upload"
}
func isDataURL(raw string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "data:")
}
func stringsToAnySlice(items []string) []any {
out := make([]any, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -0,0 +1,274 @@
package openai
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
)
type inlineUploadDSStub struct {
uploadCalls []deepseek.UploadFileRequest
lastCtx context.Context
completionReq map[string]any
createSession string
uploadErr error
completionResp *http.Response
}
func (m *inlineUploadDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
if strings.TrimSpace(m.createSession) == "" {
return "session-id", nil
}
return m.createSession, nil
}
func (m *inlineUploadDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) {
m.lastCtx = ctx
m.uploadCalls = append(m.uploadCalls, req)
if m.uploadErr != nil {
return nil, m.uploadErr
}
return &deepseek.UploadFileResult{
ID: "file-inline-1",
Filename: req.Filename,
Bytes: int64(len(req.Data)),
Status: "uploaded",
Purpose: req.Purpose,
}, nil
}
func (m *inlineUploadDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) {
m.completionReq = payload
if m.completionResp != nil {
return m.completionResp, nil
}
return makeOpenAISSEHTTPResponse(
`data: {"p":"response/content","v":"ok"}`,
`data: [DONE]`,
), nil
}
func (m *inlineUploadDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) {
return &deepseek.DeleteSessionResult{Success: true}, nil
}
func (m *inlineUploadDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func TestPreprocessInlineFileInputsReplacesDataURLAndCollectsRefFileIDs(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{DS: ds}
req := map[string]any{
"messages": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="},
},
},
},
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := h.preprocessInlineFileInputs(ctx, &auth.RequestAuth{DeepSeekToken: "token"}, req); err != nil {
t.Fatalf("preprocess failed: %v", err)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload, got %d", len(ds.uploadCalls))
}
if ds.lastCtx != ctx {
t.Fatalf("expected upload to use request context")
}
if ds.uploadCalls[0].ContentType != "image/png" {
t.Fatalf("expected image/png, got %q", ds.uploadCalls[0].ContentType)
}
if ds.uploadCalls[0].Filename != "image.png" {
t.Fatalf("expected inferred filename image.png, got %q", ds.uploadCalls[0].Filename)
}
messages, _ := req["messages"].([]any)
first, _ := messages[0].(map[string]any)
content, _ := first["content"].([]any)
block, _ := content[0].(map[string]any)
if block["type"] != "input_image" {
t.Fatalf("expected input_image replacement, got %#v", block)
}
if block["file_id"] != "file-inline-1" {
t.Fatalf("expected file-inline-1 replacement id, got %#v", block)
}
refIDs, _ := req["ref_file_ids"].([]any)
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
t.Fatalf("unexpected ref_file_ids: %#v", req["ref_file_ids"])
}
}
func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{DS: ds}
req := map[string]any{
"messages": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}},
},
},
},
}
if err := h.preprocessInlineFileInputs(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, req); err != nil {
t.Fatalf("preprocess failed: %v", err)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected deduplicated single upload, got %d", len(ds.uploadCalls))
}
refIDs, _ := req["ref_file_ids"].([]any)
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
t.Fatalf("unexpected ref_file_ids after dedupe: %#v", req["ref_file_ids"])
}
}
func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ChatCompletions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
if ds.completionReq == nil {
t.Fatal("expected completion payload to be captured")
}
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"])
}
}
func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
r := chi.NewRouter()
RegisterRoutes(r, h)
reqBody := `{"model":"deepseek-chat","input":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
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())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"])
}
}
func TestChatCompletionsInlineUploadFailureReturnsBadRequest(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,%%%"}}]}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ChatCompletions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if ds.completionReq != nil {
t.Fatalf("did not expect completion call on upload decode error")
}
}
func TestResponsesInlineUploadFailureReturnsInternalServerError(t *testing.T) {
ds := &inlineUploadDSStub{uploadErr: errors.New("boom")}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
r := chi.NewRouter()
RegisterRoutes(r, h)
reqBody := `{"model":"deepseek-chat","input":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String())
}
if ds.completionReq != nil {
t.Fatalf("did not expect completion call after upload failure")
}
}
func TestVercelPrepareUploadsInlineFilesBeforeLeasePayload(t *testing.T) {
t.Setenv("VERCEL", "1")
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
ds := &inlineUploadDSStub{}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
r := chi.NewRouter()
RegisterRoutes(r, h)
reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":true}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__stream_prepare=1", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("X-Ds2-Internal-Token", "stream-secret")
req.Header.Set("Content-Type", "application/json")
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())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
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())
}
payload, _ := out["payload"].(map[string]any)
if payload == nil {
t.Fatalf("expected payload in prepare response, got %#v", out)
}
refIDs, _ := payload["ref_file_ids"].([]any)
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
t.Fatalf("unexpected payload ref_file_ids: %#v", payload["ref_file_ids"])
}
}

View File

@@ -0,0 +1,73 @@
package openai
import "strings"
func collectOpenAIRefFileIDs(req map[string]any) []string {
if len(req) == 0 {
return nil
}
out := make([]string, 0, 4)
seen := map[string]struct{}{}
for _, raw := range []any{
req["ref_file_ids"],
req["file_ids"],
req["attachments"],
req["messages"],
req["input"],
} {
appendOpenAIRefFileIDs(&out, seen, raw)
}
if len(out) == 0 {
return nil
}
return out
}
func appendOpenAIRefFileIDs(out *[]string, seen map[string]struct{}, raw any) {
switch x := raw.(type) {
case string:
addOpenAIRefFileID(out, seen, x)
case []string:
for _, item := range x {
addOpenAIRefFileID(out, seen, item)
}
case []any:
for _, item := range x {
appendOpenAIRefFileIDs(out, seen, item)
}
case map[string]any:
if fileID := strings.TrimSpace(asString(x["file_id"])); fileID != "" {
addOpenAIRefFileID(out, seen, fileID)
}
if strings.Contains(strings.ToLower(strings.TrimSpace(asString(x["type"]))), "file") {
if fileID := strings.TrimSpace(asString(x["id"])); fileID != "" {
addOpenAIRefFileID(out, seen, fileID)
}
}
if fileMap, ok := x["file"].(map[string]any); ok {
if fileID := strings.TrimSpace(asString(fileMap["file_id"])); fileID != "" {
addOpenAIRefFileID(out, seen, fileID)
}
if fileID := strings.TrimSpace(asString(fileMap["id"])); fileID != "" {
addOpenAIRefFileID(out, seen, fileID)
}
}
for _, key := range []string{"ref_file_ids", "file_ids", "attachments", "messages", "input", "content", "files", "items", "data", "source"} {
if nested, ok := x[key]; ok {
appendOpenAIRefFileIDs(out, seen, nested)
}
}
}
}
func addOpenAIRefFileID(out *[]string, seen map[string]struct{}, fileID string) {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return
}
if _, ok := seen[fileID]; ok {
return
}
seen[fileID] = struct{}{}
*out = append(*out, fileID)
}

View File

@@ -0,0 +1,156 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
)
type filesRouteDSStub struct {
lastReq deepseek.UploadFileRequest
upload *deepseek.UploadFileResult
err error
}
func (m *filesRouteDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "", nil
}
func (m *filesRouteDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "", nil
}
func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) {
m.lastReq = req
if m.err != nil {
return nil, m.err
}
if m.upload != nil {
return m.upload, nil
}
return &deepseek.UploadFileResult{ID: "file-123", Filename: req.Filename, Bytes: int64(len(req.Data)), Purpose: req.Purpose, Status: "uploaded"}, nil
}
func (m *filesRouteDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
return nil, errors.New("not implemented")
}
func (m *filesRouteDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) {
return &deepseek.DeleteSessionResult{Success: true}, nil
}
func (m *filesRouteDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func newMultipartUploadRequest(t *testing.T, purpose string, filename string, data []byte) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if purpose != "" {
if err := writer.WriteField("purpose", purpose); err != nil {
t.Fatalf("write purpose failed: %v", err)
}
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file failed: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write file failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer failed: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/files", &body)
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestFilesRouteUploadSuccess(t *testing.T) {
ds := &filesRouteDSStub{}
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world"))
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())
}
if ds.lastReq.Filename != "notes.txt" {
t.Fatalf("expected filename notes.txt, got %q", ds.lastReq.Filename)
}
if ds.lastReq.Purpose != "assistants" {
t.Fatalf("expected purpose assistants, got %q", ds.lastReq.Purpose)
}
if string(ds.lastReq.Data) != "hello world" {
t.Fatalf("unexpected uploaded data: %q", string(ds.lastReq.Data))
}
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["object"] != "file" {
t.Fatalf("expected file object, got %#v", out)
}
if out["id"] != "file-123" {
t.Fatalf("expected file id file-123, got %#v", out["id"])
}
if out["filename"] != "notes.txt" {
t.Fatalf("expected filename notes.txt, got %#v", out["filename"])
}
}
func TestFilesRouteRejectsNonMultipart(t *testing.T) {
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewBufferString(`{"purpose":"assistants"}`))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestFilesRouteRequiresFileField(t *testing.T) {
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}}
r := chi.NewRouter()
RegisterRoutes(r, h)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("purpose", "assistants"); err != nil {
t.Fatalf("write field failed: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer failed: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/files", &body)
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -48,6 +48,10 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil {
writeOpenAIInlineFileError(w, err)
return
}
stdReq, err := normalizeOpenAIChatRequest(h.Store, req, requestTraceID(r))
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())

View File

@@ -27,6 +27,10 @@ func (m *autoDeleteModeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _
return "pow", nil
}
func (m *autoDeleteModeDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) {
return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil
}
func (m *autoDeleteModeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
return m.resp, nil
}

View File

@@ -0,0 +1,90 @@
package openai
import (
"io"
"net/http"
"strings"
"time"
"ds2api/internal/auth"
"ds2api/internal/deepseek"
)
const openAIUploadMaxMemory = 32 << 20
func (h *Handler) UploadFile(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
}
writeOpenAIError(w, status, detail)
return
}
defer h.Auth.Release(a)
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "multipart/form-data") {
writeOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data")
return
}
if err := r.ParseMultipartForm(openAIUploadMaxMemory); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid multipart form")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
r = r.WithContext(auth.WithAuth(r.Context(), a))
file, header, err := r.FormFile("file")
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, "failed to read uploaded file")
return
}
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
if contentType == "" && len(data) > 0 {
contentType = http.DetectContentType(data)
}
result, err := h.DS.UploadFile(r.Context(), a, deepseek.UploadFileRequest{
Filename: header.Filename,
ContentType: contentType,
Purpose: strings.TrimSpace(r.FormValue("purpose")),
Data: data,
}, 3)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.")
return
}
writeJSON(w, http.StatusOK, buildOpenAIFileObject(result))
}
func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any {
if result == nil {
return map[string]any{
"id": "",
"object": "file",
"bytes": 0,
"created_at": time.Now().Unix(),
"filename": "",
"purpose": "",
"status": "uploaded",
"status_details": nil,
}
}
return map[string]any{
"id": result.ID,
"object": "file",
"bytes": result.Bytes,
"created_at": time.Now().Unix(),
"filename": result.Filename,
"purpose": result.Purpose,
"status": result.Status,
"status_details": nil,
}
}

View File

@@ -46,6 +46,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
r.Post("/v1/chat/completions", h.ChatCompletions)
r.Post("/v1/responses", h.Responses)
r.Get("/v1/responses/{response_id}", h.GetResponseByID)
r.Post("/v1/files", h.UploadFile)
r.Post("/v1/embeddings", h.Embeddings)
}

View File

@@ -156,6 +156,33 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItemPreservesConcatenatedA
}
}
func TestCollectOpenAIRefFileIDs(t *testing.T) {
got := collectOpenAIRefFileIDs(map[string]any{
"ref_file_ids": []any{"file-top", "file-dup"},
"attachments": []any{
map[string]any{"file_id": "file-attachment"},
},
"input": []any{
map[string]any{
"type": "message",
"content": []any{
map[string]any{"type": "input_file", "file_id": "file-input"},
map[string]any{"type": "input_file", "id": "file-dup"},
},
},
},
})
want := []string{"file-top", "file-dup", "file-attachment", "file-input"}
if len(got) != len(want) {
t.Fatalf("expected %d file ids, got %#v", len(want), got)
}
for i, id := range want {
if got[i] != id {
t.Fatalf("unexpected file ids at %d: got=%#v want=%#v", i, got, want)
}
}
}
func TestExtractEmbeddingInputs(t *testing.T) {
got := extractEmbeddingInputs([]any{"a", "b"})
if len(got) != 2 || got[0] != "a" || got[1] != "b" {

View File

@@ -70,6 +70,10 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil {
writeOpenAIInlineFileError(w, err)
return
}
traceID := requestTraceID(r)
stdReq, err := normalizeOpenAIResponsesRequest(h.Store, req, traceID)
if err != nil {

View File

@@ -27,6 +27,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)
refFileIDs := collectOpenAIRefFileIDs(req)
return util.StandardRequest{
Surface: "openai_chat",
@@ -40,6 +41,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
Stream: util.ToBool(req["stream"]),
Thinking: thinkingEnabled,
Search: searchEnabled,
RefFileIDs: refFileIDs,
PassThrough: passThrough,
}, nil
}
@@ -80,6 +82,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
toolPolicy.Allowed = namesToSet(toolNames)
}
passThrough := collectOpenAIChatPassThrough(req)
refFileIDs := collectOpenAIRefFileIDs(req)
return util.StandardRequest{
Surface: "openai_responses",
@@ -93,6 +96,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
Stream: util.ToBool(req["stream"]),
Thinking: thinkingEnabled,
Search: searchEnabled,
RefFileIDs: refFileIDs,
PassThrough: passThrough,
}, nil
}

View File

@@ -41,6 +41,36 @@ func TestNormalizeOpenAIChatRequest(t *testing.T) {
}
}
func TestNormalizeOpenAIChatRequestCollectsRefFileIDs(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{
"model": "gpt-5-codex",
"messages": []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "input_text", "text": "hello"},
map[string]any{"type": "input_file", "file_id": "file-msg"},
},
},
},
"attachments": []any{
map[string]any{"file_id": "file-attachment"},
},
"ref_file_ids": []any{"file-top", "file-attachment"},
}
n, err := normalizeOpenAIChatRequest(store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if len(n.RefFileIDs) != 3 {
t.Fatalf("expected 3 distinct file ids, got %#v", n.RefFileIDs)
}
if n.RefFileIDs[0] != "file-top" || n.RefFileIDs[1] != "file-attachment" || n.RefFileIDs[2] != "file-msg" {
t.Fatalf("unexpected file ids: %#v", n.RefFileIDs)
}
}
func TestNormalizeOpenAIResponsesRequestInput(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{

View File

@@ -50,6 +50,10 @@ func (m streamStatusDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int
return "pow", nil
}
func (m streamStatusDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) {
return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil
}
func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
return m.resp, nil
}

View File

@@ -52,6 +52,10 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil {
writeOpenAIInlineFileError(w, err)
return
}
if !util.ToBool(req["stream"]) {
writeOpenAIError(w, http.StatusBadRequest, "stream must be true")
return