diff --git a/API.en.md b/API.en.md index afa1a3e..14fbd50 100644 --- a/API.en.md +++ b/API.en.md @@ -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":"..."}` diff --git a/API.md b/API.md index c3b4f3c..9ee139f 100644 --- a/API.md +++ b/API.md @@ -621,7 +621,7 @@ data: {"type":"message_stop"} 不同模块错误格式不完全一致(按当前实现): -- OpenAI 接口常见:`{"error":"..."}` +- OpenAI 接口:`{"error":{"message":"...","type":"..."}}` - Claude 接口常见:`{"error":{"type":"...","message":"..."}}` - Admin 接口常见:`{"detail":"..."}` diff --git a/go.mod b/go.mod index 712f09b..1d47c76 100644 --- a/go.mod +++ b/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 diff --git a/internal/adapter/openai/handler.go b/internal/adapter/openai/handler.go index a212299..319aacd 100644 --- a/internal/adapter/openai/handler.go +++ b/internal/adapter/openai/handler.go @@ -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" + } +} diff --git a/internal/deepseek/transport/transport.go b/internal/deepseek/transport/transport.go index 8dbae33..9bcaa1e 100644 --- a/internal/deepseek/transport/transport.go +++ b/internal/deepseek/transport/transport.go @@ -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 +} diff --git a/internal/sse/parser.go b/internal/sse/parser.go index b5fd322..38429d9 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -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") { diff --git a/internal/sse/parser_test.go b/internal/sse/parser_test.go index 63d4c08..b036f57 100644 --- a/internal/sse/parser_test.go +++ b/internal/sse/parser_test.go @@ -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) + } +} diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index b464aec..d240f4b 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -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 (
setApiKey(e.target.value)} /> + {customKeyActive && ( +

+ {customKeyManaged ? t('apiTester.modeManaged') : t('apiTester.modeDirect')} +

+ )}
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index ff9a9a3..0daf15f 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -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." } } -} \ No newline at end of file +} diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index b5a04af..b405ee4 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -145,6 +145,8 @@ "apiKeyOptional": "API 密钥 (可选)", "apiKeyDefault": "默认: ...{suffix}", "apiKeyPlaceholder": "输入自定义密钥", + "modeManaged": "当前使用托管 key 模式(会走账号池)。", + "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", "statusError": "错误", "reasoningTrace": "思维链过程", "generating": "正在生成响应...", @@ -234,4 +236,4 @@ "four": "触发重新部署以应用新的环境变量。" } } -} \ No newline at end of file +}