mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
feat: Enhance DeepSeek API compatibility by updating SSE parsing, standardizing error responses, and improving API key management in the tester UI.
This commit is contained in:
@@ -593,7 +593,7 @@ Or manual deploy required:
|
||||
|
||||
Error payload formats are not fully unified in current code:
|
||||
|
||||
- OpenAI routes often return: `{"error":"..."}`
|
||||
- OpenAI routes return: `{"error":{"message":"...","type":"..."}}`
|
||||
- Claude routes often return: `{"error":{"type":"...","message":"..."}}`
|
||||
- Admin routes often return: `{"detail":"..."}`
|
||||
|
||||
|
||||
2
API.md
2
API.md
@@ -621,7 +621,7 @@ data: {"type":"message_stop"}
|
||||
|
||||
不同模块错误格式不完全一致(按当前实现):
|
||||
|
||||
- OpenAI 接口常见:`{"error":"..."}`
|
||||
- OpenAI 接口:`{"error":{"message":"...","type":"..."}}`
|
||||
- Claude 接口常见:`{"error":{"type":"...","message":"..."}}`
|
||||
- Admin 接口常见:`{"detail":"..."}`
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module ds2api
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/refraction-networking/utls v1.8.1
|
||||
@@ -10,7 +11,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
|
||||
@@ -42,7 +42,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
if err == auth.ErrNoAccount {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": detail})
|
||||
writeOpenAIError(w, status, detail)
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
@@ -50,18 +50,18 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
messagesRaw, _ := req["messages"].([]any)
|
||||
if model == "" || len(messagesRaw) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Request must include 'model' and 'messages'."})
|
||||
writeOpenAIError(w, http.StatusBadRequest, "Request must include 'model' and 'messages'.")
|
||||
return
|
||||
}
|
||||
thinkingEnabled, searchEnabled, ok := config.GetModelConfig(model)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": fmt.Sprintf("Model '%s' is not available.", model)})
|
||||
writeOpenAIError(w, http.StatusServiceUnavailable, fmt.Sprintf("Model '%s' is not available.", model))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,12 +74,16 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid token."})
|
||||
if a.UseConfigToken {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.")
|
||||
} else {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.")
|
||||
}
|
||||
return
|
||||
}
|
||||
pow, err := h.DS.GetPow(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Failed to get PoW (invalid token or unknown error)."})
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
@@ -92,7 +96,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "Failed to get completion."})
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
|
||||
return
|
||||
}
|
||||
if toBool(req["stream"]) {
|
||||
@@ -106,7 +110,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeJSON(w, resp.StatusCode, map[string]any{"error": string(body)})
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
thinking := strings.Builder{}
|
||||
@@ -183,7 +187,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeJSON(w, resp.StatusCode, map[string]any{"error": string(body)})
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
@@ -191,7 +195,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "streaming unsupported"})
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -436,3 +440,32 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeOpenAIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": message,
|
||||
"type": openAIErrorType(status),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func openAIErrorType(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request_error"
|
||||
case http.StatusUnauthorized:
|
||||
return "authentication_error"
|
||||
case http.StatusForbidden:
|
||||
return "permission_error"
|
||||
case http.StatusTooManyRequests:
|
||||
return "rate_limit_error"
|
||||
case http.StatusServiceUnavailable:
|
||||
return "service_unavailable_error"
|
||||
default:
|
||||
if status >= 500 {
|
||||
return "api_error"
|
||||
}
|
||||
return "invalid_request_error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package transport
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -44,16 +45,36 @@ func safariTLSDialer() func(ctx context.Context, network, addr string) (net.Conn
|
||||
return nil, err
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
uCfg := &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"http/1.1"},
|
||||
}
|
||||
uCfg := &utls.Config{ServerName: host}
|
||||
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
|
||||
if err := forceHTTP11ALPN(uConn); err != nil {
|
||||
_ = plainConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
err = uConn.HandshakeContext(ctx)
|
||||
if err != nil {
|
||||
_ = plainConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if negotiated := uConn.ConnectionState().NegotiatedProtocol; negotiated != "" && negotiated != "http/1.1" {
|
||||
_ = uConn.Close()
|
||||
return nil, fmt.Errorf("unexpected ALPN protocol negotiated: %s", negotiated)
|
||||
}
|
||||
return uConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func forceHTTP11ALPN(uConn *utls.UConn) error {
|
||||
if err := uConn.BuildHandshakeState(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ext := range uConn.Extensions {
|
||||
alpnExt, ok := ext.(*utls.ALPNExtension)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
alpnExt.AlpnProtocols = []string{"http/1.1"}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,6 +59,42 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
|
||||
}
|
||||
}
|
||||
newType := currentFragmentType
|
||||
parts := make([]ContentPart, 0, 8)
|
||||
|
||||
// Newer DeepSeek responses may emit fragment APPEND directly on
|
||||
// path "response/fragments" instead of wrapping it in path "response".
|
||||
if path == "response/fragments" {
|
||||
if op, _ := chunk["o"].(string); strings.EqualFold(op, "APPEND") {
|
||||
if frags, ok := v.([]any); ok {
|
||||
for _, frag := range frags {
|
||||
fm, ok := frag.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := fm["type"].(string)
|
||||
content, _ := fm["content"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
switch t {
|
||||
case "THINK", "THINKING":
|
||||
newType = "thinking"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
}
|
||||
case "RESPONSE":
|
||||
newType = "text"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
default:
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path == "response" {
|
||||
if arr, ok := v.([]any); ok {
|
||||
for _, it := range arr {
|
||||
@@ -99,7 +135,6 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
|
||||
partType = newType
|
||||
}
|
||||
}
|
||||
parts := make([]ContentPart, 0, 8)
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "FINISHED" && (path == "" || path == "status") {
|
||||
|
||||
@@ -47,3 +47,43 @@ func TestIsCitation(t *testing.T) {
|
||||
t.Fatal("expected citation false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendSwitchToResponse(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "你好",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "thinking")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected next type text, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "text" || parts[0].Text != "你好" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentAfterAppendUsesUpdatedType(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments/-1/content",
|
||||
"v": "!",
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected next type text, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "text" || parts[0].Text != "!" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
const apiFetch = authFetch || fetch
|
||||
const accounts = config.accounts || []
|
||||
const configuredKeys = config.keys || []
|
||||
const trimmedApiKey = apiKey.trim()
|
||||
const defaultKey = configuredKeys[0] || ''
|
||||
const effectiveKey = trimmedApiKey || defaultKey
|
||||
const customKeyActive = trimmedApiKey !== ''
|
||||
const customKeyManaged = customKeyActive && configuredKeys.includes(trimmedApiKey)
|
||||
const models = [
|
||||
{ id: "deepseek-chat", name: "deepseek-chat", icon: MessageSquare, desc: t('apiTester.models.chat'), color: "text-amber-500" },
|
||||
{ id: "deepseek-reasoner", name: "deepseek-reasoner", icon: Cpu, desc: t('apiTester.models.reasoner'), color: "text-amber-600" },
|
||||
@@ -58,6 +64,28 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
|
||||
const extractErrorMessage = async (res) => {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await res.text()
|
||||
} catch {
|
||||
return t('apiTester.requestFailed')
|
||||
}
|
||||
if (!raw) {
|
||||
return t('apiTester.requestFailed')
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
const fromErrorObject = data?.error?.message
|
||||
const fromErrorString = typeof data?.error === 'string' ? data.error : ''
|
||||
const detail = typeof data?.detail === 'string' ? data.detail : ''
|
||||
const message = typeof data?.message === 'string' ? data.message : ''
|
||||
return fromErrorObject || fromErrorString || detail || message || t('apiTester.requestFailed')
|
||||
} catch {
|
||||
return raw.length > 240 ? `${raw.slice(0, 240)}...` : raw
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
if (loading) return
|
||||
|
||||
@@ -70,8 +98,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const key = apiKey || (config.keys?.[0] || '')
|
||||
if (!key) {
|
||||
if (!effectiveKey) {
|
||||
onMessage('error', t('apiTester.missingApiKey'))
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
@@ -80,7 +107,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${key}`,
|
||||
'Authorization': `Bearer ${effectiveKey}`,
|
||||
}
|
||||
if (selectedAccount) {
|
||||
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||
@@ -98,8 +125,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
const errorMsg = data.error?.message || t('apiTester.requestFailed')
|
||||
const errorMsg = await extractErrorMessage(res)
|
||||
setResponse({ success: false, error: errorMsg })
|
||||
onMessage('error', errorMsg)
|
||||
setLoading(false)
|
||||
@@ -280,12 +306,22 @@ return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
|
||||
<input
|
||||
type="password"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
|
||||
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
{customKeyActive && (
|
||||
<p className={clsx(
|
||||
"text-[11px] mt-1",
|
||||
customKeyManaged ? "text-emerald-600" : "text-amber-600"
|
||||
)}>
|
||||
{customKeyManaged ? t('apiTester.modeManaged') : t('apiTester.modeDirect')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
"apiKeyOptional": "API Key (optional)",
|
||||
"apiKeyDefault": "Default: ...{suffix}",
|
||||
"apiKeyPlaceholder": "Enter a custom key",
|
||||
"modeManaged": "Managed key mode (uses account pool).",
|
||||
"modeDirect": "Direct token mode (requires a valid DeepSeek token).",
|
||||
"statusError": "Error",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
"generating": "Generating response...",
|
||||
@@ -234,4 +236,4 @@
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
"apiKeyOptional": "API 密钥 (可选)",
|
||||
"apiKeyDefault": "默认: ...{suffix}",
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
"statusError": "错误",
|
||||
"reasoningTrace": "思维链过程",
|
||||
"generating": "正在生成响应...",
|
||||
@@ -234,4 +236,4 @@
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user