mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-17 22:55:10 +08:00
refactor backend API structure
This commit is contained in:
31
internal/httpapi/openai/shared/citation_links.go
Normal file
31
internal/httpapi/openai/shared/citation_links.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var citationMarkerPattern = regexp.MustCompile(`(?i)\[citation:\s*(\d+)\]`)
|
||||
|
||||
func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string {
|
||||
if strings.TrimSpace(text) == "" || len(links) == 0 {
|
||||
return text
|
||||
}
|
||||
return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
sub := citationMarkerPattern.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(sub[1]))
|
||||
if err != nil || idx <= 0 {
|
||||
return match
|
||||
}
|
||||
url := strings.TrimSpace(links[idx])
|
||||
if url == "" {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprintf("[%d](%s)", idx, url)
|
||||
})
|
||||
}
|
||||
68
internal/httpapi/openai/shared/deps.go
Normal file
68
internal/httpapi/openai/shared/deps.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/chathistory"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// UploadMaxSize limits total multipart request body size (100 MiB).
|
||||
UploadMaxSize = 100 << 20
|
||||
// GeneralMaxSize limits total JSON request body size (100 MiB).
|
||||
GeneralMaxSize = 100 << 20
|
||||
)
|
||||
|
||||
type AuthResolver interface {
|
||||
Determine(req *http.Request) (*auth.RequestAuth, error)
|
||||
DetermineCaller(req *http.Request) (*auth.RequestAuth, error)
|
||||
Release(a *auth.RequestAuth)
|
||||
}
|
||||
|
||||
type DeepSeekCaller interface {
|
||||
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
UploadFile(ctx context.Context, a *auth.RequestAuth, req dsclient.UploadFileRequest, maxAttempts int) (*dsclient.UploadFileResult, error)
|
||||
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
|
||||
DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*dsclient.DeleteSessionResult, error)
|
||||
DeleteAllSessionsForToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type ConfigReader interface {
|
||||
ModelAliases() map[string]string
|
||||
CompatWideInputStrictOutput() bool
|
||||
CompatStripReferenceMarkers() bool
|
||||
ToolcallMode() string
|
||||
ToolcallEarlyEmitConfidence() string
|
||||
ResponsesStoreTTLSeconds() int
|
||||
EmbeddingsProvider() string
|
||||
AutoDeleteMode() string
|
||||
AutoDeleteSessions() bool
|
||||
HistorySplitEnabled() bool
|
||||
HistorySplitTriggerAfterTurns() int
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
Store ConfigReader
|
||||
Auth AuthResolver
|
||||
DS DeepSeekCaller
|
||||
ChatHistory *chathistory.Store
|
||||
}
|
||||
|
||||
func CompatStripReferenceMarkers(store ConfigReader) bool {
|
||||
if store == nil {
|
||||
return true
|
||||
}
|
||||
return store.CompatStripReferenceMarkers()
|
||||
}
|
||||
|
||||
var WriteJSON = util.WriteJSON
|
||||
|
||||
var _ AuthResolver = (*auth.Resolver)(nil)
|
||||
var _ DeepSeekCaller = (*dsclient.Client)(nil)
|
||||
var _ ConfigReader = (*config.Store)(nil)
|
||||
63
internal/httpapi/openai/shared/handler_errors.go
Normal file
63
internal/httpapi/openai/shared/handler_errors.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package shared
|
||||
|
||||
import "net/http"
|
||||
|
||||
func WriteOpenAIError(w http.ResponseWriter, status int, message string) {
|
||||
WriteOpenAIErrorWithCode(w, status, message, "")
|
||||
}
|
||||
|
||||
func WriteOpenAIErrorWithCode(w http.ResponseWriter, status int, message, code string) {
|
||||
if code == "" {
|
||||
code = OpenAIErrorCode(status)
|
||||
}
|
||||
WriteJSON(w, status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": message,
|
||||
"type": OpenAIErrorType(status),
|
||||
"code": code,
|
||||
"param": nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
func OpenAIErrorCode(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case http.StatusUnauthorized:
|
||||
return "authentication_failed"
|
||||
case http.StatusForbidden:
|
||||
return "forbidden"
|
||||
case http.StatusTooManyRequests:
|
||||
return "rate_limit_exceeded"
|
||||
case http.StatusNotFound:
|
||||
return "not_found"
|
||||
case http.StatusServiceUnavailable:
|
||||
return "service_unavailable"
|
||||
default:
|
||||
if status >= 500 {
|
||||
return "internal_error"
|
||||
}
|
||||
return "invalid_request"
|
||||
}
|
||||
}
|
||||
101
internal/httpapi/openai/shared/handler_toolcall_format.go
Normal file
101
internal/httpapi/openai/shared/handler_toolcall_format.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"ds2api/internal/toolstream"
|
||||
)
|
||||
|
||||
func FormatIncrementalStreamToolCallDeltas(deltas []toolstream.ToolCallDelta, ids map[int]string) []map[string]any {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(deltas))
|
||||
for _, d := range deltas {
|
||||
if d.Name == "" && d.Arguments == "" {
|
||||
continue
|
||||
}
|
||||
callID, ok := ids[d.Index]
|
||||
if !ok || callID == "" {
|
||||
callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
ids[d.Index] = callID
|
||||
}
|
||||
item := map[string]any{
|
||||
"index": d.Index,
|
||||
"id": callID,
|
||||
"type": "function",
|
||||
}
|
||||
fn := map[string]any{}
|
||||
if d.Name != "" {
|
||||
fn["name"] = d.Name
|
||||
}
|
||||
if d.Arguments != "" {
|
||||
fn["arguments"] = d.Arguments
|
||||
}
|
||||
if len(fn) > 0 {
|
||||
item["function"] = fn
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FilterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]toolstream.ToolCallDelta, 0, len(deltas))
|
||||
for _, d := range deltas {
|
||||
if d.Name != "" {
|
||||
if seenNames != nil {
|
||||
seenNames[d.Index] = d.Name
|
||||
}
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
if seenNames == nil {
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(seenNames[d.Index])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(calls))
|
||||
for i, c := range calls {
|
||||
callID := ""
|
||||
if ids != nil {
|
||||
callID = strings.TrimSpace(ids[i])
|
||||
}
|
||||
if callID == "" {
|
||||
callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if ids != nil {
|
||||
ids[i] = callID
|
||||
}
|
||||
}
|
||||
args, _ := json.Marshal(c.Input)
|
||||
out = append(out, map[string]any{
|
||||
"index": i,
|
||||
"id": callID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": c.Name,
|
||||
"arguments": string(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package shared
|
||||
|
||||
func ToolcallFeatureMatchEnabled(_ ConfigReader) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func ToolcallEarlyEmitHighConfidence(_ ConfigReader) bool {
|
||||
return true
|
||||
}
|
||||
115
internal/httpapi/openai/shared/leaked_output_sanitize.go
Normal file
115
internal/httpapi/openai/shared/leaked_output_sanitize.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
|
||||
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
|
||||
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
|
||||
|
||||
var leakedThinkTagPattern = regexp.MustCompile(`(?is)</?\s*think\s*>`)
|
||||
|
||||
// leakedBOSMarkerPattern matches DeepSeek BOS markers in BOTH forms:
|
||||
// - ASCII underscore: <|begin_of_sentence|>
|
||||
// - U+2581 variant: <|begin▁of▁sentence|>
|
||||
var leakedBOSMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*begin[_▁]of[_▁]sentence\s*[|\|]>`)
|
||||
|
||||
// leakedMetaMarkerPattern matches the remaining DeepSeek special tokens in BOTH forms:
|
||||
// - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|>
|
||||
// - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|>
|
||||
var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`)
|
||||
|
||||
// leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through
|
||||
// when the sieve fails to capture them. These are applied only to complete
|
||||
// wrapper blocks so standalone "<result>" examples in normal output remain
|
||||
// untouched.
|
||||
var leakedAgentXMLBlockPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?is)<attempt_completion\b[^>]*>(.*?)</attempt_completion>`),
|
||||
regexp.MustCompile(`(?is)<ask_followup_question\b[^>]*>(.*?)</ask_followup_question>`),
|
||||
regexp.MustCompile(`(?is)<new_task\b[^>]*>(.*?)</new_task>`),
|
||||
}
|
||||
|
||||
var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)</?(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
|
||||
var leakedAgentWrapperPlusResultOpenPattern = regexp.MustCompile(`(?is)<(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>\s*<result>`)
|
||||
var leakedAgentResultPlusWrapperClosePattern = regexp.MustCompile(`(?is)</result>\s*</(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
|
||||
var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)</?result>`)
|
||||
|
||||
func sanitizeLeakedOutput(text string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
out := emptyJSONFencePattern.ReplaceAllString(text, "")
|
||||
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
|
||||
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
|
||||
out = stripDanglingThinkSuffix(out)
|
||||
out = leakedThinkTagPattern.ReplaceAllString(out, "")
|
||||
out = leakedBOSMarkerPattern.ReplaceAllString(out, "")
|
||||
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
|
||||
out = sanitizeLeakedAgentXMLBlocks(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func stripDanglingThinkSuffix(text string) string {
|
||||
matches := leakedThinkTagPattern.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return text
|
||||
}
|
||||
depth := 0
|
||||
lastOpen := -1
|
||||
for _, loc := range matches {
|
||||
tag := strings.ToLower(text[loc[0]:loc[1]])
|
||||
compact := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(tag), " ", ""), "\t", "")
|
||||
if strings.HasPrefix(compact, "</") {
|
||||
if depth > 0 {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
lastOpen = -1
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if depth == 0 {
|
||||
lastOpen = loc[0]
|
||||
}
|
||||
depth++
|
||||
}
|
||||
if depth == 0 || lastOpen < 0 {
|
||||
return text
|
||||
}
|
||||
prefix := text[:lastOpen]
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func sanitizeLeakedAgentXMLBlocks(text string) string {
|
||||
out := text
|
||||
for _, pattern := range leakedAgentXMLBlockPatterns {
|
||||
out = pattern.ReplaceAllStringFunc(out, func(match string) string {
|
||||
submatches := pattern.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
// Preserve the inner text so leaked agent instructions do not erase
|
||||
// the actual answer, but strip the wrapper/result markup itself.
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "")
|
||||
})
|
||||
}
|
||||
// Fallback for truncated output streams: strip any dangling wrapper tags
|
||||
// that were not part of a complete block replacement. If we detect leaked
|
||||
// wrapper tags, strip only adjacent <result> tags to avoid exposing agent
|
||||
// markup without altering unrelated user-visible <result> examples.
|
||||
if leakedAgentWrapperTagPattern.MatchString(out) {
|
||||
out = leakedAgentWrapperPlusResultOpenPattern.ReplaceAllStringFunc(out, func(match string) string {
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
|
||||
})
|
||||
out = leakedAgentResultPlusWrapperClosePattern.ReplaceAllStringFunc(out, func(match string) string {
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
|
||||
})
|
||||
out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
28
internal/httpapi/openai/shared/models.go
Normal file
28
internal/httpapi/openai/shared/models.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
type ModelsHandler struct {
|
||||
Store ConfigReader
|
||||
}
|
||||
|
||||
func (h *ModelsHandler) ListModels(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, config.OpenAIModelsResponse())
|
||||
}
|
||||
|
||||
func (h *ModelsHandler) GetModel(w http.ResponseWriter, r *http.Request) {
|
||||
modelID := strings.TrimSpace(chi.URLParam(r, "model_id"))
|
||||
model, ok := config.OpenAIModelByID(h.Store, modelID)
|
||||
if !ok {
|
||||
WriteOpenAIError(w, http.StatusNotFound, "Model not found.")
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, model)
|
||||
}
|
||||
13
internal/httpapi/openai/shared/output_clean.go
Normal file
13
internal/httpapi/openai/shared/output_clean.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package shared
|
||||
|
||||
import textclean "ds2api/internal/textclean"
|
||||
|
||||
func CleanVisibleOutput(text string, stripReferenceMarkers bool) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
if stripReferenceMarkers {
|
||||
text = textclean.StripReferenceMarkers(text)
|
||||
}
|
||||
return sanitizeLeakedOutput(text)
|
||||
}
|
||||
8
internal/httpapi/openai/shared/string_helpers.go
Normal file
8
internal/httpapi/openai/shared/string_helpers.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package shared
|
||||
|
||||
func AsString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
21
internal/httpapi/openai/shared/trace.go
Normal file
21
internal/httpapi/openai/shared/trace.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func RequestTraceID(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("__trace_id")); q != "" {
|
||||
return q
|
||||
}
|
||||
if h := strings.TrimSpace(r.Header.Get("X-Ds2-Test-Trace")); h != "" {
|
||||
return h
|
||||
}
|
||||
return strings.TrimSpace(middleware.GetReqID(r.Context()))
|
||||
}
|
||||
27
internal/httpapi/openai/shared/upstream_empty.go
Normal file
27
internal/httpapi/openai/shared/upstream_empty.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package shared
|
||||
|
||||
import "net/http"
|
||||
|
||||
func ShouldWriteUpstreamEmptyOutputError(text string) bool {
|
||||
return text == ""
|
||||
}
|
||||
|
||||
func UpstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) {
|
||||
_ = text
|
||||
if contentFilter {
|
||||
return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter"
|
||||
}
|
||||
if thinking != "" {
|
||||
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned reasoning without visible output.", "upstream_empty_output"
|
||||
}
|
||||
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned empty output.", "upstream_empty_output"
|
||||
}
|
||||
|
||||
func WriteUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool {
|
||||
if !ShouldWriteUpstreamEmptyOutputError(text) {
|
||||
return false
|
||||
}
|
||||
status, message, code := UpstreamEmptyOutputDetail(contentFilter, text, thinking)
|
||||
WriteOpenAIErrorWithCode(w, status, message, code)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user