Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/adapter/claude/standard_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
thinkingEnabled = false
searchEnabled = false
}
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
finalPrompt := deepseek.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}
Expand Down
2 changes: 1 addition & 1 deletion internal/adapter/gemini/convert_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
}

toolsRaw := convertGeminiTools(req["tools"])
finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "")
finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled)
passThrough := collectGeminiPassThrough(req)

return util.StandardRequest{
Expand Down
12 changes: 6 additions & 6 deletions internal/adapter/openai/prompt_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ import (
"ds2api/internal/util"
)

func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string) (string, []string) {
return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy())
func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) {
return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy(), thinkingEnabled)
}

func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy) (string, []string) {
func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) {
messages := normalizeOpenAIMessagesForPrompt(messagesRaw, traceID)
toolNames := []string{}
if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 {
messages, toolNames = injectToolPrompt(messages, tools, toolPolicy)
}
return deepseek.MessagesPrepare(messages), toolNames
return deepseek.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames
}

// BuildPromptForAdapter exposes the OpenAI-compatible prompt building flow so
// other protocol adapters (for example Gemini) can reuse the same tool/history
// normalization logic and remain behavior-compatible with chat/completions.
func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string) (string, []string) {
return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID)
func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) {
return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID, thinkingEnabled)
}
4 changes: 2 additions & 2 deletions internal/adapter/openai/prompt_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
},
}

finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "")
finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "", false)
if len(toolNames) != 1 || toolNames[0] != "get_weather" {
t.Fatalf("unexpected tool names: %#v", toolNames)
}
Expand Down Expand Up @@ -73,7 +73,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
},
}

finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "")
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: Output ONLY the <tool_calls>...</tool_calls> XML block when calling tools.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/adapter/openai/standard_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
responseModel = resolvedModel
}
toolPolicy := util.DefaultToolChoicePolicy()
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)

Expand Down Expand Up @@ -74,7 +74,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
if err != nil {
return util.StandardRequest{}, err
}
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
if !toolPolicy.IsNone() {
toolPolicy.Allowed = namesToSet(toolNames)
Expand Down
4 changes: 4 additions & 0 deletions internal/deepseek/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ import "ds2api/internal/prompt"
func MessagesPrepare(messages []map[string]any) string {
return prompt.MessagesPrepare(messages)
}

func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string {
return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled)
}
21 changes: 19 additions & 2 deletions internal/prompt/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,23 @@ import (
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)

const (
beginSentenceMarker = "<|begin▁of▁sentence|>"
systemMarker = "<|System|>"
userMarker = "<|User|>"
assistantMarker = "<|Assistant|>"
toolMarker = "<|Tool|>"
endSentenceMarker = "<|end▁of▁sentence|>"
endToolResultsMarker = "<|end▁of▁toolresults|>"
endInstructionsMarker = "<|end▁of▁instructions|>"
openThinkMarker = "<think>"
closeThinkMarker = "</think>"
)

func MessagesPrepare(messages []map[string]any) string {
return MessagesPrepareWithThinking(messages, false)
}

func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string {
type block struct {
Role string
Text string
Expand All @@ -41,11 +48,14 @@ func MessagesPrepare(messages []map[string]any) string {
}
merged = append(merged, msg)
}
parts := make([]string, 0, len(merged))
parts := make([]string, 0, len(merged)+2)
parts = append(parts, beginSentenceMarker)
lastRole := ""
for _, m := range merged {
lastRole = m.Role
switch m.Role {
case "assistant":
parts = append(parts, formatRoleBlock(assistantMarker, m.Text, endSentenceMarker))
parts = append(parts, formatRoleBlock(assistantMarker, closeThinkMarker+m.Text, endSentenceMarker))
case "tool":
if strings.TrimSpace(m.Text) != "" {
parts = append(parts, formatRoleBlock(toolMarker, m.Text, endToolResultsMarker))
Expand All @@ -62,6 +72,13 @@ func MessagesPrepare(messages []map[string]any) string {
}
}
}
if lastRole != "assistant" {
thinkPrefix := closeThinkMarker
if thinkingEnabled {
thinkPrefix = openThinkMarker
}
parts = append(parts, assistantMarker+thinkPrefix)
Comment on lines +76 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip think delimiters from visible model output

This change starts every generation with raw <think>/</think> delimiters, but output cleanup still only strips DeepSeek role/end markers in sanitizeLeakedOutput and does not remove think tags. When upstream echoes prompt-template tokens (the same class of leakage already handled for <|Assistant|>/<|end...|>), those delimiters will now be returned to clients as literal answer text, polluting responses and exposing internal reasoning boundaries. Please add <think>/</think> (and the new BOS marker if needed) to output sanitization or avoid emitting them into user-visible channels.

Useful? React with 👍 / 👎.

}
out := strings.Join(parts, "\n\n")
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
}
Expand Down
13 changes: 12 additions & 1 deletion internal/prompt/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) {
{"role": "assistant", "content": "Answer"},
}
got := MessagesPrepare(messages)
if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") {
t.Fatalf("expected begin-of-sentence marker, got %q", got)
}
if !strings.Contains(got, "<|System|>\nSystem rule<|end▁of▁instructions|>") {
t.Fatalf("expected system instructions suffix, got %q", got)
}
if !strings.Contains(got, "<|User|>\nQuestion<|end▁of▁sentence|>") {
t.Fatalf("expected user sentence suffix, got %q", got)
}
if !strings.Contains(got, "<|Assistant|>\nAnswer<|end▁of▁sentence|>") {
if !strings.Contains(got, "<|Assistant|>\n</think>Answer<|end▁of▁sentence|>") {
t.Fatalf("expected assistant sentence suffix, got %q", got)
}
}
Expand All @@ -51,3 +54,11 @@ func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
t.Fatalf("expected fallback to content when text is empty, got %q", got)
}
}

func TestMessagesPrepareWithThinkingEndsWithOpenThink(t *testing.T) {
messages := []map[string]any{{"role": "user", "content": "Question"}}
got := MessagesPrepareWithThinking(messages, true)
if !strings.HasSuffix(got, "<|Assistant|><think>") {
t.Fatalf("expected thinking suffix, got %q", got)
}
}
11 changes: 7 additions & 4 deletions internal/util/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestMessagesPrepareBasic(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty prompt")
}
if got != "<|User|>\nHello<|end▁of▁sentence|>" {
if got != "<|begin▁of▁sentence|>\n\n<|User|>\nHello<|end▁of▁sentence|>\n\n<|Assistant|></think>" {
t.Fatalf("unexpected prompt: %q", got)
}
}
Expand All @@ -29,10 +29,13 @@ func TestMessagesPrepareRoles(t *testing.T) {
if !contains(got, "<|System|>\nYou are helper<|end▁of▁instructions|>\n\n<|User|>\nHi<|end▁of▁sentence|>") {
t.Fatalf("expected system/user separation in %q", got)
}
if !contains(got, "<|User|>\nHi<|end▁of▁sentence|>\n\n<|Assistant|>\nHello<|end▁of▁sentence|>") {
if !contains(got, "<|begin▁of▁sentence|>") {
t.Fatalf("expected begin marker in %q", got)
}
if !contains(got, "<|User|>\nHi<|end▁of▁sentence|>\n\n<|Assistant|>\n</think>Hello<|end▁of▁sentence|>") {
t.Fatalf("expected user/assistant separation in %q", got)
}
if !contains(got, "<|Assistant|>\nHello<|end▁of▁sentence|>\n\n<|Tool|>\nSearch results<|end▁of▁toolresults|>") {
if !contains(got, "<|Assistant|>\n</think>Hello<|end▁of▁sentence|>\n\n<|Tool|>\nSearch results<|end▁of▁toolresults|>") {
t.Fatalf("expected assistant/tool separation in %q", got)
}
if !contains(got, "<|Tool|>\nSearch results<|end▁of▁toolresults|>\n\n<|User|>\nHow are you<|end▁of▁sentence|>") {
Expand Down Expand Up @@ -74,7 +77,7 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) {
},
}
got := MessagesPrepare(messages)
if got != "<|User|>\nline1\nline2<|end▁of▁sentence|>" {
if got != "<|begin▁of▁sentence|>\n\n<|User|>\nline1\nline2<|end▁of▁sentence|>\n\n<|Assistant|></think>" {
t.Fatalf("unexpected content from text variants: %q", got)
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/util/util_edge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) {
{"role": "user", "content": "World"},
}
got := MessagesPrepare(messages)
if !strings.HasPrefix(got, "<|User|>") {
if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") {
t.Fatalf("expected user marker at the start, got %q", got)
}
if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
Expand Down Expand Up @@ -193,7 +193,7 @@ func TestMessagesPrepareAssistantMarkers(t *testing.T) {
if strings.Count(got, "<|end▁of▁sentence|>") != 2 {
t.Fatalf("expected both turns to be terminated, got %q", got)
}
if !strings.Contains(got, "<|Assistant|>\nHello!<|end▁of▁sentence|>") {
if !strings.Contains(got, "<|Assistant|>\n</think>Hello!<|end▁of▁sentence|>") {
t.Fatalf("expected assistant EOS suffix, got %q", got)
}
if strings.Contains(got, "<system_instructions>") {
Expand Down
Loading