mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-11 03:37:40 +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:
127
internal/util/messages.go
Normal file
127
internal/util/messages.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
|
||||
|
||||
const ClaudeDefaultModel = "claude-sonnet-4-20250514"
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
func MessagesPrepare(messages []map[string]any) string {
|
||||
type block struct {
|
||||
Role string
|
||||
Text string
|
||||
}
|
||||
processed := make([]block, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
role, _ := m["role"].(string)
|
||||
text := normalizeContent(m["content"])
|
||||
processed = append(processed, block{Role: role, Text: text})
|
||||
}
|
||||
if len(processed) == 0 {
|
||||
return ""
|
||||
}
|
||||
merged := make([]block, 0, len(processed))
|
||||
for _, msg := range processed {
|
||||
if len(merged) > 0 && merged[len(merged)-1].Role == msg.Role {
|
||||
merged[len(merged)-1].Text += "\n\n" + msg.Text
|
||||
continue
|
||||
}
|
||||
merged = append(merged, msg)
|
||||
}
|
||||
parts := make([]string, 0, len(merged))
|
||||
for i, m := range merged {
|
||||
switch m.Role {
|
||||
case "assistant":
|
||||
parts = append(parts, "<|Assistant|>"+m.Text+"<|end▁of▁sentence|>")
|
||||
case "user", "system":
|
||||
if i > 0 {
|
||||
parts = append(parts, "<|User|>"+m.Text)
|
||||
} else {
|
||||
parts = append(parts, m.Text)
|
||||
}
|
||||
default:
|
||||
parts = append(parts, m.Text)
|
||||
}
|
||||
}
|
||||
out := strings.Join(parts, "")
|
||||
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
|
||||
}
|
||||
|
||||
func normalizeContent(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []any:
|
||||
parts := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["type"] == "text" {
|
||||
if txt, ok := m["text"].(string); ok {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertClaudeToDeepSeek(claudeReq map[string]any, store *config.Store) map[string]any {
|
||||
messages, _ := claudeReq["messages"].([]any)
|
||||
model, _ := claudeReq["model"].(string)
|
||||
if model == "" {
|
||||
model = ClaudeDefaultModel
|
||||
}
|
||||
mapping := store.ClaudeMapping()
|
||||
dsModel := mapping["fast"]
|
||||
if dsModel == "" {
|
||||
dsModel = "deepseek-chat"
|
||||
}
|
||||
modelLower := strings.ToLower(model)
|
||||
if strings.Contains(modelLower, "opus") || strings.Contains(modelLower, "reasoner") || strings.Contains(modelLower, "slow") {
|
||||
if slow := mapping["slow"]; slow != "" {
|
||||
dsModel = slow
|
||||
}
|
||||
}
|
||||
convertedMessages := make([]any, 0, len(messages)+1)
|
||||
if system, ok := claudeReq["system"].(string); ok && system != "" {
|
||||
convertedMessages = append(convertedMessages, map[string]any{"role": "system", "content": system})
|
||||
}
|
||||
convertedMessages = append(convertedMessages, messages...)
|
||||
|
||||
out := map[string]any{"model": dsModel, "messages": convertedMessages}
|
||||
for _, k := range []string{"temperature", "top_p", "stream"} {
|
||||
if v, ok := claudeReq[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if stopSeq, ok := claudeReq["stop_sequences"]; ok {
|
||||
out["stop"] = stopSeq
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func EstimateTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
n := len([]rune(text)) / 4
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
69
internal/util/messages_test.go
Normal file
69
internal/util/messages_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func TestMessagesPrepareBasic(t *testing.T) {
|
||||
messages := []map[string]any{{"role": "user", "content": "Hello"}}
|
||||
got := MessagesPrepare(messages)
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty prompt")
|
||||
}
|
||||
if got != "Hello" {
|
||||
t.Fatalf("unexpected prompt: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesPrepareRoles(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": "You are helper"},
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": "user", "content": "How are you"},
|
||||
}
|
||||
got := MessagesPrepare(messages)
|
||||
if !contains(got, "<|Assistant|>") {
|
||||
t.Fatalf("expected assistant marker in %q", got)
|
||||
}
|
||||
if !contains(got, "<|User|>") {
|
||||
t.Fatalf("expected user marker in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeToDeepSeek(t *testing.T) {
|
||||
store := config.LoadStore()
|
||||
req := map[string]any{
|
||||
"model": "claude-sonnet-4-20250514-slow",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||||
"system": "You are helpful",
|
||||
"stream": true,
|
||||
}
|
||||
out := ConvertClaudeToDeepSeek(req, store)
|
||||
if out["model"] == "" {
|
||||
t.Fatal("expected mapped model")
|
||||
}
|
||||
msgs, ok := out["messages"].([]any)
|
||||
if !ok || len(msgs) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
first, _ := msgs[0].(map[string]any)
|
||||
if first["role"] != "system" {
|
||||
t.Fatalf("expected first message system, got %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || (len(s) > 0 && (indexOf(s, sub) >= 0)))
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
69
internal/util/toolcalls.go
Normal file
69
internal/util/toolcalls.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
|
||||
|
||||
type ParsedToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
}
|
||||
|
||||
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
m := toolCallPattern.FindStringSubmatch(text)
|
||||
if len(m) < 2 {
|
||||
return nil
|
||||
}
|
||||
payload := "{" + `"tool_calls":[` + m[1] + "]}"
|
||||
var obj struct {
|
||||
ToolCalls []ParsedToolCall `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
for _, name := range availableToolNames {
|
||||
allowed[name] = struct{}{}
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(obj.ToolCalls))
|
||||
for _, tc := range obj.ToolCalls {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if len(allowed) > 0 {
|
||||
if _, ok := allowed[tc.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatOpenAIToolCalls(calls []ParsedToolCall) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
args, _ := json.Marshal(c.Input)
|
||||
out = append(out, map[string]any{
|
||||
"id": "call_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": c.Name,
|
||||
"arguments": string(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
internal/util/toolcalls_test.go
Normal file
33
internal/util/toolcalls_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseToolCalls(t *testing.T) {
|
||||
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "search" {
|
||||
t.Fatalf("unexpected tool name: %s", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsRejectUnknown(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected 0 calls, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOpenAIToolCalls(t *testing.T) {
|
||||
formatted := FormatOpenAIToolCalls([]ParsedToolCall{{Name: "search", Input: map[string]any{"q": "x"}}})
|
||||
if len(formatted) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(formatted))
|
||||
}
|
||||
fn, _ := formatted[0]["function"].(map[string]any)
|
||||
if fn["name"] != "search" {
|
||||
t.Fatalf("unexpected function name: %#v", fn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user