mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-15 21:55:09 +08:00
feat: Introduce a new Go-based DeepSeek API proxy with adapters for Claude and OpenAI, including SSE parsing and updated build configurations.
This commit is contained in:
224
internal/sse/parser.go
Normal file
224
internal/sse/parser.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ContentPart struct {
|
||||
Text string
|
||||
Type string
|
||||
}
|
||||
|
||||
var skipPatterns = []string{
|
||||
"quasi_status", "elapsed_secs", "token_usage", "pending_fragment", "conversation_mode",
|
||||
"fragments/-1/status", "fragments/-2/status", "fragments/-3/status",
|
||||
}
|
||||
|
||||
func ParseDeepSeekSSELine(raw []byte) (map[string]any, bool, bool) {
|
||||
line := strings.TrimSpace(string(raw))
|
||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||
return nil, false, false
|
||||
}
|
||||
dataStr := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if dataStr == "[DONE]" {
|
||||
return nil, true, true
|
||||
}
|
||||
chunk := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
return chunk, false, true
|
||||
}
|
||||
|
||||
func shouldSkipPath(path string) bool {
|
||||
if path == "response/search_status" {
|
||||
return true
|
||||
}
|
||||
for _, p := range skipPatterns {
|
||||
if strings.Contains(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, bool, string) {
|
||||
v, ok := chunk["v"]
|
||||
if !ok {
|
||||
return nil, false, currentFragmentType
|
||||
}
|
||||
path, _ := chunk["p"].(string)
|
||||
if shouldSkipPath(path) {
|
||||
return nil, false, currentFragmentType
|
||||
}
|
||||
if path == "response/status" {
|
||||
if s, ok := v.(string); ok && s == "FINISHED" {
|
||||
return nil, true, currentFragmentType
|
||||
}
|
||||
}
|
||||
newType := currentFragmentType
|
||||
if path == "response" {
|
||||
if arr, ok := v.([]any); ok {
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["p"] == "fragments" && m["o"] == "APPEND" {
|
||||
if frags, ok := m["v"].([]any); ok {
|
||||
for _, frag := range frags {
|
||||
fm, ok := frag.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := fm["type"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
if t == "THINK" || t == "THINKING" {
|
||||
newType = "thinking"
|
||||
} else if t == "RESPONSE" {
|
||||
newType = "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
partType := "text"
|
||||
switch {
|
||||
case path == "response/thinking_content":
|
||||
partType = "thinking"
|
||||
case path == "response/content":
|
||||
partType = "text"
|
||||
case strings.Contains(path, "response/fragments") && strings.Contains(path, "/content"):
|
||||
partType = newType
|
||||
case path == "":
|
||||
if thinkingEnabled {
|
||||
partType = newType
|
||||
}
|
||||
}
|
||||
parts := make([]ContentPart, 0, 8)
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "FINISHED" && (path == "" || path == "status") {
|
||||
return nil, true, newType
|
||||
}
|
||||
if val != "" {
|
||||
parts = append(parts, ContentPart{Text: val, Type: partType})
|
||||
}
|
||||
case []any:
|
||||
pp, finished := extractContentRecursive(val, partType)
|
||||
if finished {
|
||||
return nil, true, newType
|
||||
}
|
||||
parts = append(parts, pp...)
|
||||
case map[string]any:
|
||||
resp := val
|
||||
if wrapped, ok := val["response"].(map[string]any); ok {
|
||||
resp = wrapped
|
||||
}
|
||||
if frags, ok := resp["fragments"].([]any); ok {
|
||||
for _, item := range frags {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := m["type"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
if t == "THINK" || t == "THINKING" {
|
||||
newType = "thinking"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
}
|
||||
} else if t == "RESPONSE" {
|
||||
newType = "text"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
} else if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: partType})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts, false, newType
|
||||
}
|
||||
|
||||
func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bool) {
|
||||
parts := make([]ContentPart, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemPath, _ := m["p"].(string)
|
||||
itemV, hasV := m["v"]
|
||||
if !hasV {
|
||||
continue
|
||||
}
|
||||
if itemPath == "status" {
|
||||
if s, ok := itemV.(string); ok && s == "FINISHED" {
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
if shouldSkipPath(itemPath) {
|
||||
continue
|
||||
}
|
||||
if content, ok := m["content"].(string); ok && content != "" {
|
||||
typeName, _ := m["type"].(string)
|
||||
typeName = strings.ToUpper(typeName)
|
||||
switch typeName {
|
||||
case "THINK", "THINKING":
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
case "RESPONSE":
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
default:
|
||||
parts = append(parts, ContentPart{Text: content, Type: defaultType})
|
||||
}
|
||||
continue
|
||||
}
|
||||
partType := defaultType
|
||||
if strings.Contains(itemPath, "thinking") {
|
||||
partType = "thinking"
|
||||
} else if strings.Contains(itemPath, "content") || itemPath == "response" || itemPath == "fragments" {
|
||||
partType = "text"
|
||||
}
|
||||
switch v := itemV.(type) {
|
||||
case string:
|
||||
if v != "" && v != "FINISHED" {
|
||||
parts = append(parts, ContentPart{Text: v, Type: partType})
|
||||
}
|
||||
case []any:
|
||||
for _, inner := range v {
|
||||
switch x := inner.(type) {
|
||||
case map[string]any:
|
||||
ct, _ := x["content"].(string)
|
||||
if ct == "" {
|
||||
continue
|
||||
}
|
||||
typeName, _ := x["type"].(string)
|
||||
typeName = strings.ToUpper(typeName)
|
||||
if typeName == "THINK" || typeName == "THINKING" {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "thinking"})
|
||||
} else if typeName == "RESPONSE" {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "text"})
|
||||
} else {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: partType})
|
||||
}
|
||||
case string:
|
||||
if x != "" {
|
||||
parts = append(parts, ContentPart{Text: x, Type: partType})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts, false
|
||||
}
|
||||
|
||||
func IsCitation(text string) bool {
|
||||
return bytes.HasPrefix([]byte(strings.TrimSpace(text)), []byte("[citation:"))
|
||||
}
|
||||
49
internal/sse/parser_test.go
Normal file
49
internal/sse/parser_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package sse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDeepSeekSSELine(t *testing.T) {
|
||||
chunk, done, ok := ParseDeepSeekSSELine([]byte(`data: {"v":"你好"}`))
|
||||
if !ok || done {
|
||||
t.Fatalf("expected parsed chunk")
|
||||
}
|
||||
if chunk["v"] != "你好" {
|
||||
t.Fatalf("unexpected chunk: %#v", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineDone(t *testing.T) {
|
||||
_, done, ok := ParseDeepSeekSSELine([]byte(`data: [DONE]`))
|
||||
if !ok || !done {
|
||||
t.Fatalf("expected done signal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentSimple(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{"v": "hello"}, false, "text")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Text != "hello" || parts[0].Type != "text" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentThinking(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{"p": "response/thinking_content", "v": "think"}, true, "thinking")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCitation(t *testing.T) {
|
||||
if !IsCitation("[citation:1] abc") {
|
||||
t.Fatal("expected citation true")
|
||||
}
|
||||
if IsCitation("normal text") {
|
||||
t.Fatal("expected citation false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user