mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-07 01:45:27 +08:00
feat: implement cross-account validation and improved error handling for file attachments in API tester
This commit is contained in:
@@ -16,6 +16,30 @@ import (
|
||||
"ds2api/internal/deepseek"
|
||||
)
|
||||
|
||||
type managedFilesAuthStub struct{}
|
||||
|
||||
func (managedFilesAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) {
|
||||
return &auth.RequestAuth{
|
||||
UseConfigToken: true,
|
||||
DeepSeekToken: "managed-token",
|
||||
CallerID: "caller:test",
|
||||
AccountID: "acct-123",
|
||||
TriedAccounts: map[string]bool{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (managedFilesAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, error) {
|
||||
return &auth.RequestAuth{
|
||||
UseConfigToken: true,
|
||||
DeepSeekToken: "managed-token",
|
||||
CallerID: "caller:test",
|
||||
AccountID: "acct-123",
|
||||
TriedAccounts: map[string]bool{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (managedFilesAuthStub) Release(_ *auth.RequestAuth) {}
|
||||
|
||||
type filesRouteDSStub struct {
|
||||
lastReq deepseek.UploadFileRequest
|
||||
upload *deepseek.UploadFileResult
|
||||
@@ -115,6 +139,28 @@ func TestFilesRouteUploadSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) {
|
||||
ds := &filesRouteDSStub{}
|
||||
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, 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())
|
||||
}
|
||||
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["account_id"] != "acct-123" {
|
||||
t.Fatalf("expected account_id acct-123, got %#v", out["account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRouteRejectsNonMultipart(t *testing.T) {
|
||||
h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}}
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -61,12 +61,15 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.")
|
||||
return
|
||||
}
|
||||
if result != nil && result.AccountID == "" {
|
||||
result.AccountID = a.AccountID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildOpenAIFileObject(result))
|
||||
}
|
||||
|
||||
func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any {
|
||||
if result == nil {
|
||||
return map[string]any{
|
||||
obj := map[string]any{
|
||||
"id": "",
|
||||
"object": "file",
|
||||
"bytes": 0,
|
||||
@@ -76,8 +79,9 @@ func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any {
|
||||
"status": "uploaded",
|
||||
"status_details": nil,
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return map[string]any{
|
||||
obj := map[string]any{
|
||||
"id": result.ID,
|
||||
"object": "file",
|
||||
"bytes": result.Bytes,
|
||||
@@ -87,4 +91,8 @@ func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any {
|
||||
"status": result.Status,
|
||||
"status_details": nil,
|
||||
}
|
||||
if result.AccountID != "" {
|
||||
obj["account_id"] = result.AccountID
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type UploadFileResult struct {
|
||||
Bytes int64
|
||||
Status string
|
||||
Purpose string
|
||||
AccountID string
|
||||
IsImage bool
|
||||
Raw map[string]any
|
||||
RawHeaders http.Header
|
||||
@@ -117,6 +118,9 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
|
||||
if result.Purpose == "" {
|
||||
result.Purpose = purpose
|
||||
}
|
||||
if result.AccountID == "" {
|
||||
result.AccountID = a.AccountID
|
||||
}
|
||||
if result.ID == "" {
|
||||
return nil, errors.New("upload file succeeded without file id")
|
||||
}
|
||||
@@ -234,6 +238,9 @@ func extractUploadFileResult(resp map[string]any) *UploadFileResult {
|
||||
if result.Purpose == "" {
|
||||
result.Purpose = firstNonEmptyString(m, "purpose")
|
||||
}
|
||||
if result.AccountID == "" {
|
||||
result.AccountID = firstNonEmptyString(m, "account_id", "accountId", "owner_account_id", "ownerAccountId")
|
||||
}
|
||||
if result.Bytes == 0 {
|
||||
result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size")
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) {
|
||||
setMessage={setMessage}
|
||||
attachedFiles={attachedFiles}
|
||||
setAttachedFiles={setAttachedFiles}
|
||||
setSelectedAccount={setSelectedAccount}
|
||||
effectiveKey={effectiveKey}
|
||||
selectedAccount={selectedAccount}
|
||||
onMessage={onMessage}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Bot, Loader2, Send, Square, User, Zap, Paperclip, X, FileIcon } from 'l
|
||||
import clsx from 'clsx'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { getAttachedFileAccountIds } from './fileAccountBinding'
|
||||
|
||||
export default function ChatPanel({
|
||||
t,
|
||||
message,
|
||||
setMessage,
|
||||
attachedFiles = [],
|
||||
setAttachedFiles,
|
||||
setSelectedAccount,
|
||||
effectiveKey,
|
||||
selectedAccount,
|
||||
onMessage,
|
||||
@@ -32,6 +35,8 @@ export default function ChatPanel({
|
||||
}
|
||||
|
||||
setUploadingFiles(true)
|
||||
const initialSelectedAccount = String(selectedAccount || '').trim()
|
||||
let boundAccount = initialSelectedAccount
|
||||
for (const file of files) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -40,8 +45,8 @@ export default function ChatPanel({
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${effectiveKey}`,
|
||||
}
|
||||
if (selectedAccount) {
|
||||
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||
if (boundAccount) {
|
||||
headers['X-Ds2-Target-Account'] = boundAccount
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -57,11 +62,18 @@ export default function ChatPanel({
|
||||
}
|
||||
const data = await res.json()
|
||||
setAttachedFiles(prev => [...prev, data])
|
||||
const uploadedAccount = String(data?.account_id || '').trim()
|
||||
if (!boundAccount && uploadedAccount) {
|
||||
boundAccount = uploadedAccount
|
||||
}
|
||||
} catch (error) {
|
||||
onMessage('error', error.message || 'Network error during upload')
|
||||
}
|
||||
}
|
||||
setUploadingFiles(false)
|
||||
if (!initialSelectedAccount && boundAccount && setSelectedAccount) {
|
||||
setSelectedAccount(boundAccount)
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
@@ -70,6 +82,9 @@ export default function ChatPanel({
|
||||
const removeFile = (id) => {
|
||||
setAttachedFiles(prev => prev.filter(f => f.id !== id))
|
||||
}
|
||||
|
||||
const attachmentAccountIds = getAttachedFileAccountIds(attachedFiles)
|
||||
const attachmentAccountId = attachmentAccountIds.length === 1 ? attachmentAccountIds[0] : ''
|
||||
return (
|
||||
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
|
||||
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
|
||||
@@ -143,6 +158,16 @@ export default function ChatPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{attachmentAccountIds.length > 1 && (
|
||||
<div className="max-w-4xl mx-auto mb-3 text-[11px] text-amber-600">
|
||||
{t('apiTester.fileAccountConflict')}
|
||||
</div>
|
||||
)}
|
||||
{attachmentAccountId && (
|
||||
<div className="max-w-4xl mx-auto mb-3 text-[11px] text-muted-foreground">
|
||||
{t('apiTester.attachmentAccountHint', { account: attachmentAccountId })}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-4xl mx-auto relative group">
|
||||
<input
|
||||
type="file"
|
||||
|
||||
19
webui/src/features/apiTester/fileAccountBinding.js
Normal file
19
webui/src/features/apiTester/fileAccountBinding.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export function getAttachedFileAccountIds(attachedFiles = []) {
|
||||
const ids = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const file of attachedFiles || []) {
|
||||
const raw = file?.account_id ?? file?.accountId ?? file?.owner_account_id ?? file?.ownerAccountId ?? ''
|
||||
const id = String(raw).trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
export function getAttachedFileAccountId(attachedFiles = []) {
|
||||
const ids = getAttachedFileAccountIds(attachedFiles)
|
||||
return ids.length > 0 ? ids[0] : ''
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getAttachedFileAccountIds } from './fileAccountBinding'
|
||||
|
||||
export function useChatStreamClient({
|
||||
t,
|
||||
onMessage,
|
||||
@@ -47,6 +49,20 @@ export function useChatStreamClient({
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const resolveAttachmentAccount = useCallback(() => {
|
||||
const ids = getAttachedFileAccountIds(attachedFiles)
|
||||
if (ids.length > 1) {
|
||||
return {
|
||||
accountId: '',
|
||||
error: t('apiTester.fileAccountConflict'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
accountId: ids[0] || '',
|
||||
error: '',
|
||||
}
|
||||
}, [attachedFiles, t])
|
||||
|
||||
const runTest = useCallback(async () => {
|
||||
if (!effectiveKey) {
|
||||
onMessage('error', t('apiTester.missingApiKey'))
|
||||
@@ -63,12 +79,31 @@ export function useChatStreamClient({
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const selectedAccountId = String(selectedAccount || '').trim()
|
||||
const attachmentBinding = resolveAttachmentAccount()
|
||||
if (attachmentBinding.error) {
|
||||
setResponse({ success: false, error: attachmentBinding.error })
|
||||
onMessage('error', attachmentBinding.error)
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
return
|
||||
}
|
||||
if (attachmentBinding.accountId && selectedAccountId && selectedAccountId !== attachmentBinding.accountId) {
|
||||
const errorMsg = t('apiTester.fileAccountMismatch', { account: attachmentBinding.accountId })
|
||||
setResponse({ success: false, error: errorMsg })
|
||||
onMessage('error', errorMsg)
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
return
|
||||
}
|
||||
const requestAccount = selectedAccountId || attachmentBinding.accountId
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${effectiveKey}`,
|
||||
}
|
||||
if (selectedAccount) {
|
||||
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||
if (requestAccount) {
|
||||
headers['X-Ds2-Target-Account'] = requestAccount
|
||||
}
|
||||
|
||||
const body = {
|
||||
@@ -104,6 +139,8 @@ export function useChatStreamClient({
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let accumulatedThinking = ''
|
||||
let accumulatedContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -126,9 +163,11 @@ export function useChatStreamClient({
|
||||
if (choice?.delta) {
|
||||
const delta = choice.delta
|
||||
if (delta.reasoning_content) {
|
||||
accumulatedThinking += delta.reasoning_content
|
||||
setStreamingThinking(prev => prev + delta.reasoning_content)
|
||||
}
|
||||
if (delta.content) {
|
||||
accumulatedContent += delta.content
|
||||
setStreamingContent(prev => prev + delta.content)
|
||||
}
|
||||
}
|
||||
@@ -137,11 +176,26 @@ export function useChatStreamClient({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResponse({
|
||||
success: true,
|
||||
status_code: res.status,
|
||||
choices: [{
|
||||
finish_reason: 'stop',
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: accumulatedContent,
|
||||
reasoning_content: accumulatedThinking,
|
||||
},
|
||||
}],
|
||||
})
|
||||
onMessage('success', t('apiTester.testSuccess', { account: requestAccount || selectedAccountId || 'Auto', time: Math.max(0, Date.now() - startedAt) }))
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setResponse({ success: true, status_code: res.status, ...data })
|
||||
const elapsed = Math.max(0, Date.now() - startedAt)
|
||||
onMessage('success', t('apiTester.testSuccess', { account: selectedAccount || 'Auto', time: elapsed }))
|
||||
onMessage('success', t('apiTester.testSuccess', { account: requestAccount || 'Auto', time: elapsed }))
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
@@ -163,6 +217,7 @@ export function useChatStreamClient({
|
||||
message,
|
||||
model,
|
||||
onMessage,
|
||||
resolveAttachmentAccount,
|
||||
selectedAccount,
|
||||
setIsStreaming,
|
||||
setLoading,
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"apiKeyPlaceholder": "Enter a custom key",
|
||||
"modeManaged": "Managed key mode (uses account pool).",
|
||||
"modeDirect": "Direct token mode (requires a valid DeepSeek token).",
|
||||
"attachmentAccountHint": "Attached files are bound to account {account}. Sending will reuse the same account.",
|
||||
"fileAccountConflict": "Attached files came from different accounts. Clear them and upload again under one account.",
|
||||
"fileAccountMismatch": "The selected account does not match the attachment account. Switch to the bound account or clear the attachments and try again.",
|
||||
"statusError": "Error",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
"generating": "Generating response...",
|
||||
|
||||
@@ -224,6 +224,9 @@
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
"attachmentAccountHint": "附件已绑定账号:{account},发送时会自动沿用同一账号。",
|
||||
"fileAccountConflict": "附件来自不同账号,请先清空后重新上传。",
|
||||
"fileAccountMismatch": "当前选择的账号与附件绑定账号不一致,请切换到绑定账号或清空附件后重试。",
|
||||
"statusError": "错误",
|
||||
"reasoningTrace": "思维链过程",
|
||||
"generating": "正在生成响应...",
|
||||
|
||||
Reference in New Issue
Block a user