Unverified Commit a013693f authored by Grace's avatar Grace Committed by GitHub
Browse files

DeepseekV3 Family Parser (#13484)

parent f6a016f4
package parsers
import (
"encoding/json"
"errors"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
type DeepSeek3ParserState int
const (
DeepSeekCollectingThinking DeepSeek3ParserState = iota
DeepSeekCollectingContent
DeepSeekCollectingToolCalls
DeepSeekCollectingToolOutput
)
const (
deepseekThinkingCloseTag = "</think>"
deepseekToolCallsBeginTag = "<|tool▁calls▁begin|>"
deepseekToolCallsEndTag = "<|tool▁calls▁end|>"
deepseekToolCallBeginTag = "<|tool▁call▁begin|>"
deepseekToolCallEndTag = "<|tool▁call▁end|>"
deepseekToolSepTag = "<|tool▁sep|>"
deepseekToolOutputBeginTag = "<|tool▁output▁begin|>"
deepseekToolOutputEndTag = "<|tool▁output▁end|>"
)
type DeepSeek3Parser struct {
state DeepSeek3ParserState
buffer strings.Builder
hasThinkingSupport bool
}
func (p *DeepSeek3Parser) HasToolSupport() bool {
return true
}
func (p *DeepSeek3Parser) HasThinkingSupport() bool {
return p.hasThinkingSupport
}
func (p *DeepSeek3Parser) setInitialState(lastMessage *api.Message, tools []api.Tool, thinkValue *api.ThinkValue) {
prefill := lastMessage != nil && lastMessage.Role == "assistant"
// Check both model capability AND request preference
thinkingEnabled := p.HasThinkingSupport() && (thinkValue != nil && thinkValue.Bool())
if !thinkingEnabled {
p.state = DeepSeekCollectingContent
return
}
if prefill && lastMessage.Content != "" {
p.state = DeepSeekCollectingContent
return
}
p.state = DeepSeekCollectingThinking
}
func (p *DeepSeek3Parser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.setInitialState(lastMessage, tools, thinkValue)
return tools
}
type deepseekEvent interface {
isDeepSeekEvent()
}
type deepseekEventThinkingContent struct {
content string
}
type deepseekEventContent struct {
content string
}
type deepseekEventToolCall struct {
toolCall api.ToolCall
}
func (deepseekEventThinkingContent) isDeepSeekEvent() {}
func (deepseekEventContent) isDeepSeekEvent() {}
func (deepseekEventToolCall) isDeepSeekEvent() {}
func (p *DeepSeek3Parser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
events := p.parseEvents()
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
for _, event := range events {
switch event := event.(type) {
case deepseekEventToolCall:
toolCalls = append(toolCalls, event.toolCall)
case deepseekEventThinkingContent:
thinkingSb.WriteString(event.content)
case deepseekEventContent:
contentSb.WriteString(event.content)
}
}
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *DeepSeek3Parser) parseEvents() []deepseekEvent {
var all []deepseekEvent
keepLooping := true
for keepLooping {
var events []deepseekEvent
events, keepLooping = p.eat()
if len(events) > 0 {
all = append(all, events...)
}
}
return all
}
func (p *DeepSeek3Parser) eat() ([]deepseekEvent, bool) {
var events []deepseekEvent
bufStr := p.buffer.String()
if bufStr == "" {
return events, false
}
switch p.state {
case DeepSeekCollectingThinking:
if strings.Contains(bufStr, deepseekThinkingCloseTag) { // thinking[</think>] -> content
split := strings.SplitN(bufStr, deepseekThinkingCloseTag, 2)
thinking := split[0]
thinking = strings.TrimRightFunc(thinking, unicode.IsSpace)
remaining := split[1]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingContent
if len(thinking) > 0 {
events = append(events, deepseekEventThinkingContent{content: thinking})
}
return events, true
} else if overlapLen := overlap(bufStr, deepseekThinkingCloseTag); overlapLen > 0 { // partial </think>
beforePartialTag := bufStr[:len(bufStr)-overlapLen]
trailingLen := trailingWhitespaceLen(beforePartialTag)
ambiguousStart := len(beforePartialTag) - trailingLen
unambiguous := bufStr[:ambiguousStart]
ambiguous := bufStr[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, deepseekEventThinkingContent{content: unambiguous})
}
return events, false
} else { // otherwise its thinking content
whitespaceLen := trailingWhitespaceLen(bufStr)
ambiguousStart := len(bufStr) - whitespaceLen
unambiguous := bufStr[:ambiguousStart]
ambiguous := bufStr[ambiguousStart:]
p.buffer.Reset()
p.buffer.WriteString(ambiguous)
if len(unambiguous) > 0 {
events = append(events, deepseekEventThinkingContent{content: unambiguous})
}
return events, false
}
case DeepSeekCollectingContent:
switch {
case strings.Contains(bufStr, deepseekToolCallsBeginTag): // content[<|tool▁calls▁begin|>] -> tool calls
split := strings.SplitN(bufStr, deepseekToolCallsBeginTag, 2)
contentBefore := strings.TrimRightFunc(split[0], unicode.IsSpace)
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingToolCalls
if len(contentBefore) > 0 {
events = append(events, deepseekEventContent{content: contentBefore})
}
return events, true
case strings.Contains(bufStr, deepseekToolOutputBeginTag): // content[<|tool▁output▁begin|>] -> tool output
split := strings.SplitN(bufStr, deepseekToolOutputBeginTag, 2)
contentBefore := split[0] // Don't trim whitespace - preserve spaces
remaining := split[1]
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingToolOutput
if len(contentBefore) > 0 {
events = append(events, deepseekEventContent{content: contentBefore})
}
return events, true
default: // otherwise its content
p.buffer.Reset()
if len(bufStr) > 0 {
events = append(events, deepseekEventContent{content: bufStr})
}
return events, false
}
case DeepSeekCollectingToolCalls:
if idx := strings.Index(bufStr, deepseekToolCallBeginTag); idx != -1 {
startIdx := idx + len(deepseekToolCallBeginTag)
if endIdx := strings.Index(bufStr[startIdx:], deepseekToolCallEndTag); endIdx != -1 {
toolCallContent := bufStr[startIdx : startIdx+endIdx]
if toolCall, err := p.parseToolCallContent(toolCallContent); err == nil {
remaining := bufStr[startIdx+endIdx+len(deepseekToolCallEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
events = append(events, deepseekEventToolCall{toolCall: toolCall})
return events, true
} else {
slog.Warn("deepseek tool call parsing failed", "error", err)
}
}
}
if idx := strings.Index(bufStr, deepseekToolCallsEndTag); idx != -1 {
remaining := bufStr[idx+len(deepseekToolCallsEndTag):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingContent
return events, true
}
return events, false
case DeepSeekCollectingToolOutput:
if idx := strings.Index(bufStr, deepseekToolOutputEndTag); idx != -1 {
toolOutputContent := bufStr[:idx]
remaining := bufStr[idx+len(deepseekToolOutputEndTag):]
// Don't trim whitespace - preserve spaces after tool output tags
p.buffer.Reset()
p.buffer.WriteString(remaining)
p.state = DeepSeekCollectingContent
if len(toolOutputContent) > 0 {
events = append(events, deepseekEventContent{content: toolOutputContent})
}
return events, true
}
return events, false
}
return events, false
}
func (p *DeepSeek3Parser) parseToolCallContent(content string) (api.ToolCall, error) {
// Expected format: tool_name<|tool▁sep|>{args}
parts := strings.SplitN(content, deepseekToolSepTag, 2)
if len(parts) < 2 {
return api.ToolCall{}, errors.New("invalid format")
}
toolName := strings.TrimSpace(parts[0])
argsJSON := strings.TrimSpace(parts[1])
var args api.ToolCallFunctionArguments
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return api.ToolCall{}, err
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: toolName,
Arguments: args,
},
}, nil
}
This diff is collapsed.
...@@ -58,6 +58,8 @@ func ParserForName(name string) Parser { ...@@ -58,6 +58,8 @@ func ParserForName(name string) Parser {
return harmony.NewHarmonyMessageHandler() return harmony.NewHarmonyMessageHandler()
case "cogito": case "cogito":
return &CogitoParser{} return &CogitoParser{}
case "deepseek3":
return &DeepSeek3Parser{hasThinkingSupport: true}
case "olmo3": case "olmo3":
return &Olmo3Parser{} return &Olmo3Parser{}
case "olmo3-think": case "olmo3-think":
......
...@@ -70,7 +70,16 @@ func (r *DeepSeek3Renderer) Render(messages []api.Message, tools []api.Tool, thi ...@@ -70,7 +70,16 @@ func (r *DeepSeek3Renderer) Render(messages []api.Message, tools []api.Tool, thi
isTool := false isTool := false
isLastUser := false isLastUser := false
for _, message := range messages { // Find the index of the last user message to determine which assistant message is "current"
lastUserIndex := -1
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUserIndex = i
break
}
}
for i, message := range messages {
switch message.Role { switch message.Role {
case "user": case "user":
isTool = false isTool = false
...@@ -101,9 +110,11 @@ func (r *DeepSeek3Renderer) Render(messages []api.Message, tools []api.Tool, thi ...@@ -101,9 +110,11 @@ func (r *DeepSeek3Renderer) Render(messages []api.Message, tools []api.Tool, thi
} else { } else {
if isLastUser { if isLastUser {
sb.WriteString("<|Assistant|>") sb.WriteString("<|Assistant|>")
// message["prefix"] is defined and message["prefix"] and thinking hasThinking := message.Thinking != ""
// message.Thinking != "" represents message["prefix"] being defined
if message.Thinking != "" && thinking { // only use <think> for the current turn (after last user message)
isCurrentTurn := i > lastUserIndex
if hasThinking && thinking && isCurrentTurn {
sb.WriteString("<think>") sb.WriteString("<think>")
} else { } else {
sb.WriteString("</think>") sb.WriteString("</think>")
......
...@@ -422,7 +422,7 @@ Second instruction<|User|>Hello<|Assistant|></think>`, ...@@ -422,7 +422,7 @@ Second instruction<|User|>Hello<|Assistant|></think>`,
{Role: "user", Content: "How do they interact with matter?"}, {Role: "user", Content: "How do they interact with matter?"},
}, },
thinkValue: &api.ThinkValue{Value: true}, thinkValue: &api.ThinkValue{Value: true},
expected: `<|begin▁of▁sentence|><|User|>Explain quantum physics<|Assistant|><think>Quantum physics is the study of matter and energy at the smallest scales.<|end▁of▁sentence|><|User|>What about photons?<|Assistant|></think>Photons are particles of light with no mass.<|end▁of▁sentence|><|User|>How do they interact with matter?<|Assistant|><think>`, expected: `<|begin▁of▁sentence|><|User|>Explain quantum physics<|Assistant|></think>Quantum physics is the study of matter and energy at the smallest scales.<|end▁of▁sentence|><|User|>What about photons?<|Assistant|></think>Photons are particles of light with no mass.<|end▁of▁sentence|><|User|>How do they interact with matter?<|Assistant|><think>`,
}, },
{ {
name: "tool call with thinking content in response", name: "tool call with thinking content in response",
...@@ -959,6 +959,73 @@ Where: ...@@ -959,6 +959,73 @@ Where:
- For multiple tool calls, chain them directly without separators or spaces - For multiple tool calls, chain them directly without separators or spaces
<|User|>What's the weather?<|Assistant|></think>`, <|User|>What's the weather?<|Assistant|></think>`,
}, },
{
name: "multi-turn conversation with thinking content on each turn",
messages: []api.Message{
{Role: "user", Content: "hey!"},
{
Role: "assistant",
Content: "Hey! 😊 How's it going? What's on your mind today?",
Thinking: "Hmm, the user just said \"hey!\" which is a simple greeting. This is a straightforward opening where they're likely just starting a conversation or testing the interaction.",
},
{Role: "user", Content: "fantastic, how has yours been"},
{
Role: "assistant",
Content: "Glad to hear you're having a fantastic day! That's awesome.\n\nMine's been great, thanks for asking! Just buzzing along, helping people out and having conversations like this one. So what's making your day so fantastic? Anything fun happening?",
Thinking: "Ah, the user is responding warmly and asking about my \"day.\" Since I'm an AI, I need to gently remind them I don't experience time like a human, but frame it positively to keep the conversation flowing.",
},
{Role: "user", Content: "awesome, can you tell me a 10 word story?"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: `<|begin▁of▁sentence|><|User|>hey!<|Assistant|></think>Hey! 😊 How's it going? What's on your mind today?<|end▁of▁sentence|><|User|>fantastic, how has yours been<|Assistant|></think>Glad to hear you're having a fantastic day! That's awesome.
Mine's been great, thanks for asking! Just buzzing along, helping people out and having conversations like this one. So what's making your day so fantastic? Anything fun happening?<|end▁of▁sentence|><|User|>awesome, can you tell me a 10 word story?<|Assistant|><think>`,
},
{
name: "vLLM documentation example - multi-turn with full thinking content",
messages: []api.Message{
{Role: "system", Content: "You are a helpful assistant"},
{Role: "user", Content: "Who are you?"},
{
Role: "assistant",
Content: "I am DeepSeek",
Thinking: "Hmm",
},
{Role: "user", Content: "9.11 and 9.8, which is greater?"},
{
Role: "assistant",
Content: "9.8 is greater than 9.11.\n\nTo compare them easily, you can align the decimal places: \n- 9.11 has a tenths digit of 1 and a hundredths digit of 1. \n- 9.8 can be written as 9.80, which has a tenths digit of 8 and a hundredths digit of 0. \n\nSince the whole number part (9) is the same, compare the tenths place: 8 is greater than 1, so 9.80 (or 9.8) is greater than 9.11.",
Thinking: "First, the user is asking which number is greater between 9.11 and 9.8. These are decimal numbers.\n\nI need to compare 9.11 and 9.8. To make it easier, I should think of them as decimals with the same number of decimal places.\n\n9.11 has two decimal places, and 9.8 has one decimal place. I can write 9.8 as 9.80 to make it comparable.\n\nSo, 9.11 versus 9.80.\n\nNow, comparing the whole number parts: both have 9, so they are equal in the units place.\n\nNext, compare the tenths place: for 9.11, the tenths digit is 1. For 9.80, the tenths digit is 8. Since 8 is greater than 1, 9.80 is greater than 9.11.\n\nTherefore, 9.8 is greater than 9.11.\n\nI can also think of them as fractions: 9.11 is 911/100, and 9.8 is 98/10 or 980/100. Comparing 911/100 and 980/100, 980/100 is larger, so 9.8 is greater.\n\nSo, the answer should be that 9.8 is greater than 9.11.\n\nNow, I need to respond helpfully. Since the user might be learning or need clarification, I should explain briefly.\n\nFinally, my response should be in English, as the query is in English.",
},
{Role: "user", Content: "Thanks! Can you tell me a 10 word story?"},
{
Role: "assistant",
Content: "Of course. Here is a 10-word story:\n\nHe found the key, unlocking a door to forgotten memories.",
Thinking: "Hmm, the user just asked for a 10-word story after the previous number comparison question. This is a quick creative task with a strict word count constraint. \n\nThe story needs to be exactly 10 words while maintaining coherence and a hint of narrative. A micro-story about finding a key could work - it implies a larger unseen story. \n\nChecking the word count: \"He found the key, unlocking a door to forgotten memories.\" That's 10 words with a beginning, middle and implied end. It fits the requirement while leaving room for imagination.",
},
{Role: "user", Content: "That was beautiful! Now can you write a haiku?"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: `<|begin▁of▁sentence|>You are a helpful assistant<|User|>Who are you?<|Assistant|></think>I am DeepSeek<|end▁of▁sentence|><|User|>9.11 and 9.8, which is greater?<|Assistant|></think>9.8 is greater than 9.11.
To compare them easily, you can align the decimal places:
- 9.11 has a tenths digit of 1 and a hundredths digit of 1.
- 9.8 can be written as 9.80, which has a tenths digit of 8 and a hundredths digit of 0.
Since the whole number part (9) is the same, compare the tenths place: 8 is greater than 1, so 9.80 (or 9.8) is greater than 9.11.<|end▁of▁sentence|><|User|>Thanks! Can you tell me a 10 word story?<|Assistant|></think>Of course. Here is a 10-word story:
He found the key, unlocking a door to forgotten memories.<|end▁of▁sentence|><|User|>That was beautiful! Now can you write a haiku?<|Assistant|><think>`,
},
{
name: "no system prompt - content with embedded thinking tags",
messages: []api.Message{
{Role: "user", Content: "Who are you?"},
{Role: "assistant", Content: "<think>Hmm</think>I am DeepSeek"},
{Role: "user", Content: "Thanks! Can you tell me a 10 word story?"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: `<|begin▁of▁sentence|><|User|>Who are you?<|Assistant|></think>I am DeepSeek<|end▁of▁sentence|><|User|>Thanks! Can you tell me a 10 word story?<|Assistant|><think>`,
},
} }
renderer := &DeepSeek3Renderer{IsThinking: true, Variant: Deepseek31} renderer := &DeepSeek3Renderer{IsThinking: true, Variant: Deepseek31}
......
...@@ -59,7 +59,7 @@ func rendererForName(name string) Renderer { ...@@ -59,7 +59,7 @@ func rendererForName(name string) Renderer {
case "cogito": case "cogito":
renderer := &CogitoRenderer{isThinking: true} renderer := &CogitoRenderer{isThinking: true}
return renderer return renderer
case "deepseek-v3.1": case "deepseek3.1":
renderer := &DeepSeek3Renderer{IsThinking: true, Variant: Deepseek31} renderer := &DeepSeek3Renderer{IsThinking: true, Variant: Deepseek31}
return renderer return renderer
case "olmo3": case "olmo3":
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment