mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-21 22:33:56 +00:00
parsers/renderers: olmo3 think (#13290)
This commit is contained in:
170
model/parsers/olmo3_think.go
Normal file
170
model/parsers/olmo3_think.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/logutil"
|
||||
)
|
||||
|
||||
type olmo3ThinkParserState int
|
||||
|
||||
const (
|
||||
olmo3CollectingThink olmo3ThinkParserState = iota
|
||||
olmo3CollectingContent
|
||||
)
|
||||
|
||||
const (
|
||||
olmo3ThinkCloseTag = "</think>"
|
||||
)
|
||||
|
||||
type Olmo3ThinkParser struct {
|
||||
state olmo3ThinkParserState
|
||||
buffer strings.Builder
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) HasToolSupport() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) HasThinkingSupport() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) setInitialState(lastMessage *api.Message) {
|
||||
prefill := lastMessage != nil && lastMessage.Role == "assistant"
|
||||
|
||||
// If prefilling with content, skip to content collection
|
||||
if prefill && lastMessage.Content != "" {
|
||||
p.state = olmo3CollectingContent
|
||||
return
|
||||
}
|
||||
|
||||
// Model always thinks first (the <think> tag is injected in the prompt)
|
||||
p.state = olmo3CollectingThink
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
|
||||
p.setInitialState(lastMessage)
|
||||
return tools
|
||||
}
|
||||
|
||||
// Event types for internal parser communication
|
||||
type olmo3Event interface {
|
||||
isOlmo3Event()
|
||||
}
|
||||
|
||||
type olmo3EventThinkContent struct {
|
||||
content string
|
||||
}
|
||||
|
||||
type olmo3EventContent struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func (olmo3EventThinkContent) isOlmo3Event() {}
|
||||
func (olmo3EventContent) isOlmo3Event() {}
|
||||
|
||||
func (p *Olmo3ThinkParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
|
||||
p.buffer.WriteString(s)
|
||||
events := p.parseEvents()
|
||||
|
||||
var contentSb strings.Builder
|
||||
var thinkingSb strings.Builder
|
||||
for _, event := range events {
|
||||
switch event := event.(type) {
|
||||
case olmo3EventThinkContent:
|
||||
thinkingSb.WriteString(event.content)
|
||||
case olmo3EventContent:
|
||||
contentSb.WriteString(event.content)
|
||||
}
|
||||
}
|
||||
|
||||
return contentSb.String(), thinkingSb.String(), nil, nil
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) parseEvents() []olmo3Event {
|
||||
var all []olmo3Event
|
||||
|
||||
keepLooping := true
|
||||
for keepLooping {
|
||||
var events []olmo3Event
|
||||
events, keepLooping = p.eat()
|
||||
if len(events) > 0 {
|
||||
all = append(all, events...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) > 0 {
|
||||
slog.Log(context.TODO(), logutil.LevelTrace, "olmo3 events parsed", "events", all, "state", p.state, "buffer", p.buffer.String())
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
func (p *Olmo3ThinkParser) eat() ([]olmo3Event, bool) {
|
||||
var events []olmo3Event
|
||||
bufStr := p.buffer.String()
|
||||
if bufStr == "" {
|
||||
return events, false
|
||||
}
|
||||
|
||||
switch p.state {
|
||||
case olmo3CollectingThink:
|
||||
if strings.Contains(bufStr, olmo3ThinkCloseTag) {
|
||||
// Found complete </think> tag
|
||||
split := strings.SplitN(bufStr, olmo3ThinkCloseTag, 2)
|
||||
thinking := strings.TrimRightFunc(split[0], unicode.IsSpace)
|
||||
remaining := strings.TrimLeftFunc(split[1], unicode.IsSpace)
|
||||
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(remaining)
|
||||
p.state = olmo3CollectingContent
|
||||
|
||||
if len(thinking) > 0 {
|
||||
events = append(events, olmo3EventThinkContent{content: thinking})
|
||||
}
|
||||
return events, true
|
||||
} else if overlapLen := overlap(bufStr, olmo3ThinkCloseTag); overlapLen > 0 {
|
||||
// Partial </think> tag - withhold ambiguous content
|
||||
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, olmo3EventThinkContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
} else {
|
||||
// Regular thinking content - withhold trailing whitespace in case </think> follows
|
||||
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, olmo3EventThinkContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
}
|
||||
|
||||
case olmo3CollectingContent:
|
||||
// Emit all content directly
|
||||
p.buffer.Reset()
|
||||
if len(bufStr) > 0 {
|
||||
events = append(events, olmo3EventContent{content: bufStr})
|
||||
}
|
||||
return events, false
|
||||
}
|
||||
|
||||
return events, false
|
||||
}
|
||||
390
model/parsers/olmo3_think_test.go
Normal file
390
model/parsers/olmo3_think_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package parsers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestOlmo3ThinkParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedContent string
|
||||
expectedThinking string
|
||||
lastMessage *api.Message
|
||||
}{
|
||||
{
|
||||
name: "thinking_only",
|
||||
input: "I need to think about this.</think>Here is my response.",
|
||||
expectedContent: "Here is my response.",
|
||||
expectedThinking: "I need to think about this.",
|
||||
},
|
||||
{
|
||||
name: "thinking_with_newlines",
|
||||
input: "Let me think step by step.\n\n1. First point\n2. Second point</think>The answer is 42.",
|
||||
expectedContent: "The answer is 42.",
|
||||
expectedThinking: "Let me think step by step.\n\n1. First point\n2. Second point",
|
||||
},
|
||||
{
|
||||
name: "thinking_then_content",
|
||||
input: "Deep thinking here.</think>Here is my detailed response with multiple sentences. I have thought carefully.",
|
||||
expectedContent: "Here is my detailed response with multiple sentences. I have thought carefully.",
|
||||
expectedThinking: "Deep thinking here.",
|
||||
},
|
||||
{
|
||||
name: "empty_thinking",
|
||||
input: "</think>Just content here.",
|
||||
expectedContent: "Just content here.",
|
||||
expectedThinking: "",
|
||||
},
|
||||
{
|
||||
name: "prefill_skips_thinking",
|
||||
input: "Continuing from previous content.",
|
||||
expectedContent: "Continuing from previous content.",
|
||||
lastMessage: &api.Message{
|
||||
Role: "assistant",
|
||||
Content: "Previous content",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking_with_whitespace",
|
||||
input: " Some thinking </think> Content here ",
|
||||
expectedContent: "Content here ",
|
||||
expectedThinking: " Some thinking",
|
||||
},
|
||||
{
|
||||
name: "real_model_output_with_newlines",
|
||||
input: "Yes, that should work. Let me go with that response.\n\n</think>\n\nHi! I'm all set and ready to assist. How about you? How are you today? 😊",
|
||||
expectedThinking: "Yes, that should work. Let me go with that response.",
|
||||
expectedContent: "Hi! I'm all set and ready to assist. How about you? How are you today? 😊",
|
||||
},
|
||||
// Edge cases
|
||||
{
|
||||
name: "nested_think_tags_in_thinking",
|
||||
input: "I'm thinking <think>nested</think> more thinking</think>Final content.",
|
||||
expectedContent: "more thinking</think>Final content.",
|
||||
expectedThinking: "I'm thinking <think>nested",
|
||||
},
|
||||
{
|
||||
name: "multiple_think_close_tags",
|
||||
input: "First thinking</think>Content</think>More content.",
|
||||
expectedContent: "Content</think>More content.",
|
||||
expectedThinking: "First thinking",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
parser.Init(nil, tt.lastMessage, nil)
|
||||
|
||||
content, thinking, toolCalls, err := parser.Add(tt.input, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error = %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.expectedContent, content); diff != "" {
|
||||
t.Errorf("content mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.expectedThinking, thinking); diff != "" {
|
||||
t.Errorf("thinking mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// No tool calls expected
|
||||
if len(toolCalls) > 0 {
|
||||
t.Errorf("expected no tool calls, got %d", len(toolCalls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_Streaming(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
chunks := []string{
|
||||
"I am ",
|
||||
"thinking about",
|
||||
" this.</think>Here ",
|
||||
"is the response.",
|
||||
}
|
||||
|
||||
var finalContent, finalThinking strings.Builder
|
||||
|
||||
for i, chunk := range chunks {
|
||||
done := i == len(chunks)-1
|
||||
content, thinking, _, err := parser.Add(chunk, done)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
||||
}
|
||||
|
||||
finalContent.WriteString(content)
|
||||
finalThinking.WriteString(thinking)
|
||||
}
|
||||
|
||||
expectedContent := "Here is the response."
|
||||
expectedThinking := "I am thinking about this."
|
||||
|
||||
if finalContent.String() != expectedContent {
|
||||
t.Errorf("expected content %q, got %q", expectedContent, finalContent.String())
|
||||
}
|
||||
|
||||
if finalThinking.String() != expectedThinking {
|
||||
t.Errorf("expected thinking %q, got %q", expectedThinking, finalThinking.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_StreamingEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
expectedContent string
|
||||
expectedThinking string
|
||||
}{
|
||||
{
|
||||
name: "thinking_tag_split_across_chunks",
|
||||
chunks: []string{
|
||||
"This is thinking content",
|
||||
"</think>",
|
||||
"This is content.",
|
||||
},
|
||||
expectedContent: "This is content.",
|
||||
expectedThinking: "This is thinking content",
|
||||
},
|
||||
{
|
||||
name: "thinking_tag_split_mid_token",
|
||||
chunks: []string{
|
||||
"Thinking?</",
|
||||
"think>",
|
||||
"Content here.",
|
||||
},
|
||||
expectedContent: "Content here.",
|
||||
expectedThinking: "Thinking?",
|
||||
},
|
||||
{
|
||||
name: "thinking_tag_split_at_angle_bracket",
|
||||
chunks: []string{
|
||||
"Thinking<",
|
||||
"/think>",
|
||||
"Content.",
|
||||
},
|
||||
expectedContent: "Content.",
|
||||
expectedThinking: "Thinking",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
var finalContent, finalThinking strings.Builder
|
||||
|
||||
for i, chunk := range tt.chunks {
|
||||
done := i == len(tt.chunks)-1
|
||||
content, thinking, _, err := parser.Add(chunk, done)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
||||
}
|
||||
|
||||
finalContent.WriteString(content)
|
||||
finalThinking.WriteString(thinking)
|
||||
}
|
||||
|
||||
if finalContent.String() != tt.expectedContent {
|
||||
t.Errorf("expected content %q, got %q", tt.expectedContent, finalContent.String())
|
||||
}
|
||||
|
||||
if finalThinking.String() != tt.expectedThinking {
|
||||
t.Errorf("expected thinking %q, got %q", tt.expectedThinking, finalThinking.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOlmo3ThinkParser_ThinkBoundary tests streaming thinking content
|
||||
// where thinking chunks come in succession before the </think> tag
|
||||
func TestOlmo3ThinkParser_ThinkBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
expectedThinking string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "multiple_thinking_chunks",
|
||||
chunks: []string{
|
||||
"First part of thinking. ",
|
||||
"Second part of thinking. ",
|
||||
"Third part.</think>",
|
||||
"Content here.",
|
||||
},
|
||||
expectedThinking: "First part of thinking. Second part of thinking. Third part.",
|
||||
expectedContent: "Content here.",
|
||||
},
|
||||
{
|
||||
name: "thinking_chunks_with_newlines",
|
||||
chunks: []string{
|
||||
"Step 1: Analyze the problem.\n",
|
||||
"Step 2: Consider options.\n",
|
||||
"Step 3: Make decision.</think>",
|
||||
"Here is my answer.",
|
||||
},
|
||||
expectedThinking: "Step 1: Analyze the problem.\nStep 2: Consider options.\nStep 3: Make decision.",
|
||||
expectedContent: "Here is my answer.",
|
||||
},
|
||||
{
|
||||
name: "single_char_thinking_chunks",
|
||||
chunks: []string{
|
||||
"H", "e", "l", "l", "o", "</think>", "World",
|
||||
},
|
||||
expectedThinking: "Hello",
|
||||
expectedContent: "World",
|
||||
},
|
||||
{
|
||||
name: "thinking_with_special_chars",
|
||||
chunks: []string{
|
||||
"Let me think... ",
|
||||
"Option A: $100 ",
|
||||
"Option B: €200</think>",
|
||||
"I recommend Option A.",
|
||||
},
|
||||
expectedThinking: "Let me think... Option A: $100 Option B: €200",
|
||||
expectedContent: "I recommend Option A.",
|
||||
},
|
||||
{
|
||||
name: "long_thinking_multiple_chunks",
|
||||
chunks: []string{
|
||||
"This is a very long thinking process. ",
|
||||
"I need to consider many factors. ",
|
||||
"First, let me look at the data. ",
|
||||
"The numbers show interesting patterns. ",
|
||||
"Based on my analysis, ",
|
||||
"I can conclude that...</think>",
|
||||
"The answer is 42.",
|
||||
},
|
||||
expectedThinking: "This is a very long thinking process. I need to consider many factors. First, let me look at the data. The numbers show interesting patterns. Based on my analysis, I can conclude that...",
|
||||
expectedContent: "The answer is 42.",
|
||||
},
|
||||
{
|
||||
name: "thinking_ends_exactly_at_chunk_boundary",
|
||||
chunks: []string{
|
||||
"Thinking content",
|
||||
"</think>",
|
||||
"Content",
|
||||
},
|
||||
expectedThinking: "Thinking content",
|
||||
expectedContent: "Content",
|
||||
},
|
||||
{
|
||||
name: "empty_chunks_between_thinking",
|
||||
chunks: []string{
|
||||
"Start thinking",
|
||||
"",
|
||||
" middle ",
|
||||
"",
|
||||
"end</think>",
|
||||
"Content",
|
||||
},
|
||||
expectedThinking: "Start thinking middle end",
|
||||
expectedContent: "Content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
var finalContent, finalThinking strings.Builder
|
||||
|
||||
for i, chunk := range tt.chunks {
|
||||
done := i == len(tt.chunks)-1
|
||||
content, thinking, _, err := parser.Add(chunk, done)
|
||||
if err != nil {
|
||||
t.Fatalf("Add() error on chunk %d: %v", i, err)
|
||||
}
|
||||
|
||||
finalContent.WriteString(content)
|
||||
finalThinking.WriteString(thinking)
|
||||
}
|
||||
|
||||
if finalThinking.String() != tt.expectedThinking {
|
||||
t.Errorf("thinking mismatch:\nexpected: %q\ngot: %q", tt.expectedThinking, finalThinking.String())
|
||||
}
|
||||
|
||||
if finalContent.String() != tt.expectedContent {
|
||||
t.Errorf("content mismatch:\nexpected: %q\ngot: %q", tt.expectedContent, finalContent.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOlmo3ThinkParser_StateTransitions tests that state transitions work correctly
|
||||
func TestOlmo3ThinkParser_StateTransitions(t *testing.T) {
|
||||
t.Run("thinking_to_content", func(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
if parser.state != olmo3CollectingThink {
|
||||
t.Errorf("initial state should be olmo3CollectingThink, got %v", parser.state)
|
||||
}
|
||||
|
||||
parser.Add("thinking</think>content", true)
|
||||
|
||||
if parser.state != olmo3CollectingContent {
|
||||
t.Errorf("state after </think> should be olmo3CollectingContent, got %v", parser.state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_HasToolSupport(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
if parser.HasToolSupport() {
|
||||
t.Error("Olmo3ThinkParser should NOT support tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_HasThinkingSupport(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
if !parser.HasThinkingSupport() {
|
||||
t.Error("Olmo3ThinkParser should support thinking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_Init(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
|
||||
tools := []api.Tool{
|
||||
{Function: api.ToolFunction{Name: "test_tool"}},
|
||||
}
|
||||
|
||||
lastMessage := &api.Message{Role: "assistant", Content: "previous"}
|
||||
|
||||
returnedTools := parser.Init(tools, lastMessage, nil)
|
||||
|
||||
if len(returnedTools) != len(tools) {
|
||||
t.Errorf("expected %d tools returned, got %d", len(tools), len(returnedTools))
|
||||
}
|
||||
|
||||
// Should be in content collection mode due to prefill
|
||||
if parser.state != olmo3CollectingContent {
|
||||
t.Errorf("expected state olmo3CollectingContent, got %v", parser.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlmo3ThinkParser_InitWithoutPrefill(t *testing.T) {
|
||||
parser := &Olmo3ThinkParser{}
|
||||
|
||||
parser.Init(nil, nil, nil)
|
||||
|
||||
// Should be in thinking collection mode (model always thinks first)
|
||||
if parser.state != olmo3CollectingThink {
|
||||
t.Errorf("expected state olmo3CollectingThink, got %v", parser.state)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ func ParserForName(name string) Parser {
|
||||
return harmony.NewHarmonyMessageHandler()
|
||||
case "cogito":
|
||||
return &CogitoParser{}
|
||||
case "olmo3-think":
|
||||
return &Olmo3ThinkParser{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
45
model/renderers/json.go
Normal file
45
model/renderers/json.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package renderers
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// marshalWithSpaces marshals v to JSON and adds a space after each ':' and ','
|
||||
// that appears outside of string values. This matches the formatting expected
|
||||
// by certain model architectures.
|
||||
func marshalWithSpaces(v any) ([]byte, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(b)+len(b)/8)
|
||||
inStr, esc := false, false
|
||||
for _, c := range b {
|
||||
if inStr {
|
||||
out = append(out, c)
|
||||
if esc {
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inStr = true
|
||||
out = append(out, c)
|
||||
case ':':
|
||||
out = append(out, ':', ' ')
|
||||
case ',':
|
||||
out = append(out, ',', ' ')
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
// TODO(drifkin): this will be moved to utils in the near future and used by other renderers as well
|
||||
func TestMarshalWithSpaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
130
model/renderers/olmo3_think.go
Normal file
130
model/renderers/olmo3_think.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package renderers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
olmo3ThinkDefaultSystemMessage = "You are OLMo, a helpful function-calling AI assistant built by Ai2. Your date cutoff is November 2024, and your model weights are available at https://huggingface.co/allenai."
|
||||
olmo3ThinkNoFunctionsMessage = " You do not currently have access to any functions."
|
||||
)
|
||||
|
||||
type Olmo3ThinkRenderer struct{}
|
||||
|
||||
type olmo3ThinkToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function olmo3ThinkToolCallFunc `json:"function"`
|
||||
}
|
||||
|
||||
type olmo3ThinkToolCallFunc struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
func (r *Olmo3ThinkRenderer) Render(messages []api.Message, tools []api.Tool, _ *api.ThinkValue) (string, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
var systemMessage *api.Message
|
||||
filteredMessages := make([]api.Message, 0, len(messages))
|
||||
for i, message := range messages {
|
||||
if message.Role == "system" {
|
||||
if systemMessage == nil {
|
||||
systemMessage = &messages[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
filteredMessages = append(filteredMessages, message)
|
||||
}
|
||||
|
||||
systemContent := olmo3ThinkDefaultSystemMessage
|
||||
if systemMessage != nil {
|
||||
systemContent = systemMessage.Content
|
||||
}
|
||||
|
||||
sb.WriteString("<|im_start|>system\n")
|
||||
sb.WriteString(systemContent)
|
||||
|
||||
if len(tools) > 0 {
|
||||
functionsJSON, err := marshalWithSpaces(tools)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sb.WriteString(" <functions>")
|
||||
sb.WriteString(string(functionsJSON))
|
||||
sb.WriteString("</functions>")
|
||||
} else {
|
||||
sb.WriteString(olmo3ThinkNoFunctionsMessage)
|
||||
sb.WriteString(" <functions></functions>")
|
||||
}
|
||||
sb.WriteString("<|im_end|>\n")
|
||||
|
||||
for i, message := range filteredMessages {
|
||||
lastMessage := i == len(filteredMessages)-1
|
||||
|
||||
switch message.Role {
|
||||
case "user":
|
||||
sb.WriteString("<|im_start|>user\n")
|
||||
sb.WriteString(message.Content)
|
||||
sb.WriteString("<|im_end|>\n")
|
||||
|
||||
case "assistant":
|
||||
sb.WriteString("<|im_start|>assistant\n")
|
||||
|
||||
if message.Content != "" {
|
||||
sb.WriteString(message.Content)
|
||||
}
|
||||
|
||||
if len(message.ToolCalls) > 0 {
|
||||
toolCalls := make([]olmo3ThinkToolCall, len(message.ToolCalls))
|
||||
for j, tc := range message.ToolCalls {
|
||||
argsJSON, err := json.Marshal(tc.Function.Arguments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
toolCalls[j] = olmo3ThinkToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: olmo3ThinkToolCallFunc{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
toolCallsJSON, err := marshalWithSpaces(toolCalls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sb.WriteString("<function_calls>")
|
||||
sb.WriteString(string(toolCallsJSON))
|
||||
sb.WriteString("</function_calls>")
|
||||
}
|
||||
|
||||
if !lastMessage {
|
||||
sb.WriteString("<|im_end|>\n")
|
||||
}
|
||||
|
||||
case "tool":
|
||||
sb.WriteString("<|im_start|>environment\n")
|
||||
sb.WriteString(message.Content)
|
||||
sb.WriteString("<|im_end|>\n")
|
||||
}
|
||||
}
|
||||
|
||||
needsGenerationPrompt := true
|
||||
if len(filteredMessages) > 0 {
|
||||
lastMsg := filteredMessages[len(filteredMessages)-1]
|
||||
if lastMsg.Role == "assistant" && len(lastMsg.ToolCalls) == 0 && lastMsg.Content != "" {
|
||||
needsGenerationPrompt = false
|
||||
}
|
||||
}
|
||||
|
||||
if needsGenerationPrompt {
|
||||
sb.WriteString("<|im_start|>assistant\n<think>")
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
224
model/renderers/olmo3_think_test.go
Normal file
224
model/renderers/olmo3_think_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package renderers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestOlmo3ThinkRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgs []api.Message
|
||||
tools []api.Tool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic without system - adds default system",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"You are OLMo, a helpful function-calling AI assistant built by Ai2. Your date cutoff is November 2024, and your model weights are available at https://huggingface.co/allenai. You do not currently have access to any functions. <functions></functions><|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"Hello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "with system message no tools",
|
||||
msgs: []api.Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"You are a helpful assistant. You do not currently have access to any functions. <functions></functions><|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"Hello!<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "with system message and tools",
|
||||
msgs: []api.Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "What is the weather?"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"location"},
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"location": {Type: api.PropertyType{"string"}, Description: "The city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
`You are a helpful assistant. <functions>[{"type": "function", "function": {"name": "get_weather", "description": "Get the current weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "The city"}}}}}]</functions><|im_end|>` + "\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"What is the weather?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "assistant with tool calls",
|
||||
msgs: []api.Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "What is the weather in SF?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check the weather.",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{
|
||||
"location": "San Francisco",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temperature": 68}`, ToolName: "get_weather"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"location"},
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"location": {Type: api.PropertyType{"string"}, Description: "The city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
`You are a helpful assistant. <functions>[{"type": "function", "function": {"name": "get_weather", "description": "Get the current weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "The city"}}}}}]</functions><|im_end|>` + "\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"What is the weather in SF?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
`Let me check the weather.<function_calls>[{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"San Francisco\"}"}}]</function_calls><|im_end|>` + "\n" +
|
||||
"<|im_start|>environment\n" +
|
||||
`{"temperature": 68}<|im_end|>` + "\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "multi-turn conversation",
|
||||
msgs: []api.Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "assistant", Content: "Hi there!"},
|
||||
{Role: "user", Content: "How are you?"},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"You are a helpful assistant. You do not currently have access to any functions. <functions></functions><|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"Hello<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"Hi there!<|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"How are you?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "parallel tool calls",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Get weather in SF and NYC"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "call_2",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]any{"location": "New York"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temperature": 68}`, ToolName: "get_weather"},
|
||||
{Role: "tool", Content: `{"temperature": 55}`, ToolName: "get_weather"},
|
||||
},
|
||||
tools: []api.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: map[string]api.ToolProperty{
|
||||
"location": {Type: api.PropertyType{"string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
`You are OLMo, a helpful function-calling AI assistant built by Ai2. Your date cutoff is November 2024, and your model weights are available at https://huggingface.co/allenai. <functions>[{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}}}]</functions><|im_end|>` + "\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"Get weather in SF and NYC<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
`<function_calls>[{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"San Francisco\"}"}}, {"id": "call_2", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"New York\"}"}}]</function_calls><|im_end|>` + "\n" +
|
||||
"<|im_start|>environment\n" +
|
||||
`{"temperature": 68}<|im_end|>` + "\n" +
|
||||
"<|im_start|>environment\n" +
|
||||
`{"temperature": 55}<|im_end|>` + "\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
{
|
||||
name: "assistant message only content no tool calls",
|
||||
msgs: []api.Message{
|
||||
{Role: "user", Content: "Tell me a joke"},
|
||||
{Role: "assistant", Content: "Why did the chicken cross the road?"},
|
||||
{Role: "user", Content: "I don't know, why?"},
|
||||
},
|
||||
expected: "<|im_start|>system\n" +
|
||||
"You are OLMo, a helpful function-calling AI assistant built by Ai2. Your date cutoff is November 2024, and your model weights are available at https://huggingface.co/allenai. You do not currently have access to any functions. <functions></functions><|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"Tell me a joke<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"Why did the chicken cross the road?<|im_end|>\n" +
|
||||
"<|im_start|>user\n" +
|
||||
"I don't know, why?<|im_end|>\n" +
|
||||
"<|im_start|>assistant\n" +
|
||||
"<think>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rendered, err := (&Olmo3ThinkRenderer{}).Render(tt.msgs, tt.tools, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(rendered, tt.expected); diff != "" {
|
||||
t.Errorf("mismatch (-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,11 @@
|
||||
package renderers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func marshalWithSpaces(v any) ([]byte, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(b)+len(b)/8)
|
||||
inStr, esc := false, false
|
||||
for _, c := range b {
|
||||
if inStr {
|
||||
out = append(out, c)
|
||||
if esc {
|
||||
esc = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
esc = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inStr = true
|
||||
out = append(out, c)
|
||||
case ':':
|
||||
out = append(out, ':', ' ')
|
||||
case ',':
|
||||
out = append(out, ',', ' ')
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type Qwen3VLRenderer struct {
|
||||
isThinking bool
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ func rendererForName(name string) Renderer {
|
||||
case "cogito":
|
||||
renderer := &CogitoRenderer{isThinking: true}
|
||||
return renderer
|
||||
case "olmo3-think":
|
||||
renderer := &Olmo3ThinkRenderer{}
|
||||
return renderer
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user